@applicaster/zapp-react-native-ui-components 15.1.8 → 15.1.9-alpha.3547421944

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.
@@ -1,6 +1,15 @@
1
1
  import React from "react";
2
2
  import { render } from "@testing-library/react-native";
3
3
 
4
+ let mockContent = { title: null, summary: null };
5
+
6
+ // Read lazily inside the factory: `jest.mock` is hoisted above the `let` above,
7
+ // so capturing the value eagerly would freeze it at `undefined`.
8
+ jest.mock(
9
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerContent",
10
+ () => ({ usePlayerContent: () => mockContent })
11
+ );
12
+
4
13
  import { AudioPlayerTV } from "..";
5
14
 
6
15
  jest.mock("@applicaster/zapp-react-native-utils/audioPlayerUtils", () => ({
@@ -51,7 +60,17 @@ const audioPlayerProps = {
51
60
 
52
61
  describe("<AudioPlayerTV />", () => {
53
62
  it("renders correctly", () => {
63
+ // AudioPlayerTV no longer resolves its own title/summary from
64
+ // audio_item - it reads them from the player manager's content channel.
65
+ // Publishing the same values `audio_item.title`/`.summary` used to resolve
66
+ // to keeps this snapshot byte-for-byte identical to before that change.
67
+ mockContent = {
68
+ title: audioPlayerProps.audio_item.title,
69
+ summary: audioPlayerProps.audio_item.summary,
70
+ };
71
+
54
72
  const { toJSON } = render(<AudioPlayerTV {...audioPlayerProps} />);
73
+
55
74
  expect(toJSON()).toMatchSnapshot();
56
75
  });
57
76
  });
@@ -0,0 +1,120 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react-native";
3
+
4
+ import { AudioPlayerTV } from "..";
5
+
6
+ jest.mock("@applicaster/zapp-react-native-utils/audioPlayerUtils", () => ({
7
+ useArtworkImage: jest.fn(() => "artwork_url"),
8
+ }));
9
+
10
+ let mockContent: {
11
+ title: Option<string | number>;
12
+ summary: Option<string | number>;
13
+ } = { title: null, summary: null };
14
+
15
+ // Read lazily inside the factory: `jest.mock` is hoisted above the `let`
16
+ // above, so capturing the value eagerly would freeze it at `undefined`.
17
+ jest.mock(
18
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerContent",
19
+ () => ({ usePlayerContent: () => mockContent })
20
+ );
21
+
22
+ const baseAudioItem = {
23
+ title: "tittle",
24
+ summary: "Summary",
25
+ content: {
26
+ src: "https://example.com",
27
+ },
28
+ media_group: [
29
+ {
30
+ type: "audio",
31
+ media_item: [
32
+ {
33
+ type: "audio",
34
+ src: "https://example.com",
35
+ key: "base_code",
36
+ },
37
+ ],
38
+ },
39
+ ],
40
+ extensions: {
41
+ audio_player_artwork_aspect_ratio: "1:1",
42
+ audio_player_background_image: "https://example.com",
43
+ audio_player_background_color: "black",
44
+ audio_player_channel_icon: "https://example.com",
45
+ audio_player_title_color: "white",
46
+ audio_player_summary_color: "white",
47
+ audio_player_rtl: true,
48
+ start_time: "10:10",
49
+ end_time: "11:11",
50
+ episode_name: "Episode Custom Title",
51
+ episode_summary: "Episode Custom Summary",
52
+ },
53
+ };
54
+
55
+ const basePluginConfiguration = {
56
+ audio_player_background_color: "black",
57
+ audio_player_title_color: "white",
58
+ audio_player_summary_color: "white",
59
+ audio_player_rtl: true,
60
+ audio_player_background_image: "https://example.com",
61
+ audio_player_artwork_aspect_ratio: "1:1",
62
+ };
63
+
64
+ function renderAudioPlayer(
65
+ plugin_configuration,
66
+ content: {
67
+ title?: Option<string | number>;
68
+ summary?: Option<string | number>;
69
+ } = {}
70
+ ) {
71
+ mockContent = {
72
+ title: content.title ?? null,
73
+ summary: content.summary ?? null,
74
+ };
75
+
76
+ return render(
77
+ <AudioPlayerTV
78
+ audio_item={baseAudioItem}
79
+ plugin_configuration={plugin_configuration}
80
+ style={{}}
81
+ />
82
+ );
83
+ }
84
+
85
+ describe("<AudioPlayerTV /> player content", () => {
86
+ it("renders the title and summary published by the player manager", () => {
87
+ const { getByText } = renderAudioPlayer(basePluginConfiguration, {
88
+ title: "Episode Custom Title",
89
+ summary: "Episode Custom Summary",
90
+ });
91
+
92
+ expect(getByText("Episode Custom Title")).toBeTruthy();
93
+ expect(getByText("Episode Custom Summary")).toBeTruthy();
94
+ });
95
+
96
+ it("renders nothing for title/summary when the player published neither", () => {
97
+ const { queryByText } = renderAudioPlayer(basePluginConfiguration);
98
+
99
+ expect(queryByText("tittle")).toBeNull();
100
+ expect(queryByText("Summary")).toBeNull();
101
+ });
102
+
103
+ it("renders blank rather than crashing when no player is active", () => {
104
+ // `usePlayerContent` returns `{ title: null, summary: null }` when there is
105
+ // no player. A regression here means AudioPlayerTV crashes instead of
106
+ // rendering blank.
107
+ mockContent = { title: null, summary: null };
108
+
109
+ const { queryByText } = render(
110
+ <AudioPlayerTV
111
+ audio_item={baseAudioItem}
112
+ plugin_configuration={basePluginConfiguration}
113
+ style={{}}
114
+ />
115
+ );
116
+
117
+ expect(queryByText("tittle")).toBeNull();
118
+ expect(queryByText("Summary")).toBeNull();
119
+ });
120
+ });
@@ -3,6 +3,8 @@ import React, { useCallback, useMemo } from "react";
3
3
  import { platformSelect } from "@applicaster/zapp-react-native-utils/reactUtils";
4
4
  import { useArtworkImage } from "@applicaster/zapp-react-native-utils/audioPlayerUtils";
5
5
 
6
+ import { usePlayerContent } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerContent";
7
+
6
8
  import { AudioPlayerTVLayout } from "./Layout";
7
9
 
8
10
  import { Channel } from "./Channel";
@@ -15,10 +17,12 @@ import { Props } from "../types";
15
17
 
16
18
  export function AudioPlayerTV(props: Props) {
17
19
  const { audio_item, plugin_configuration, style = {} } = props;
18
- const { extensions, title, summary } = audio_item;
20
+ const { extensions } = audio_item;
19
21
 
20
22
  const artwork = useArtworkImage(audio_item);
21
23
 
24
+ const { title, summary } = usePlayerContent();
25
+
22
26
  const getProp = useCallback(
23
27
  getPropertyFromEntryOrConfig({
24
28
  entry: audio_item,
@@ -18,8 +18,11 @@ export const Toggle = (props: Props) => {
18
18
 
19
19
  const { trackColor, trackColorActive, thumbColor, thumbColorActive } = style;
20
20
 
21
+ // `focused_selected` is the selected state with a finger on the row, so the
22
+ // switch has to read as on for both. Leaving it out reads a pressed selected
23
+ // row as off, which flips the thumb for the duration of the press.
21
24
  const isSelected = React.useMemo(
22
- () => cellState === "selected",
25
+ () => cellState === "selected" || cellState === "focused_selected",
23
26
  [item?.id, cellState]
24
27
  );
25
28
 
@@ -30,13 +33,16 @@ export const Toggle = (props: Props) => {
30
33
  // selection state and writes through the entry's tap actions, the same path a
31
34
  // tap on the row takes.
32
35
  //
33
- // The identifier alone cannot tell that case apart from a real action plugin.
34
- // The cell manifest declares an initial_value for `toggle_action_identifier`,
35
- // and `castOrDefault` substitutes that default whenever the configured value
36
- // is empty - and an empty string counts as empty. Clearing the field in the
37
- // app configuration therefore cannot produce an empty identifier here. What
38
- // does distinguish the two is whether the identifier resolves to an installed
39
- // action plugin, so that is what the route is decided on.
36
+ // An empty identifier is the way to ask for that, which is why the cell
37
+ // manifest declares no initial_value for `toggle_action_identifier`:
38
+ // `castOrDefault` substitutes the default whenever the configured value is
39
+ // empty - and an empty string counts as empty - so any default there would be
40
+ // uncleanable and would force every toggle onto that plugin.
41
+ //
42
+ // The route is still decided on whether the identifier resolves to an
43
+ // installed action plugin rather than on the string being non-empty. App
44
+ // configurations saved while the old default was in the manifest still carry
45
+ // it, and a mistyped plugin id has to fall back the same way.
40
46
  const hasActionPlugin = Boolean(
41
47
  action?.identifier && actionContext?.invokeAction
42
48
  );
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { fireEvent, render } from "@testing-library/react-native";
3
+ import { Switch } from "react-native";
3
4
  import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
4
5
 
5
6
  import { Toggle } from "../Toggle";
@@ -24,7 +25,6 @@ const style = {
24
25
  trackColorActive: "rgba(4,207,153,1)",
25
26
  thumbColor: "rgba(255,255,255,1)",
26
27
  thumbColorActive: "rgba(255,255,255,1)",
27
- iosBackgroundColor: "rgba(0,0,0,1)",
28
28
  } as any;
29
29
 
30
30
  const item = { id: "true", title: "I agree" } as any;
@@ -56,6 +56,21 @@ describe("Toggle with an empty action identifier", () => {
56
56
  expect(getByRole("switch").props.value).toBe(false);
57
57
  });
58
58
 
59
+ // A finger on a selected row resolves the cell to focused_selected. Reading
60
+ // that as unselected flipped the thumb for the duration of the press and let
61
+ // it jump back on release, which reads as a second tap.
62
+ it("stays on while a selected cell is pressed", () => {
63
+ const { getByRole } = renderToggle("", "focused_selected");
64
+
65
+ expect(getByRole("switch").props.value).toBe(true);
66
+ });
67
+
68
+ it("is off while an unselected cell is pressed", () => {
69
+ const { getByRole } = renderToggle("", "focused");
70
+
71
+ expect(getByRole("switch").props.value).toBe(false);
72
+ });
73
+
59
74
  it("routes a change through the cell click path", () => {
60
75
  const { getByRole } = renderToggle("", "default");
61
76
 
@@ -70,6 +85,28 @@ describe("Toggle with an empty action identifier", () => {
70
85
 
71
86
  expect(mockInitialEntryState).not.toHaveBeenCalled();
72
87
  });
88
+
89
+ // Asserted on the Switch element rather than the rendered host component:
90
+ // what Toggle owns is the props it hands to Switch, and React Native rewrites
91
+ // them into platform-specific ones (tintColor, a style entry) underneath.
92
+ it("forwards every configured color to the switch", () => {
93
+ const { UNSAFE_getByType } = renderToggle("", "default");
94
+
95
+ expect(UNSAFE_getByType(Switch).props).toMatchObject({
96
+ trackColor: { false: style.trackColor, true: style.trackColorActive },
97
+ thumbColor: style.thumbColor,
98
+ });
99
+ });
100
+
101
+ // ios_backgroundColor paints an opaque layer behind the track that the track
102
+ // color cannot cover, which on iOS reads as a halo around the switch. The
103
+ // switch is left on its platform default instead, so the prop must stay
104
+ // unset even if a style happens to carry a color for it.
105
+ it("does not set an iOS background color on the switch", () => {
106
+ const { UNSAFE_getByType } = renderToggle("", "default");
107
+
108
+ expect(UNSAFE_getByType(Switch).props.ios_backgroundColor).toBeUndefined();
109
+ });
73
110
  });
74
111
 
75
112
  describe("Toggle with an action plugin identifier", () => {
@@ -92,11 +129,11 @@ describe("Toggle with an action plugin identifier", () => {
92
129
  });
93
130
 
94
131
  describe("Toggle with an identifier whose action plugin is not installed", () => {
95
- // The cell manifest declares initial_value "firebase-push-topic-action" for
96
- // toggle_action_identifier, and castOrDefault replaces an empty configured
97
- // value with that default. A form toggle therefore always arrives with a
98
- // non-empty identifier it never asked for - the presence of a real action
99
- // context, not the identifier string, is what decides the route.
132
+ // The cell manifest no longer defaults toggle_action_identifier, but app
133
+ // configurations saved while it did still carry "firebase-push-topic-action",
134
+ // and a mistyped id is always possible. A toggle can therefore still arrive
135
+ // with a non-empty identifier it never asked for - the presence of a real
136
+ // action context, not the identifier string, is what decides the route.
100
137
  beforeEach(() => {
101
138
  jest.clearAllMocks();
102
139
 
@@ -189,12 +189,10 @@ const renderApplePlayer = ({
189
189
  return null;
190
190
  }
191
191
 
192
- const { title, summary } = item || {};
193
-
192
+ // Title/Subtitle are not resolved here: `ProgramInfo` reads them from the
193
+ // player manager's content channel via `usePlayerContent`.
194
194
  return (
195
195
  <ProgramInfo
196
- title={title}
197
- subtitle={summary}
198
196
  plugin_configuration={pluginConfiguration}
199
197
  videoStyle={videoStyle}
200
198
  />
@@ -7,12 +7,11 @@ import * as React from "react";
7
7
  import { Text, TextStyle, View, ViewStyle } from "react-native";
8
8
  import { QBImage as Image } from "@applicaster/zapp-react-native-ui-components/Components/Image";
9
9
 
10
+ import { usePlayerContent } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerContent";
10
11
  import * as programStyles from "./styles";
11
12
 
12
13
  type Props = {
13
14
  logo?: string;
14
- title: string;
15
- subtitle: string;
16
15
  isAudioContent: boolean;
17
16
  styles: {};
18
17
  plugin_configuration: {
@@ -28,13 +27,13 @@ const defaultColor = "#EFEFEF";
28
27
 
29
28
  function ProgramInfoComponent({
30
29
  logo,
31
- title = "",
32
- subtitle = "",
33
30
  isAudioContent,
34
31
  styles,
35
32
  plugin_configuration = {},
36
33
  videoStyle = {},
37
34
  }: Props) {
35
+ const { title = "", summary: subtitle = "" } = usePlayerContent();
36
+
38
37
  const platformKey = styleKeys.style_namespace;
39
38
 
40
39
  const colorTitle =
@@ -17,17 +17,37 @@ jest.mock(
17
17
  );
18
18
 
19
19
  jest.mock("@applicaster/zapp-react-native-utils/reactUtils", () => ({
20
+ // Defaults to `false` for the hardware-back-handling tests below, which
21
+ // key off `isTV`. The ProgramInfo/content-context describe block below
22
+ // overrides this per test (`isTV()` is also what PlayerContainer's
23
+ // `playerContentValue` uses to pick TV vs mobile data keys, decoupled
24
+ // from `isTvOSPlatform` below).
20
25
  isTV: jest.fn(() => false),
21
26
  isAndroidTVPlatform: jest.fn(() => false),
22
- isTvOSPlatform: jest.fn(() => false),
27
+ // `isTvOSPlatform` backs a module-level constant in PlayerContainer.tsx that
28
+ // is read once at import time, so it must already be `true` here in order
29
+ // to exercise `renderApplePlayer`'s tvOS-only ProgramInfo branch in this
30
+ // test file. It does not affect the hardware-back-handling tests below,
31
+ // which key off `isTV` instead.
32
+ isTvOSPlatform: jest.fn(() => true),
23
33
  isApplePlatform: jest.fn(() => true),
24
34
  platformSelect: jest.fn(({ native }) => native),
25
35
  }));
26
36
 
27
- jest.mock("@applicaster/zapp-react-native-utils/playerUtils", () => ({
28
- isAudioItem: jest.fn(() => false),
29
- isInlineTV: jest.fn(() => false),
30
- }));
37
+ jest.mock("@applicaster/zapp-react-native-utils/playerUtils", () => {
38
+ // Keep the real data-key resolver (createConfigValueGetter,
39
+ // resolvePlayerContentText, VIDEO_PLAYER_CONTENT_DATA_KEYS) so the tvOS
40
+ // ProgramInfo data-key tests exercise actual resolution logic, not a stub.
41
+ const actual = jest.requireActual(
42
+ "@applicaster/zapp-react-native-utils/playerUtils"
43
+ );
44
+
45
+ return {
46
+ ...actual,
47
+ isAudioItem: jest.fn(() => false),
48
+ isInlineTV: jest.fn(() => false),
49
+ };
50
+ });
31
51
 
32
52
  jest.mock(
33
53
  "@applicaster/zapp-react-native-tvos-ui-components/Components/TVEventHandlerComponent",
@@ -76,6 +96,11 @@ jest.mock("@applicaster/zapp-react-native-bridge/QuickBrick", () => ({
76
96
  sendQuickBrickEvent: jest.fn(),
77
97
  }));
78
98
 
99
+ // Stubbed so PlayerContainer can render without ProgramInfo's own
100
+ // redux/style plumbing. It no longer needs to capture Title/Subtitle: those
101
+ // come from the player manager's content channel, and the key-set selection
102
+ // they depend on is covered directly in
103
+ // `playerManager/__tests__/playerContent.test.ts`.
79
104
  jest.mock("../ProgramInfo", () => ({
80
105
  ProgramInfo: () => null,
81
106
  }));
@@ -143,14 +168,33 @@ jest.mock(
143
168
  jest.mock("../PlayerContainerContext", () => {
144
169
  const ReactActual = require("react");
145
170
 
171
+ const PlayerContainerContext = ReactActual.createContext({
172
+ isLanguageOverlayVisible: false,
173
+ bottomFocusableId: "bottom",
174
+ showComponentsContainer: false,
175
+ refs: null,
176
+ });
177
+
146
178
  return {
147
- PlayerContainerContext: ReactActual.createContext({
148
- isLanguageOverlayVisible: false,
149
- bottomFocusableId: "bottom",
150
- showComponentsContainer: false,
151
- refs: null,
152
- }),
153
- PlayerContainerContextProvider: ({ children }) => children,
179
+ PlayerContainerContext,
180
+ // Mirrors just enough of the real provider for these tests: renders an
181
+ // actual `PlayerContainerContext.Provider` so the real
182
+ // `PlayerContainerContext.Consumer` inside PlayerContainer.tsx sees the
183
+ // container fields a real provider would give it, instead of a bare
184
+ // `({ children }) => children` passthrough.
185
+ PlayerContainerContextProvider: ({ children }: any) =>
186
+ ReactActual.createElement(
187
+ PlayerContainerContext.Provider,
188
+ {
189
+ value: {
190
+ isLanguageOverlayVisible: false,
191
+ bottomFocusableId: "bottom",
192
+ showComponentsContainer: false,
193
+ refs: null,
194
+ },
195
+ },
196
+ children
197
+ ),
154
198
  };
155
199
  });
156
200
 
@@ -203,7 +247,14 @@ const { __player: mockPlayer } = jest.requireMock(
203
247
  "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer"
204
248
  );
205
249
 
206
- const Player = React.forwardRef(() => null);
250
+ const { isTV: mockIsTV } = jest.requireMock(
251
+ "@applicaster/zapp-react-native-utils/reactUtils"
252
+ );
253
+
254
+ // Renders children so that `renderApplePlayer`'s returned <ProgramInfo>
255
+ // element (passed as this component's `children` prop) actually mounts —
256
+ // otherwise React never invokes the ProgramInfo function component at all.
257
+ const Player = React.forwardRef((props: any, _ref) => props.children ?? null);
207
258
 
208
259
  const item = {
209
260
  id: "entry-1",
@@ -237,6 +288,7 @@ const pressHardwareBack = () => {
237
288
  describe("PlayerContainer hardware back handling", () => {
238
289
  beforeEach(() => {
239
290
  jest.clearAllMocks();
291
+ mockIsTV.mockReturnValue(false);
240
292
  mockBackHandlerRef.current = null;
241
293
  mockPlayer.getPluginConfiguration.mockReturnValue({});
242
294
  mockPlayer.isPaused.mockReturnValue(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "15.1.8",
3
+ "version": "15.1.9-alpha.3547421944",
4
4
  "description": "Applicaster Zapp React Native ui components for the Quick Brick App",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -28,10 +28,10 @@
28
28
  },
29
29
  "homepage": "https://github.com/applicaster/quickbrick#readme",
30
30
  "dependencies": {
31
- "@applicaster/applicaster-types": "15.1.8",
32
- "@applicaster/zapp-react-native-bridge": "15.1.8",
33
- "@applicaster/zapp-react-native-redux": "15.1.8",
34
- "@applicaster/zapp-react-native-utils": "15.1.8",
31
+ "@applicaster/applicaster-types": "15.1.9-alpha.3547421944",
32
+ "@applicaster/zapp-react-native-bridge": "15.1.9-alpha.3547421944",
33
+ "@applicaster/zapp-react-native-redux": "15.1.9-alpha.3547421944",
34
+ "@applicaster/zapp-react-native-utils": "15.1.9-alpha.3547421944",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "url": "^0.11.0",