@applicaster/zapp-react-native-utils 16.0.0-alpha.1929433645 → 16.0.0-alpha.2105365027

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 (46) hide show
  1. package/actionUtils/index.ts +3 -1
  2. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +139 -5
  3. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +24 -0
  4. package/actionsExecutor/actions/navigateToScreen.ts +29 -45
  5. package/actionsExecutor/actions/openBottomSheet.ts +1 -0
  6. package/appUtils/playerManager/__tests__/playerNative.test.ts +76 -0
  7. package/appUtils/playerManager/__tests__/playerNativeCommand.test.ts +62 -0
  8. package/appUtils/playerManager/__tests__/playerToolbox.test.ts +97 -0
  9. package/appUtils/playerManager/__tests__/usePlayerControllerSetup.test.tsx +62 -0
  10. package/appUtils/playerManager/index.ts +7 -0
  11. package/appUtils/playerManager/player.ts +1 -0
  12. package/appUtils/playerManager/playerNative.ts +22 -0
  13. package/appUtils/playerManager/playerNativeCommand.ts +2 -6
  14. package/appUtils/playerManager/playerToolbox.ts +157 -0
  15. package/appUtils/playerManager/usePlayerControllerSetup.tsx +6 -0
  16. package/arrayUtils/__tests__/reorderByIds.test.ts +13 -0
  17. package/arrayUtils/index.ts +26 -43
  18. package/cellUtils/__tests__/getButtonsEnabled.test.ts +28 -0
  19. package/cellUtils/__tests__/getCellNavigationEnabled.test.ts +36 -0
  20. package/cellUtils/__tests__/getIsCellNavigationEnabled.test.ts +35 -0
  21. package/cellUtils/__tests__/isCellNavigationEnabled.test.ts +21 -0
  22. package/cellUtils/index.ts +74 -19
  23. package/configurationUtils/__tests__/modalBlocksParser.test.ts +123 -0
  24. package/configurationUtils/index.ts +77 -73
  25. package/configurationUtils/modalBlocksParser.ts +38 -0
  26. package/focusManager/FocusManager.ts +26 -0
  27. package/focusManager/__tests__/FocusManager.test.ts +9 -0
  28. package/localizationUtils/index.ts +12 -6
  29. package/logger/__tests__/xrayToolbox.test.ts +69 -0
  30. package/logger/xrayToolbox.ts +67 -0
  31. package/manifestUtils/defaultManifestConfigurations/player.js +8 -0
  32. package/manifestUtils/keys.js +6 -1
  33. package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
  34. package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +1 -1
  35. package/manifestUtils/mobileAction/groups/defaults.js +2 -1
  36. package/navigationUtils/xrayNavigator.ts +12 -0
  37. package/package.json +2 -2
  38. package/perfTracker.ts +203 -0
  39. package/reactHooks/__tests__/getPluginConfiguration.test.ts +82 -0
  40. package/reactHooks/connection/index.tsx +8 -5
  41. package/reactHooks/feed/__tests__/useMarkPipesDataStale.test.tsx +243 -0
  42. package/reactHooks/feed/useMarkPipesDataStale.ts +68 -23
  43. package/reactHooks/usePluginConfiguration.ts +84 -46
  44. package/riverComponetsMeasurementProvider/index.tsx +8 -1
  45. package/uiActionsRegistrator/__tests__/subscribe.test.ts +26 -0
  46. package/uiActionsRegistrator/registry.ts +16 -0
@@ -257,6 +257,69 @@ describe("getEmptyModalBlocksStructure", () => {
257
257
  );
258
258
  });
259
259
 
260
+ it("maps text label and now playing font keys onto the label style", () => {
261
+ const result = parseModalBlocksConfiguration(
262
+ {
263
+ "item.text_label1.ios.font_family": "SFProText-Bold",
264
+ "item.text_label1.letter_spacing": -0.4,
265
+ "item.text_label1.text_transform": "uppercase",
266
+ "now_playing_item.now_playing_label.ios.font_family":
267
+ "SFProText-Semibold",
268
+ "now_playing_item.now_playing_label.font_size": 18,
269
+ },
270
+ "queue-action"
271
+ );
272
+
273
+ expect(result.item.textLabel1.default).toEqual(
274
+ expect.objectContaining({
275
+ fontFamily: "SFProText-Bold",
276
+ letterSpacing: -0.4,
277
+ textTransform: "uppercase",
278
+ })
279
+ );
280
+
281
+ expect(result.nowPlayingItem?.nowPlayingLabel.default).toEqual(
282
+ expect.objectContaining({
283
+ fontFamily: "SFProText-Semibold",
284
+ fontSize: 18,
285
+ })
286
+ );
287
+ });
288
+
289
+ it("maps queue remove and reorder assets onto item action buttons", () => {
290
+ const result = parseModalBlocksConfiguration(
291
+ {
292
+ "item.remove_asset": "https://example.com/remove.png",
293
+ "item.reorder_handle_asset": "https://example.com/reorder.png",
294
+ },
295
+ "queue-action"
296
+ );
297
+
298
+ expect(result.item.actionButtons.default).toEqual(
299
+ expect.objectContaining({
300
+ removeAsset: "https://example.com/remove.png",
301
+ dragAsset: "https://example.com/reorder.png",
302
+ })
303
+ );
304
+ });
305
+
306
+ it("maps now playing play and pause assets onto action buttons", () => {
307
+ const result = parseModalBlocksConfiguration(
308
+ {
309
+ "now_playing_item.play_asset": "https://example.com/play.png",
310
+ "now_playing_item.pause_asset": "https://example.com/pause.png",
311
+ },
312
+ "queue-action"
313
+ );
314
+
315
+ expect(result.nowPlayingItem?.actionButtons.default).toEqual(
316
+ expect.objectContaining({
317
+ playAsset: "https://example.com/play.png",
318
+ pauseAsset: "https://example.com/pause.png",
319
+ })
320
+ );
321
+ });
322
+
260
323
  it("should leave sectionHeader empty when keys are absent", () => {
261
324
  const result = parseModalBlocksConfiguration(
262
325
  { "item.text_label1.font_color": "#FFFFFF" },
@@ -266,4 +329,64 @@ describe("getEmptyModalBlocksStructure", () => {
266
329
  expect(result.sectionHeader.title).toEqual({ default: {} });
267
330
  expect(result.sectionHeader.actionButtons).toEqual({ default: {} });
268
331
  });
332
+
333
+ it("maps playlist button font family and the new green style keys", () => {
334
+ const result = parseModalBlocksConfiguration(
335
+ {
336
+ "button.title.ios.font_family": "GreycliffCF-Bold",
337
+ "button.title.inactive_font_color": "#111111",
338
+ "button.title.focused_font_color": "#222222",
339
+ "button.title.text_transform": "uppercase",
340
+ "button.background.inactive_color": "#999999",
341
+ "button.background.horizontal_gutter": 6,
342
+ "button.icons.width": 24,
343
+ "button.icons.height": 24,
344
+ },
345
+ "playlist-experience"
346
+ );
347
+
348
+ expect(result.button.title.default).toEqual(
349
+ expect.objectContaining({
350
+ fontFamily: "GreycliffCF-Bold",
351
+ inactiveFontColor: "#111111",
352
+ textTransform: "uppercase",
353
+ })
354
+ );
355
+
356
+ // "focused_" is a state prefix, so this key lands on the focused bucket.
357
+ expect(result.button.title.focused).toEqual(
358
+ expect.objectContaining({
359
+ fontColor: "#222222",
360
+ })
361
+ );
362
+
363
+ expect(result.button.background.default).toEqual(
364
+ expect.objectContaining({
365
+ inactiveColor: "#999999",
366
+ horizontalGutter: 6,
367
+ })
368
+ );
369
+
370
+ expect(result.button.icons.default).toEqual(
371
+ expect.objectContaining({
372
+ width: 24,
373
+ height: 24,
374
+ })
375
+ );
376
+ });
377
+
378
+ it("keeps a font-selector object on the button title", () => {
379
+ const result = parseModalBlocksConfiguration(
380
+ {
381
+ "button.title.ios.font_family": { fontFamily: "GreycliffCF-Bold" },
382
+ },
383
+ "playlist-experience"
384
+ );
385
+
386
+ expect(result.button.title.default).toEqual(
387
+ expect.objectContaining({
388
+ fontFamily: { fontFamily: "GreycliffCF-Bold" },
389
+ })
390
+ );
391
+ });
269
392
  });
@@ -207,53 +207,57 @@ export function imageSrcFromMediaItem(
207
207
  return isEmpty(src) ? undefined : src;
208
208
  }
209
209
 
210
- /**
211
- * map of type checks to apply to each manifest field in order to check if
212
- * the provided value is valid for that given manifest type
213
- */
214
- const typeChecks = {
215
- switch: R.is(Boolean),
216
- checkbox: R.is(Boolean),
217
- number_input: R.is(Number),
218
- text_input: R.is(String),
219
- };
220
-
221
- /**
222
- * This function checks if a provided value is valid for the provided type
223
- * curried function of the form isInvalidForType(type)(value);
224
- * returns true if the value is invalid, and false if the value is ok.
225
- * @param {*} type
226
- * @returns {Function}
227
- * @param {Any} value to check
228
- * @returns {boolean}
229
- */
230
- function isInvalidForType(type: string): (arg0: any) => boolean {
231
- const typeCheck = typeChecks[type] || (() => true);
232
-
233
- return function (value: TAnyValue): boolean {
234
- return !typeCheck(value);
235
- };
236
- }
237
-
238
210
  /**
239
211
  * Checks if a value is valid for a provided type. Checks if the the type is correct,
240
212
  * but also if the value is not empty, not null, and not NaN
241
- * curried function of the form isInvalidValue(type)(value). Returns true if the value
242
- * is invalid, and false otherwise
213
+ * Returns true if the value is invalid, and false otherwise
243
214
  * @param {String} type
244
- * @returns {Function}
245
215
  * @param {Any} value to check for
246
216
  * @returns {boolean}
247
217
  */
248
218
  const isInvalid = (type: string, value: TAnyValue): boolean => {
249
- const validators = [R.isEmpty, R.isNil, Number.isNaN, isInvalidForType(type)];
219
+ if (value == null || Number.isNaN(value)) {
220
+ return true;
221
+ }
250
222
 
251
- return validators.some((valid) => valid(value));
252
- };
223
+ if (typeof value === "string") {
224
+ if (value === "") return true;
253
225
 
254
- function isInvalidValue(type: string): (arg0: TAnyValue) => boolean {
255
- return (value) => isInvalid(type, value);
256
- }
226
+ if (type === "switch" || type === "checkbox" || type === "number_input") {
227
+ return true;
228
+ }
229
+
230
+ return false;
231
+ }
232
+
233
+ if (typeof value === "number") {
234
+ if (type === "switch" || type === "checkbox" || type === "text_input") {
235
+ return true;
236
+ }
237
+
238
+ return false;
239
+ }
240
+
241
+ if (typeof value === "boolean") {
242
+ if (type === "number_input" || type === "text_input") return true;
243
+
244
+ return false;
245
+ }
246
+
247
+ if (Array.isArray(value)) {
248
+ return value.length === 0;
249
+ }
250
+
251
+ if (typeof value === "object") {
252
+ for (const _ in value) {
253
+ return false;
254
+ }
255
+
256
+ return true;
257
+ }
258
+
259
+ return false;
260
+ };
257
261
 
258
262
  /**
259
263
  * This function will sanitize a value from a provided configuration, by trying
@@ -283,7 +287,7 @@ function castOrDefault<T>({
283
287
  ): T {
284
288
  const value = transform(configuration?.[key]);
285
289
 
286
- if (isInvalidValue(type)(value) && !skipDefaults) {
290
+ if (isInvalid(type, value) && !skipDefaults) {
287
291
  return transform(initial_value);
288
292
  }
289
293
 
@@ -303,28 +307,32 @@ const applyRemappedKeys = (
303
307
  config: PluginConfiguration,
304
308
  remappedKeys: PluginConfiguration
305
309
  ) => {
306
- const oldKeys = R.keys(remappedKeys || {});
307
- const configKeys = R.keys(config);
310
+ if (!config || !remappedKeys) return config;
311
+
312
+ let hasAny = false;
313
+
314
+ for (const oldKey in remappedKeys) {
315
+ if (oldKey in config) {
316
+ hasAny = true;
317
+ break;
318
+ }
319
+ }
308
320
 
309
- if (
310
- !R.compose(R.length, R.intersection(oldKeys))(configKeys) ||
311
- !R.length(oldKeys)
312
- ) {
321
+ if (!hasAny) {
313
322
  return config;
314
323
  }
315
324
 
316
- // need to update keys
317
- return R.reduce(
318
- (updatedConfig, oldKey: string) => {
319
- if (R.has(oldKey, config) && !R.has(remappedKeys[oldKey], config)) {
320
- return R.assoc(remappedKeys[oldKey], config[oldKey], updatedConfig);
321
- }
322
-
323
- return updatedConfig;
324
- },
325
- config,
326
- oldKeys
327
- );
325
+ const updatedConfig = { ...config };
326
+
327
+ for (const oldKey in remappedKeys) {
328
+ const newKey = remappedKeys[oldKey];
329
+
330
+ if (oldKey in config && !(newKey in config)) {
331
+ updatedConfig[newKey] = config[oldKey];
332
+ }
333
+ }
334
+
335
+ return updatedConfig;
328
336
  };
329
337
 
330
338
  /**
@@ -342,23 +350,19 @@ type TRemapUpdatedKeys = (
342
350
 
343
351
  export const remapUpdatedKeys = R.curry<TRemapUpdatedKeys>(
344
352
  (manifest, config) => {
345
- return R.reduce(
346
- (updatedConfig, configSection: string) => {
347
- const remappedKeys = R.pathOr<string[] | null>(
348
- null,
349
- [configSection, "updated_keys"],
350
- manifest
351
- );
352
-
353
- updatedConfig[configSection] = remappedKeys
354
- ? applyRemappedKeys(config[configSection], remappedKeys)
355
- : config[configSection];
356
-
357
- return updatedConfig;
358
- },
359
- {},
360
- R.keys(config)
361
- );
353
+ if (!config) return config;
354
+
355
+ const updatedConfig: any = {};
356
+
357
+ for (const configSection in config) {
358
+ const remappedKeys = manifest?.[configSection]?.updated_keys;
359
+
360
+ updatedConfig[configSection] = remappedKeys
361
+ ? applyRemappedKeys(config[configSection], remappedKeys)
362
+ : config[configSection];
363
+ }
364
+
365
+ return updatedConfig;
362
366
  }
363
367
  );
364
368
 
@@ -210,5 +210,43 @@ export const parseModalBlocksConfiguration = (
210
210
 
211
211
  parseBlock("button", ["background", "title", "icons", "leading_icon"]);
212
212
 
213
+ // Leaf uploader keys. They are not `<block>_<sub>_<prop>` styles, so
214
+ // getAllSpecificStyles never sees them.
215
+ const assignLeafAsset = (
216
+ block: ModalBlocksConfig["item"] | undefined,
217
+ key: string,
218
+ prop: "playAsset" | "pauseAsset" | "removeAsset" | "dragAsset"
219
+ ) => {
220
+ if (!block) return;
221
+
222
+ const value = configuration[key];
223
+
224
+ if (typeof value !== "string" || !value.trim()) return;
225
+
226
+ block.actionButtons = {
227
+ ...block.actionButtons,
228
+ default: {
229
+ ...(block.actionButtons?.default || {}),
230
+ [prop]: value,
231
+ },
232
+ };
233
+ };
234
+
235
+ assignLeafAsset(
236
+ result.nowPlayingItem,
237
+ "now_playing_item.play_asset",
238
+ "playAsset"
239
+ );
240
+
241
+ assignLeafAsset(
242
+ result.nowPlayingItem,
243
+ "now_playing_item.pause_asset",
244
+ "pauseAsset"
245
+ );
246
+
247
+ assignLeafAsset(result.item, "item.remove_asset", "removeAsset");
248
+
249
+ assignLeafAsset(result.item, "item.reorder_handle_asset", "dragAsset");
250
+
213
251
  return result;
214
252
  };
@@ -224,12 +224,38 @@ class FocusManager {
224
224
 
225
225
  unregisterFocusable(focusableId: string) {
226
226
  const node = this.tree.find(focusableId);
227
+ const parentNode = this.tree.findParent(focusableId);
227
228
 
228
229
  this.focusableComponents = this.focusableComponents.filter(
229
230
  (c) => c !== node?.component
230
231
  );
231
232
 
232
233
  this.tree.remove(focusableId);
234
+
235
+ if (this._focusedId === focusableId) {
236
+ this._focusedId = null;
237
+ this.retainFocus(parentNode);
238
+ }
239
+ }
240
+
241
+ // Empty groups drop their focusable list after the feed fails, which is
242
+ // after Layout's one-shot focus pass. Land on the nearest ancestor that is
243
+ // still mounted so key handlers keep a target.
244
+ private retainFocus(node: TreeNode | null) {
245
+ let current = node;
246
+
247
+ while (current) {
248
+ const ref = current.component;
249
+
250
+ if (ref?.current?.props?.id) {
251
+ this.setFocusLocal(ref);
252
+ ref.current.onFocus?.(ref.current, {}, undefined);
253
+
254
+ return;
255
+ }
256
+
257
+ current = current.parentId ? this.tree.find(current.parentId) : null;
258
+ }
233
259
  }
234
260
 
235
261
  private setNextFocus(
@@ -231,6 +231,15 @@ describe("FocusManager", () => {
231
231
  ).toBeFalsy();
232
232
  });
233
233
 
234
+ it("moves focus to the mounted parent when the focused element unregisters", () => {
235
+ focusManager.setFocus("child1");
236
+
237
+ focusManager.unregisterFocusable("child1");
238
+
239
+ expect(focusManager.focusedId).toBe("group");
240
+ expect(focusManager.focused).toBe(group.current);
241
+ });
242
+
234
243
  it("focusManager registerFocusable should be defined", () => {
235
244
  expect(focusManager.registerFocusable).toBeDefined();
236
245
  });
@@ -28,17 +28,23 @@ const RTL_LOCALES = [
28
28
  "yi", // Yiddish
29
29
  ];
30
30
 
31
+ // The strings for the UI language, else the first language present
32
+ export const getLocalizedStrings = (
33
+ localizations = {},
34
+ languageCode = getUILanguage()
35
+ ) =>
36
+ localizations?.[languageCode] ||
37
+ Object.values(localizations).find(
38
+ (value) => typeof value === "object" && value !== null
39
+ ) ||
40
+ {};
41
+
31
42
  export function useLocalizedStrings({ localizations = {} }) {
32
43
  const languageCode = getUILanguage();
33
44
 
34
45
  // TODO: probably we can call getLocalizations here from localizationsHelper.js
35
46
  const result = useMemo(
36
- () =>
37
- localizations?.[languageCode] ||
38
- Object.values(localizations).find(
39
- (value) => typeof value === "object" && value !== null
40
- ) ||
41
- {},
47
+ () => getLocalizedStrings(localizations, languageCode),
42
48
  [languageCode, localizations]
43
49
  );
44
50
 
@@ -0,0 +1,69 @@
1
+ const xray = require("@applicaster/quick-brick-xray");
2
+
3
+ const toolbox = { name: "test", description: "", tools: [] };
4
+
5
+ const load = (): typeof import("../xrayToolbox") => {
6
+ let module;
7
+
8
+ jest.isolateModules(() => {
9
+ module = require("../xrayToolbox");
10
+ });
11
+
12
+ return module;
13
+ };
14
+
15
+ describe("registerXrayToolbox", () => {
16
+ afterEach(() => {
17
+ delete xray.registerReactToolbox;
18
+ });
19
+
20
+ it("registers once per name", async () => {
21
+ xray.registerReactToolbox = jest.fn().mockResolvedValue(undefined);
22
+ const { registerXrayToolbox } = load();
23
+
24
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(true);
25
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(true);
26
+
27
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it("resolves false without a toolbox API", async () => {
31
+ await expect(load().registerXrayToolbox(toolbox)).resolves.toBe(false);
32
+ });
33
+
34
+ it("keeps a failed registration instead of retrying it", async () => {
35
+ xray.registerReactToolbox = jest
36
+ .fn()
37
+ .mockRejectedValue(new Error("XRayToolboxBridge is not available"));
38
+
39
+ const { registerXrayToolbox } = load();
40
+
41
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(false);
42
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(false);
43
+
44
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it("registers new tools under the same name again (Fast Refresh)", async () => {
48
+ xray.registerReactToolbox = jest.fn().mockResolvedValue(undefined);
49
+ const { registerXrayToolbox } = load();
50
+
51
+ await registerXrayToolbox(toolbox);
52
+ await registerXrayToolbox({ ...toolbox, tools: [] });
53
+
54
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ it("stops trying every toolbox once the bridge is missing", async () => {
58
+ xray.registerReactToolbox = jest.fn().mockRejectedValue(new Error("no"));
59
+ const { registerXrayToolbox } = load();
60
+
61
+ await registerXrayToolbox(toolbox);
62
+
63
+ await expect(
64
+ registerXrayToolbox({ ...toolbox, name: "other" })
65
+ ).resolves.toBe(false);
66
+
67
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(1);
68
+ });
69
+ });
@@ -0,0 +1,67 @@
1
+ import { getXray } from ".";
2
+
3
+ /** One tool of an X-Ray React toolbox: an agent calls it by name with JSON arguments. */
4
+ export type XrayToolSpec = {
5
+ name: string;
6
+ description: string;
7
+ parameters?: Array<{
8
+ name: string;
9
+ type: "number" | "string";
10
+ description: string;
11
+ }>;
12
+ handler: (args: Record<string, unknown>) => unknown;
13
+ };
14
+
15
+ export type XrayToolbox = {
16
+ name: string;
17
+ description: string;
18
+ tools: XrayToolSpec[];
19
+ };
20
+
21
+ type RegisterReactToolbox = (toolbox: XrayToolbox) => Promise<void>;
22
+
23
+ const registered = new Map<
24
+ string,
25
+ { tools: XrayToolSpec[]; registration: Promise<boolean> }
26
+ >();
27
+
28
+ /** Set once a registration fails: the build has no X-Ray bridge, which cannot appear later. */
29
+ let bridgeMissing = false;
30
+
31
+ /**
32
+ * Registers an X-Ray React toolbox and resolves whether it is registered. The same tools are
33
+ * registered once; new tools under the same name (Fast Refresh re-ran the module) replace them.
34
+ * A build without the X-Ray native bridge (release builds) rejects the first attempt and every
35
+ * later call resolves false at once, so callers on hot paths pay for one attempt only.
36
+ */
37
+ export const registerXrayToolbox = (toolbox: XrayToolbox): Promise<boolean> => {
38
+ const cached = registered.get(toolbox.name);
39
+
40
+ if (cached?.tools === toolbox.tools) return cached.registration;
41
+
42
+ if (bridgeMissing) return Promise.resolve(false);
43
+
44
+ const register = (
45
+ getXray() as
46
+ | { registerReactToolbox?: RegisterReactToolbox }
47
+ | null
48
+ | undefined
49
+ )?.registerReactToolbox;
50
+
51
+ const registration = register
52
+ ? register(toolbox).then(
53
+ () => true,
54
+ () => {
55
+ bridgeMissing = true;
56
+
57
+ return false;
58
+ }
59
+ )
60
+ : Promise.resolve(false);
61
+
62
+ if (!register) bridgeMissing = true;
63
+
64
+ registered.set(toolbox.name, { tools: toolbox.tools, registration });
65
+
66
+ return registration;
67
+ };
@@ -830,6 +830,14 @@ function getPlayerConfiguration({ platform, version }) {
830
830
  type: "switch",
831
831
  label_tooltip:
832
832
  "Enable this option to have a floating video player when the app is in the background.",
833
+ },
834
+ {
835
+ key: "vertical_video_player",
836
+ label: "Vertical video mode",
837
+ initial_value: false,
838
+ type: "switch",
839
+ label_tooltip:
840
+ "Enable this option to use the vertical video player layout.",
833
841
  }
834
842
  );
835
843
 
@@ -412,6 +412,12 @@ const mobileActionButtonContainerFields = (positionOptions) => [
412
412
  type: ZAPPIFEST_FIELDS.switch,
413
413
  suffix: "buttons enabled",
414
414
  },
415
+ {
416
+ type: ZAPPIFEST_FIELDS.switch,
417
+ suffix: "cell navigation enabled",
418
+ label_tooltip:
419
+ "Controls whether tapping on the cell (outside the buttons) triggers its navigation behavior. When disabled, only the configured buttons are interactive. Useful for Home screens (tap anywhere to open) vs. Details screens (only buttons should work).",
420
+ },
415
421
  {
416
422
  type: ZAPPIFEST_FIELDS.select,
417
423
  suffix: "position",
@@ -472,7 +478,6 @@ const MOBILE_ACTION_BUTTON_FIELDS = [
472
478
  suffix: "assign action",
473
479
  options: [
474
480
  { text: "primary navigation", value: "navigation_action" },
475
- { text: "secondary navigation", value: "secondary_navigation" },
476
481
  { text: "favorite", value: "local_storage_favourites_action" },
477
482
  { text: "more", value: "open-modal-bottom-sheet-cell-action" },
478
483
  { text: "add to calendar", value: "add_to_calendar_action" },
@@ -7,6 +7,7 @@ describe("mobileActionButtonsContainer", () => {
7
7
  description: "container",
8
8
  defaults: {
9
9
  buttonsEnabled: false,
10
+ cellNavigationEnabled: false,
10
11
  position: ["over_image", "text_label_1", "text_label_2"],
11
12
  align: "left",
12
13
  overImagePosition: "bottom_left",
@@ -31,6 +32,26 @@ describe("mobileActionButtonsContainer", () => {
31
32
  expect(enabledField).toBeTruthy();
32
33
  expect(enabledField.type).toBe("switch");
33
34
 
35
+ const cellNavigationField = result.fields.find(
36
+ (field) =>
37
+ field.key === "mobile_buttons_container_cell_navigation_enabled"
38
+ );
39
+
40
+ expect(cellNavigationField).toMatchObject({
41
+ type: "switch",
42
+ label: "Cell navigation enabled",
43
+ initial_value: false,
44
+ label_tooltip: expect.stringContaining(
45
+ "Controls whether tapping on the cell"
46
+ ),
47
+ conditional_fields: [
48
+ {
49
+ key: "styles/mobile_buttons_container_buttons_enabled",
50
+ condition_value: true,
51
+ },
52
+ ],
53
+ });
54
+
34
55
  const positionField = result.fields.find(
35
56
  (field) => field.key === "mobile_buttons_container_position"
36
57
  );
@@ -67,7 +67,7 @@ describe("buildMobileActionButtonGroups", () => {
67
67
  );
68
68
 
69
69
  expect(button2EnabledField.initial_value).toBe(false);
70
- expect(button2AssignActionField.initial_value).toBe("secondary_navigation");
70
+ expect(button2AssignActionField.initial_value).toBe("trailer_action");
71
71
 
72
72
  const button3Fields = groups[3].fields;
73
73