@applicaster/zapp-react-native-utils 16.0.0-alpha.7845362739 → 16.0.0-alpha.8101951023

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 (120) hide show
  1. package/actionsExecutor/ActionExecutor.ts +50 -3
  2. package/actionsExecutor/ActionExecutorContext.tsx +99 -17
  3. package/actionsExecutor/__tests__/ActionExecutor.test.ts +71 -0
  4. package/actionsExecutor/__tests__/feedDecorator.test.ts +61 -0
  5. package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +25 -0
  6. package/actionsExecutor/actions/__tests__/goBack.test.ts +62 -0
  7. package/actionsExecutor/actions/__tests__/goHome.test.ts +19 -0
  8. package/actionsExecutor/actions/__tests__/loadItemsFromSource.test.ts +88 -0
  9. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +133 -0
  10. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +76 -0
  11. package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +212 -0
  12. package/actionsExecutor/actions/__tests__/presentScreen.test.ts +127 -0
  13. package/actionsExecutor/actions/__tests__/showAlert.test.ts +49 -0
  14. package/actionsExecutor/actions/__tests__/showToast.test.ts +88 -0
  15. package/actionsExecutor/actions/dismissBottomSheet.ts +23 -0
  16. package/actionsExecutor/actions/goBack.ts +60 -0
  17. package/actionsExecutor/actions/goHome.ts +31 -0
  18. package/actionsExecutor/actions/index.ts +12 -0
  19. package/actionsExecutor/actions/navigateToScreen.ts +18 -4
  20. package/actionsExecutor/actions/openBottomSheet.ts +329 -0
  21. package/actionsExecutor/actions/presentScreen.ts +78 -0
  22. package/actionsExecutor/actions/refreshComponent.ts +6 -0
  23. package/actionsExecutor/actions/sendCloudEvent.ts +6 -1
  24. package/actionsExecutor/actions/showAlert.ts +51 -0
  25. package/actionsExecutor/actions/showToast.ts +64 -8
  26. package/actionsExecutor/consts.ts +27 -0
  27. package/actionsExecutor/feedDecorator.ts +6 -6
  28. package/analyticsUtils/PlayerAnalyticsManager.ts +1 -1
  29. package/appUtils/HooksManager/index.ts +9 -0
  30. package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +142 -59
  31. package/appUtils/playerManager/OverlayObserver/__tests__/liveContent.test.ts +205 -0
  32. package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +56 -0
  33. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.android.tv.ts +4 -0
  34. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ios.tv.ts +4 -0
  35. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ts +11 -0
  36. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.web.ts +4 -0
  37. package/appUtils/playerManager/OverlayObserver/utils.ts +51 -20
  38. package/appUtils/playerManager/__tests__/playerContent.test.ts +403 -0
  39. package/appUtils/playerManager/__tests__/playerFactory.test.ts +1 -1
  40. package/appUtils/playerManager/__tests__/playerNative.test.ts +38 -0
  41. package/appUtils/playerManager/__tests__/usePlayerContent.test.tsx +64 -0
  42. package/appUtils/playerManager/{conts.ts → const.ts} +20 -0
  43. package/appUtils/playerManager/index.ts +1 -1
  44. package/appUtils/playerManager/player.ts +115 -3
  45. package/appUtils/playerManager/playerFactory.ts +1 -1
  46. package/appUtils/playerManager/playerNative.ts +6 -4
  47. package/appUtils/playerManager/usePlayerContent.tsx +43 -0
  48. package/appUtils/playerManager/usePlayerControllerSetup.tsx +1 -1
  49. package/appUtils/playerManager/userLanguagePreference.ts +1 -1
  50. package/arrayUtils/__tests__/reorderByIds.test.ts +50 -0
  51. package/arrayUtils/index.ts +79 -0
  52. package/cellUtils/index.ts +30 -3
  53. package/configurationUtils/__tests__/manifestKeyParser.test.ts +21 -0
  54. package/configurationUtils/__tests__/modalBlocksParser.test.ts +269 -0
  55. package/configurationUtils/manifestKeyParser.ts +50 -10
  56. package/configurationUtils/modalBlocksParser.ts +214 -0
  57. package/entryActions/__tests__/buildEntryActions.test.ts +235 -0
  58. package/entryActions/aliasPresentation.ts +108 -0
  59. package/entryActions/buildEntryActions.ts +167 -0
  60. package/entryActions/index.ts +15 -0
  61. package/entryActions/types.ts +39 -0
  62. package/manifestUtils/__tests__/player.audioControls.test.js +158 -0
  63. package/manifestUtils/_internals/index.js +8 -3
  64. package/manifestUtils/defaultManifestConfigurations/generalContent.js +6 -0
  65. package/manifestUtils/defaultManifestConfigurations/player.js +330 -1
  66. package/manifestUtils/index.js +3 -0
  67. package/manifestUtils/modalBlocks.js +304 -0
  68. package/modalState/ContentViewModel.ts +113 -0
  69. package/modalState/EditableCollection.ts +17 -0
  70. package/modalState/EditableCollectionRegistry.ts +51 -0
  71. package/modalState/ModalOrchestrator.ts +211 -0
  72. package/modalState/RemoteEditableCollection.ts +235 -0
  73. package/modalState/__tests__/ContentViewModel.editable.test.ts +69 -0
  74. package/modalState/__tests__/ContentViewModel.test.ts +165 -0
  75. package/modalState/__tests__/EditableCollectionRegistry.test.ts +112 -0
  76. package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +47 -0
  77. package/modalState/__tests__/RemoteEditableCollection.test.ts +415 -0
  78. package/modalState/__tests__/index.test.ts +30 -1
  79. package/modalState/__tests__/useBottomSheetContent.test.ts +349 -0
  80. package/modalState/__tests__/useModalStoreState.test.ts +142 -0
  81. package/modalState/index.ts +35 -83
  82. package/modalState/store.ts +109 -0
  83. package/modalState/types.ts +160 -0
  84. package/modalState/useBottomSheetContent.ts +109 -0
  85. package/package.json +2 -2
  86. package/playerUtils/__tests__/contentDataKeys.test.ts +297 -0
  87. package/playerUtils/__tests__/resolvePlayerActions.test.ts +138 -0
  88. package/playerUtils/__tests__/resolvePlayerTitleSubtitle.test.ts +180 -0
  89. package/playerUtils/contentDataKeys.ts +134 -0
  90. package/playerUtils/index.ts +37 -16
  91. package/playerUtils/isAudioItem.ts +26 -0
  92. package/playerUtils/resolvePlayerActions.ts +71 -0
  93. package/playerUtils/resolvePlayerTitleSubtitle.ts +76 -0
  94. package/pluginUtils/__tests__/pluginUtils.test.js +32 -0
  95. package/pluginUtils/index.ts +29 -0
  96. package/reactHooks/actions/index.ts +67 -2
  97. package/reactHooks/cell-click/__tests__/index.test.js +64 -0
  98. package/reactHooks/cell-click/index.ts +21 -14
  99. package/reactHooks/index.ts +2 -0
  100. package/reactHooks/layout/index.ts +1 -1
  101. package/reactHooks/navigation/__tests__/index.test.tsx +176 -1
  102. package/reactHooks/navigation/__tests__/usePresentScreen.test.ts +154 -0
  103. package/reactHooks/navigation/index.ts +2 -0
  104. package/reactHooks/navigation/usePresentScreen.ts +157 -0
  105. package/reactHooks/navigation/useRoute.ts +22 -5
  106. package/reactHooks/usePluginConfiguration.ts +4 -1
  107. package/reactHooks/utils/__tests__/index.test.js +22 -2
  108. package/reactHooks/utils/index.ts +11 -1
  109. package/uiActionsRegistrator/__tests__/resolveActionIdentifiers.test.ts +93 -0
  110. package/uiActionsRegistrator/__tests__/subscribe.test.ts +107 -0
  111. package/uiActionsRegistrator/__tests__/usableActionItems.test.ts +52 -0
  112. package/uiActionsRegistrator/index.ts +25 -203
  113. package/uiActionsRegistrator/registry.ts +335 -0
  114. package/uiActionsRegistrator/resolveActionIdentifiers.ts +100 -0
  115. package/uiActionsRegistrator/usableActionItems.ts +66 -0
  116. package/zappFrameworkUtils/localStorageHelper.ts +10 -2
  117. package/appUtils/playerManager/usePlayerTitleSummaryOverlay.tsx +0 -56
  118. package/playerUtils/__tests__/getPlayerActionButtons.test.ts +0 -54
  119. package/playerUtils/getPlayerActionButtons.ts +0 -17
  120. /package/reactHooks/actions/__tests__/{index.test.js → index.test.tsx} +0 -0
@@ -67,3 +67,23 @@ export enum PlayerRole {
67
67
  Chromecast = "Chromecast",
68
68
  Unspecified = "Unspecified",
69
69
  }
70
+
71
+ /**
72
+ * The playback engine backing a web player. Published on
73
+ * `window.applicaster.playerType` when a player is created, so that plugins
74
+ * observing the player can tell which engine they are looking at instead of
75
+ * inferring it from the API the instance happens to expose.
76
+ *
77
+ * Keep in sync with `ApplicasterGlobals["playerType"]` in applicaster-types.
78
+ */
79
+ export enum WebPlayerEngine {
80
+ Videojs = "videojs",
81
+ Hlsjs = "hlsjs",
82
+ Dashjs = "dashjs",
83
+ }
84
+
85
+ export enum PLAYER_EVENTS {
86
+ PlayNextTriggered = "play_next_triggered",
87
+ }
88
+
89
+ export type PlayNextTriggeredEvent = { id: string };
@@ -9,7 +9,7 @@ import {
9
9
  unsubscribeToDeviceNetworkChanges,
10
10
  } from "@applicaster/quick-brick-core/App/NetworkStatusProvider/utils";
11
11
  import { setUserLanguagePreference } from "./userLanguagePreference";
12
- import { PlayerRole } from "./conts";
12
+ import { PlayerRole } from "./const";
13
13
  import { RNPlayerManager } from "./nativePlayerManager";
14
14
 
15
15
  const { log_error, log_debug, log_warning } = createLogger({
@@ -1,9 +1,22 @@
1
1
  import * as R from "ramda";
2
- import { BehaviorSubject } from "rxjs";
2
+ import { BehaviorSubject, Observable } from "rxjs";
3
+ import { distinctUntilChanged } from "rxjs/operators";
3
4
  import { isEmptyOrNil } from "@applicaster/zapp-react-native-utils/cellUtils";
4
5
  import { createLogger, utilsLogger } from "../../logger";
5
- import { getBoolFromConfigValue } from "../../configurationUtils";
6
- import { PlayerRole, SharedPlayerCallBacksKeys } from "./conts";
6
+ import {
7
+ getBoolFromConfigValue,
8
+ imageSrcFromMediaItem,
9
+ } from "../../configurationUtils";
10
+ // Imported from the leaf modules rather than the `playerUtils` barrel: that
11
+ // barrel imports `playerManager`, so going through it would create a require
12
+ // cycle (playerManager/index -> player -> playerUtils/index -> playerManager).
13
+ import {
14
+ resolvePlayerTitleSubtitle,
15
+ getPlayerContentDataKeys,
16
+ } from "../../playerUtils/resolvePlayerTitleSubtitle";
17
+ import { isAudioItem } from "../../playerUtils/isAudioItem";
18
+ import { isTV } from "../../reactUtils";
19
+ import { PlayerRole, SharedPlayerCallBacksKeys } from "./const";
7
20
  import { OverlaysObserver } from "./OverlayObserver/OverlaysObserver";
8
21
  import { TVSeekController } from "../../reactHooks/player/TVSeekControlller/TVSeekController";
9
22
 
@@ -19,6 +32,23 @@ export declare type PlayerControllerListenerData = {
19
32
  id: string;
20
33
  listener: QuickBrickPlayer.SharedPlayerCallBacks;
21
34
  };
35
+
36
+ /**
37
+ * The player's resolved Title/Subtitle text, published by the player the same
38
+ * way duration and progress are. Consumers render these values as-is rather
39
+ * than reading `entry` or the plugin configuration themselves.
40
+ */
41
+ export type PlayerContent = {
42
+ title: Option<string | number>;
43
+ summary: Option<string | number>;
44
+ /**
45
+ * Cover art for the content currently playing. Published alongside the text
46
+ * (rather than read from `entry` by whoever needs it) so it follows the same
47
+ * entry: on live audio that means the programme's own artwork, refreshed
48
+ * with every feed poll, instead of the channel's.
49
+ */
50
+ imageUrl: Option<string>;
51
+ };
22
52
  /**
23
53
  * Player Instance Controller
24
54
  * Instance exposes utility methods that will help set or get state
@@ -45,6 +75,7 @@ export class Player {
45
75
  readonly playerRole: PlayerRole = PlayerRole.Unspecified;
46
76
  private overlaysObserver?: OverlaysObserver | null;
47
77
  private entryObservable?: BehaviorSubject<ZappEntry> | null;
78
+ private contentSubject?: BehaviorSubject<PlayerContent> | null;
48
79
 
49
80
  protected listeners: {
50
81
  [key: string]: QuickBrickPlayer.SharedPlayerCallBacks;
@@ -58,7 +89,17 @@ export class Player {
58
89
 
59
90
  set entry(newEntry: ZappEntry | null) {
60
91
  this._entry = newEntry;
92
+ // The live entry is scoped to the entry it overlays - see
93
+ // `OverlaysObserver.clearLiveEntry`. No-op when there is no observer,
94
+ // which is every non-live player.
95
+ this.overlaysObserver?.clearLiveEntry();
61
96
  this.entryObservable?.next(newEntry);
97
+ // Optional chaining short-circuits the whole call expression, so
98
+ // `getContent()` is NOT evaluated while no subscriber exists. That
99
+ // matters: `PlayerNative` assigns `this.entry` in its constructor, before
100
+ // subclass arrow-function properties such as `getPluginConfiguration`
101
+ // have been assigned.
102
+ this.contentSubject?.next(this.getContent());
62
103
  }
63
104
 
64
105
  constructor(props) {
@@ -117,6 +158,77 @@ export class Player {
117
158
  return this.entryObservable;
118
159
  };
119
160
 
161
+ /**
162
+ * The player's resolved Title/Subtitle.
163
+ *
164
+ * Resolved on demand rather than cached: the plugin configuration arrives
165
+ * asynchronously on both the native and cast paths, so a value computed once
166
+ * at entry-set time would be stale for whichever arrives first.
167
+ *
168
+ * Every fallback (no config, empty/whitespace key, unresolvable key, key
169
+ * resolving to an empty string or to a non-string/non-number) lives in
170
+ * `resolvePlayerContentText` and is never reimplemented here.
171
+ */
172
+ getContent = (): PlayerContent => {
173
+ // Live audio keeps playing one channel while the programme on air
174
+ // changes, so the observer's freshly-loaded programme entry wins when
175
+ // there is one. Read through `?.` so this never constructs an observer:
176
+ // a VOD or cast player has none and falls straight through to its entry.
177
+ const entry = this.overlaysObserver?.liveEntry ?? this.entry;
178
+
179
+ return {
180
+ ...resolvePlayerTitleSubtitle({
181
+ entry,
182
+ pluginConfiguration: this.getPluginConfiguration(),
183
+ dataKeys: getPlayerContentDataKeys({
184
+ isTV: isTV(),
185
+ isAudioContent: isAudioItem(entry),
186
+ }),
187
+ }),
188
+ // Same selection Android's `EntryHelpers.extractCoverImage` makes:
189
+ // prefer the `image_base` media item, otherwise the first one available.
190
+ // Reused rather than reimplemented so the two platforms cannot drift.
191
+ imageUrl: entry ? imageSrcFromMediaItem(entry, []) : undefined,
192
+ };
193
+ };
194
+
195
+ /**
196
+ * Created lazily, like `entryObservable`. An eagerly created subject would
197
+ * resolve during `PlayerNative`'s constructor and call a not-yet-assigned
198
+ * `getPluginConfiguration`.
199
+ *
200
+ * The seed is produced by calling the resolver, never from raw entry fields:
201
+ * a `BehaviorSubject` replays its seed synchronously on subscribe, so a raw
202
+ * seed would overwrite correctly-resolved text at mount.
203
+ */
204
+ getContentObservable = (): Observable<PlayerContent> => {
205
+ if (!this.contentSubject) {
206
+ this.contentSubject = new BehaviorSubject<PlayerContent>(
207
+ this.getContent()
208
+ );
209
+ }
210
+
211
+ return this.contentSubject.asObservable().pipe(
212
+ // `resolvePlayerTitleSubtitle` returns a fresh object every call, so
213
+ // reference equality would never dedupe. Compare by value.
214
+ distinctUntilChanged(
215
+ (previous, next) =>
216
+ previous.title === next.title &&
217
+ previous.summary === next.summary &&
218
+ previous.imageUrl === next.imageUrl
219
+ )
220
+ );
221
+ };
222
+
223
+ /**
224
+ * Re-resolves and publishes the current content. Called when something the
225
+ * resolution depends on changed without `entry` itself being reassigned -
226
+ * today that is the observer swapping in a newly-loaded live programme.
227
+ */
228
+ publishContent = () => {
229
+ this.contentSubject?.next(this.getContent());
230
+ };
231
+
120
232
  /**
121
233
  * ---------------------------------------- PLAYER SETTERS GETTERS ----------------------------------------
122
234
  * These methods let you get information about the content / ad playing
@@ -1,7 +1,7 @@
1
1
  import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
2
2
  import { findPluginByIdentifier } from "@applicaster/zapp-react-native-utils/pluginUtils";
3
3
  import { Player } from "./player";
4
- import { PlayerRole } from "./conts";
4
+ import { PlayerRole } from "./const";
5
5
 
6
6
  type PlayerFactoryItem = {
7
7
  Component: any;
@@ -3,7 +3,7 @@ import { isEmptyOrNil } from "@applicaster/zapp-react-native-utils/cellUtils";
3
3
  import { createLogger, utilsLogger } from "../../logger";
4
4
  import { Player } from "./player";
5
5
  import { findNodeHandle } from "react-native";
6
- import { PlayerRole } from "./conts";
6
+ import { PlayerRole } from "./const";
7
7
  import { toBooleanWithDefaultFalse } from "../../booleanUtils";
8
8
  import { RNPlayerManager } from "./nativePlayerManager";
9
9
 
@@ -296,9 +296,11 @@ export class PlayerNative extends Player {
296
296
  }, listeners);
297
297
  };
298
298
 
299
- isAudioItem = () =>
300
- this.getEntry()?.content?.type?.includes?.("audio") ||
301
- this.getEntry()?.type?.value === "audio";
299
+ isAudioItem = (): boolean =>
300
+ Boolean(
301
+ this.getEntry()?.content?.type?.includes?.("audio") ||
302
+ this.getEntry()?.type?.value?.includes?.("audio")
303
+ );
302
304
 
303
305
  isFullScreenSupported = (): boolean => {
304
306
  const config = this.getConfig();
@@ -0,0 +1,43 @@
1
+ import * as React from "react";
2
+ import { Player, PlayerContent } from "./player";
3
+ import { usePlayer } from "./usePlayer";
4
+
5
+ const EMPTY_CONTENT: PlayerContent = {
6
+ title: null,
7
+ summary: null,
8
+ imageUrl: null,
9
+ };
10
+
11
+ /**
12
+ * Subscribes to the player's resolved Title/Subtitle, the same way
13
+ * `usePlayerState` subscribes to playback state. Modeled on that hook
14
+ * deliberately: one React bridge pattern for everything the manager publishes.
15
+ *
16
+ * Passing no `playerId` uses the active player, which is the right default -
17
+ * only one player instance is live at a time. `usePlayer` additionally swaps
18
+ * to the casting receiver while casting, so this follows cast handover
19
+ * automatically.
20
+ */
21
+ export const usePlayerContent = (playerId?: string): PlayerContent => {
22
+ const player: Player = usePlayer(playerId);
23
+
24
+ const [content, setContent] = React.useState<PlayerContent>(
25
+ () => player?.getContent() ?? EMPTY_CONTENT
26
+ );
27
+
28
+ React.useEffect(() => {
29
+ if (!player) {
30
+ setContent(EMPTY_CONTENT);
31
+
32
+ return;
33
+ }
34
+
35
+ const subscription = player
36
+ .getContentObservable()
37
+ .subscribe((next) => setContent(next));
38
+
39
+ return () => subscription.unsubscribe();
40
+ }, [player]);
41
+
42
+ return content;
43
+ };
@@ -3,7 +3,7 @@ import { useConfiguration } from "../../reactHooks/useConfiguration";
3
3
  import { useEffect } from "react";
4
4
  import { playerManager } from "./index";
5
5
  import { useValidatePlayerConfig } from "../../playerUtils/useValidatePlayerConfig";
6
- import { PlayerRole } from "./conts";
6
+ import { PlayerRole } from "./const";
7
7
  import { isAppleTV } from "@applicaster/zapp-react-native-ui-components/Helpers/Platform";
8
8
  import { TVSeekController } from "../../reactHooks/player/TVSeekControlller/TVSeekController";
9
9
  import { KeyInputHandler } from "../keyInputHandler/KeyInputHandler";
@@ -1,7 +1,7 @@
1
1
  import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
2
2
  import { sessionStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/SessionStorage";
3
3
  import { createLogger, utilsLogger } from "../../logger";
4
- import { userPreferencesNamespace } from "./conts";
4
+ import { userPreferencesNamespace } from "./const";
5
5
 
6
6
  export const preferredAudioLanguageStorageKey = "preferred_audio_language";
7
7
 
@@ -0,0 +1,50 @@
1
+ import { reorderByIds } from "../index";
2
+
3
+ describe("reorderByIds", () => {
4
+ const items = [
5
+ { id: "1", title: "Track 1" },
6
+ { id: "2", title: "Track 2" },
7
+ { id: "3", title: "Track 3" },
8
+ { id: "4", title: "Track 4" },
9
+ ];
10
+
11
+ it("returns an empty array if items is empty or nullish", () => {
12
+ expect(reorderByIds([], ["1"])).toEqual([]);
13
+ expect(reorderByIds(null as any, ["1"])).toEqual([]);
14
+ });
15
+
16
+ it("returns a copy of items if orderedIds is empty or nullish", () => {
17
+ expect(reorderByIds(items, [])).toEqual(items);
18
+ expect(reorderByIds(items, null as any)).toEqual(items);
19
+ });
20
+
21
+ it("reorders items based on the provided orderedIds sequence", () => {
22
+ const result = reorderByIds(items, ["3", "1", "4", "2"]);
23
+
24
+ expect(result.map((i) => i.id)).toEqual(["3", "1", "4", "2"]);
25
+ });
26
+
27
+ it("replaces items in their original slots when reordering a subset", () => {
28
+ const result = reorderByIds(items, ["4", "2"]);
29
+
30
+ expect(result.map((i) => i.id)).toEqual(["1", "4", "3", "2"]);
31
+ });
32
+
33
+ it("ignores IDs in orderedIds that do not exist in items", () => {
34
+ const result = reorderByIds(items, ["unknown", "3", "99", "1"]);
35
+
36
+ expect(result.map((i) => i.id)).toEqual(["3", "2", "1", "4"]);
37
+ });
38
+
39
+ it("supports custom getId selector function", () => {
40
+ const customItems = [
41
+ { key: "a", name: "Alpha" },
42
+ { key: "b", name: "Beta" },
43
+ { key: "c", name: "Gamma" },
44
+ ];
45
+
46
+ const result = reorderByIds(customItems, ["c", "a"], (item) => item.key);
47
+
48
+ expect(result.map((i) => i.key)).toEqual(["c", "b", "a"]);
49
+ });
50
+ });
@@ -158,3 +158,82 @@ export const allTruthy = (xs: boolean[]) =>
158
158
 
159
159
  /** Returns `true` when at least one value in `xs` is truthy. */
160
160
  export const anyTruthy = (xs: boolean[]) => xs.some(Boolean);
161
+
162
+ /**
163
+ * Reorders `items` based on an ordered array of IDs (`orderedIds`).
164
+ * Items whose IDs are in `orderedIds` appear first in that exact sequence.
165
+ * Any remaining items not present in `orderedIds` are appended at the end preserving their relative order.
166
+ */
167
+ export function reorderByIds<T>(
168
+ items: T[],
169
+ orderedIds: string[],
170
+ getId: (item: T) => string | string[] = (item: any) => item?.id
171
+ ): T[] {
172
+ if (!items || items.length === 0) return [];
173
+ if (!orderedIds || orderedIds.length === 0) return [...items];
174
+
175
+ const itemMap = new Map<string, T>();
176
+
177
+ items.forEach((item) => {
178
+ const rawId = getId(item);
179
+ const keys = Array.isArray(rawId) ? rawId : [rawId];
180
+
181
+ keys.forEach((key) => {
182
+ if (key !== undefined && key !== null && key !== "") {
183
+ itemMap.set(String(key), item);
184
+ }
185
+ });
186
+ });
187
+
188
+ const reorderedSubset: T[] = [];
189
+ const orderedIdsSet = new Set<string>();
190
+
191
+ orderedIds.forEach((id) => {
192
+ const strId = String(id);
193
+ const item = itemMap.get(strId);
194
+
195
+ if (item && !reorderedSubset.includes(item)) {
196
+ reorderedSubset.push(item);
197
+ const rawId = getId(item);
198
+ const keys = Array.isArray(rawId) ? rawId : [rawId];
199
+
200
+ keys.forEach((k) => {
201
+ if (k !== undefined && k !== null && k !== "") {
202
+ orderedIdsSet.add(String(k));
203
+ }
204
+ });
205
+ }
206
+ });
207
+
208
+ if (reorderedSubset.length === 0) return [...items];
209
+
210
+ let subsetIdx = 0;
211
+ const result: T[] = [];
212
+
213
+ items.forEach((item) => {
214
+ const rawId = getId(item);
215
+ const keys = Array.isArray(rawId) ? rawId : [rawId];
216
+
217
+ const isMatched = keys.some(
218
+ (k) =>
219
+ k !== undefined &&
220
+ k !== null &&
221
+ k !== "" &&
222
+ orderedIdsSet.has(String(k))
223
+ );
224
+
225
+ if (isMatched) {
226
+ if (subsetIdx < reorderedSubset.length) {
227
+ result.push(reorderedSubset[subsetIdx++]);
228
+ }
229
+ } else {
230
+ result.push(item);
231
+ }
232
+ });
233
+
234
+ while (subsetIdx < reorderedSubset.length) {
235
+ result.push(reorderedSubset[subsetIdx++]);
236
+ }
237
+
238
+ return result;
239
+ }
@@ -65,11 +65,38 @@ export const isEmptyOrNil = (value: any): boolean =>
65
65
  */
66
66
  export const getLabel = (dataKeyValue, customDataKeyValue) => (entry) => {
67
67
  const label = getKeyForLabel(dataKeyValue, customDataKeyValue);
68
- const isPath = label && R.length(label) > 0;
69
- const fallback = isEmptyOrNil(label) ? "" : R.head(label);
68
+ const isPath = label && label.length > 0;
69
+
70
+ // An unresolved key is rendered as-is, because a data key is allowed to be a
71
+ // literal label ("More") rather than a path. Fall back to the whole key
72
+ // instead of its first segment: "extensions.section" reads as an unresolved
73
+ // path, while "extensions" reads as a legitimate label and hides the problem.
74
+ const fallback = isEmptyOrNil(label) ? "" : label.join(".");
70
75
 
71
76
  try {
72
- return isPath ? R.pathOr(fallback, label, entry) : fallback;
77
+ if (!isPath) {
78
+ return fallback;
79
+ }
80
+
81
+ const resolved = label.reduce(
82
+ (value, key) => (value == null ? undefined : value[key]),
83
+ entry
84
+ );
85
+
86
+ if (isNotNil(resolved)) {
87
+ return resolved;
88
+ }
89
+
90
+ // A multi-segment key is unambiguously a path, so failing to resolve it is
91
+ // a misconfiguration rather than someone typing a literal label.
92
+ if (label.length > 1) {
93
+ cellUtilsLogger.warning({
94
+ message: `getLabel: data key "${fallback}" was not found on the entry, rendering the key itself`,
95
+ data: { dataKeyValue, customDataKeyValue, entryId: entry?.id },
96
+ });
97
+ }
98
+
99
+ return fallback;
73
100
  } catch (error) {
74
101
  cellUtilsLogger.warning({
75
102
  data: { error, dataKeyValue, customDataKeyValue },
@@ -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