@applicaster/zapp-react-native-utils 16.0.0-rc.3 → 16.0.0-rc.30

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 (50) hide show
  1. package/actionsExecutor/ActionExecutorContext.tsx +27 -5
  2. package/analyticsUtils/AnalyticsEvents/sendMenuClickEvent.ts +2 -2
  3. package/analyticsUtils/PlayerAnalyticsManager.ts +11 -1
  4. package/analyticsUtils/__tests__/analyticsMapper.test.ts +35 -0
  5. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testACP_events.json +1 -1
  6. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testBlockUnlistedParams_rules.json +1 -1
  7. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEmptyRenameKeepsName_events.json +4 -0
  8. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEmptyRenameKeepsName_rules.json +6 -0
  9. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEventRegex_events.json +3 -9
  10. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testIgnoreOrdering_events.json +18 -0
  11. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testIgnoreOrdering_rules.json +16 -0
  12. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexEventNoFallback_events.json +4 -0
  13. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexEventNoFallback_rules.json +6 -0
  14. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexMultiGroupRename_events.json +4 -0
  15. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexMultiGroupRename_rules.json +6 -0
  16. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexNonNamedFullMatch_events.json +4 -0
  17. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexNonNamedFullMatch_rules.json +6 -0
  18. package/analyticsUtils/__tests__/fixtures/index.js +20 -0
  19. package/analyticsUtils/analyticsMapper.ts +4 -1
  20. package/analyticsUtils/playerAnalyticsTracker.ts +26 -3
  21. package/appUtils/HooksManager/index.ts +3 -8
  22. package/appUtils/contextKeysManager/utils/index.ts +38 -25
  23. package/appUtils/playerManager/__tests__/playerFactory.test.ts +150 -0
  24. package/appUtils/playerManager/playerFactory.ts +17 -34
  25. package/cellUtils/index.ts +3 -5
  26. package/colorUtils/__tests__/isTransparentColor.test.ts +76 -0
  27. package/colorUtils/__tests__/isValidColor.test.ts +70 -0
  28. package/colorUtils/index.ts +50 -0
  29. package/manifestUtils/defaultManifestConfigurations/generalContent.js +35 -0
  30. package/manifestUtils/keys.js +1 -0
  31. package/manifestUtils/platformIsTV.js +1 -0
  32. package/package.json +2 -2
  33. package/pipesUtils/__tests__/buildUrlWithQuery.test.ts +33 -0
  34. package/pipesUtils/__tests__/withPipesEndpoint.test.tsx +56 -0
  35. package/pipesUtils/index.ts +1 -0
  36. package/pipesUtils/withPipesEndpoint.tsx +54 -0
  37. package/playerUtils/index.ts +24 -2
  38. package/reactHooks/cell-click/index.ts +15 -7
  39. package/reactHooks/feed/__mocks__/useMarkPipesDataStale.ts +4 -0
  40. package/reactHooks/feed/index.ts +5 -1
  41. package/reactHooks/feed/useBatchLoading.ts +9 -1
  42. package/reactHooks/feed/{usePipesCacheReset.ts → useMarkPipesDataStale.ts} +12 -4
  43. package/reactHooks/screen/__tests__/useIsStandaloneFullscreen.test.ts +114 -0
  44. package/reactHooks/screen/index.ts +2 -0
  45. package/reactHooks/screen/useIsStandaloneFullscreen.ts +12 -0
  46. package/reactHooks/utils/index.ts +3 -2
  47. package/reactHooks/videoModal/hooks/useVideoModalScreenData.tsx +22 -4
  48. package/riverComponetsMeasurementProvider/index.tsx +12 -8
  49. package/zappFrameworkUtils/HookCallback/callbackNavigationAction.ts +1 -1
  50. package/reactHooks/feed/__mocks__/usePipesCacheReset.ts +0 -1
@@ -0,0 +1,150 @@
1
+ const mockAppStoreGet = jest.fn();
2
+ const mockFindPluginByIdentifier = jest.fn();
3
+
4
+ jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
5
+ appStore: { get: (key: string) => mockAppStoreGet(key) },
6
+ }));
7
+
8
+ jest.mock("@applicaster/zapp-react-native-utils/pluginUtils", () => ({
9
+ findPluginByIdentifier: (id: string, plugins: any) =>
10
+ mockFindPluginByIdentifier(id, plugins),
11
+ }));
12
+
13
+ import { PlayerRole } from "../conts";
14
+ import { playerFactory } from "../playerFactory";
15
+
16
+ const fakeComponent = () => null;
17
+
18
+ class FakeController {
19
+ receivedConfig: any;
20
+ constructor(config: any) {
21
+ this.receivedConfig = config;
22
+ }
23
+ }
24
+
25
+ const baseConfig = {
26
+ player: {},
27
+ playerId: "p1",
28
+ autoplay: false,
29
+ entry: { id: "entry-1" } as any,
30
+ muted: false,
31
+ playerPluginId: "test-plugin",
32
+ screenConfig: { foo: "bar" },
33
+ playerRole: PlayerRole.Cell,
34
+ };
35
+
36
+ const installPluginWithProtocol = (
37
+ protocolReturn: any | ((screenConfig: any, entry: any) => any)
38
+ ) => {
39
+ const playerProtocol =
40
+ typeof protocolReturn === "function"
41
+ ? protocolReturn
42
+ : () => protocolReturn;
43
+
44
+ mockAppStoreGet.mockImplementation((key: string) =>
45
+ key === "plugins" ? { "test-plugin": {} } : null
46
+ );
47
+
48
+ mockFindPluginByIdentifier.mockReturnValue({
49
+ module: { playerProtocol },
50
+ });
51
+ };
52
+
53
+ describe("playerFactory", () => {
54
+ beforeEach(() => {
55
+ mockAppStoreGet.mockReset();
56
+ mockFindPluginByIdentifier.mockReset();
57
+ });
58
+
59
+ it("resolves with controller and Component when playerProtocol returns synchronously", async () => {
60
+ installPluginWithProtocol({
61
+ Component: fakeComponent,
62
+ controllerClass: FakeController,
63
+ });
64
+
65
+ const item = await playerFactory(baseConfig);
66
+
67
+ expect(item).not.toBeNull();
68
+ expect(item?.Component).toBe(fakeComponent);
69
+ expect(item?.controller).toBeInstanceOf(FakeController);
70
+
71
+ expect((item?.controller as FakeController).receivedConfig).toBe(
72
+ baseConfig
73
+ );
74
+ });
75
+
76
+ it("resolves with controller and Component when playerProtocol returns a Promise (delayed import)", async () => {
77
+ installPluginWithProtocol(async () => ({
78
+ Component: fakeComponent,
79
+ controllerClass: FakeController,
80
+ }));
81
+
82
+ const item = await playerFactory(baseConfig);
83
+
84
+ expect(item).not.toBeNull();
85
+ expect(item?.Component).toBe(fakeComponent);
86
+ expect(item?.controller).toBeInstanceOf(FakeController);
87
+ });
88
+
89
+ it("passes screenConfig and entry through to playerProtocol", async () => {
90
+ const playerProtocol = jest.fn(() => ({
91
+ Component: fakeComponent,
92
+ controllerClass: FakeController,
93
+ }));
94
+
95
+ mockAppStoreGet.mockImplementation((key: string) =>
96
+ key === "plugins" ? { "test-plugin": {} } : null
97
+ );
98
+
99
+ mockFindPluginByIdentifier.mockReturnValue({ module: { playerProtocol } });
100
+
101
+ await playerFactory(baseConfig);
102
+
103
+ expect(playerProtocol).toHaveBeenCalledWith(
104
+ baseConfig.screenConfig,
105
+ baseConfig.entry
106
+ );
107
+ });
108
+
109
+ it("resolves to null when playerPluginId is missing", async () => {
110
+ expect(
111
+ await playerFactory({ ...baseConfig, playerPluginId: "" } as any)
112
+ ).toBeNull();
113
+ });
114
+
115
+ it("resolves to null when appStore has no plugins", async () => {
116
+ mockAppStoreGet.mockReturnValue(null);
117
+
118
+ expect(await playerFactory(baseConfig)).toBeNull();
119
+ });
120
+
121
+ it("resolves to null when the plugin has no playerProtocol", async () => {
122
+ mockAppStoreGet.mockImplementation((key: string) =>
123
+ key === "plugins" ? { "test-plugin": {} } : null
124
+ );
125
+
126
+ mockFindPluginByIdentifier.mockReturnValue({ module: {} });
127
+
128
+ expect(await playerFactory(baseConfig)).toBeNull();
129
+ });
130
+
131
+ it("resolves to null when playerProtocol returns without a Component", async () => {
132
+ installPluginWithProtocol({ controllerClass: FakeController });
133
+
134
+ expect(await playerFactory(baseConfig)).toBeNull();
135
+ });
136
+
137
+ it("resolves to null when playerProtocol returns without a controllerClass", async () => {
138
+ installPluginWithProtocol({ Component: fakeComponent });
139
+
140
+ expect(await playerFactory(baseConfig)).toBeNull();
141
+ });
142
+
143
+ it("rejects when playerProtocol throws synchronously", async () => {
144
+ installPluginWithProtocol(() => {
145
+ throw new Error("boom");
146
+ });
147
+
148
+ await expect(playerFactory(baseConfig)).rejects.toThrow("boom");
149
+ });
150
+ });
@@ -19,52 +19,35 @@ type PlayerFactoryProps = {
19
19
  playerRole: PlayerRole;
20
20
  };
21
21
 
22
- interface PlayerConstructor {
22
+ interface PlayerControllerConstructor {
23
23
  new (params: any): Player;
24
24
  }
25
25
 
26
- export const playerFactory = (
27
- config: PlayerFactoryProps
28
- ): PlayerFactoryItem | null => {
29
- const IDENTIFIER = config?.playerPluginId;
26
+ type PlayerProtocol = {
27
+ Component: any;
28
+ controllerClass: PlayerControllerConstructor;
29
+ };
30
30
 
31
- if (!IDENTIFIER) {
32
- return null;
33
- }
31
+ export const playerFactory = async (
32
+ config: PlayerFactoryProps
33
+ ): Promise<PlayerFactoryItem | null> => {
34
+ if (!config?.playerPluginId) return null;
34
35
 
35
36
  const plugins = appStore.get("plugins");
37
+ if (!plugins) return null;
36
38
 
37
- if (!plugins) {
38
- return null;
39
- }
40
-
41
- const playerPlugin = findPluginByIdentifier(IDENTIFIER, plugins);
39
+ const playerPlugin = findPluginByIdentifier(config.playerPluginId, plugins);
40
+ if (!playerPlugin?.module?.playerProtocol) return null;
42
41
 
43
- if (!playerPlugin?.module?.playerProtocol) {
44
- return null;
45
- }
46
-
47
- const playerProtocol: PlayerConstructor = playerPlugin.module.playerProtocol(
48
- config?.screenConfig,
49
- config?.entry
50
- );
42
+ const playerProtocol: PlayerProtocol | null =
43
+ await playerPlugin.module.playerProtocol(config.screenConfig, config.entry);
51
44
 
52
- const playerView = (playerProtocol as any)?.Component || null;
53
-
54
- if (!playerView) {
45
+ if (!playerProtocol?.Component || !playerProtocol.controllerClass) {
55
46
  return null;
56
47
  }
57
48
 
58
- const Controller = (playerProtocol as any)?.controllerClass || null;
59
-
60
- if (Controller === null) {
61
- return null;
62
- }
63
-
64
- const controller = new Controller(config);
65
-
66
49
  return {
67
- controller,
68
- Component: playerView,
50
+ controller: new playerProtocol.controllerClass(config),
51
+ Component: playerProtocol.Component,
69
52
  };
70
53
  };
@@ -1,7 +1,7 @@
1
1
  import * as R from "ramda";
2
2
  import { dayjs } from "../dateUtils";
3
- import validateColor from "validate-color";
4
3
 
4
+ import { isValidColor } from "@applicaster/zapp-react-native-utils/colorUtils";
5
5
  import { transformColorCode as fixColorHexCode } from "@applicaster/zapp-react-native-utils/transform";
6
6
  import {
7
7
  capitalize,
@@ -468,15 +468,13 @@ export const getColorFromData = ({
468
468
  data,
469
469
  valueFromLayout,
470
470
  }: GetColorFromData): string => {
471
- // Temporary hack to fix color validation when alpha is floating point number
472
- // https://github.com/dreamyguy/validate-color/issues/44
473
- if (validateColor(valueFromLayout.replace(".00", ""))) {
471
+ if (isValidColor(valueFromLayout)) {
474
472
  return valueFromLayout;
475
473
  }
476
474
 
477
475
  const pathValue = R.path(valueFromLayout.split("."), data);
478
476
 
479
- if (pathValue && validateColor(pathValue)) {
477
+ if (pathValue && isValidColor(pathValue)) {
480
478
  return pathValue;
481
479
  }
482
480
 
@@ -0,0 +1,76 @@
1
+ import { isTransparentColor } from "..";
2
+
3
+ describe("isTransparentColor", () => {
4
+ it("returns true for transparent keyword and rgba colors with zero alpha", () => {
5
+ const transparentColors = [
6
+ "transparent",
7
+ "rgba(0,0,0,0)",
8
+ "rgba(255, 255, 255, 0)",
9
+ "rgba(0,0,0,0.0)",
10
+ "rgba(0,0,0,0.00)",
11
+ "rgba(0,0,0, 0)",
12
+ "hsla(0,0%,0%,0)",
13
+ ];
14
+
15
+ expect.assertions(transparentColors.length);
16
+
17
+ transparentColors.forEach((color) => {
18
+ expect(isTransparentColor(color)).toBe(true);
19
+ });
20
+ });
21
+
22
+ it("returns false for valid colors that are not fully transparent", () => {
23
+ const nonTransparentColors = [
24
+ "red",
25
+ "#fff",
26
+ "#ffffff",
27
+ "rgb(0,0,0)",
28
+ "rgba(0,0,0)",
29
+ "rgba(0,0,0,0.5)",
30
+ "rgba(255, 255, 255, 0.3)",
31
+ "rgba(255, 255, 255, 1.0)",
32
+ "rgba(239,239,239,1.0)",
33
+ "currentColor",
34
+ "inherit",
35
+ "#00000000",
36
+ "#fff0",
37
+ ];
38
+
39
+ expect.assertions(nonTransparentColors.length);
40
+
41
+ nonTransparentColors.forEach((color) => {
42
+ expect(isTransparentColor(color)).toBe(false);
43
+ });
44
+ });
45
+
46
+ it("returns false for case variants of the transparent keyword", () => {
47
+ expect(isTransparentColor("Transparent")).toBe(false);
48
+ expect(isTransparentColor("TRANSPARENT")).toBe(false);
49
+ });
50
+
51
+ it("returns false for invalid color strings", () => {
52
+ const invalidColors = [
53
+ "invalid",
54
+ "",
55
+ "#gggggg",
56
+ "#fff.00",
57
+ "unset",
58
+ " rgba(0,0,0,0) ",
59
+ "transparent ",
60
+ ];
61
+
62
+ expect.assertions(invalidColors.length);
63
+
64
+ invalidColors.forEach((color) => {
65
+ expect(isTransparentColor(color)).toBe(false);
66
+ });
67
+ });
68
+
69
+ it("returns false for nullish and non-string values", () => {
70
+ expect(isTransparentColor(undefined)).toBe(false);
71
+ expect(isTransparentColor(null)).toBe(false);
72
+ expect(isTransparentColor(123)).toBe(false);
73
+ expect(isTransparentColor({})).toBe(false);
74
+ expect(isTransparentColor([])).toBe(false);
75
+ });
76
+ });
@@ -0,0 +1,70 @@
1
+ import { isValidColor } from "..";
2
+
3
+ describe("isValidColor", () => {
4
+ it("returns true for valid hex colors", () => {
5
+ const validHexColors = ["#fff", "#ffffff", "#FF0000", "#000", "#abc123"];
6
+
7
+ expect.assertions(validHexColors.length);
8
+
9
+ validHexColors.forEach((color) => {
10
+ expect(isValidColor(color)).toBe(true);
11
+ });
12
+ });
13
+
14
+ it("returns true for valid rgb and rgba colors", () => {
15
+ const validRgbColors = [
16
+ "rgb(255,0,0)",
17
+ "rgb(255, 0, 0)",
18
+ "rgba(255,0,0,0.5)",
19
+ "rgba(255, 0, 0, 0.5)",
20
+ ];
21
+
22
+ expect.assertions(validRgbColors.length);
23
+
24
+ validRgbColors.forEach((color) => {
25
+ expect(isValidColor(color)).toBe(true);
26
+ });
27
+ });
28
+
29
+ it("returns true for valid named and special CSS colors", () => {
30
+ const validNamedColors = ["red", "transparent", "currentColor", "inherit"];
31
+
32
+ expect.assertions(validNamedColors.length);
33
+
34
+ validNamedColors.forEach((color) => {
35
+ expect(isValidColor(color)).toBe(true);
36
+ });
37
+ });
38
+
39
+ it("returns true for valid hsl colors", () => {
40
+ expect(isValidColor("hsl(0, 100%, 50%)")).toBe(true);
41
+ });
42
+
43
+ it("returns false for invalid color strings", () => {
44
+ const invalidColors = [
45
+ "invalid",
46
+ "",
47
+ "#gggggg",
48
+ "#fff.00",
49
+ "unset",
50
+ undefined,
51
+ null,
52
+ ];
53
+
54
+ expect.assertions(invalidColors.length);
55
+
56
+ invalidColors.forEach((color) => {
57
+ expect(isValidColor(color)).toBe(false);
58
+ });
59
+ });
60
+
61
+ it("returns true for valid rgb and rgba colors", () => {
62
+ const validRgbColors = ["rgba(239,239,239,1.0)"];
63
+
64
+ expect.assertions(validRgbColors.length);
65
+
66
+ validRgbColors.forEach((color) => {
67
+ expect(isValidColor(color)).toBe(true);
68
+ });
69
+ });
70
+ });
@@ -0,0 +1,50 @@
1
+ import validateColor from "validate-color";
2
+ import { isNil } from "@applicaster/zapp-react-native-utils/utils";
3
+ import { isString } from "@applicaster/zapp-react-native-utils/stringUtils";
4
+
5
+ const normalizeRgbaAlpha = (color: string): string => {
6
+ return color.replace(
7
+ /^rgba\(([^)]+),\s*(\d+(?:\.\d+)?)\s*\)$/i,
8
+ (_, rgb, alpha) => {
9
+ const alphaValue = parseFloat(alpha);
10
+
11
+ if (!Number.isFinite(alphaValue) || !Number.isInteger(alphaValue)) {
12
+ return color;
13
+ }
14
+
15
+ return `rgba(${rgb},${alphaValue})`;
16
+ }
17
+ );
18
+ };
19
+
20
+ export const isValidColor = (color: string): boolean => {
21
+ if (isNil(color) || !isString(color)) {
22
+ return false;
23
+ }
24
+
25
+ if (validateColor(color)) {
26
+ return true;
27
+ }
28
+
29
+ // validate-color rejects integer alpha values written as floats (e.g. 1.0)
30
+ // https://github.com/dreamyguy/validate-color/issues/44
31
+ const normalizedColor = normalizeRgbaAlpha(color);
32
+
33
+ return normalizedColor !== color && validateColor(normalizedColor);
34
+ };
35
+
36
+ function isRgbaAlphaZero(color: string): boolean {
37
+ const layers = color
38
+ .replace("rgba(", "")
39
+ .replace(")", "")
40
+ .split(",")
41
+ .map((layer) => layer.trim());
42
+
43
+ return Number(layers[3]) === 0;
44
+ }
45
+
46
+ export const isTransparentColor = (color: string): boolean => {
47
+ return (
48
+ isValidColor(color) && (color === "transparent" || isRgbaAlphaZero(color))
49
+ );
50
+ };
@@ -308,6 +308,41 @@ const generalContent = () => ({
308
308
  key: "pull_to_refresh_enabled",
309
309
  initial_value: false,
310
310
  },
311
+ {
312
+ type: "switch",
313
+ label: "Allow using this screen as a hook",
314
+ label_tooltip:
315
+ "Make sure that screen uses 'finishHook' action or performs navigation action to exit the screen after performing the hook action, or user will be stuck on the screen", // eslint-disable-line max-len
316
+ key: "available_as_hook",
317
+ initial_value: false,
318
+ },
319
+ {
320
+ type: "data_source_selector",
321
+ label: "Skip hook endpoint",
322
+ key: "skip_hook_endpoint",
323
+ label_tooltip:
324
+ "If set, this endpoint will check with the server whether the hook should be skipped",
325
+ conditional_fields: [
326
+ {
327
+ key: "rules/available_as_hook",
328
+ condition_value: true,
329
+ },
330
+ ],
331
+ },
332
+ {
333
+ key: "skip_hook_storage_key",
334
+ type: "text_input",
335
+ label: "Hook will be skipped if storage key is set",
336
+ initial_value: "",
337
+ label_tooltip:
338
+ "Comma-separated keys in namespace.key format (key without a dot uses the default namespace)", // eslint-disable-line max-len
339
+ conditional_fields: [
340
+ {
341
+ key: "rules/available_as_hook",
342
+ condition_value: true,
343
+ },
344
+ ],
345
+ },
311
346
  ],
312
347
  },
313
348
  });
@@ -43,6 +43,7 @@ const TV_PLATFORMS = [
43
43
  "tvos_for_quickbrick",
44
44
  "samsung_tv",
45
45
  "lg_tv",
46
+ "vidaa",
46
47
  "vizio",
47
48
  ];
48
49
 
@@ -4,6 +4,7 @@ function platformIsTV(platform) {
4
4
  "android_tv_for_quickbrick",
5
5
  "amazon_fire_tv_for_quickbrick",
6
6
  "lg_tv",
7
+ "vidaa",
7
8
  "samsung_tv",
8
9
  "web",
9
10
  "vizio",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-rc.3",
3
+ "version": "16.0.0-rc.30",
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.3",
30
+ "@applicaster/applicaster-types": "16.0.0-rc.30",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -0,0 +1,33 @@
1
+ import { buildUrlWithQuery } from "../withPipesEndpoint";
2
+
3
+ describe("buildUrlWithQuery", () => {
4
+ it("returns the url unchanged when requestParams is null", () => {
5
+ expect(buildUrlWithQuery("https://foo.com/path", null)).toBe(
6
+ "https://foo.com/path"
7
+ );
8
+ });
9
+
10
+ it("returns the url unchanged when requestParams has no params", () => {
11
+ expect(buildUrlWithQuery("https://foo.com/path", { headers: {} })).toBe(
12
+ "https://foo.com/path"
13
+ );
14
+ });
15
+
16
+ it("returns the url unchanged when url is empty", () => {
17
+ expect(buildUrlWithQuery("", { params: { a: "1" } })).toBe("");
18
+ });
19
+
20
+ it("appends params to a url with no existing query", () => {
21
+ expect(
22
+ buildUrlWithQuery("https://foo.com/path", { params: { token: "abc" } })
23
+ ).toBe("https://foo.com/path?token=abc");
24
+ });
25
+
26
+ it("merges params with an existing query string", () => {
27
+ expect(
28
+ buildUrlWithQuery("https://foo.com/path?existing=1", {
29
+ params: { token: "abc" },
30
+ })
31
+ ).toBe("https://foo.com/path?existing=1&token=abc");
32
+ });
33
+ });
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+ import { render } from "@testing-library/react-native";
3
+ import { Text } from "react-native";
4
+
5
+ const mockedUseBuildPipesUrl = jest.fn();
6
+
7
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks/feed", () => ({
8
+ useBuildPipesUrl: (args) => mockedUseBuildPipesUrl(args),
9
+ }));
10
+
11
+ const { withPipesEndpoint } = require("../withPipesEndpoint");
12
+
13
+ // eslint-disable-next-line react/display-name
14
+ const Wrapped = React.forwardRef((props: any, _ref) => (
15
+ <Text testID="wrapped">{`${props.uri}|${JSON.stringify(props.headers)}`}</Text>
16
+ ));
17
+
18
+ describe("withPipesEndpoint", () => {
19
+ afterEach(() => jest.clearAllMocks());
20
+
21
+ it("renders nothing while the endpoint context is resolving", () => {
22
+ mockedUseBuildPipesUrl.mockReturnValue({ requestParams: null });
23
+
24
+ const Decorated = withPipesEndpoint(Wrapped);
25
+ const { queryByTestId } = render(<Decorated uri="https://foo.com" />);
26
+
27
+ expect(queryByTestId("wrapped")).toBeNull();
28
+ });
29
+
30
+ it("passes the uri through unchanged when there are no params", () => {
31
+ mockedUseBuildPipesUrl.mockReturnValue({ requestParams: {} });
32
+
33
+ const Decorated = withPipesEndpoint(Wrapped);
34
+ const { getByTestId } = render(<Decorated uri="https://foo.com/path" />);
35
+
36
+ expect(getByTestId("wrapped").props.children).toBe(
37
+ "https://foo.com/path|{}"
38
+ );
39
+ });
40
+
41
+ it("merges resolved params into the uri and forwards headers", () => {
42
+ mockedUseBuildPipesUrl.mockReturnValue({
43
+ requestParams: {
44
+ params: { token: "abc" },
45
+ headers: { Authorization: "Bearer xyz" },
46
+ },
47
+ });
48
+
49
+ const Decorated = withPipesEndpoint(Wrapped);
50
+ const { getByTestId } = render(<Decorated uri="https://foo.com/path" />);
51
+
52
+ expect(getByTestId("wrapped").props.children).toBe(
53
+ 'https://foo.com/path?token=abc|{"Authorization":"Bearer xyz"}'
54
+ );
55
+ });
56
+ });
@@ -0,0 +1 @@
1
+ export { withPipesEndpoint, buildUrlWithQuery } from "./withPipesEndpoint";
@@ -0,0 +1,54 @@
1
+ import React, { forwardRef, RefObject } from "react";
2
+ import URL from "url";
3
+
4
+ import { useBuildPipesUrl } from "@applicaster/zapp-react-native-utils/reactHooks/feed";
5
+
6
+ /**
7
+ * Merges the query params resolved from a pipes endpoint context into the url.
8
+ * Returns the url unchanged when there is nothing to merge.
9
+ */
10
+ export function buildUrlWithQuery(url: string, requestParams): string {
11
+ if (!url || !requestParams?.params) {
12
+ return url;
13
+ }
14
+
15
+ const parsedURL = URL.parse(url, true);
16
+
17
+ parsedURL.query = { ...parsedURL.query, ...requestParams.params };
18
+ parsedURL.search = null;
19
+
20
+ return URL.format(parsedURL);
21
+ }
22
+
23
+ type Props = {
24
+ uri: string;
25
+ } & Record<string, unknown>;
26
+
27
+ /**
28
+ * HOC that resolves the wrapped component's `uri` against its matching Zapp
29
+ * Pipes endpoint. Endpoint context keys are added as query params (merged into
30
+ * the uri) and as request `headers`. Rendering is deferred until the context
31
+ * has been resolved so the component never loads without its required headers.
32
+ */
33
+ export function withPipesEndpoint(Component) {
34
+ function WithPipesEndpoint(props: Props, ref: RefObject<unknown>) {
35
+ const { requestParams } = useBuildPipesUrl({ url: props.uri });
36
+
37
+ if (requestParams !== null) {
38
+ const urlWithQuery = buildUrlWithQuery(props.uri, requestParams);
39
+
40
+ return (
41
+ <Component
42
+ ref={ref}
43
+ {...props}
44
+ uri={urlWithQuery}
45
+ headers={requestParams.headers || {}}
46
+ />
47
+ );
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ return forwardRef(WithPipesEndpoint);
54
+ }