@applicaster/zapp-react-native-ui-components 15.1.0-rc.9 → 16.0.0-alpha.1740103407

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 (156) hide show
  1. package/Components/BackgroundImage/BackgroundImage.tsx +42 -0
  2. package/Components/BackgroundImage/BackgroundImage.tv.android.tsx +28 -0
  3. package/Components/BackgroundImage/BackgroundImage.tv.ios.tsx +24 -0
  4. package/Components/BackgroundImage/index.ts +1 -0
  5. package/Components/BaseFocusable/index.ios.ts +12 -2
  6. package/Components/Cell/FocusableWrapper.tsx +3 -0
  7. package/Components/Cell/TvOSCellComponent.tsx +6 -3
  8. package/Components/Focusable/Focusable.tsx +4 -2
  9. package/Components/Focusable/FocusableTvOS.tsx +18 -1
  10. package/Components/Focusable/__tests__/__snapshots__/FocusableTvOS.test.tsx.snap +1 -0
  11. package/Components/FocusableGroup/FocusableTvOS.tsx +41 -8
  12. package/Components/GeneralContentScreen/utils/__tests__/useCurationAPI.test.js +1 -1
  13. package/Components/HandlePlayable/HandlePlayable.tsx +13 -8
  14. package/Components/Layout/FullWidthRow.tsx +38 -0
  15. package/Components/Layout/TV/NavBarContainer.tsx +1 -10
  16. package/Components/Layout/TV/ScreenContainer.tsx +2 -6
  17. package/Components/Layout/TV/__tests__/__snapshots__/NavBarContainer.test.tsx.snap +7 -12
  18. package/Components/Layout/TV/__tests__/__snapshots__/ScreenContainer.test.tsx.snap +7 -12
  19. package/Components/Layout/TV/__tests__/__snapshots__/index.test.tsx.snap +0 -1
  20. package/Components/Layout/TV/index.tsx +5 -7
  21. package/Components/Layout/TV/index.web.tsx +5 -7
  22. package/Components/LinkHandler/LinkHandler.tsx +2 -2
  23. package/Components/MasterCell/CONFIG_BUILDER_TO_REACT_COMPONENT.md +144 -0
  24. package/Components/MasterCell/DefaultComponents/ActionButton.tsx +16 -5
  25. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/model.test.ts +80 -0
  26. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/placement.test.ts +187 -0
  27. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/selectors.test.ts +45 -0
  28. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/style.test.ts +49 -0
  29. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/ActionButtonController.tsx +165 -0
  30. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/__tests__/ActionButtonController.test.tsx +405 -0
  31. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/index.ts +1 -0
  32. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/model.ts +47 -0
  33. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/placement.ts +170 -0
  34. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/selectors.ts +26 -0
  35. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/style.ts +29 -0
  36. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/types.ts +37 -0
  37. package/Components/MasterCell/DefaultComponents/BorderContainerView/index.tsx +4 -10
  38. package/Components/MasterCell/DefaultComponents/Button.tsx +0 -15
  39. package/Components/MasterCell/DefaultComponents/ButtonContainerView/components/HorizontalSeparator.tsx +8 -0
  40. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.tsx +15 -0
  41. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.tv.android.tsx +58 -0
  42. package/Components/MasterCell/DefaultComponents/{tv/ButtonContainerView/index.tsx → ButtonContainerView/index.tv.tsx} +3 -11
  43. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.web.ts +1 -0
  44. package/Components/MasterCell/DefaultComponents/ButtonContainerView/types.ts +40 -0
  45. package/Components/MasterCell/DefaultComponents/DataProvider/index.tsx +163 -0
  46. package/Components/MasterCell/DefaultComponents/FocusableView/index.android.tsx +2 -23
  47. package/Components/MasterCell/DefaultComponents/FocusableView/index.tsx +4 -22
  48. package/Components/MasterCell/DefaultComponents/Image/Image.android.tsx +8 -2
  49. package/Components/MasterCell/DefaultComponents/Image/Image.ios.tsx +11 -3
  50. package/Components/MasterCell/DefaultComponents/Image/Image.web.tsx +9 -1
  51. package/Components/MasterCell/DefaultComponents/Image/hooks/useImage.ts +15 -14
  52. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +1 -2
  53. package/Components/MasterCell/DefaultComponents/PressableView.tsx +34 -0
  54. package/Components/MasterCell/DefaultComponents/SecondaryImage/hooks/__tests__/useGetImageDimensions.test.ts +7 -6
  55. package/Components/MasterCell/DefaultComponents/Text/hooks/useText.ts +11 -0
  56. package/Components/MasterCell/DefaultComponents/__tests__/DataProvider.test.tsx +141 -0
  57. package/Components/MasterCell/DefaultComponents/index.ts +9 -3
  58. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +135 -0
  59. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Asset.ts +33 -0
  60. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/AssetComponent.tsx +22 -0
  61. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Button.ts +125 -0
  62. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Spacer.ts +16 -0
  63. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/TextLabel.ts +67 -0
  64. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/TextLabelsContainer.ts +37 -0
  65. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +393 -0
  66. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/builders.test.ts +141 -0
  67. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/index.test.ts +343 -0
  68. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/helpers.ts +105 -0
  69. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/index.ts +122 -0
  70. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/utils/__tests__/insertButtons.test.ts +118 -0
  71. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/utils/index.ts +238 -0
  72. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/Asset.ts +4 -18
  73. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/Button.ts +24 -73
  74. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/TextLabelsContainer.ts +37 -18
  75. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/TvActionButton.tsx +27 -0
  76. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/__tests__/index.test.ts +89 -0
  77. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/__tests__/renderedTree.test.tsx +231 -0
  78. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/index.ts +47 -48
  79. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/__tests__/getPluginIdentifier.test.ts +115 -29
  80. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/index.ts +101 -144
  81. package/Components/MasterCell/MappingFunctions/index.js +3 -2
  82. package/Components/MasterCell/README.md +4 -0
  83. package/Components/MasterCell/__tests__/__snapshots__/dataAdapter.test.js.snap +24 -0
  84. package/Components/MasterCell/__tests__/configInflater.test.js +1 -0
  85. package/Components/MasterCell/__tests__/elementMapper.test.js +46 -0
  86. package/Components/MasterCell/dataAdapter.ts +4 -1
  87. package/Components/MasterCell/elementMapper.tsx +52 -7
  88. package/Components/MasterCell/utils/__tests__/cloneChildrenWithIds.test.tsx +43 -0
  89. package/Components/MasterCell/utils/__tests__/useFilterChildren.test.tsx +80 -0
  90. package/Components/MasterCell/utils/index.ts +85 -15
  91. package/Components/OfflineHandler/NotificationView/NotificationView.tsx +2 -2
  92. package/Components/OfflineHandler/NotificationView/__tests__/index.test.tsx +17 -18
  93. package/Components/OfflineHandler/__tests__/index.test.tsx +27 -18
  94. package/Components/PlayerContainer/PlayerContainer.tsx +8 -5
  95. package/Components/PlayerContainer/__tests__/PlayerContainer.test.tsx +284 -0
  96. package/Components/River/ComponentsMap/ComponentsMap.tsx +188 -118
  97. package/Components/River/ComponentsMap/hooks/__tests__/useLoadingState.test.ts +1 -1
  98. package/Components/River/RefreshControl.tsx +19 -88
  99. package/Components/River/River.tsx +9 -82
  100. package/Components/River/TV/River.tsx +31 -14
  101. package/Components/River/TV/index.tsx +8 -4
  102. package/Components/River/TV/utils/__tests__/toStringOrEmpty.test.ts +30 -0
  103. package/Components/River/TV/utils/index.ts +4 -0
  104. package/Components/River/TV/withFocusableGroupForContent.tsx +71 -0
  105. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -22
  106. package/Components/River/__tests__/componentsMap.test.js +38 -0
  107. package/Components/River/hooks/__tests__/usePullToRefresh.test.ts +132 -0
  108. package/Components/River/hooks/index.ts +1 -0
  109. package/Components/River/hooks/usePullToRefresh.ts +51 -0
  110. package/Components/Screen/TV/index.web.tsx +4 -2
  111. package/Components/Screen/__tests__/Screen.test.tsx +65 -42
  112. package/Components/Screen/__tests__/__snapshots__/Screen.test.tsx.snap +68 -44
  113. package/Components/Screen/hooks.ts +2 -3
  114. package/Components/Screen/index.tsx +2 -3
  115. package/Components/Screen/orientationHandler.ts +3 -3
  116. package/Components/ScreenResolver/index.tsx +9 -5
  117. package/Components/ScreenRevealManager/Overlay.tsx +34 -0
  118. package/Components/ScreenRevealManager/ScreenRevealManager.ts +40 -8
  119. package/Components/ScreenRevealManager/__tests__/Overlay.test.tsx +88 -0
  120. package/Components/ScreenRevealManager/__tests__/ScreenRevealManager.test.ts +86 -69
  121. package/Components/ScreenRevealManager/withScreenRevealManager.tsx +9 -20
  122. package/Components/Tabs/TabContent.tsx +7 -4
  123. package/Components/TopCutoffOverlay/__tests__/TopCutoffOverlay.test.tsx +201 -0
  124. package/Components/TopCutoffOverlay/hooks/__tests__/useMarginTop.test.ts +130 -0
  125. package/Components/TopCutoffOverlay/hooks/index.ts +1 -0
  126. package/Components/TopCutoffOverlay/hooks/useMarginTop.ts +59 -0
  127. package/Components/TopCutoffOverlay/index.tsx +55 -0
  128. package/Components/Transitioner/index.js +3 -3
  129. package/Components/VideoModal/ModalAnimation/ModalAnimationContext.tsx +5 -5
  130. package/Components/VideoModal/hooks/__tests__/useDelayedPlayerDetails.test.ts +15 -7
  131. package/Components/VideoModal/utils.ts +12 -10
  132. package/Components/Viewport/ViewportAware/__tests__/viewportAware.test.js +0 -2
  133. package/Components/Viewport/ViewportAware/index.tsx +16 -7
  134. package/Components/Viewport/ViewportEvents/__tests__/viewportEvents.test.js +1 -1
  135. package/Components/ZappFrameworkComponents/BarView/BarView.tsx +4 -6
  136. package/Components/ZappFrameworkComponents/BarView/__tests__/BarView.test.tsx +2 -2
  137. package/Components/default-cell-renderer/viewTrees/mobile/index.ts +0 -3
  138. package/Contexts/ScreenContext/index.tsx +25 -18
  139. package/Contexts/ScreenTrackedViewPositionsContext/__tests__/index.test.tsx +1 -1
  140. package/Decorators/Analytics/index.tsx +6 -5
  141. package/Decorators/ConfigurationWrapper/__tests__/__snapshots__/withConfigurationProvider.test.tsx.snap +1 -0
  142. package/Decorators/ConfigurationWrapper/const.ts +1 -0
  143. package/Decorators/ZappPipesDataConnector/__tests__/UrlFeedResolver.test.tsx +39 -21
  144. package/Decorators/ZappPipesDataConnector/__tests__/zappPipesDataConnector.test.js +1 -1
  145. package/Decorators/ZappPipesDataConnector/index.tsx +2 -2
  146. package/Decorators/ZappPipesDataConnector/resolvers/StaticFeedResolver.tsx +1 -1
  147. package/Decorators/ZappPipesDataConnector/resolvers/UrlFeedResolver.tsx +18 -7
  148. package/Helpers/DataSourceHelper/__tests__/itemLimitForData.test.ts +80 -0
  149. package/Helpers/DataSourceHelper/index.ts +19 -0
  150. package/Helpers/index.js +7 -1
  151. package/package.json +5 -5
  152. package/Components/Layout/TV/LayoutBackground.tsx +0 -28
  153. package/Components/MasterCell/DefaultComponents/tv/ButtonContainerView/index.android.tsx +0 -135
  154. package/Components/MasterCell/DefaultComponents/tv/ButtonContainerView/types.ts +0 -25
  155. package/Components/River/TV/withTVEventHandler.tsx +0 -36
  156. package/Helpers/DataSourceHelper/index.js +0 -19
@@ -0,0 +1,165 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
9
+
10
+ type ActionDefinition = {
11
+ identifier?: string;
12
+ };
13
+
14
+ type ControllerRenderProps = {
15
+ actionContext: any;
16
+ actionState: any;
17
+ entry: any;
18
+ isActive: boolean;
19
+ onPress: () => Promise<unknown> | unknown;
20
+ };
21
+
22
+ type Props = {
23
+ action?: ActionDefinition;
24
+ pluginIdentifier?: string;
25
+ entry?: any;
26
+ onMissingActionContext?: (identifier?: string) => void;
27
+ children: (props: ControllerRenderProps) => React.ReactNode;
28
+ };
29
+
30
+ const resolveIsActive = (actionState: any, fallbackSelected = false) => {
31
+ if (actionState == null) {
32
+ return fallbackSelected;
33
+ }
34
+
35
+ return Boolean(
36
+ actionState?.active ??
37
+ actionState?.isActive ??
38
+ actionState?.selected ??
39
+ actionState?.isSelected ??
40
+ fallbackSelected
41
+ );
42
+ };
43
+
44
+ const buildLegacySelection = (entry: any, actionContext: any) => {
45
+ const defaultIsSelected = Array.isArray(actionContext?.state)
46
+ ? (actionContext?.state || []).includes(entry)
47
+ : false;
48
+
49
+ return actionContext?.masterCell?.isSelected
50
+ ? actionContext.masterCell.isSelected(entry)
51
+ : defaultIsSelected;
52
+ };
53
+
54
+ export function ActionButtonController({
55
+ action,
56
+ pluginIdentifier,
57
+ entry,
58
+ onMissingActionContext,
59
+ children,
60
+ }: Props) {
61
+ const resolvedEntry = entry;
62
+ const resolvedIdentifier = action?.identifier || pluginIdentifier || "";
63
+ const actionContext = useActions(resolvedIdentifier);
64
+
65
+ const actionDisabled =
66
+ typeof actionContext?.isActionAvailable === "function" &&
67
+ !actionContext.isActionAvailable(resolvedEntry);
68
+
69
+ const supportsEntryState =
70
+ typeof actionContext?.initialEntryState === "function" && !actionDisabled;
71
+
72
+ const hydrationKey = `${resolvedIdentifier}::${String(resolvedEntry?.id ?? "")}`;
73
+ const lastHydratedKeyRef = useRef<string | null>(null);
74
+
75
+ const [actionState, setActionState] = useState(() =>
76
+ supportsEntryState ? actionContext.initialEntryState(resolvedEntry) : null
77
+ );
78
+
79
+ useEffect(() => {
80
+ if (supportsEntryState) {
81
+ if (lastHydratedKeyRef.current === hydrationKey) {
82
+ return;
83
+ }
84
+
85
+ lastHydratedKeyRef.current = hydrationKey;
86
+ setActionState(actionContext.initialEntryState(resolvedEntry));
87
+ }
88
+ }, [supportsEntryState, actionContext, resolvedEntry, hydrationKey]);
89
+
90
+ useEffect(() => {
91
+ if (typeof actionContext?.addListener === "function") {
92
+ return actionContext.addListener(String(resolvedEntry?.id), (nextState) =>
93
+ setActionState(nextState)
94
+ );
95
+ }
96
+
97
+ if (typeof actionContext?.addListeners === "function") {
98
+ return actionContext.addListeners(
99
+ ({ entryState, entry: updatedEntry }) => {
100
+ if (updatedEntry?.id === resolvedEntry?.id) {
101
+ setActionState(entryState);
102
+ }
103
+ }
104
+ );
105
+ }
106
+
107
+ return undefined;
108
+ }, [
109
+ actionContext,
110
+ resolvedEntry?.id,
111
+ resolvedIdentifier,
112
+ actionContext?.addListener,
113
+ actionContext?.addListeners,
114
+ ]);
115
+
116
+ const legacySelected = useMemo(() => {
117
+ if (!actionContext || supportsEntryState) {
118
+ return false;
119
+ }
120
+
121
+ return buildLegacySelection(resolvedEntry, actionContext);
122
+ }, [actionContext, supportsEntryState, resolvedEntry]);
123
+
124
+ const isActive = resolveIsActive(actionState, legacySelected);
125
+
126
+ const onPress = useCallback(async () => {
127
+ if (!actionContext) {
128
+ return;
129
+ }
130
+
131
+ if (supportsEntryState) {
132
+ return actionContext.invokeAction?.(resolvedEntry, {
133
+ updateState: setActionState,
134
+ });
135
+ }
136
+
137
+ const favouritesAction = legacySelected
138
+ ? actionContext.removeFavourite
139
+ : actionContext.addFavourite;
140
+
141
+ const toggleAction = actionContext?.invokeAction ?? favouritesAction;
142
+
143
+ return toggleAction?.(resolvedEntry);
144
+ }, [actionContext, supportsEntryState, resolvedEntry, legacySelected]);
145
+
146
+ if (!actionContext || actionDisabled) {
147
+ if (!actionContext) {
148
+ onMissingActionContext?.(resolvedIdentifier);
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ return (
155
+ <>
156
+ {children({
157
+ actionContext,
158
+ actionState,
159
+ entry: resolvedEntry,
160
+ isActive,
161
+ onPress,
162
+ })}
163
+ </>
164
+ );
165
+ }
@@ -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
+ };