@applicaster/zapp-react-native-ui-components 16.0.0-rc.113 → 16.0.0-rc.114
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.
- package/Components/AudioPlayer/tv/Layout.tsx +3 -3
- package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +40 -0
- package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +55 -15
- package/Components/ModalComponent/BottomSheetModalContent.tsx +5 -1
- package/Components/ModalComponent/__tests__/showTextInput.test.ts +25 -0
- package/Components/ModalComponent/showTextInput.ts +2 -0
- package/Components/River/ComponentsMap/ComponentsMap.tsx +4 -1
- package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
- package/Decorators/ZappPipesDataConnector/__tests__/screenToolbox.test.ts +347 -0
- package/Decorators/ZappPipesDataConnector/__tests__/screenToolboxUnavailable.test.ts +53 -0
- package/Decorators/ZappPipesDataConnector/index.tsx +19 -4
- package/Decorators/ZappPipesDataConnector/screenToolbox.ts +384 -0
- package/package.json +5 -5
|
@@ -7,7 +7,7 @@ import { Artwork } from "./Artwork";
|
|
|
7
7
|
import { directionStyles } from "./helpers";
|
|
8
8
|
|
|
9
9
|
type Props = {
|
|
10
|
-
artwork: string
|
|
10
|
+
artwork: Option<string>;
|
|
11
11
|
config: {
|
|
12
12
|
titleColor: string;
|
|
13
13
|
summaryColor: string;
|
|
@@ -152,7 +152,7 @@ export function AudioPlayerTVLayout({
|
|
|
152
152
|
resizeMode="cover"
|
|
153
153
|
>
|
|
154
154
|
<View style={[mainContainerStyles, backgroundOverlayStyles]}>
|
|
155
|
-
<Artwork srcImage={artwork} config={config} />
|
|
155
|
+
{artwork ? <Artwork srcImage={artwork} config={config} /> : null}
|
|
156
156
|
<View style={textContainerStyles}>{children}</View>
|
|
157
157
|
</View>
|
|
158
158
|
</ImageBackground>
|
|
@@ -161,7 +161,7 @@ export function AudioPlayerTVLayout({
|
|
|
161
161
|
|
|
162
162
|
return (
|
|
163
163
|
<View style={mainContainerStyles}>
|
|
164
|
-
<Artwork srcImage={artwork} config={config} />
|
|
164
|
+
{artwork ? <Artwork srcImage={artwork} config={config} /> : null}
|
|
165
165
|
<View style={textContainerStyles}>{children}</View>
|
|
166
166
|
</View>
|
|
167
167
|
);
|
|
@@ -287,6 +287,46 @@ describe("mergeButtonConfiguration background", () => {
|
|
|
287
287
|
expect(merged.title.androidLetterSpacing).toBe(-0.5);
|
|
288
288
|
expect(merged.title.textTransform).toBe("uppercase");
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
it("uses the family string inside a font-selector object and ignores {}", () => {
|
|
292
|
+
const fromObject = mergeButtonConfiguration(buttonBase as any, {
|
|
293
|
+
title: {
|
|
294
|
+
default: { fontFamily: { fontFamily: "GreycliffCF-Bold" } },
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(fromObject.title.iosFontFamily).toBe("GreycliffCF-Bold");
|
|
299
|
+
expect(fromObject.title.androidFontFamily).toBe("GreycliffCF-Bold");
|
|
300
|
+
|
|
301
|
+
const fromEmpty = mergeButtonConfiguration(buttonBase as any, {
|
|
302
|
+
title: {
|
|
303
|
+
default: { fontFamily: {} },
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(fromEmpty.title.iosFontFamily).toBe(buttonBase.title.iosFontFamily);
|
|
308
|
+
|
|
309
|
+
expect(fromEmpty.title.androidFontFamily).toBe(
|
|
310
|
+
buttonBase.title.androidFontFamily
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("maps inactive color, gutter, and focused and inactive font colors", () => {
|
|
315
|
+
const merged = mergeButtonConfiguration(buttonBase as any, {
|
|
316
|
+
background: {
|
|
317
|
+
default: { inactiveColor: "#999999", horizontalGutter: 6 },
|
|
318
|
+
},
|
|
319
|
+
title: {
|
|
320
|
+
default: { inactiveFontColor: "#111111" },
|
|
321
|
+
focused: { fontColor: "#222222" },
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(merged.background.inactiveColor).toBe("#999999");
|
|
326
|
+
expect(merged.background.horizontalGutter).toBe(6);
|
|
327
|
+
expect(merged.title.inactiveFontColor).toBe("#111111");
|
|
328
|
+
expect(merged.title.focusedFontColor).toBe("#222222");
|
|
329
|
+
});
|
|
290
330
|
});
|
|
291
331
|
|
|
292
332
|
describe("mergeHeaderConfiguration section header (Variant 3)", () => {
|
|
@@ -381,6 +381,22 @@ export function mergeHeaderConfiguration(
|
|
|
381
381
|
// Button Merging Helpers
|
|
382
382
|
// ---------------------------------------------------------------------------
|
|
383
383
|
|
|
384
|
+
function fontFamilyName(value: unknown): string | undefined {
|
|
385
|
+
if (typeof value === "string" && value.trim()) {
|
|
386
|
+
return value;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (value && typeof value === "object") {
|
|
390
|
+
const family = (value as { fontFamily?: unknown }).fontFamily;
|
|
391
|
+
|
|
392
|
+
if (typeof family === "string" && family.trim()) {
|
|
393
|
+
return family;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
384
400
|
function mergeButtonBackground(
|
|
385
401
|
base: ButtonBackgroundConfiguration,
|
|
386
402
|
defaultOverride?: Record<string, any>,
|
|
@@ -399,6 +415,9 @@ function mergeButtonBackground(
|
|
|
399
415
|
...(defaultOverride?.backgroundColor
|
|
400
416
|
? { defaultColor: defaultOverride.backgroundColor }
|
|
401
417
|
: {}),
|
|
418
|
+
...(defaultOverride?.inactiveColor
|
|
419
|
+
? { inactiveColor: defaultOverride.inactiveColor }
|
|
420
|
+
: {}),
|
|
402
421
|
...(focusedColor != null && focusedColor !== "" ? { focusedColor } : {}),
|
|
403
422
|
...(defaultOverride?.borderRadius !== undefined
|
|
404
423
|
? { cornerRadius: defaultOverride.borderRadius }
|
|
@@ -415,47 +434,62 @@ function mergeButtonBackground(
|
|
|
415
434
|
...(defaultOverride?.paddingLeft !== undefined
|
|
416
435
|
? { paddingLeft: defaultOverride.paddingLeft }
|
|
417
436
|
: {}),
|
|
437
|
+
...(defaultOverride?.horizontalGutter !== undefined
|
|
438
|
+
? { horizontalGutter: defaultOverride.horizontalGutter }
|
|
439
|
+
: {}),
|
|
418
440
|
};
|
|
419
441
|
}
|
|
420
442
|
|
|
421
443
|
function mergeButtonTitle(
|
|
422
444
|
base: ButtonTitleConfiguration,
|
|
423
|
-
override?: Record<string, any
|
|
445
|
+
override?: Record<string, any>,
|
|
446
|
+
focusedOverride?: Record<string, any>
|
|
424
447
|
): ButtonTitleConfiguration {
|
|
425
|
-
if (!override) return base;
|
|
448
|
+
if (!override && !focusedOverride) return base;
|
|
449
|
+
|
|
450
|
+
const fontFamily = fontFamilyName(override?.fontFamily);
|
|
451
|
+
|
|
452
|
+
const focusedFontColor =
|
|
453
|
+
focusedOverride?.fontColor || override?.focusedFontColor;
|
|
454
|
+
|
|
455
|
+
const inactiveFontColor = override?.inactiveFontColor;
|
|
426
456
|
|
|
427
457
|
return {
|
|
428
458
|
...base,
|
|
429
|
-
...(override
|
|
430
|
-
...(
|
|
431
|
-
...(
|
|
459
|
+
...(override?.fontColor ? { fontColor: override.fontColor } : {}),
|
|
460
|
+
...(focusedFontColor ? { focusedFontColor } : {}),
|
|
461
|
+
...(inactiveFontColor ? { inactiveFontColor } : {}),
|
|
462
|
+
...(override?.fontSize !== undefined
|
|
463
|
+
? { fontSize: override.fontSize }
|
|
464
|
+
: {}),
|
|
465
|
+
...(override?.lineHeight !== undefined
|
|
432
466
|
? { lineHeight: override.lineHeight }
|
|
433
467
|
: {}),
|
|
434
|
-
...(
|
|
468
|
+
...(fontFamily
|
|
435
469
|
? {
|
|
436
|
-
iosFontFamily:
|
|
437
|
-
androidFontFamily:
|
|
470
|
+
iosFontFamily: fontFamily,
|
|
471
|
+
androidFontFamily: fontFamily,
|
|
438
472
|
}
|
|
439
473
|
: {}),
|
|
440
|
-
...(override
|
|
474
|
+
...(override?.iosFontFamily
|
|
441
475
|
? { iosFontFamily: override.iosFontFamily }
|
|
442
476
|
: {}),
|
|
443
|
-
...(override
|
|
477
|
+
...(override?.androidFontFamily
|
|
444
478
|
? { androidFontFamily: override.androidFontFamily }
|
|
445
479
|
: {}),
|
|
446
|
-
...(override
|
|
480
|
+
...(override?.letterSpacing !== undefined
|
|
447
481
|
? {
|
|
448
482
|
iosLetterSpacing: override.letterSpacing,
|
|
449
483
|
androidLetterSpacing: override.letterSpacing,
|
|
450
484
|
}
|
|
451
485
|
: {}),
|
|
452
|
-
...(override
|
|
486
|
+
...(override?.iosLetterSpacing !== undefined
|
|
453
487
|
? { iosLetterSpacing: override.iosLetterSpacing }
|
|
454
488
|
: {}),
|
|
455
|
-
...(override
|
|
489
|
+
...(override?.androidLetterSpacing !== undefined
|
|
456
490
|
? { androidLetterSpacing: override.androidLetterSpacing }
|
|
457
491
|
: {}),
|
|
458
|
-
...(override
|
|
492
|
+
...(override?.textTransform
|
|
459
493
|
? { textTransform: override.textTransform }
|
|
460
494
|
: {}),
|
|
461
495
|
};
|
|
@@ -511,13 +545,19 @@ export function mergeButtonConfiguration(
|
|
|
511
545
|
const titleOverride =
|
|
512
546
|
buttonBlock?.title?.default || actionBlock?.title?.default;
|
|
513
547
|
|
|
548
|
+
const titleFocused =
|
|
549
|
+
buttonBlock?.title?.focused ||
|
|
550
|
+
buttonBlock?.title?.focus ||
|
|
551
|
+
actionBlock?.title?.focused ||
|
|
552
|
+
actionBlock?.title?.focus;
|
|
553
|
+
|
|
514
554
|
const iconsOverride = buttonBlock?.icons || actionBlock?.icons;
|
|
515
555
|
const leadingOverride = buttonBlock?.leadingIcon || actionBlock?.leadingIcon;
|
|
516
556
|
|
|
517
557
|
return {
|
|
518
558
|
...base,
|
|
519
559
|
background: mergeButtonBackground(base.background, bgDefault, bgFocused),
|
|
520
|
-
title: mergeButtonTitle(base.title, titleOverride),
|
|
560
|
+
title: mergeButtonTitle(base.title, titleOverride, titleFocused),
|
|
521
561
|
icons: mergeButtonIcons(base.icons, iconsOverride, leadingOverride),
|
|
522
562
|
};
|
|
523
563
|
}
|
|
@@ -90,6 +90,7 @@ type ModalComponentProps = {
|
|
|
90
90
|
/** Optional body that replaces the items list (header stays DefaultModalHeader). */
|
|
91
91
|
contentComponent?: React.ComponentType<any>;
|
|
92
92
|
contentComponentProps?: Record<string, any>;
|
|
93
|
+
themePluginId?: string;
|
|
93
94
|
styleOverrides?: ModalBlocksConfig | Record<string, any>;
|
|
94
95
|
measurementPhase?: boolean;
|
|
95
96
|
/** Copy under Next Up when the queue has no upcoming items. */
|
|
@@ -115,6 +116,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
115
116
|
iconPlacement,
|
|
116
117
|
contentComponent: ContentComponent,
|
|
117
118
|
contentComponentProps = {},
|
|
119
|
+
themePluginId: themePluginIdProp,
|
|
118
120
|
measurementPhase = false,
|
|
119
121
|
emptyMessage,
|
|
120
122
|
} = props;
|
|
@@ -139,12 +141,14 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
139
141
|
role,
|
|
140
142
|
behavior,
|
|
141
143
|
editableCollection,
|
|
142
|
-
themePluginId,
|
|
144
|
+
themePluginId: themePluginIdFromSheet,
|
|
143
145
|
} = useBottomSheetContent({
|
|
144
146
|
onPress,
|
|
145
147
|
viewModel,
|
|
146
148
|
});
|
|
147
149
|
|
|
150
|
+
const themePluginId = themePluginIdFromSheet || themePluginIdProp;
|
|
151
|
+
|
|
148
152
|
const plugins = usePlugins();
|
|
149
153
|
|
|
150
154
|
const plugin = useMemo(
|
|
@@ -92,4 +92,29 @@ describe("showTextInputAction", () => {
|
|
|
92
92
|
expect(result).toBe(ActionResult.Error);
|
|
93
93
|
expect(actionExecutor.handleAction).not.toHaveBeenCalled();
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
it("forwards themePluginId so the sheet can read plugin button styles", async () => {
|
|
97
|
+
const action = {
|
|
98
|
+
type: "showTextInput",
|
|
99
|
+
options: {
|
|
100
|
+
headerTitle: "Create New Playlist",
|
|
101
|
+
inputLabel: "Name your playlist",
|
|
102
|
+
buttonLabel: "Create",
|
|
103
|
+
themePluginId: "playlist-experience",
|
|
104
|
+
actions: [{ type: "sendCloudEvent", options: {} }],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
await showTextInputAction(action as any);
|
|
109
|
+
|
|
110
|
+
expect(actionExecutor.handleAction).toHaveBeenCalledWith(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
type: "openBottomSheet",
|
|
113
|
+
options: expect.objectContaining({
|
|
114
|
+
themePluginId: "playlist-experience",
|
|
115
|
+
}),
|
|
116
|
+
}),
|
|
117
|
+
undefined
|
|
118
|
+
);
|
|
119
|
+
});
|
|
95
120
|
});
|
|
@@ -21,6 +21,7 @@ export async function showTextInputAction(
|
|
|
21
21
|
validationLabel,
|
|
22
22
|
actions,
|
|
23
23
|
maxLength,
|
|
24
|
+
themePluginId,
|
|
24
25
|
} = action.options || {};
|
|
25
26
|
|
|
26
27
|
if (!headerTitle || !buttonLabel || !actions || !actions.length) {
|
|
@@ -34,6 +35,7 @@ export async function showTextInputAction(
|
|
|
34
35
|
header: {
|
|
35
36
|
title: headerTitle,
|
|
36
37
|
},
|
|
38
|
+
...(themePluginId ? { themePluginId } : {}),
|
|
37
39
|
content: {
|
|
38
40
|
component: TextInputContent,
|
|
39
41
|
props: {
|
|
@@ -118,7 +118,10 @@ function ComponentsMapComponent(props: Props) {
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
return (
|
|
121
|
-
<ScreenLoadingMeasurementsListItemWrapper
|
|
121
|
+
<ScreenLoadingMeasurementsListItemWrapper
|
|
122
|
+
index={index}
|
|
123
|
+
testID={`component-${item.id}`}
|
|
124
|
+
>
|
|
122
125
|
<RiverItem {...riverItemProps} />
|
|
123
126
|
</ScreenLoadingMeasurementsListItemWrapper>
|
|
124
127
|
);
|
|
@@ -213,6 +213,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
|
|
|
213
213
|
<View
|
|
214
214
|
onLayout={[Function]}
|
|
215
215
|
style={{}}
|
|
216
|
+
testID="component-A1"
|
|
216
217
|
>
|
|
217
218
|
<View />
|
|
218
219
|
</View>
|
|
@@ -225,6 +226,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
|
|
|
225
226
|
<View
|
|
226
227
|
onLayout={[Function]}
|
|
227
228
|
style={{}}
|
|
229
|
+
testID="component-A2"
|
|
228
230
|
/>
|
|
229
231
|
</View>
|
|
230
232
|
<View
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react-native";
|
|
2
|
+
|
|
3
|
+
const mockRegister = jest.fn().mockResolvedValue(undefined);
|
|
4
|
+
|
|
5
|
+
// The X-Ray mock (__mocks__/@applicaster/quick-brick-xray) has no toolbox API: give it one.
|
|
6
|
+
require("@applicaster/quick-brick-xray").registerReactToolbox = mockRegister;
|
|
7
|
+
|
|
8
|
+
// Required after the toolbox API is in place: loading them can register the player toolbox.
|
|
9
|
+
const {
|
|
10
|
+
modalStore,
|
|
11
|
+
} = require("@applicaster/zapp-react-native-utils/modalState");
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
setXrayNavigator,
|
|
15
|
+
} = require("@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator");
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
zappHookModalStore,
|
|
19
|
+
} = require("@applicaster/zapp-react-native-ui-components/Contexts/ZappHookModalContext");
|
|
20
|
+
|
|
21
|
+
const mockStore = {
|
|
22
|
+
rivers: { home: { name: "Home", type: "general_content" } },
|
|
23
|
+
plugins: [
|
|
24
|
+
{ identifier: "quick-brick-epg-screen", name: "EPG" },
|
|
25
|
+
{ identifier: "login-plugin", name: "Login" },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
|
|
30
|
+
appStore: { get: (key) => mockStore[key] },
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const { useScreenToolboxRecord, getTopPathname } = require("../screenToolbox");
|
|
34
|
+
|
|
35
|
+
const home = { id: "home", name: "Home", type: "general_content" };
|
|
36
|
+
|
|
37
|
+
const stack = {
|
|
38
|
+
"/river/home": { screen: home },
|
|
39
|
+
"/river/epg": {
|
|
40
|
+
screen: { id: "epg", name: "Guide", type: "quick-brick-epg-screen" },
|
|
41
|
+
},
|
|
42
|
+
"/river/tabs": {
|
|
43
|
+
screen: { id: "tabs", name: "Tabs", type: "quick-brick-tabs" },
|
|
44
|
+
nested: { screen: { id: "t1", name: "Tab 1", type: "general_content" } },
|
|
45
|
+
},
|
|
46
|
+
"/river/home/hooks/login": {
|
|
47
|
+
screen: { identifier: "login-plugin", name: "Login" },
|
|
48
|
+
entry: { payload: { id: "e1", title: "One" } },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const navigate = (pathname: string, videoModalState = { visible: false }) =>
|
|
53
|
+
setXrayNavigator({
|
|
54
|
+
getPathname: () => pathname,
|
|
55
|
+
getStackForPathname: (route) =>
|
|
56
|
+
stack[route] ? { route, state: stack[route] } : undefined,
|
|
57
|
+
videoModalState,
|
|
58
|
+
} as unknown as QuickBrickAppNavigator);
|
|
59
|
+
|
|
60
|
+
const mount = (
|
|
61
|
+
component,
|
|
62
|
+
index,
|
|
63
|
+
feed?,
|
|
64
|
+
parentId?,
|
|
65
|
+
pathname = "/river/home"
|
|
66
|
+
) => {
|
|
67
|
+
const hook = renderHook(() =>
|
|
68
|
+
useScreenToolboxRecord(component, "home", index, parentId, pathname)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
hook.result.current.feed = feed;
|
|
72
|
+
|
|
73
|
+
return hook;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const current = (args = {}) => {
|
|
77
|
+
// Other toolboxes (player) may register first.
|
|
78
|
+
const { tools } = mockRegister.mock.calls.find(
|
|
79
|
+
([toolbox]) => toolbox.name === "screen"
|
|
80
|
+
)[0];
|
|
81
|
+
|
|
82
|
+
return tools.find((tool) => tool.name === "current").handler(args);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const presentHook = (overrides = {}) =>
|
|
86
|
+
zappHookModalStore.setState({
|
|
87
|
+
state: {
|
|
88
|
+
path: "/hooks/login",
|
|
89
|
+
screenData: {
|
|
90
|
+
hookPlugin: { identifier: "login-plugin", name: "Login" },
|
|
91
|
+
payload: { id: "e1", title: "One" },
|
|
92
|
+
},
|
|
93
|
+
} as any,
|
|
94
|
+
isHooksExecutionInProgress: true,
|
|
95
|
+
isRunningInBackground: false,
|
|
96
|
+
...overrides,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("screenToolbox", () => {
|
|
100
|
+
// Registers the toolbox; the registration is kept for the whole module, as in the app.
|
|
101
|
+
beforeAll(() => mount({ id: "init" }, 0).unmount());
|
|
102
|
+
|
|
103
|
+
beforeEach(() => navigate("/river/home"));
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
modalStore.getState().setModalState({ visible: false, screen: null });
|
|
107
|
+
zappHookModalStore.getState().resetState();
|
|
108
|
+
zappHookModalStore.getState().setIsHooksExecutionInProgress(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns the top screen's mounted components in order, with feed and entries", () => {
|
|
112
|
+
const second = mount({ id: "c2", component_type: "grid" }, 1, {
|
|
113
|
+
url: "https://feed/2",
|
|
114
|
+
loading: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const first = mount({ id: "c1", component_type: "horizontal_list" }, 0, {
|
|
118
|
+
url: "https://feed/1",
|
|
119
|
+
loading: false,
|
|
120
|
+
data: {
|
|
121
|
+
entry: [
|
|
122
|
+
{ id: "e1", title: "One" },
|
|
123
|
+
{ id: "e2", title: "Two" },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const third = mount({ id: "c3", component_type: "grid" }, 2, {
|
|
129
|
+
url: "https://feed/3",
|
|
130
|
+
error: new Error("404"),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(current({ limit: 1 })).toEqual({
|
|
134
|
+
pathname: "/river/home",
|
|
135
|
+
screen: {
|
|
136
|
+
kind: "river",
|
|
137
|
+
id: "home",
|
|
138
|
+
name: "Home",
|
|
139
|
+
type: "general_content",
|
|
140
|
+
},
|
|
141
|
+
entry: null,
|
|
142
|
+
rivers: [
|
|
143
|
+
{
|
|
144
|
+
id: "home",
|
|
145
|
+
name: "Home",
|
|
146
|
+
type: "general_content",
|
|
147
|
+
components: [
|
|
148
|
+
{
|
|
149
|
+
index: 0,
|
|
150
|
+
id: "c1",
|
|
151
|
+
type: "horizontal_list",
|
|
152
|
+
feed: { url: "https://feed/1", state: "loaded", count: 2 },
|
|
153
|
+
entries: [{ index: 0, id: "e1", title: "One" }],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
index: 1,
|
|
157
|
+
id: "c2",
|
|
158
|
+
type: "grid",
|
|
159
|
+
feed: { url: "https://feed/2", state: "loading", count: 0 },
|
|
160
|
+
entries: [],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
index: 2,
|
|
164
|
+
id: "c3",
|
|
165
|
+
type: "grid",
|
|
166
|
+
feed: {
|
|
167
|
+
url: "https://feed/3",
|
|
168
|
+
state: "error",
|
|
169
|
+
error: "404",
|
|
170
|
+
count: 0,
|
|
171
|
+
},
|
|
172
|
+
entries: [],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
mounted: ["/river/home"],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
[first, second, third].forEach((hook) => hook.unmount());
|
|
181
|
+
|
|
182
|
+
expect(current().rivers).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("nests a group's children under it", () => {
|
|
186
|
+
const group = mount({ id: "g", component_type: "group" }, 1);
|
|
187
|
+
|
|
188
|
+
const child = mount(
|
|
189
|
+
{ id: "g0", component_type: "hero" },
|
|
190
|
+
0,
|
|
191
|
+
undefined,
|
|
192
|
+
"g"
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const row = mount({ id: "r", component_type: "grid" }, 0);
|
|
196
|
+
|
|
197
|
+
expect(
|
|
198
|
+
current().rivers[0].components.map(({ id, children }) => ({
|
|
199
|
+
id,
|
|
200
|
+
children: children?.map((item) => item.id),
|
|
201
|
+
}))
|
|
202
|
+
).toEqual([
|
|
203
|
+
{ id: "r", children: undefined },
|
|
204
|
+
{ id: "g", children: ["g0"] },
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
[group, child, row].forEach((hook) => hook.unmount());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("reads another mounted screen by pathname", () => {
|
|
211
|
+
const other = mount({ id: "c" }, 0, undefined, undefined, "/river/epg");
|
|
212
|
+
|
|
213
|
+
expect(current({ pathname: "/river/epg" }).rivers[0].components).toEqual([
|
|
214
|
+
expect.objectContaining({ id: "c" }),
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
expect(current().mounted).toEqual(["/river/epg"]);
|
|
218
|
+
|
|
219
|
+
other.unmount();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("names the plugin that renders a screen plugin", () => {
|
|
223
|
+
navigate("/river/epg");
|
|
224
|
+
|
|
225
|
+
expect(current().screen).toEqual({
|
|
226
|
+
kind: "plugin",
|
|
227
|
+
id: "epg",
|
|
228
|
+
name: "Guide",
|
|
229
|
+
type: "quick-brick-epg-screen",
|
|
230
|
+
plugin: { identifier: "quick-brick-epg-screen", name: "EPG" },
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("reports the nested tab screen", () => {
|
|
235
|
+
navigate("/river/tabs");
|
|
236
|
+
|
|
237
|
+
expect(current().nested).toEqual({
|
|
238
|
+
id: "t1",
|
|
239
|
+
name: "Tab 1",
|
|
240
|
+
type: "general_content",
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("unwraps the entry of a hook screen on the stack", () => {
|
|
245
|
+
navigate("/river/home/hooks/login");
|
|
246
|
+
|
|
247
|
+
expect(current()).toMatchObject({
|
|
248
|
+
screen: { kind: "hook", plugin: { identifier: "login-plugin" } },
|
|
249
|
+
entry: { id: "e1", title: "One" },
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("says when there is no screen yet", () => {
|
|
254
|
+
navigate("/");
|
|
255
|
+
|
|
256
|
+
expect(current().screen).toEqual({ kind: "none" });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("reads a modal's screen from the modal store, its pathname as ModalProvider builds it", () => {
|
|
260
|
+
modalStore.getState().openModal({ item: home, props: { screenId: "m1" } });
|
|
261
|
+
|
|
262
|
+
expect(current()).toMatchObject({
|
|
263
|
+
pathname: "modal/m1",
|
|
264
|
+
screen: { kind: "river", id: "home" },
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("describes a modal hook by its plugin", () => {
|
|
269
|
+
modalStore.getState().openModal({
|
|
270
|
+
item: {
|
|
271
|
+
hookPlugin: { identifier: "login-plugin", name: "Login" },
|
|
272
|
+
payload: { id: "e1", title: "One" },
|
|
273
|
+
},
|
|
274
|
+
props: { screenType: "hooks" },
|
|
275
|
+
} as any);
|
|
276
|
+
|
|
277
|
+
expect(current()).toMatchObject({
|
|
278
|
+
screen: {
|
|
279
|
+
kind: "hook",
|
|
280
|
+
plugin: { identifier: "login-plugin", name: "Login" },
|
|
281
|
+
},
|
|
282
|
+
entry: { id: "e1", title: "One" },
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("describes a bottom sheet", () => {
|
|
287
|
+
modalStore.getState().openModal({
|
|
288
|
+
item: { modalBottomSheetContentProps: {} },
|
|
289
|
+
} as any);
|
|
290
|
+
|
|
291
|
+
expect(current().screen).toEqual({ kind: "bottomSheet" });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("puts a modal over the video modal, and the video modal over the stack unless docked", () => {
|
|
295
|
+
const item = { id: "v1", title: "Video" };
|
|
296
|
+
|
|
297
|
+
navigate("/river/home", {
|
|
298
|
+
visible: true,
|
|
299
|
+
mode: "MAXIMIZED",
|
|
300
|
+
item,
|
|
301
|
+
} as any);
|
|
302
|
+
|
|
303
|
+
expect(current()).toMatchObject({
|
|
304
|
+
pathname: "video-modal/v1",
|
|
305
|
+
screen: { kind: "player" },
|
|
306
|
+
entry: { id: "v1", title: "Video" },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
modalStore.getState().openModal({ item: home });
|
|
310
|
+
|
|
311
|
+
expect(getTopPathname()).toBe("modal/home");
|
|
312
|
+
|
|
313
|
+
modalStore.getState().setModalState({ visible: false, screen: null });
|
|
314
|
+
|
|
315
|
+
navigate("/river/home", {
|
|
316
|
+
visible: true,
|
|
317
|
+
mode: "MINIMIZED",
|
|
318
|
+
item,
|
|
319
|
+
} as any);
|
|
320
|
+
|
|
321
|
+
expect(getTopPathname()).toBe("/river/home");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("puts a presented hook screen on top, not one running in the background", () => {
|
|
325
|
+
presentHook();
|
|
326
|
+
|
|
327
|
+
expect(current()).toMatchObject({
|
|
328
|
+
pathname: "hooks-modal/Login",
|
|
329
|
+
screen: {
|
|
330
|
+
kind: "hook",
|
|
331
|
+
name: "Login",
|
|
332
|
+
plugin: { identifier: "login-plugin" },
|
|
333
|
+
},
|
|
334
|
+
entry: { id: "e1", title: "One" },
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
presentHook({ isRunningInBackground: true });
|
|
338
|
+
|
|
339
|
+
expect(getTopPathname()).toBe("/river/home");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("describes nothing for a modal pathname that is no longer presented", () => {
|
|
343
|
+
expect(current({ pathname: "modal/gone" }).screen).toEqual({
|
|
344
|
+
kind: "none",
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react-native";
|
|
2
|
+
|
|
3
|
+
// Fails the screen toolbox as a build without the X-Ray bridge does. Others (player, loaded with
|
|
4
|
+
// the toolbox's imports) register, or the helper would never let the screen toolbox try.
|
|
5
|
+
const mockRegister = jest.fn((toolbox) =>
|
|
6
|
+
toolbox.name === "screen"
|
|
7
|
+
? Promise.reject(new Error("no bridge"))
|
|
8
|
+
: Promise.resolve()
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
// The X-Ray mock (__mocks__/@applicaster/quick-brick-xray) has no toolbox API: give it one.
|
|
12
|
+
require("@applicaster/quick-brick-xray").registerReactToolbox = mockRegister;
|
|
13
|
+
|
|
14
|
+
jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
|
|
15
|
+
appStore: { get: () => undefined },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const { useScreenToolboxRecord } = require("../screenToolbox");
|
|
19
|
+
|
|
20
|
+
const mount = (id: string) =>
|
|
21
|
+
renderHook(() =>
|
|
22
|
+
useScreenToolboxRecord({ id }, "home", 0, undefined, "/river/home")
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const mounted = () => {
|
|
26
|
+
// Other toolboxes (player) may register first.
|
|
27
|
+
const { tools } = mockRegister.mock.calls.find(
|
|
28
|
+
([toolbox]) => toolbox.name === "screen"
|
|
29
|
+
)[0];
|
|
30
|
+
|
|
31
|
+
return tools
|
|
32
|
+
.find((tool) => tool.name === "current")
|
|
33
|
+
.handler({ pathname: "/river/home" }).mounted;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("screenToolbox without the X-Ray bridge", () => {
|
|
37
|
+
it("drops its records and stops recording", async () => {
|
|
38
|
+
mount("c1");
|
|
39
|
+
|
|
40
|
+
// Let the failed registration settle.
|
|
41
|
+
await act(async () => {});
|
|
42
|
+
|
|
43
|
+
expect(mounted()).toEqual([]);
|
|
44
|
+
|
|
45
|
+
mount("c2");
|
|
46
|
+
|
|
47
|
+
expect(mounted()).toEqual([]);
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
mockRegister.mock.calls.filter(([toolbox]) => toolbox.name === "screen")
|
|
51
|
+
).toHaveLength(1);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -5,7 +5,9 @@ import { useRoute } from "@applicaster/zapp-react-native-utils/reactHooks/naviga
|
|
|
5
5
|
import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
|
|
6
6
|
import { ZappPipesSearchContext } from "@applicaster/zapp-react-native-ui-components/Contexts";
|
|
7
7
|
import { useScreenContext } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
|
|
8
|
+
import { useUIComponentContext } from "@applicaster/zapp-react-native-ui-components/Contexts/UIComponentContext";
|
|
8
9
|
import { ResolverSelector } from "./ResolverSelector";
|
|
10
|
+
import { useScreenToolboxRecord } from "./screenToolbox";
|
|
9
11
|
import { ComponentDataSourceContext } from "./types";
|
|
10
12
|
|
|
11
13
|
type Props = {
|
|
@@ -22,7 +24,7 @@ export function zappPipesDataConnector(
|
|
|
22
24
|
Component: React.FC<any> | React.ComponentClass<any>
|
|
23
25
|
) {
|
|
24
26
|
return function WrappedWithZappPipesData(props: Props) {
|
|
25
|
-
const { screenData } = useRoute();
|
|
27
|
+
const { screenData, pathname } = useRoute();
|
|
26
28
|
const plugins = usePlugins();
|
|
27
29
|
const screenContextData = useScreenContext();
|
|
28
30
|
|
|
@@ -53,6 +55,16 @@ export function zappPipesDataConnector(
|
|
|
53
55
|
|
|
54
56
|
const [searchContext] = ZappPipesSearchContext.useZappPipesContext();
|
|
55
57
|
|
|
58
|
+
const parent = useUIComponentContext() as { id?: string } | null;
|
|
59
|
+
|
|
60
|
+
const toolboxRecord = useScreenToolboxRecord(
|
|
61
|
+
component,
|
|
62
|
+
riverId,
|
|
63
|
+
componentIndex,
|
|
64
|
+
parent?.id,
|
|
65
|
+
pathname
|
|
66
|
+
);
|
|
67
|
+
|
|
56
68
|
// Prepare context for resolvers
|
|
57
69
|
const resolverProps: ComponentDataSourceContext = {
|
|
58
70
|
component,
|
|
@@ -71,9 +83,12 @@ export function zappPipesDataConnector(
|
|
|
71
83
|
|
|
72
84
|
return (
|
|
73
85
|
<ResolverSelector {...resolverProps}>
|
|
74
|
-
{(zappPipesDataProps) =>
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
{(zappPipesDataProps) => {
|
|
87
|
+
// A plain field on the toolbox record, read only when the agent asks: no render.
|
|
88
|
+
toolboxRecord.feed = zappPipesDataProps.zappPipesData;
|
|
89
|
+
|
|
90
|
+
return <Component {...props} {...zappPipesDataProps} />;
|
|
91
|
+
}}
|
|
77
92
|
</ResolverSelector>
|
|
78
93
|
);
|
|
79
94
|
};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import * as R from "ramda";
|
|
4
|
+
import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
|
|
5
|
+
import {
|
|
6
|
+
registerXrayToolbox,
|
|
7
|
+
XrayToolSpec,
|
|
8
|
+
} from "@applicaster/zapp-react-native-utils/logger/xrayToolbox";
|
|
9
|
+
import { modalStore } from "@applicaster/zapp-react-native-utils/modalState";
|
|
10
|
+
import { ROUTE_TYPES } from "@applicaster/zapp-react-native-utils/navigationUtils/routeTypes";
|
|
11
|
+
import { getXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
|
|
12
|
+
import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils";
|
|
13
|
+
import { zappHookModalStore } from "@applicaster/zapp-react-native-ui-components/Contexts/ZappHookModalContext";
|
|
14
|
+
|
|
15
|
+
import { ZappPipesData } from "./types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* X-Ray `screen.*` toolbox: what the current screen shows — its river components in order, the
|
|
19
|
+
* feed each one loaded and the entries it displays. Every river component, on every platform,
|
|
20
|
+
* goes through the zappPipes decorator, which writes a record here on mount and drops it on
|
|
21
|
+
* unmount: a plain Set, nothing subscribes to it, so it never renders anything. Records are
|
|
22
|
+
* dropped for good once X-Ray turns out to have no toolbox bridge (release builds).
|
|
23
|
+
*
|
|
24
|
+
* Screens that are not rivers (screen plugins, hooks, the player) report what renders them,
|
|
25
|
+
* read from navigation and the modal stores; a plugin with state of its own registers its own
|
|
26
|
+
* toolbox, as the player does.
|
|
27
|
+
*
|
|
28
|
+
* Mounted is not visible (lists are virtualised): which rows and cells are on screen comes from
|
|
29
|
+
* the native `ui.inspect`. River rows carry testID `component-<componentId>` (mobile, tvOS,
|
|
30
|
+
* Android TV) and cells carry their entry id in their testID.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type ComponentRecord = {
|
|
34
|
+
component: ZappUIComponent;
|
|
35
|
+
riverId: string;
|
|
36
|
+
index?: number;
|
|
37
|
+
parentId?: string;
|
|
38
|
+
pathname: string;
|
|
39
|
+
/** Written on each render: the data the component was given, after item filter and limit. */
|
|
40
|
+
feed?: ZappPipesData;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** What a route shows; `screenType` is how ScreenResolver renders it. */
|
|
44
|
+
type RouteState = {
|
|
45
|
+
screenType?: string;
|
|
46
|
+
screen?: any;
|
|
47
|
+
entry?: Partial<ZappEntry>;
|
|
48
|
+
nested?: { screen?: ZappRiver };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A screen presented over the navigation stack. `covers` is false when it is out of the way. */
|
|
52
|
+
type Layer = RouteState & { pathname: string; covers: boolean };
|
|
53
|
+
|
|
54
|
+
const records = new Set<ComponentRecord>();
|
|
55
|
+
|
|
56
|
+
/** Set once registration failed: X-Ray has no toolbox bridge, so nothing will ever read records. */
|
|
57
|
+
let unavailable = false;
|
|
58
|
+
|
|
59
|
+
const DEFAULT_ENTRIES_LIMIT = 10;
|
|
60
|
+
|
|
61
|
+
const toLimit = (value: unknown) =>
|
|
62
|
+
typeof value === "number" && value >= 0 ? value : DEFAULT_ENTRIES_LIMIT;
|
|
63
|
+
|
|
64
|
+
/** The hooks modal, with the pathname ZappHooksModal gives it; hidden while it runs in the background. */
|
|
65
|
+
const hooksModalLayer = (): Layer | undefined => {
|
|
66
|
+
const { state, isHooksExecutionInProgress, isRunningInBackground } =
|
|
67
|
+
zappHookModalStore.getState();
|
|
68
|
+
|
|
69
|
+
if (!isHooksExecutionInProgress || !state.path) return undefined;
|
|
70
|
+
|
|
71
|
+
const hookPlugin = state.screenData?.hookPlugin;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
pathname: `${ROUTE_TYPES.HOOKS_MODAL}/${hookPlugin ? hookPlugin.name : ""}`,
|
|
75
|
+
covers:
|
|
76
|
+
!isRunningInBackground && hookPlugin?.module?.presentFullScreen !== false,
|
|
77
|
+
screenType: "hooks",
|
|
78
|
+
screen: hookPlugin,
|
|
79
|
+
entry: state.screenData?.payload as ZappEntry,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The modal screen or bottom sheet, with the pathname and props ModalProvider gives it. */
|
|
84
|
+
const modalLayer = (): Layer | undefined => {
|
|
85
|
+
const { visible, screen, props } = modalStore.getState().modalState;
|
|
86
|
+
|
|
87
|
+
if (!visible || !screen) return undefined;
|
|
88
|
+
|
|
89
|
+
const { screenId, screenType, entry, screenData } = {
|
|
90
|
+
screenId: screen.id,
|
|
91
|
+
screenType: "river",
|
|
92
|
+
...props,
|
|
93
|
+
} as Record<string, any>;
|
|
94
|
+
|
|
95
|
+
// A modal hook (and the ad interstitial) presents a { hookPlugin, payload } wrapper.
|
|
96
|
+
const isHook = screenType === "hooks";
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
pathname: `${ROUTE_TYPES.MODAL}/${screenId}`,
|
|
100
|
+
covers: true,
|
|
101
|
+
screenType,
|
|
102
|
+
screen: isHook ? (screen.hookPlugin ?? screenData?.hookPlugin) : screen,
|
|
103
|
+
entry: isHook ? screen.payload : entry,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const videoModalLayer = (): Layer | undefined => {
|
|
108
|
+
const videoModal = getXrayNavigator()?.videoModalState;
|
|
109
|
+
|
|
110
|
+
if (!videoModal?.visible) return undefined;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
pathname: `${ROUTE_TYPES.VIDEO_MODAL}/${videoModal.item?.id}`,
|
|
114
|
+
covers: videoModal.mode !== "MINIMIZED",
|
|
115
|
+
screenType: "playable",
|
|
116
|
+
entry: videoModal.item,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Topmost first: the hooks modal and modals are native RN Modals, drawn over the video modal.
|
|
122
|
+
* Only layers this platform renders: the mobile layout renders the hooks and video modals, and
|
|
123
|
+
* ModalProvider renders nothing on web.
|
|
124
|
+
*/
|
|
125
|
+
const presentedLayers = () => {
|
|
126
|
+
const web = Platform.OS === "web";
|
|
127
|
+
const mobile = !web && !isTV();
|
|
128
|
+
|
|
129
|
+
return [
|
|
130
|
+
mobile && hooksModalLayer(),
|
|
131
|
+
!web && modalLayer(),
|
|
132
|
+
mobile && videoModalLayer(),
|
|
133
|
+
].filter((layer): layer is Layer => Boolean(layer));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** The pathname of what is on top: a covering presented layer, else the stack top. */
|
|
137
|
+
export const getTopPathname = (): string | undefined =>
|
|
138
|
+
presentedLayers().find((layer) => layer.covers)?.pathname ??
|
|
139
|
+
getXrayNavigator()?.getPathname();
|
|
140
|
+
|
|
141
|
+
/** What the route at `pathname` shows. Presented layers are not on the stack: their stores hold them. */
|
|
142
|
+
const routeState = (pathname: string): RouteState => {
|
|
143
|
+
const layer = presentedLayers().find((item) => item.pathname === pathname);
|
|
144
|
+
|
|
145
|
+
if (layer) return layer;
|
|
146
|
+
|
|
147
|
+
// Not presented any more (a stale modal pathname): nothing to describe.
|
|
148
|
+
if (!pathname.startsWith("/")) return {};
|
|
149
|
+
|
|
150
|
+
const state = getXrayNavigator()?.getStackForPathname(pathname)?.state;
|
|
151
|
+
|
|
152
|
+
// A stack route ends in <screenType>/<screenId>.
|
|
153
|
+
const screenType = pathname.split("/").slice(-2)[0];
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
screenType,
|
|
157
|
+
...state,
|
|
158
|
+
// A hook screen's entry is the { payload } wrapper it was pushed with.
|
|
159
|
+
entry:
|
|
160
|
+
screenType === "hooks"
|
|
161
|
+
? ((state?.entry as any)?.payload ?? state?.entry)
|
|
162
|
+
: state?.entry,
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Screen types ScreenResolver renders itself rather than through a screen plugin. */
|
|
167
|
+
const BUILT_IN_KINDS = {
|
|
168
|
+
playable: "player",
|
|
169
|
+
link: "link",
|
|
170
|
+
favorites: "favorites",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What renders the screen, as ScreenResolver picks it: a river of components, a built-in screen,
|
|
175
|
+
* a hook plugin, or a screen plugin (a river whose type is a plugin identifier, or a route whose
|
|
176
|
+
* screen type is one).
|
|
177
|
+
*/
|
|
178
|
+
const describeKind = ({
|
|
179
|
+
screenType,
|
|
180
|
+
screen,
|
|
181
|
+
}: RouteState): { kind: string; identifier?: string } => {
|
|
182
|
+
if (!screenType) return { kind: "none" };
|
|
183
|
+
|
|
184
|
+
if (screen?.modalBottomSheetContentProps) return { kind: "bottomSheet" };
|
|
185
|
+
|
|
186
|
+
if (screenType === "hooks") {
|
|
187
|
+
return { kind: "hook", identifier: screen?.identifier };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (BUILT_IN_KINDS[screenType]) return { kind: BUILT_IN_KINDS[screenType] };
|
|
191
|
+
|
|
192
|
+
if (screenType !== "river") return { kind: "plugin", identifier: screenType };
|
|
193
|
+
|
|
194
|
+
return screen?.type === "general_content"
|
|
195
|
+
? { kind: "river" }
|
|
196
|
+
: { kind: "plugin", identifier: screen?.type };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const describePlugin = (identifier?: string) => {
|
|
200
|
+
if (!identifier) return null;
|
|
201
|
+
|
|
202
|
+
const plugins = (appStore.get("plugins") ?? []) as QuickBrickPlugin[];
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
identifier,
|
|
206
|
+
name: plugins.find((plugin) => plugin.identifier === identifier)?.name,
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const describeFeed = (feed?: ZappPipesData) => {
|
|
211
|
+
if (!feed?.url) return null;
|
|
212
|
+
|
|
213
|
+
const error = feed.error as { message?: string } | undefined;
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
url: feed.url,
|
|
217
|
+
state: error ? "error" : feed.loading ? "loading" : "loaded",
|
|
218
|
+
...(error && { error: error.message ?? String(error) }),
|
|
219
|
+
count: feed.data?.entry?.length ?? 0,
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const byIndex = (a: ComponentRecord, b: ComponentRecord) =>
|
|
224
|
+
(a.index ?? 0) - (b.index ?? 0);
|
|
225
|
+
|
|
226
|
+
/** Components in order, a group's children nested under it (they share the river and its indexes). */
|
|
227
|
+
const describeComponents = (
|
|
228
|
+
mounted: ComponentRecord[],
|
|
229
|
+
belongs: (record: ComponentRecord) => boolean,
|
|
230
|
+
limit: number
|
|
231
|
+
) =>
|
|
232
|
+
mounted
|
|
233
|
+
.filter(belongs)
|
|
234
|
+
.sort(byIndex)
|
|
235
|
+
.map((record) => {
|
|
236
|
+
const children = describeComponents(
|
|
237
|
+
mounted,
|
|
238
|
+
(child) => child.parentId === record.component.id,
|
|
239
|
+
limit
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
index: record.index,
|
|
244
|
+
id: record.component.id,
|
|
245
|
+
type: record.component.component_type,
|
|
246
|
+
name: record.component.name,
|
|
247
|
+
feed: describeFeed(record.feed),
|
|
248
|
+
entries: (record.feed?.data?.entry ?? [])
|
|
249
|
+
.slice(0, limit)
|
|
250
|
+
.map((entry, index) => ({ index, id: entry.id, title: entry.title })),
|
|
251
|
+
...(children.length > 0 && { children }),
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const describeRiverComponents = (mounted: ComponentRecord[], limit: number) => {
|
|
256
|
+
const ids = new Set(mounted.map((record) => record.component.id));
|
|
257
|
+
|
|
258
|
+
return describeComponents(
|
|
259
|
+
mounted,
|
|
260
|
+
(record) => !record.parentId || !ids.has(record.parentId),
|
|
261
|
+
limit
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const describeScreen = (pathname: string, limit: number) => {
|
|
266
|
+
const state = routeState(pathname);
|
|
267
|
+
const { screen, entry, nested } = state;
|
|
268
|
+
const { kind, identifier } = describeKind(state);
|
|
269
|
+
const plugin = describePlugin(identifier);
|
|
270
|
+
|
|
271
|
+
const mounted = Array.from(records).filter(
|
|
272
|
+
(record) => record.pathname === pathname
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const riverIds = R.uniq(mounted.map((record) => record.riverId));
|
|
276
|
+
const rivers = appStore.get("rivers") as Record<string, ZappRiver>;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
pathname,
|
|
280
|
+
screen: {
|
|
281
|
+
kind,
|
|
282
|
+
...(screen && { id: screen.id, name: screen.name, type: screen.type }),
|
|
283
|
+
...(plugin && { plugin }),
|
|
284
|
+
},
|
|
285
|
+
...(nested?.screen && {
|
|
286
|
+
nested: {
|
|
287
|
+
id: nested.screen.id,
|
|
288
|
+
name: nested.screen.name,
|
|
289
|
+
type: nested.screen.type,
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
entry: entry ? { id: entry.id, title: entry.title } : null,
|
|
293
|
+
rivers: riverIds.map((riverId) => ({
|
|
294
|
+
id: riverId,
|
|
295
|
+
name: rivers?.[riverId]?.name,
|
|
296
|
+
type: rivers?.[riverId]?.type,
|
|
297
|
+
components: describeRiverComponents(
|
|
298
|
+
mounted.filter((record) => record.riverId === riverId),
|
|
299
|
+
limit
|
|
300
|
+
),
|
|
301
|
+
})),
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const tools: XrayToolSpec[] = [
|
|
306
|
+
{
|
|
307
|
+
name: "current",
|
|
308
|
+
description:
|
|
309
|
+
"The screen on top (hooks modal, modal or bottom sheet, video modal unless docked, else stack top) " +
|
|
310
|
+
"or the one at `pathname`: what renders it (kind river, plugin, hook, player, link, favorites, " +
|
|
311
|
+
"bottomSheet, and the plugin), its river components in order (a group's children nested) with " +
|
|
312
|
+
"their feed (url, state, count) and first entries. " +
|
|
313
|
+
"Lists mount ahead of the viewport, so a component here may be off screen: use the native " +
|
|
314
|
+
"ui.inspect for what is visible (row testID component-<id>; cell testIDs contain the entry id).",
|
|
315
|
+
parameters: [
|
|
316
|
+
{
|
|
317
|
+
name: "pathname",
|
|
318
|
+
type: "string",
|
|
319
|
+
description:
|
|
320
|
+
"A mounted screen's route (see mounted); defaults to the top one.",
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "limit",
|
|
324
|
+
type: "number",
|
|
325
|
+
description: `Entries per component, default ${DEFAULT_ENTRIES_LIMIT}.`,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
handler: ({ pathname, limit }) => {
|
|
329
|
+
const target =
|
|
330
|
+
typeof pathname === "string" && pathname ? pathname : getTopPathname();
|
|
331
|
+
|
|
332
|
+
if (!target) throw new Error("Navigation is not available yet");
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
...describeScreen(target, toLimit(limit)),
|
|
336
|
+
mounted: R.uniq(Array.from(records, (record) => record.pathname)),
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
const registerScreenToolbox = () =>
|
|
343
|
+
registerXrayToolbox({
|
|
344
|
+
name: "screen",
|
|
345
|
+
description:
|
|
346
|
+
"What the current screen shows: what renders it, river components, their feeds and entries.",
|
|
347
|
+
tools,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Keeps a record of a mounted river component for the toolbox. Returns the record so the caller
|
|
352
|
+
* can attach the feed data it renders with.
|
|
353
|
+
*/
|
|
354
|
+
export const useScreenToolboxRecord = (
|
|
355
|
+
component: ZappUIComponent,
|
|
356
|
+
riverId: string,
|
|
357
|
+
index: number | undefined,
|
|
358
|
+
parentId: string | undefined,
|
|
359
|
+
pathname: string
|
|
360
|
+
): ComponentRecord => {
|
|
361
|
+
const record = useMemo(
|
|
362
|
+
() => ({ component, riverId, index, parentId, pathname }),
|
|
363
|
+
[component, riverId, index, parentId, pathname]
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (unavailable) return;
|
|
368
|
+
|
|
369
|
+
registerScreenToolbox().then((registered) => {
|
|
370
|
+
if (!registered) {
|
|
371
|
+
unavailable = true;
|
|
372
|
+
records.clear();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
records.add(record);
|
|
377
|
+
|
|
378
|
+
return () => {
|
|
379
|
+
records.delete(record);
|
|
380
|
+
};
|
|
381
|
+
}, [record]);
|
|
382
|
+
|
|
383
|
+
return record;
|
|
384
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/zapp-react-native-ui-components",
|
|
3
|
-
"version": "16.0.0-rc.
|
|
3
|
+
"version": "16.0.0-rc.114",
|
|
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": "16.0.0-rc.
|
|
32
|
-
"@applicaster/zapp-react-native-bridge": "16.0.0-rc.
|
|
33
|
-
"@applicaster/zapp-react-native-redux": "16.0.0-rc.
|
|
34
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-rc.
|
|
31
|
+
"@applicaster/applicaster-types": "16.0.0-rc.114",
|
|
32
|
+
"@applicaster/zapp-react-native-bridge": "16.0.0-rc.114",
|
|
33
|
+
"@applicaster/zapp-react-native-redux": "16.0.0-rc.114",
|
|
34
|
+
"@applicaster/zapp-react-native-utils": "16.0.0-rc.114",
|
|
35
35
|
"fast-json-stable-stringify": "^2.1.0",
|
|
36
36
|
"promise": "^8.3.0",
|
|
37
37
|
"react-native-sortables": "1.7.1",
|