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

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.
@@ -9,7 +9,7 @@ import { ActionExecutionContext, ActionHandler } from "./types";
9
9
  import { batchRemoveAllFromNamespaceForStorage } from "../zappFrameworkUtils/localStorageHelper";
10
10
  import { sessionStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/SessionStorage";
11
11
 
12
- import { useNavigation, useRivers } from "../reactHooks";
12
+ import { useNavigation, usePresentScreen, useRivers } from "../reactHooks";
13
13
  import { useContentTypes } from "@applicaster/zapp-react-native-redux/hooks";
14
14
  import { useSubscriberFor } from "../reactHooks/useSubscriberFor";
15
15
  import { APP_EVENTS } from "../appUtils/events";
@@ -25,6 +25,7 @@ import {
25
25
  localStorageRemoveAction,
26
26
  localStorageSetAction,
27
27
  localStorageToggleFlagAction,
28
+ createPresentScreenAction,
28
29
  openBottomSheetAction,
29
30
  refreshComponentAction,
30
31
  screenSetVariableAction,
@@ -157,6 +158,7 @@ export function withActionExecutor(Component) {
157
158
  const navigator = useNavigation();
158
159
  const rivers = useRivers();
159
160
  const contentTypes = useContentTypes();
161
+ const presentScreen = usePresentScreen();
160
162
 
161
163
  const handlers = useMemo(() => {
162
164
  return {
@@ -191,6 +193,13 @@ export function withActionExecutor(Component) {
191
193
  );
192
194
  }, [navigator, rivers, contentTypes]);
193
195
 
196
+ useEffect(() => {
197
+ return _actionExecutor.registerAction(
198
+ ACTION_TYPES.PRESENT_SCREEN,
199
+ createPresentScreenAction(presentScreen)
200
+ );
201
+ }, [presentScreen]);
202
+
194
203
  useEffect(() => {
195
204
  return _actionExecutor.registerAction(
196
205
  ACTION_TYPES.GO_BACK,
@@ -0,0 +1,127 @@
1
+ import { createPresentScreenAction } from "../presentScreen";
2
+ import { ActionResult } from "../../ActionExecutor";
3
+
4
+ const contextEntry = { id: "context-entry" } as any;
5
+
6
+ const succeeded = { success: true, cancelled: false };
7
+
8
+ const cancelled = { success: false, cancelled: true };
9
+
10
+ describe("presentScreenAction", () => {
11
+ let presentScreen: jest.Mock;
12
+ let presentScreenAction: ReturnType<typeof createPresentScreenAction>;
13
+
14
+ beforeEach(() => {
15
+ presentScreen = jest.fn().mockResolvedValue(succeeded);
16
+ presentScreenAction = createPresentScreenAction(presentScreen);
17
+ });
18
+
19
+ it("presents the screen named by typeMapping", async () => {
20
+ await presentScreenAction({
21
+ type: "presentScreen",
22
+ options: { typeMapping: "parent_lock" },
23
+ });
24
+
25
+ expect(presentScreen).toHaveBeenCalledWith({
26
+ typeMapping: "parent_lock",
27
+ entry: undefined,
28
+ });
29
+ });
30
+
31
+ it("fails without presenting anything when typeMapping is missing", async () => {
32
+ const result = await presentScreenAction({
33
+ type: "presentScreen",
34
+ options: {},
35
+ });
36
+
37
+ expect(result).toBe(ActionResult.Error);
38
+ expect(presentScreen).not.toHaveBeenCalled();
39
+ });
40
+
41
+ it("resolves the '@{entry/}' token to the entry from the execution context", async () => {
42
+ await presentScreenAction(
43
+ {
44
+ type: "presentScreen",
45
+ options: { typeMapping: "parent_lock", entry: "@{entry/}" },
46
+ },
47
+ { entry: contextEntry }
48
+ );
49
+
50
+ expect(presentScreen).toHaveBeenCalledWith({
51
+ typeMapping: "parent_lock",
52
+ entry: contextEntry,
53
+ });
54
+ });
55
+
56
+ it("presents an entry supplied inline in the options", async () => {
57
+ const inlineEntry = { id: "inline-entry" } as any;
58
+
59
+ await presentScreenAction({
60
+ type: "presentScreen",
61
+ options: { typeMapping: "parent_lock", entry: inlineEntry },
62
+ });
63
+
64
+ expect(presentScreen).toHaveBeenCalledWith({
65
+ typeMapping: "parent_lock",
66
+ entry: inlineEntry,
67
+ });
68
+ });
69
+
70
+ it("fails without presenting anything when the entry option is not an object", async () => {
71
+ const result = await presentScreenAction({
72
+ type: "presentScreen",
73
+ options: { typeMapping: "parent_lock", entry: "not-an-entry" as any },
74
+ });
75
+
76
+ expect(result).toBe(ActionResult.Error);
77
+ expect(presentScreen).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("succeeds when the presented screen reports success", async () => {
81
+ presentScreen.mockResolvedValue(succeeded);
82
+
83
+ const result = await presentScreenAction({
84
+ type: "presentScreen",
85
+ options: { typeMapping: "parent_lock" },
86
+ });
87
+
88
+ expect(result).toBe(ActionResult.Success);
89
+ });
90
+
91
+ it("cancels the action chain when the presented screen is cancelled", async () => {
92
+ presentScreen.mockResolvedValue(cancelled);
93
+
94
+ const result = await presentScreenAction({
95
+ type: "presentScreen",
96
+ options: { typeMapping: "parent_lock" },
97
+ });
98
+
99
+ expect(result).toBe(ActionResult.Cancel);
100
+ });
101
+
102
+ it("cancels the action chain when the presented screen reports failure", async () => {
103
+ presentScreen.mockResolvedValue({ success: false, cancelled: false });
104
+
105
+ const result = await presentScreenAction({
106
+ type: "presentScreen",
107
+ options: { typeMapping: "parent_lock" },
108
+ });
109
+
110
+ expect(result).toBe(ActionResult.Cancel);
111
+ });
112
+
113
+ it("errors when the screen could not be presented at all", async () => {
114
+ presentScreen.mockResolvedValue({
115
+ success: false,
116
+ cancelled: false,
117
+ error: "can not resolve screen type mapping: parent_lock",
118
+ });
119
+
120
+ const result = await presentScreenAction({
121
+ type: "presentScreen",
122
+ options: { typeMapping: "parent_lock" },
123
+ });
124
+
125
+ expect(result).toBe(ActionResult.Error);
126
+ });
127
+ });
@@ -27,6 +27,8 @@ export { screenToggleFlagAction } from "./screenToggleFlag";
27
27
 
28
28
  export { createNavigateToScreenAction } from "./navigateToScreen";
29
29
 
30
+ export { createPresentScreenAction } from "./presentScreen";
31
+
30
32
  export { createGoBackAction } from "./goBack";
31
33
 
32
34
  export { createGoHomeAction } from "./goHome";
@@ -0,0 +1,78 @@
1
+ /// <reference types="@applicaster/applicaster-types" />
2
+ import { ActionResult } from "../ActionExecutor";
3
+ import { ActionHandler, ActionExecutionContext } from "../types";
4
+ import type { PresentScreenFn } from "../../reactHooks/navigation/usePresentScreen";
5
+ import { createLogger } from "../../logger";
6
+
7
+ const { log_error } = createLogger({
8
+ subsystem: "ActionExecutorContext",
9
+ category: "General",
10
+ });
11
+
12
+ /**
13
+ * Options for the presentScreen action.
14
+ */
15
+ export interface PresentScreenOptions {
16
+ /** The screen type to present (from content types mapping) */
17
+ typeMapping: string;
18
+
19
+ /**
20
+ * Entry to present the screen with. Can be:
21
+ * - "@{entry/}" to use the context entry
22
+ * - A ZappEntry object
23
+ * - undefined to present the screen without an entry
24
+ */
25
+ entry?: "@{entry/}" | ZappEntry;
26
+ }
27
+
28
+ /**
29
+ * Creates a present-screen action handler with the provided dependencies.
30
+ *
31
+ * Unlike navigateToScreen, this action does not return until the presented
32
+ * screen reports a result, so a screen that gates the flow (a parental lock,
33
+ * for example) can stop the remaining actions by being cancelled.
34
+ *
35
+ * @param presentScreen - Presents a screen as a modal and resolves with its result
36
+ * @returns An action handler that presents a screen and awaits its outcome
37
+ */
38
+ export function createPresentScreenAction(
39
+ presentScreen: PresentScreenFn
40
+ ): ActionHandler<PresentScreenOptions> {
41
+ return async function presentScreenAction(
42
+ action,
43
+ context?: ActionExecutionContext
44
+ ): Promise<ActionResult> {
45
+ const typeMapping = action.options?.typeMapping;
46
+
47
+ if (!typeMapping) {
48
+ log_error("presentScreen: typeMapping option is missing");
49
+
50
+ return ActionResult.Error;
51
+ }
52
+
53
+ const entrySource = action.options?.entry;
54
+
55
+ const entry = entrySource
56
+ ? entrySource === "@{entry/}"
57
+ ? context?.entry
58
+ : entrySource
59
+ : undefined;
60
+
61
+ if (entry && typeof entry !== "object") {
62
+ log_error(
63
+ `presentScreen: entry option is not an object, entry: ${entry}`
64
+ );
65
+
66
+ return ActionResult.Error;
67
+ }
68
+
69
+ const result = await presentScreen({ typeMapping, entry });
70
+
71
+ if (result.error) {
72
+ return ActionResult.Error;
73
+ }
74
+
75
+ // Anything short of success stops the remaining actions in the chain.
76
+ return result.success ? ActionResult.Success : ActionResult.Cancel;
77
+ };
78
+ }
@@ -9,6 +9,7 @@ export const ACTION_TYPES = {
9
9
  PRESENT_SUB_MENU: "presentSubMenu",
10
10
  SEND_CLOUD_EVENT: "sendCloudEvent",
11
11
  NAVIGATE_TO_SCREEN: "navigateToScreen",
12
+ PRESENT_SCREEN: "presentScreen",
12
13
  GO_BACK: "goBack",
13
14
  GO_HOME: "goHome",
14
15
  SHOW_TOAST: "showToast",
@@ -176,6 +176,27 @@ describe("getAllSpecificStyles", () => {
176
176
  });
177
177
  });
178
178
 
179
+ it("should normalize Studio focus alias onto focused state", () => {
180
+ const outStyles = {};
181
+
182
+ const configuration = {
183
+ button_style_focus_background_color: "#FF3B30",
184
+ };
185
+
186
+ getAllSpecificStyles({
187
+ componentName: "button",
188
+ subComponentName: "",
189
+ configuration,
190
+ outStyles,
191
+ });
192
+
193
+ expect(outStyles.focused).toEqual({
194
+ backgroundColor: "#FF3B30",
195
+ });
196
+
197
+ expect(outStyles.focus).toBeUndefined();
198
+ });
199
+
179
200
  it("should handle selected state", () => {
180
201
  const outStyles = {};
181
202
 
@@ -104,4 +104,157 @@ describe("parseModalBlocksConfiguration", () => {
104
104
  expect(result.button.title).toEqual({ default: {} });
105
105
  expect(result.action.leadingIcon).toEqual({ default: {} });
106
106
  });
107
+
108
+ it("should map Studio item.background.focus.* onto focused state", () => {
109
+ const result = parseModalBlocksConfiguration(
110
+ {
111
+ "item.background.default.background_color": "transparent",
112
+ "item.background.focus.background_color": "#FF3B30",
113
+ },
114
+ "playlist-experience"
115
+ );
116
+
117
+ expect(result.item.background.default).toEqual(
118
+ expect.objectContaining({
119
+ backgroundColor: "transparent",
120
+ })
121
+ );
122
+
123
+ expect(result.item.background.focused).toEqual(
124
+ expect.objectContaining({
125
+ backgroundColor: "#FF3B30",
126
+ })
127
+ );
128
+ });
129
+
130
+ it("should accept Studio text_label1 keys without underscore before digit", () => {
131
+ const result = parseModalBlocksConfiguration(
132
+ {
133
+ "item.text_label1.font_color": "#00FF00",
134
+ "item.text_label1.font_size": 28,
135
+ "item.text_label2.font_color": "#00FFFF",
136
+ },
137
+ "playlist-experience"
138
+ );
139
+
140
+ expect(result.item.textLabel1.default).toEqual(
141
+ expect.objectContaining({
142
+ fontColor: "#00FF00",
143
+ fontSize: 28,
144
+ })
145
+ );
146
+
147
+ expect(result.item.textLabel2.default).toEqual(
148
+ expect.objectContaining({
149
+ fontColor: "#00FFFF",
150
+ })
151
+ );
152
+ });
153
+
154
+ it("should map button.create_new_playlist_asset onto leadingIcon.asset", () => {
155
+ const result = parseModalBlocksConfiguration(
156
+ {
157
+ "button.create_new_playlist_asset":
158
+ "https://placehold.co/24x24/FF00FF/FFFFFF/png?text=CN",
159
+ },
160
+ "playlist-experience"
161
+ );
162
+
163
+ expect(result.button.leadingIcon.default).toEqual(
164
+ expect.objectContaining({
165
+ asset: "https://placehold.co/24x24/FF00FF/FFFFFF/png?text=CN",
166
+ })
167
+ );
168
+ });
169
+
170
+ it("should map item.checkbox_asset and checked_checkbox_asset onto item.checkbox", () => {
171
+ const result = parseModalBlocksConfiguration(
172
+ {
173
+ "item.checkbox_asset":
174
+ "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=CB",
175
+ "item.checked_checkbox_asset":
176
+ "https://placehold.co/24x24/FFD60A/000000/png?text=CK",
177
+ },
178
+ "playlist-experience"
179
+ );
180
+
181
+ expect(result.item.checkbox.default).toEqual(
182
+ expect.objectContaining({
183
+ asset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=CB",
184
+ checkedAsset: "https://placehold.co/24x24/FFD60A/000000/png?text=CK",
185
+ })
186
+ );
187
+ });
188
+
189
+ it("should map reorder/remove Studio assets onto item.actionButtons", () => {
190
+ const result = parseModalBlocksConfiguration(
191
+ {
192
+ "reorder_playlist_item.remove_from_playlist_asset":
193
+ "https://placehold.co/24x24/00FF00/000000/png?text=RM",
194
+ "reorder_playlist_item.reorder_handle_asset":
195
+ "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
196
+ },
197
+ "playlist-experience"
198
+ );
199
+
200
+ expect(result.item.actionButtons.default).toEqual(
201
+ expect.objectContaining({
202
+ removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
203
+ dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
204
+ })
205
+ );
206
+ });
207
+
208
+ it("should fall back to flavor1 reorder/remove assets", () => {
209
+ const result = parseModalBlocksConfiguration(
210
+ {
211
+ "playlist_experience_assets_flavor1.remove_from_playlist_asset":
212
+ "https://placehold.co/24x24/00FF00/000000/png?text=RM",
213
+ "playlist_experience_assets_flavor1.reorder_handle_asset":
214
+ "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
215
+ },
216
+ "playlist-experience"
217
+ );
218
+
219
+ expect(result.item.actionButtons.default).toEqual(
220
+ expect.objectContaining({
221
+ removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
222
+ dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
223
+ })
224
+ );
225
+ });
226
+
227
+ it("should fall back to flavor2 reorder/remove assets", () => {
228
+ const result = parseModalBlocksConfiguration(
229
+ {
230
+ "playlist_experience_assets_flavor2.remove_from_playlist_asset":
231
+ "https://placehold.co/24x24/00FF00/000000/png?text=RM",
232
+ "playlist_experience_assets_flavor2.reorder_handle_asset":
233
+ "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
234
+ },
235
+ "playlist-experience"
236
+ );
237
+
238
+ expect(result.item.actionButtons.default).toEqual(
239
+ expect.objectContaining({
240
+ removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
241
+ dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
242
+ })
243
+ );
244
+ });
245
+
246
+ it("should ignore playlist-only asset keys for other theme plugins", () => {
247
+ const result = parseModalBlocksConfiguration(
248
+ {
249
+ "button.create_new_playlist_asset":
250
+ "https://placehold.co/24x24/FF00FF/FFFFFF/png?text=CN",
251
+ "playlist_experience_assets_flavor1.reorder_handle_asset":
252
+ "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
253
+ },
254
+ "queue-action"
255
+ );
256
+
257
+ expect(result.button.leadingIcon.default?.asset).toBeUndefined();
258
+ expect(result.item.actionButtons.default?.dragAsset).toBeUndefined();
259
+ });
107
260
  });
@@ -11,11 +11,17 @@ const currentPlatform = platformSelect({
11
11
  android_tv: "android",
12
12
  });
13
13
 
14
- // Do not change the order, focused_selected should be first
14
+ // Do not change the order: focused_selected must be first, and "focus" must
15
+ // come *after* "focused". Matching uses startsWith(state + "_"), so putting
16
+ // "focus" first would steal focused_* / focused_selected_* keys. Adding
17
+ // "focus" here affects every getAllSpecificStyles consumer (not only modals);
18
+ // property names that themselves start with focus_ (e.g. focus_font_color as
19
+ // a non-state style) would also be classified as the focus state.
15
20
  const states = [
16
21
  "focused_selected",
17
22
  "pressed",
18
23
  "focused",
24
+ "focus",
19
25
  "selected",
20
26
  "default",
21
27
  ];
@@ -112,6 +118,11 @@ export const getAllSpecificStyles = ({
112
118
  state = defaultKey;
113
119
  }
114
120
 
121
+ // Normalize Studio "focus" keys onto the runtime "focused" state bucket.
122
+ if (state === "focus") {
123
+ state = "focused";
124
+ }
125
+
115
126
  if (!styleName) return;
116
127
 
117
128
  const camelCaseKey = toCamelCase(styleName);
@@ -25,6 +25,10 @@ export type ModalBlocksConfig = {
25
25
  buttonsContainer: Record<string, any>;
26
26
  button1: Record<string, any>;
27
27
  button2: Record<string, any>;
28
+ /** Studio item.checkbox_asset / item.checked_checkbox_asset */
29
+ checkbox?: Record<string, any>;
30
+ /** Studio reorder/remove icons for Edit Order rows */
31
+ actionButtons?: Record<string, any>;
28
32
  };
29
33
  nowPlayingItem?: {
30
34
  background: Record<string, any>;
@@ -37,6 +41,8 @@ export type ModalBlocksConfig = {
37
41
  buttonsContainer: Record<string, any>;
38
42
  button1: Record<string, any>;
39
43
  button2: Record<string, any>;
44
+ checkbox?: Record<string, any>;
45
+ actionButtons?: Record<string, any>;
40
46
  };
41
47
  button: {
42
48
  background: Record<string, any>;
@@ -75,6 +81,8 @@ export const parseModalBlocksConfiguration = (
75
81
  buttonsContainer: {},
76
82
  button1: {},
77
83
  button2: {},
84
+ checkbox: {},
85
+ actionButtons: {},
78
86
  },
79
87
  nowPlayingItem: {
80
88
  background: {},
@@ -87,6 +95,8 @@ export const parseModalBlocksConfiguration = (
87
95
  buttonsContainer: {},
88
96
  button1: {},
89
97
  button2: {},
98
+ checkbox: {},
99
+ actionButtons: {},
90
100
  },
91
101
  button: {
92
102
  background: {},
@@ -152,8 +162,12 @@ export const parseModalBlocksConfiguration = (
152
162
  "now_playing_container",
153
163
  "now_playing_asset",
154
164
  "now_playing_label",
165
+ // Underscored form (QB convention) and Studio/manifest-generator form
166
+ // without the underscore before the digit (text_label1).
155
167
  "text_label_1",
156
168
  "text_label_2",
169
+ "text_label1",
170
+ "text_label2",
157
171
  "buttons_container",
158
172
  "button_1",
159
173
  "button_2",
@@ -167,6 +181,8 @@ export const parseModalBlocksConfiguration = (
167
181
  "now_playing_label",
168
182
  "text_label_1",
169
183
  "text_label_2",
184
+ "text_label1",
185
+ "text_label2",
170
186
  "buttons_container",
171
187
  "button_1",
172
188
  "button_2",
@@ -174,5 +190,107 @@ export const parseModalBlocksConfiguration = (
174
190
 
175
191
  parseBlock("button", ["background", "title", "icons", "leading_icon"]);
176
192
 
193
+ // Playlist-experience Studio keys live in this shared parser because modal
194
+ // blocks are themed by themePluginId. Gate them so other plugins are not
195
+ // affected by playlist-only asset names.
196
+ const isPlaylistExperience =
197
+ normalizedComponentName === "playlist_experience";
198
+
199
+ // Studio uploads Create New icon as button.create_new_playlist_asset (not
200
+ // button.leading_icon.asset). Map it onto leadingIcon so AudioPlayerButton
201
+ // can override the hardcoded action-type asset.
202
+ const createNewPlaylistAsset = isPlaylistExperience
203
+ ? configuration["button.create_new_playlist_asset"] ||
204
+ configuration[
205
+ `${normalizedComponentName}_button_create_new_playlist_asset`
206
+ ]
207
+ : undefined;
208
+
209
+ if (createNewPlaylistAsset) {
210
+ if (!result.button.leadingIcon.default) {
211
+ result.button.leadingIcon.default = {};
212
+ }
213
+
214
+ result.button.leadingIcon.default.asset = createNewPlaylistAsset;
215
+ }
216
+
217
+ // Studio checkbox assets are top-level item.* keys (not a parseBlock
218
+ // sub-component). Map onto item.checkbox so multiSelect can render them.
219
+ const checkboxAsset =
220
+ configuration["item.checkbox_asset"] ||
221
+ configuration[`${normalizedComponentName}_item_checkbox_asset`];
222
+
223
+ const checkedCheckboxAsset =
224
+ configuration["item.checked_checkbox_asset"] ||
225
+ configuration[`${normalizedComponentName}_item_checked_checkbox_asset`];
226
+
227
+ if (checkboxAsset || checkedCheckboxAsset) {
228
+ if (!result.item.checkbox.default) {
229
+ result.item.checkbox.default = {};
230
+ }
231
+
232
+ if (checkboxAsset) {
233
+ result.item.checkbox.default.asset = checkboxAsset;
234
+ }
235
+
236
+ if (checkedCheckboxAsset) {
237
+ result.item.checkbox.default.checkedAsset = checkedCheckboxAsset;
238
+ }
239
+ }
240
+
241
+ // Edit Order row icons: prefer reorder_playlist_item.*, then flavor1, then
242
+ // flavor2 playlist_experience_assets_* (playlist-experience theme only).
243
+ const removeAsset = isPlaylistExperience
244
+ ? configuration["reorder_playlist_item.remove_from_playlist_asset"] ||
245
+ configuration[
246
+ `${normalizedComponentName}_reorder_playlist_item_remove_from_playlist_asset`
247
+ ] ||
248
+ configuration[
249
+ "playlist_experience_assets_flavor1.remove_from_playlist_asset"
250
+ ] ||
251
+ configuration[
252
+ `${normalizedComponentName}_assets_flavor1_remove_from_playlist_asset`
253
+ ] ||
254
+ configuration[
255
+ "playlist_experience_assets_flavor2.remove_from_playlist_asset"
256
+ ] ||
257
+ configuration[
258
+ `${normalizedComponentName}_assets_flavor2_remove_from_playlist_asset`
259
+ ]
260
+ : undefined;
261
+
262
+ const dragAsset = isPlaylistExperience
263
+ ? configuration["reorder_playlist_item.reorder_handle_asset"] ||
264
+ configuration[
265
+ `${normalizedComponentName}_reorder_playlist_item_reorder_handle_asset`
266
+ ] ||
267
+ configuration[
268
+ "playlist_experience_assets_flavor1.reorder_handle_asset"
269
+ ] ||
270
+ configuration[
271
+ `${normalizedComponentName}_assets_flavor1_reorder_handle_asset`
272
+ ] ||
273
+ configuration[
274
+ "playlist_experience_assets_flavor2.reorder_handle_asset"
275
+ ] ||
276
+ configuration[
277
+ `${normalizedComponentName}_assets_flavor2_reorder_handle_asset`
278
+ ]
279
+ : undefined;
280
+
281
+ if (removeAsset || dragAsset) {
282
+ if (!result.item.actionButtons.default) {
283
+ result.item.actionButtons.default = {};
284
+ }
285
+
286
+ if (removeAsset) {
287
+ result.item.actionButtons.default.removeAsset = removeAsset;
288
+ }
289
+
290
+ if (dragAsset) {
291
+ result.item.actionButtons.default.dragAsset = dragAsset;
292
+ }
293
+ }
294
+
177
295
  return result;
178
296
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-rc.80",
3
+ "version": "16.0.0-rc.82",
4
4
  "description": "Applicaster Zapp React Native utilities package",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "homepage": "https://github.com/applicaster/quickbrick#readme",
29
29
  "dependencies": {
30
- "@applicaster/applicaster-types": "16.0.0-rc.80",
30
+ "@applicaster/applicaster-types": "16.0.0-rc.82",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -7,6 +7,7 @@ import configureMockStore from "redux-mock-store";
7
7
  import { NavigationContext } from "@applicaster/zapp-react-native-ui-components/Contexts/NavigationContext";
8
8
  import { ROUTE_TYPES } from "@applicaster/zapp-react-native-utils/navigationUtils/routeTypes";
9
9
  import { ScreenDataContext } from "@applicaster/zapp-react-native-ui-components/Contexts/ScreenDataContext";
10
+ import { modalStore } from "@applicaster/zapp-react-native-utils/modalState";
10
11
 
11
12
  const plugins = [];
12
13
 
@@ -156,6 +157,20 @@ const videoModalWrapper = ({ children }) => (
156
157
  </Provider>
157
158
  );
158
159
 
160
+ const modalEntry = { id: "modal-entry-1", type: { value: "video" } };
161
+
162
+ const modalPathname = `${ROUTE_TYPES.MODAL}/${rivers["river-general"].id}`;
163
+
164
+ const modalWrapper = ({ children }) => (
165
+ <Provider store={store}>
166
+ <NavigationContext.Provider value={mainStackNavigator}>
167
+ <PathnameContext.Provider value={modalPathname}>
168
+ {children}
169
+ </PathnameContext.Provider>
170
+ </NavigationContext.Provider>
171
+ </Provider>
172
+ );
173
+
159
174
  const hookModalWrapper = ({ children }) => (
160
175
  <Provider store={store}>
161
176
  <NavigationContext.Provider value={hookModalNavigator}>
@@ -188,6 +203,39 @@ describe("navigation", () => {
188
203
  });
189
204
  });
190
205
 
206
+ describe("Modal components", () => {
207
+ beforeEach(() => {
208
+ modalStore.getState().openModal({
209
+ item: rivers["river-general"] as any,
210
+ props: { entry: modalEntry },
211
+ });
212
+ });
213
+
214
+ afterEach(() => {
215
+ modalStore.getState().dismissModal();
216
+ });
217
+
218
+ it("should return a correct pathname", () => {
219
+ const { result } = renderHook(() => useRoute(), {
220
+ wrapper: modalWrapper,
221
+ });
222
+
223
+ expect(result.current.pathname).toEqual(modalPathname);
224
+ });
225
+
226
+ it("should return correct screenData information", () => {
227
+ const { result } = renderHook(() => useRoute(), {
228
+ wrapper: modalWrapper,
229
+ });
230
+
231
+ expect(result.current.screenData.id).toEqual(modalEntry.id);
232
+
233
+ expect(result.current.screenData.targetScreen.id).toEqual(
234
+ rivers["river-general"].id
235
+ );
236
+ });
237
+ });
238
+
191
239
  describe("Hook modal components", () => {
192
240
  it("should return a correct pathname", () => {
193
241
  const { result } = renderHook(() => useRoute(), {
@@ -0,0 +1,154 @@
1
+ import { modalStore } from "../../../modalState";
2
+ import { isWeb } from "../../../reactUtils";
3
+ import { presentScreen } from "../usePresentScreen";
4
+
5
+ jest.mock("../../../reactUtils", () => ({
6
+ ...jest.requireActual("../../../reactUtils"),
7
+ isWeb: jest.fn(() => false),
8
+ }));
9
+
10
+ const river = { id: "river-parent-lock", type: "parent_lock" } as any;
11
+
12
+ const rivers = { "river-parent-lock": river };
13
+
14
+ const contentTypes = { parent_lock: { screen_id: "river-parent-lock" } };
15
+
16
+ const present = (args = {}) =>
17
+ presentScreen({ typeMapping: "parent_lock", rivers, contentTypes, ...args });
18
+
19
+ const modalState = () => modalStore.getState().modalState;
20
+
21
+ const screenCallback = () => modalState().props?.resultCallback as hookCallback;
22
+
23
+ describe("presentScreen", () => {
24
+ beforeEach(() => {
25
+ jest.clearAllMocks();
26
+ (isWeb as jest.Mock).mockReturnValue(false);
27
+ modalStore.getState().dismissModal();
28
+ });
29
+
30
+ it("opens a modal with the river resolved from typeMapping", () => {
31
+ void present();
32
+
33
+ expect(modalState().visible).toBe(true);
34
+ expect(modalState().screen).toBe(river);
35
+ });
36
+
37
+ it("resolves with success when the presented screen reports success", async () => {
38
+ const result = present();
39
+
40
+ screenCallback()({ success: true, payload: { verified: true } });
41
+
42
+ await expect(result).resolves.toMatchObject({
43
+ success: true,
44
+ cancelled: false,
45
+ payload: { verified: true },
46
+ });
47
+ });
48
+
49
+ it("dismisses the modal once the presented screen reports a result", async () => {
50
+ const result = present();
51
+
52
+ screenCallback()({ success: true, payload: {} });
53
+ await result;
54
+
55
+ expect(modalState().visible).toBe(false);
56
+ });
57
+
58
+ it("resolves as cancelled when the modal is dismissed without a result", async () => {
59
+ const result = present();
60
+
61
+ // What the hardware back button does: RN Modal's onRequestClose resets modal state.
62
+ modalStore.getState().dismissModal();
63
+
64
+ await expect(result).resolves.toMatchObject({
65
+ success: false,
66
+ cancelled: true,
67
+ });
68
+ });
69
+
70
+ it("resolves as cancelled when the presented screen reports cancellation", async () => {
71
+ const result = present();
72
+
73
+ screenCallback()({ success: false, payload: {}, cancelled: true });
74
+
75
+ await expect(result).resolves.toMatchObject({
76
+ success: false,
77
+ cancelled: true,
78
+ });
79
+ });
80
+
81
+ it("resolves as cancelled when another modal replaces the presented screen", async () => {
82
+ const result = present();
83
+
84
+ modalStore.getState().openModal({ item: { id: "other-modal" } as any });
85
+
86
+ await expect(result).resolves.toMatchObject({ cancelled: true });
87
+ });
88
+
89
+ it("leaves the modal that replaced the presented screen open", async () => {
90
+ const result = present();
91
+ const other = { id: "other-modal" } as any;
92
+
93
+ modalStore.getState().openModal({ item: other });
94
+ await result;
95
+
96
+ expect(modalState().screen).toBe(other);
97
+ expect(modalState().visible).toBe(true);
98
+ });
99
+
100
+ it("ignores a result reported after the modal was already cancelled", async () => {
101
+ const result = present();
102
+ const callback = screenCallback();
103
+
104
+ modalStore.getState().dismissModal();
105
+ callback({ success: true, payload: {} });
106
+
107
+ await expect(result).resolves.toMatchObject({
108
+ success: false,
109
+ cancelled: true,
110
+ });
111
+ });
112
+
113
+ it("hands the entry to the ModalProvider so it reaches ScreenDataContext", () => {
114
+ const entry = { id: "entry-1", type: { value: "video" } } as any;
115
+
116
+ void present({ entry });
117
+
118
+ expect(modalState().props?.entry).toBe(entry);
119
+ });
120
+
121
+ it("omits the entry when the screen is presented without one", () => {
122
+ void present();
123
+
124
+ expect(modalState().props).not.toHaveProperty("entry");
125
+ });
126
+
127
+ it("fails without opening a modal when typeMapping has no content type", async () => {
128
+ const result = await present({ typeMapping: "unmapped" });
129
+
130
+ expect(result).toMatchObject({ success: false, cancelled: false });
131
+ expect(result.error).toEqual(expect.any(String));
132
+ expect(modalState().visible).toBe(false);
133
+ });
134
+
135
+ it("fails without opening a modal when the content type resolves to a missing river", async () => {
136
+ const result = await present({
137
+ contentTypes: { parent_lock: { screen_id: "gone" } },
138
+ });
139
+
140
+ expect(result).toMatchObject({ success: false, cancelled: false });
141
+ expect(result.error).toEqual(expect.any(String));
142
+ expect(modalState().visible).toBe(false);
143
+ });
144
+
145
+ it("fails without opening a modal on web, where no modal host exists", async () => {
146
+ (isWeb as jest.Mock).mockReturnValue(true);
147
+
148
+ const result = await present();
149
+
150
+ expect(result).toMatchObject({ success: false, cancelled: false });
151
+ expect(result.error).toEqual(expect.any(String));
152
+ expect(modalState().visible).toBe(false);
153
+ });
154
+ });
@@ -22,6 +22,8 @@ import { useLayoutVersion } from "../layout";
22
22
 
23
23
  export { useNavigation } from "./useNavigation";
24
24
 
25
+ export { usePresentScreen, presentScreen } from "./usePresentScreen";
26
+
25
27
  export { useRoute } from "./useRoute";
26
28
 
27
29
  export { usePathname } from "./usePathname";
@@ -0,0 +1,157 @@
1
+ /// <reference types="@applicaster/applicaster-types" />
2
+ import { useCallback } from "react";
3
+
4
+ import { useContentTypes } from "@applicaster/zapp-react-native-redux/hooks";
5
+
6
+ import { modalStore } from "../../modalState";
7
+ import { isWeb } from "../../reactUtils";
8
+ import { createLogger } from "../../logger";
9
+ import { useRivers } from "../state";
10
+
11
+ const { log_error, log_info } = createLogger({
12
+ subsystem: "usePresentScreen",
13
+ category: "General",
14
+ });
15
+
16
+ export interface PresentScreenArgs {
17
+ /** The screen type to present (from content types mapping) */
18
+ typeMapping: string;
19
+
20
+ /** Entry to present the screen with. Omit to present the screen on its own. */
21
+ entry?: ZappEntry;
22
+ }
23
+
24
+ export interface PresentScreenResult {
25
+ /** The presented screen completed successfully */
26
+ success: boolean;
27
+
28
+ /** The presented screen was dismissed without completing (e.g. the back button) */
29
+ cancelled: boolean;
30
+
31
+ /** Set when the screen could not be presented at all */
32
+ error?: string;
33
+
34
+ /** Whatever the presented screen reported back */
35
+ payload?: unknown;
36
+ }
37
+
38
+ export type PresentScreenFn = (
39
+ args: PresentScreenArgs
40
+ ) => Promise<PresentScreenResult>;
41
+
42
+ interface Dependencies {
43
+ rivers: Record<string, ZappRiver>;
44
+ contentTypes: ZappContentTypes;
45
+ }
46
+
47
+ const failure = (error: string): Promise<PresentScreenResult> => {
48
+ log_error(`presentScreen: ${error}`);
49
+
50
+ return Promise.resolve({ success: false, cancelled: false, error });
51
+ };
52
+
53
+ /**
54
+ * Presents a screen as a modal and resolves once that screen reports a result
55
+ * or is dismissed.
56
+ *
57
+ * The screen is rendered by the ModalProvider through the regular ScreenResolver,
58
+ * so it receives the `resultCallback` we pass here as its `callback` prop — the
59
+ * same channel hook screens already use to report success and cancellation.
60
+ */
61
+ export const presentScreen = ({
62
+ typeMapping,
63
+ entry,
64
+ rivers,
65
+ contentTypes,
66
+ }: PresentScreenArgs & Dependencies): Promise<PresentScreenResult> => {
67
+ if (!typeMapping) {
68
+ return failure("typeMapping option is missing");
69
+ }
70
+
71
+ // The web ModalProvider renders nothing, so no result could ever arrive and
72
+ // awaiting one would stall the caller indefinitely.
73
+ if (isWeb()) {
74
+ return failure("screens can not be presented as modals on web");
75
+ }
76
+
77
+ const screenId = contentTypes?.[typeMapping]?.screen_id;
78
+
79
+ if (!screenId) {
80
+ return failure(`can not resolve screen type mapping: ${typeMapping}`);
81
+ }
82
+
83
+ const river = rivers?.[screenId];
84
+
85
+ if (!river) {
86
+ return failure(`can not resolve river for screen id: ${screenId}`);
87
+ }
88
+
89
+ return new Promise<PresentScreenResult>((resolve) => {
90
+ let settled = false;
91
+ let unsubscribe: () => void = () => {};
92
+
93
+ const settle = (result: PresentScreenResult) => {
94
+ if (settled) {
95
+ return;
96
+ }
97
+
98
+ settled = true;
99
+ unsubscribe();
100
+ resolve(result);
101
+ };
102
+
103
+ const resultCallback = ({
104
+ success,
105
+ cancelled,
106
+ payload,
107
+ }: hookCallbackArgs) =>
108
+ settle({ success: !!success, cancelled: !!cancelled, payload });
109
+
110
+ const props: Record<string, unknown> = { resultCallback };
111
+
112
+ if (entry) {
113
+ // The ModalProvider turns this into the screen's ScreenDataContext and
114
+ // screenData, so the presented screen reads the entry exactly as it
115
+ // would when navigated to.
116
+ props.entry = entry;
117
+ }
118
+
119
+ log_info(
120
+ `presentScreen: presenting screen type: ${typeMapping}${
121
+ entry ? ` with entry id: ${entry.id}` : ""
122
+ }`
123
+ );
124
+
125
+ modalStore.getState().openModal({ item: river, props });
126
+
127
+ // Covers every dismissal we do not initiate ourselves: the hardware back
128
+ // button (via the Modal's onRequestClose), a swipe dismiss, another modal
129
+ // taking our slot, or dismissModal() called from anywhere else.
130
+ unsubscribe = modalStore.subscribe(({ modalState }) => {
131
+ if (!modalState.visible || modalState.screen !== river) {
132
+ settle({ success: false, cancelled: true });
133
+ }
134
+ });
135
+ }).then((result) => {
136
+ // Only take down our own modal — by now something else may already own the slot.
137
+ if (modalStore.getState().modalState.screen === river) {
138
+ modalStore.getState().dismissModal();
139
+ }
140
+
141
+ return result;
142
+ });
143
+ };
144
+
145
+ /**
146
+ * @returns a function presenting a screen as a modal, resolving with its result.
147
+ */
148
+ export const usePresentScreen = (): PresentScreenFn => {
149
+ const rivers = useRivers();
150
+ const contentTypes = useContentTypes();
151
+
152
+ return useCallback(
153
+ (args: PresentScreenArgs) =>
154
+ presentScreen({ ...args, rivers, contentTypes }),
155
+ [rivers, contentTypes]
156
+ );
157
+ };
@@ -80,7 +80,20 @@ export const useRoute = (
80
80
  // if path is hook grab screenData from screenData
81
81
 
82
82
  if (isModalPathname(pathname)) {
83
- const screenData = modalScreenData ?? ({} as ZappEntry);
83
+ if (!modalScreenData) {
84
+ return { screenData: {} as ZappEntry, pathname };
85
+ }
86
+
87
+ // Mirror the pushed-screen shape ({ ...entry, targetScreen }) so a screen
88
+ // presented as a modal reads its entry the same way it would when
89
+ // navigated to, instead of only seeing the bare river.
90
+ const screenData = legacyScreenData(
91
+ {
92
+ screen: modalScreenData,
93
+ entry: modalState.props?.entry as NavigatorEntry,
94
+ },
95
+ plugins
96
+ );
84
97
 
85
98
  return { screenData, pathname };
86
99
  }