@applicaster/zapp-react-native-ui-components 16.0.0-alpha.1334507601 → 16.0.0-alpha.1444784746
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/LinkHandler/LinkHandler.tsx +2 -7
- package/Components/MasterCell/DefaultComponents/LiveImage/executePreloadHooks.ts +2 -4
- package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +2 -3
- package/Components/ModalComponent/AudioPlayer/Components/Header.tsx +64 -20
- package/Components/ModalComponent/AudioPlayer/Components/Input.tsx +139 -39
- package/Components/ModalComponent/AudioPlayer/Components/NowPlayingHeaderSection.tsx +147 -24
- package/Components/ModalComponent/AudioPlayer/Components/__tests__/NowPlayingHeaderSection.test.tsx +36 -0
- package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +108 -0
- package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +148 -1
- package/Components/ModalComponent/BottomSheetModalContent.tsx +95 -9
- package/Components/ModalComponent/SortableList.tsx +2 -3
- package/Components/ModalComponent/TextInputContent.tsx +27 -4
- package/Components/ModalComponent/__tests__/BottomSheetModalContent.test.tsx +74 -1
- package/Components/ModalComponent/__tests__/TextInputContent.test.tsx +14 -1
- package/Components/ModalComponent/showTextInput.ts +11 -2
- package/Components/OfflineHandler/__tests__/index.test.tsx +109 -0
- package/Components/OfflineHandler/index.tsx +42 -4
- package/Components/PlayerContainer/PlayerContainer.tsx +8 -1
- package/Components/Transitioner/AnimationManager.js +1 -1
- package/Components/Transitioner/Scene.tsx +4 -3
- package/Components/Transitioner/Transitioner.tsx +71 -33
- package/Components/Transitioner/__tests__/transitioner.test.js +372 -3
- package/Components/VideoLive/LiveImageManager.ts +8 -2
- package/Components/VideoLive/__tests__/LiveImageManager.test.ts +59 -0
- package/assets/empty-Info.png +0 -0
- package/package.json +5 -5
|
@@ -92,6 +92,8 @@ type ModalComponentProps = {
|
|
|
92
92
|
contentComponentProps?: Record<string, any>;
|
|
93
93
|
styleOverrides?: ModalBlocksConfig | Record<string, any>;
|
|
94
94
|
measurementPhase?: boolean;
|
|
95
|
+
/** Copy under Next Up when the queue has no upcoming items. */
|
|
96
|
+
emptyMessage?: string;
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
@@ -115,6 +117,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
115
117
|
contentComponentProps = {},
|
|
116
118
|
styleOverrides: propStyleOverrides,
|
|
117
119
|
measurementPhase = false,
|
|
120
|
+
emptyMessage,
|
|
118
121
|
} = props;
|
|
119
122
|
|
|
120
123
|
const route = useRef(currentRoute);
|
|
@@ -150,17 +153,81 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
150
153
|
[plugins, themePluginId]
|
|
151
154
|
);
|
|
152
155
|
|
|
153
|
-
const styleOverrides = useMemo(() => {
|
|
154
|
-
if (propStyleOverrides) {
|
|
155
|
-
return propStyleOverrides;
|
|
156
|
-
}
|
|
157
|
-
|
|
156
|
+
const styleOverrides = useMemo<ModalBlocksConfig | null>(() => {
|
|
158
157
|
const configuration =
|
|
159
158
|
plugin?.configuration || (plugin as any)?.configuration_json;
|
|
160
159
|
|
|
161
|
-
|
|
160
|
+
const parsed =
|
|
161
|
+
configuration && themePluginId
|
|
162
|
+
? parseModalBlocksConfiguration(configuration, themePluginId)
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
if (!propStyleOverrides) {
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// hydrate(QueueStyles) only emits sectionHeader after the plugin class
|
|
170
|
+
// includes it. Until then, Studio/local keys in configuration_json still
|
|
171
|
+
// apply via the parser.
|
|
172
|
+
const hydratedSection = propStyleOverrides.sectionHeader;
|
|
173
|
+
|
|
174
|
+
const hasTitleStyle = (style?: Record<string, any>) =>
|
|
175
|
+
Boolean(
|
|
176
|
+
style &&
|
|
177
|
+
(style.fontColor ||
|
|
178
|
+
style.fontSize !== undefined ||
|
|
179
|
+
style.lineHeight !== undefined ||
|
|
180
|
+
style.fontFamily ||
|
|
181
|
+
style.iosFontFamily ||
|
|
182
|
+
style.androidFontFamily ||
|
|
183
|
+
style.letterSpacing !== undefined ||
|
|
184
|
+
style.iosLetterSpacing !== undefined ||
|
|
185
|
+
style.androidLetterSpacing !== undefined ||
|
|
186
|
+
style.textTransform ||
|
|
187
|
+
style.alignment)
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const hasActionStyle = (style?: Record<string, any>) =>
|
|
191
|
+
Boolean(
|
|
192
|
+
style &&
|
|
193
|
+
(style.backgroundColor ||
|
|
194
|
+
style.fontColor ||
|
|
195
|
+
style.fontSize !== undefined ||
|
|
196
|
+
style.lineHeight !== undefined ||
|
|
197
|
+
style.fontFamily ||
|
|
198
|
+
style.iosFontFamily ||
|
|
199
|
+
style.androidFontFamily ||
|
|
200
|
+
style.letterSpacing !== undefined ||
|
|
201
|
+
style.iosLetterSpacing !== undefined ||
|
|
202
|
+
style.androidLetterSpacing !== undefined ||
|
|
203
|
+
style.textTransform ||
|
|
204
|
+
style.borderRadius !== undefined ||
|
|
205
|
+
style.cornerRadius !== undefined ||
|
|
206
|
+
style.paddingTop !== undefined ||
|
|
207
|
+
style.paddingRight !== undefined ||
|
|
208
|
+
style.paddingBottom !== undefined ||
|
|
209
|
+
style.paddingLeft !== undefined)
|
|
210
|
+
);
|
|
162
211
|
|
|
163
|
-
|
|
212
|
+
const actionNode = hydratedSection?.actionButtons;
|
|
213
|
+
|
|
214
|
+
// An empty `default: {}` is truthy and would hide fields set on the flat
|
|
215
|
+
// object or on the focused bucket.
|
|
216
|
+
const hasHydratedSection =
|
|
217
|
+
hasTitleStyle(hydratedSection?.title?.default) ||
|
|
218
|
+
hasTitleStyle(hydratedSection?.title) ||
|
|
219
|
+
hasActionStyle(actionNode?.default) ||
|
|
220
|
+
hasActionStyle(actionNode?.focused) ||
|
|
221
|
+
hasActionStyle(actionNode?.focus) ||
|
|
222
|
+
hasActionStyle(actionNode);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
...parsed,
|
|
226
|
+
...propStyleOverrides,
|
|
227
|
+
sectionHeader: hasHydratedSection
|
|
228
|
+
? hydratedSection
|
|
229
|
+
: parsed?.sectionHeader,
|
|
230
|
+
};
|
|
164
231
|
}, [propStyleOverrides, plugin, themePluginId]);
|
|
165
232
|
|
|
166
233
|
const pluginLocalizations = useLocalizedStrings({
|
|
@@ -178,17 +245,26 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
178
245
|
const nextUpTitle =
|
|
179
246
|
pluginLocalizations?.next_up_title ||
|
|
180
247
|
(normalizedPluginId
|
|
181
|
-
? pluginLocalizations?.[`${normalizedPluginId}_next_up_title`]
|
|
248
|
+
? pluginLocalizations?.[`${normalizedPluginId}_next_up_title`] ||
|
|
249
|
+
pluginLocalizations?.[`${normalizedPluginId}_label_next_up`]
|
|
182
250
|
: undefined) ||
|
|
183
251
|
"Next Up";
|
|
184
252
|
|
|
185
253
|
const nowPlayingTitle =
|
|
186
254
|
pluginLocalizations?.now_playing_title ||
|
|
187
255
|
(normalizedPluginId
|
|
188
|
-
? pluginLocalizations?.[`${normalizedPluginId}_now_playing_title`]
|
|
256
|
+
? pluginLocalizations?.[`${normalizedPluginId}_now_playing_title`] ||
|
|
257
|
+
pluginLocalizations?.[`${normalizedPluginId}_label_now_playing`]
|
|
189
258
|
: undefined) ||
|
|
190
259
|
"Now Playing";
|
|
191
260
|
|
|
261
|
+
const clearLabel =
|
|
262
|
+
pluginLocalizations?.clear_label ||
|
|
263
|
+
(normalizedPluginId
|
|
264
|
+
? pluginLocalizations?.[`${normalizedPluginId}_label_clear`]
|
|
265
|
+
: undefined) ||
|
|
266
|
+
"Clear";
|
|
267
|
+
|
|
192
268
|
const resolvedTitle = props.title || localizedHeaderTitle || title;
|
|
193
269
|
|
|
194
270
|
const itemsToRender = viewModel ? fetchedItems : items;
|
|
@@ -312,6 +388,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
312
388
|
const hasNowPlaying = hasOperation(operations, OPERATIONS.NOW_PLAYING);
|
|
313
389
|
const hasClearAll = hasOperation(operations, OPERATIONS.CLEAR_ALL);
|
|
314
390
|
const hasAdd = hasOperation(operations, OPERATIONS.ADD);
|
|
391
|
+
const isQueueEmpty = hasNowPlaying && (itemsToRender?.length ?? 0) === 0;
|
|
315
392
|
|
|
316
393
|
const handleDeleteCollection = useCallback(() => {
|
|
317
394
|
editableCollection?.deleteCollection?.();
|
|
@@ -336,6 +413,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
336
413
|
onClear={hasClearAll ? handleDeleteCollection : undefined}
|
|
337
414
|
nextUpTitle={nextUpTitle}
|
|
338
415
|
nowPlayingTitle={nowPlayingTitle}
|
|
416
|
+
isEmpty={isQueueEmpty}
|
|
417
|
+
emptyMessage={emptyMessage}
|
|
418
|
+
clearLabel={clearLabel}
|
|
339
419
|
/>
|
|
340
420
|
),
|
|
341
421
|
});
|
|
@@ -361,6 +441,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
361
441
|
handleDeleteCollection,
|
|
362
442
|
nextUpTitle,
|
|
363
443
|
nowPlayingTitle,
|
|
444
|
+
isQueueEmpty,
|
|
445
|
+
emptyMessage,
|
|
446
|
+
clearLabel,
|
|
364
447
|
hasAdd,
|
|
365
448
|
handleCreatePlaylist,
|
|
366
449
|
]);
|
|
@@ -470,6 +553,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
470
553
|
onClear={hasClearAll ? handleDeleteCollection : undefined}
|
|
471
554
|
nextUpTitle={nextUpTitle}
|
|
472
555
|
nowPlayingTitle={nowPlayingTitle}
|
|
556
|
+
isEmpty={isQueueEmpty}
|
|
557
|
+
emptyMessage={emptyMessage}
|
|
558
|
+
clearLabel={clearLabel}
|
|
473
559
|
/>
|
|
474
560
|
) : null}
|
|
475
561
|
{hasAdd ? (
|
|
@@ -87,9 +87,7 @@ export const SortableList = ({
|
|
|
87
87
|
const [localData, setLocalData] = useState(sortableData);
|
|
88
88
|
|
|
89
89
|
useEffect(() => {
|
|
90
|
-
setLocalData(
|
|
91
|
-
localData !== sortableData ? sortableData : localData
|
|
92
|
-
);
|
|
90
|
+
setLocalData(sortableData);
|
|
93
91
|
}, [sortableData]);
|
|
94
92
|
|
|
95
93
|
const handleDragEnd = useCallback(
|
|
@@ -142,6 +140,7 @@ export const SortableList = ({
|
|
|
142
140
|
>
|
|
143
141
|
{renderHeader}
|
|
144
142
|
<Sortable.Grid
|
|
143
|
+
key={`sortable-grid-${localData.length}`}
|
|
145
144
|
scrollableRef={scrollableRef}
|
|
146
145
|
customHandle
|
|
147
146
|
columns={columns}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
AudioPlayerButton,
|
|
10
10
|
VARIANT_TEXT_ONLY,
|
|
11
11
|
} from "./AudioPlayer/Components/Button";
|
|
12
|
+
import { logger } from "@applicaster/zapp-react-native-utils/logger";
|
|
12
13
|
|
|
13
14
|
export type TextInputContentProps = {
|
|
14
15
|
/** Injected by the sheet host; unused for layout (header is owned by ModalComponent). */
|
|
@@ -19,17 +20,25 @@ export type TextInputContentProps = {
|
|
|
19
20
|
inputLabel?: string;
|
|
20
21
|
defaultValue?: string;
|
|
21
22
|
buttonLabel: string;
|
|
23
|
+
validationLabel?: string;
|
|
24
|
+
maxLength?: number;
|
|
22
25
|
actions: ActionType[];
|
|
23
26
|
actionContext?: Record<string, any>;
|
|
24
27
|
};
|
|
25
28
|
|
|
29
|
+
// Spacing mirrors the `component_style_*` keys in text input component.
|
|
26
30
|
const styles = StyleSheet.create({
|
|
27
31
|
container: {
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
backgroundColor: "transparent", // component_style_background_color
|
|
33
|
+
paddingLeft: 40, // component_style_padding_left
|
|
34
|
+
paddingRight: 40, // component_style_padding_right
|
|
35
|
+
paddingTop: 0, // component_style_padding_top
|
|
36
|
+
paddingBottom: 0, // component_style_padding_bottom
|
|
37
|
+
marginTop: 20, // component_style_margin_top
|
|
38
|
+
marginBottom: 20, // component_style_margin_bottom
|
|
30
39
|
},
|
|
31
40
|
inputWrap: {
|
|
32
|
-
|
|
41
|
+
// Gap down to the submit button; owned by the modal, not the reference.
|
|
33
42
|
marginBottom: 24,
|
|
34
43
|
},
|
|
35
44
|
});
|
|
@@ -45,11 +54,24 @@ export function TextInputContent(props: TextInputContentProps) {
|
|
|
45
54
|
buttonLabel,
|
|
46
55
|
actions,
|
|
47
56
|
actionContext,
|
|
57
|
+
maxLength = 255,
|
|
58
|
+
validationLabel,
|
|
48
59
|
} = props;
|
|
49
60
|
|
|
50
61
|
const [textValue, setTextValue] = useState(defaultValue);
|
|
51
62
|
const [submitting, setSubmitting] = useState(false);
|
|
52
63
|
|
|
64
|
+
const validationMessage =
|
|
65
|
+
textValue.length > maxLength
|
|
66
|
+
? validationLabel || "Too many characters"
|
|
67
|
+
: undefined;
|
|
68
|
+
|
|
69
|
+
if (!validationLabel) {
|
|
70
|
+
logger.warn(
|
|
71
|
+
"Modal/TextInputContent: Validation label (validationLabel) is not provided, using default message 'Too many characters'."
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
const onSubmit = useCallback(async () => {
|
|
54
76
|
if (submitting) {
|
|
55
77
|
return;
|
|
@@ -97,6 +119,7 @@ export function TextInputContent(props: TextInputContentProps) {
|
|
|
97
119
|
label: inputLabel,
|
|
98
120
|
}}
|
|
99
121
|
value={textValue}
|
|
122
|
+
validationMessage={validationMessage}
|
|
100
123
|
onChangeText={setTextValue}
|
|
101
124
|
onClearPress={() => setTextValue("")}
|
|
102
125
|
/>
|
|
@@ -105,7 +128,7 @@ export function TextInputContent(props: TextInputContentProps) {
|
|
|
105
128
|
configuration={VARIANT_TEXT_ONLY}
|
|
106
129
|
data={{ title: buttonLabel }}
|
|
107
130
|
onPress={onSubmit}
|
|
108
|
-
disabled={submitting}
|
|
131
|
+
disabled={!!validationMessage || submitting}
|
|
109
132
|
/>
|
|
110
133
|
</View>
|
|
111
134
|
);
|
|
@@ -83,9 +83,14 @@ jest.mock("../AudioPlayer/Components/Item", () => ({
|
|
|
83
83
|
}));
|
|
84
84
|
|
|
85
85
|
const mockAudioPlayerButtonProps: Array<any> = [];
|
|
86
|
+
const mockNowPlayingHeaderProps: Array<any> = [];
|
|
86
87
|
|
|
87
88
|
jest.mock("../AudioPlayer/Components", () => ({
|
|
88
|
-
NowPlayingHeaderSection: () =>
|
|
89
|
+
NowPlayingHeaderSection: (props: any) => {
|
|
90
|
+
mockNowPlayingHeaderProps.push(props);
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
},
|
|
89
94
|
AudioPlayerButton: (props: any) => {
|
|
90
95
|
const { Text } = require("react-native");
|
|
91
96
|
|
|
@@ -216,6 +221,74 @@ describe("BottomSheetModalContent role/behavior cells", () => {
|
|
|
216
221
|
});
|
|
217
222
|
});
|
|
218
223
|
|
|
224
|
+
it("shows the empty Next Up message only when now playing is on and the list is empty", () => {
|
|
225
|
+
mockNowPlayingHeaderProps.length = 0;
|
|
226
|
+
const { BehaviorSubject } = require("rxjs");
|
|
227
|
+
|
|
228
|
+
const emptyQueue = new ContentViewModel({
|
|
229
|
+
title: "Queue",
|
|
230
|
+
items: [],
|
|
231
|
+
role: "dynamic_collection",
|
|
232
|
+
dynamicCollection: {
|
|
233
|
+
operations: ["nowPlaying", "remove", "reorder", "clearAll"],
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
emptyQueue.editableCollection = {
|
|
238
|
+
items$: new BehaviorSubject([]),
|
|
239
|
+
operations: ["nowPlaying", "remove", "reorder", "clearAll"],
|
|
240
|
+
move: jest.fn(),
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const { rerender } = render(
|
|
244
|
+
<BottomSheetModalContent
|
|
245
|
+
{...baseProps}
|
|
246
|
+
items={[]}
|
|
247
|
+
emptyMessage="Queued items will be added here"
|
|
248
|
+
viewModel={emptyQueue}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
|
|
253
|
+
isEmpty: true,
|
|
254
|
+
emptyMessage: "Queued items will be added here",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const withItem = new ContentViewModel({
|
|
258
|
+
title: "Queue",
|
|
259
|
+
items: [{ id: "t1", title: "Track 1" }],
|
|
260
|
+
role: "dynamic_collection",
|
|
261
|
+
dynamicCollection: {
|
|
262
|
+
operations: ["nowPlaying", "remove", "reorder", "clearAll"],
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
withItem.editableCollection = {
|
|
267
|
+
items$: new BehaviorSubject([{ id: "t1", title: "Track 1" }]),
|
|
268
|
+
operations: ["nowPlaying", "remove", "reorder", "clearAll"],
|
|
269
|
+
move: jest.fn(),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
rerender(
|
|
273
|
+
<BottomSheetModalContent
|
|
274
|
+
{...baseProps}
|
|
275
|
+
items={[{ id: "t1", title: "Track 1", label: "Track 1" }]}
|
|
276
|
+
emptyMessage="Queued items will be added here"
|
|
277
|
+
viewModel={withItem}
|
|
278
|
+
/>
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
|
|
282
|
+
isEmpty: false,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
mockNowPlayingHeaderProps.length = 0;
|
|
286
|
+
|
|
287
|
+
render(<BottomSheetModalContent {...baseProps} />);
|
|
288
|
+
|
|
289
|
+
expect(mockNowPlayingHeaderProps).toHaveLength(0);
|
|
290
|
+
});
|
|
291
|
+
|
|
219
292
|
it("honors disableScrollViewWrap in contentComponentProps to bypass ScrollView wrapping", () => {
|
|
220
293
|
const CustomComponent = () => (
|
|
221
294
|
<React.Fragment>Custom Content</React.Fragment>
|
|
@@ -19,7 +19,7 @@ jest.mock(
|
|
|
19
19
|
);
|
|
20
20
|
|
|
21
21
|
jest.mock("../AudioPlayer/Components/Input", () => ({
|
|
22
|
-
AudioPlayerInput: ({ value, onChangeText, data }: any) => {
|
|
22
|
+
AudioPlayerInput: ({ value, onChangeText, data, validationMessage }: any) => {
|
|
23
23
|
const { TextInput, Text } = require("react-native");
|
|
24
24
|
|
|
25
25
|
return (
|
|
@@ -31,6 +31,9 @@ jest.mock("../AudioPlayer/Components/Input", () => ({
|
|
|
31
31
|
onChangeText={onChangeText}
|
|
32
32
|
placeholder={data?.placeholder}
|
|
33
33
|
/>
|
|
34
|
+
{validationMessage ? (
|
|
35
|
+
<Text testID="input-validation-message">{validationMessage}</Text>
|
|
36
|
+
) : null}
|
|
34
37
|
</>
|
|
35
38
|
);
|
|
36
39
|
},
|
|
@@ -87,6 +90,16 @@ describe("TextInputContent", () => {
|
|
|
87
90
|
expect(screen.getByText("Update")).toBeTruthy();
|
|
88
91
|
});
|
|
89
92
|
|
|
93
|
+
it("shows validation below the input when its value is too long", () => {
|
|
94
|
+
render(<TextInputContent maxLength={100} {...baseProps} />);
|
|
95
|
+
|
|
96
|
+
fireEvent.changeText(screen.getByTestId("playlist-input"), "x".repeat(101));
|
|
97
|
+
|
|
98
|
+
expect(screen.getByTestId("input-validation-message")).toHaveTextContent(
|
|
99
|
+
"Too many characters"
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
90
103
|
it("on submit merges name into sendCloudEvent data then dismisses", async () => {
|
|
91
104
|
render(<TextInputContent {...baseProps} />);
|
|
92
105
|
|
|
@@ -13,8 +13,15 @@ export async function showTextInputAction(
|
|
|
13
13
|
action: ActionType,
|
|
14
14
|
context?: Record<string, any>
|
|
15
15
|
): Promise<ActionResult> {
|
|
16
|
-
const {
|
|
17
|
-
|
|
16
|
+
const {
|
|
17
|
+
headerTitle,
|
|
18
|
+
inputLabel,
|
|
19
|
+
defaultValue,
|
|
20
|
+
buttonLabel,
|
|
21
|
+
validationLabel,
|
|
22
|
+
actions,
|
|
23
|
+
maxLength,
|
|
24
|
+
} = action.options || {};
|
|
18
25
|
|
|
19
26
|
if (!headerTitle || !buttonLabel || !actions || !actions.length) {
|
|
20
27
|
return ActionResult.Error;
|
|
@@ -30,10 +37,12 @@ export async function showTextInputAction(
|
|
|
30
37
|
content: {
|
|
31
38
|
component: TextInputContent,
|
|
32
39
|
props: {
|
|
40
|
+
maxLength,
|
|
33
41
|
inputLabel,
|
|
34
42
|
defaultValue: defaultValue ?? "",
|
|
35
43
|
buttonLabel,
|
|
36
44
|
actions,
|
|
45
|
+
validationLabel,
|
|
37
46
|
},
|
|
38
47
|
},
|
|
39
48
|
},
|
|
@@ -10,6 +10,28 @@ jest.useFakeTimers({ legacyFakeTimers: true });
|
|
|
10
10
|
// isConnected/isInternetReachable/details fields, so the mock has to mirror
|
|
11
11
|
// that shape for online/offline to actually be distinguishable.
|
|
12
12
|
let mockConnectionStatus = false;
|
|
13
|
+
let mockStillOfflineAfterResumeCount = 0;
|
|
14
|
+
|
|
15
|
+
const mockComponentsLogger = {
|
|
16
|
+
log: jest.fn(),
|
|
17
|
+
debug: jest.fn(),
|
|
18
|
+
info: jest.fn(),
|
|
19
|
+
warning: jest.fn(),
|
|
20
|
+
error: jest.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
jest.mock("../../../Helpers/logger", () => {
|
|
24
|
+
const proxy = {
|
|
25
|
+
log: (event) => mockComponentsLogger.log(event),
|
|
26
|
+
debug: (event) => mockComponentsLogger.debug(event),
|
|
27
|
+
info: (event) => mockComponentsLogger.info(event),
|
|
28
|
+
warning: (event) => mockComponentsLogger.warning(event),
|
|
29
|
+
error: (event) => mockComponentsLogger.error(event),
|
|
30
|
+
addSubsystem: () => proxy,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return { componentsLogger: proxy };
|
|
34
|
+
});
|
|
13
35
|
|
|
14
36
|
jest.mock("@applicaster/zapp-react-native-utils/reactHooks/connection", () => {
|
|
15
37
|
return {
|
|
@@ -20,6 +42,7 @@ jest.mock("@applicaster/zapp-react-native-utils/reactHooks/connection", () => {
|
|
|
20
42
|
details: {},
|
|
21
43
|
isConnected: mockConnectionStatus,
|
|
22
44
|
isInternetReachable: mockConnectionStatus,
|
|
45
|
+
stillOfflineAfterResumeCount: mockStillOfflineAfterResumeCount,
|
|
23
46
|
})),
|
|
24
47
|
};
|
|
25
48
|
});
|
|
@@ -58,6 +81,7 @@ describe("OfflineHandler", () => {
|
|
|
58
81
|
|
|
59
82
|
beforeEach(() => {
|
|
60
83
|
mockConnectionStatus = false;
|
|
84
|
+
mockStillOfflineAfterResumeCount = 0;
|
|
61
85
|
});
|
|
62
86
|
|
|
63
87
|
it("renders its children without wrapping them in a notification view", () => {
|
|
@@ -131,4 +155,89 @@ describe("OfflineHandler", () => {
|
|
|
131
155
|
|
|
132
156
|
unsubscribe();
|
|
133
157
|
});
|
|
158
|
+
|
|
159
|
+
it("re-shows the offline toast when still offline after app resume", () => {
|
|
160
|
+
mockConnectionStatus = false;
|
|
161
|
+
|
|
162
|
+
const handleShowToast = jest.fn();
|
|
163
|
+
const unsubscribe = subscriberFor("showToast", handleShowToast);
|
|
164
|
+
|
|
165
|
+
const { rerender } = render(<OfflineHandler />);
|
|
166
|
+
handleShowToast.mockClear();
|
|
167
|
+
|
|
168
|
+
mockStillOfflineAfterResumeCount = 1;
|
|
169
|
+
rerender(<OfflineHandler />);
|
|
170
|
+
|
|
171
|
+
expect(handleShowToast).toHaveBeenCalledTimes(1);
|
|
172
|
+
|
|
173
|
+
expect(handleShowToast).toHaveBeenCalledWith(
|
|
174
|
+
expect.objectContaining({
|
|
175
|
+
id: "offline-status",
|
|
176
|
+
message: "No internet connection",
|
|
177
|
+
timeout: 5000,
|
|
178
|
+
}),
|
|
179
|
+
expect.any(Function)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// further renders with the same counter must not re-emit
|
|
183
|
+
rerender(<OfflineHandler />);
|
|
184
|
+
expect(handleShowToast).toHaveBeenCalledTimes(1);
|
|
185
|
+
|
|
186
|
+
unsubscribe();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("does not re-show the offline toast for a counter bump while online", () => {
|
|
190
|
+
mockConnectionStatus = true;
|
|
191
|
+
|
|
192
|
+
const handleShowToast = jest.fn();
|
|
193
|
+
const unsubscribe = subscriberFor("showToast", handleShowToast);
|
|
194
|
+
|
|
195
|
+
const { rerender } = render(<OfflineHandler />);
|
|
196
|
+
|
|
197
|
+
mockStillOfflineAfterResumeCount = 1;
|
|
198
|
+
rerender(<OfflineHandler />);
|
|
199
|
+
|
|
200
|
+
expect(handleShowToast).not.toHaveBeenCalled();
|
|
201
|
+
|
|
202
|
+
unsubscribe();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not emit on mount just because the counter is already > 0", () => {
|
|
206
|
+
mockConnectionStatus = true;
|
|
207
|
+
mockStillOfflineAfterResumeCount = 3;
|
|
208
|
+
|
|
209
|
+
const handleShowToast = jest.fn();
|
|
210
|
+
const unsubscribe = subscriberFor("showToast", handleShowToast);
|
|
211
|
+
|
|
212
|
+
render(<OfflineHandler />);
|
|
213
|
+
|
|
214
|
+
expect(handleShowToast).not.toHaveBeenCalled();
|
|
215
|
+
|
|
216
|
+
unsubscribe();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("logs toast emissions at info level", () => {
|
|
220
|
+
mockConnectionStatus = false;
|
|
221
|
+
mockComponentsLogger.info.mockClear();
|
|
222
|
+
mockComponentsLogger.warning.mockClear();
|
|
223
|
+
|
|
224
|
+
const { rerender } = render(<OfflineHandler />);
|
|
225
|
+
|
|
226
|
+
mockConnectionStatus = true;
|
|
227
|
+
rerender(<OfflineHandler />);
|
|
228
|
+
|
|
229
|
+
expect(mockComponentsLogger.info).toHaveBeenCalledWith(
|
|
230
|
+
expect.objectContaining({
|
|
231
|
+
message: "OfflineHandler: device is offline, emit toast",
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(mockComponentsLogger.info).toHaveBeenCalledWith(
|
|
236
|
+
expect.objectContaining({
|
|
237
|
+
message: "OfflineHandler: device is back online, emit toast",
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(mockComponentsLogger.warning).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
134
243
|
});
|
|
@@ -29,12 +29,22 @@ export const OfflineHandler = ({ children }: Props) => {
|
|
|
29
29
|
const { online } = parseConnectionInfo(connectionInfo);
|
|
30
30
|
const prevOnline = usePrevious(online);
|
|
31
31
|
|
|
32
|
+
const stillOfflineAfterResumeCount =
|
|
33
|
+
connectionInfo?.stillOfflineAfterResumeCount ?? 0;
|
|
34
|
+
|
|
35
|
+
const prevStillOfflineAfterResumeCount = usePrevious(
|
|
36
|
+
stillOfflineAfterResumeCount
|
|
37
|
+
);
|
|
38
|
+
|
|
32
39
|
const emitShowToast = useSubscriberFor(SHOW_TOAST_EVENT);
|
|
33
40
|
|
|
34
41
|
const { message, extraMessage, style } = useOfflineHandlerProps(online);
|
|
35
42
|
|
|
36
43
|
const showOnlineToast = useCallback(() => {
|
|
37
|
-
logger.
|
|
44
|
+
logger.info({
|
|
45
|
+
message: "OfflineHandler: device is back online, emit toast",
|
|
46
|
+
data: { online, prevOnline },
|
|
47
|
+
});
|
|
38
48
|
|
|
39
49
|
emitShowToast({
|
|
40
50
|
id: OFFLINE_STATUS_TOAST_ID,
|
|
@@ -43,10 +53,13 @@ export const OfflineHandler = ({ children }: Props) => {
|
|
|
43
53
|
style,
|
|
44
54
|
timeout: timers.goingOnline,
|
|
45
55
|
});
|
|
46
|
-
}, [emitShowToast, message, extraMessage, style]);
|
|
56
|
+
}, [emitShowToast, message, extraMessage, style, online, prevOnline]);
|
|
47
57
|
|
|
48
58
|
const showOfflineToast = useCallback(() => {
|
|
49
|
-
logger.
|
|
59
|
+
logger.info({
|
|
60
|
+
message: "OfflineHandler: device is offline, emit toast",
|
|
61
|
+
data: { online, prevOnline, stillOfflineAfterResumeCount },
|
|
62
|
+
});
|
|
50
63
|
|
|
51
64
|
emitShowToast({
|
|
52
65
|
id: OFFLINE_STATUS_TOAST_ID,
|
|
@@ -55,7 +68,15 @@ export const OfflineHandler = ({ children }: Props) => {
|
|
|
55
68
|
style,
|
|
56
69
|
timeout: timers.goingOffline,
|
|
57
70
|
});
|
|
58
|
-
}, [
|
|
71
|
+
}, [
|
|
72
|
+
emitShowToast,
|
|
73
|
+
message,
|
|
74
|
+
extraMessage,
|
|
75
|
+
style,
|
|
76
|
+
online,
|
|
77
|
+
prevOnline,
|
|
78
|
+
stillOfflineAfterResumeCount,
|
|
79
|
+
]);
|
|
59
80
|
|
|
60
81
|
useEffect(() => {
|
|
61
82
|
// On mount there is no previous state yet: only surface the offline toast
|
|
@@ -79,5 +100,22 @@ export const OfflineHandler = ({ children }: Props) => {
|
|
|
79
100
|
}
|
|
80
101
|
}, [online, prevOnline, showOfflineToast, showOnlineToast]);
|
|
81
102
|
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
// NetworkStatusProvider bumps this counter when the app resumed while offline
|
|
105
|
+
// and is still offline after the verification delay: remind the user.
|
|
106
|
+
if (
|
|
107
|
+
prevStillOfflineAfterResumeCount !== undefined &&
|
|
108
|
+
stillOfflineAfterResumeCount > prevStillOfflineAfterResumeCount &&
|
|
109
|
+
!online
|
|
110
|
+
) {
|
|
111
|
+
showOfflineToast();
|
|
112
|
+
}
|
|
113
|
+
}, [
|
|
114
|
+
stillOfflineAfterResumeCount,
|
|
115
|
+
prevStillOfflineAfterResumeCount,
|
|
116
|
+
online,
|
|
117
|
+
showOfflineToast,
|
|
118
|
+
]);
|
|
119
|
+
|
|
82
120
|
return children;
|
|
83
121
|
};
|
|
@@ -404,10 +404,17 @@ const PlayerContainerComponent = (props: Props) => {
|
|
|
404
404
|
const { eventType } = event;
|
|
405
405
|
|
|
406
406
|
if (!isLanguageOverlayVisible && eventType === "menu") {
|
|
407
|
+
// First Back dismisses Play Next, the next one closes the player
|
|
408
|
+
if (playNextOverlayState) {
|
|
409
|
+
playNextOverlayState.handleUserCancelPlayNext();
|
|
410
|
+
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
407
414
|
close();
|
|
408
415
|
}
|
|
409
416
|
},
|
|
410
|
-
[close]
|
|
417
|
+
[close, playNextOverlayState]
|
|
411
418
|
);
|
|
412
419
|
|
|
413
420
|
// Effects
|
|
@@ -66,7 +66,7 @@ export class AnimationManager {
|
|
|
66
66
|
*
|
|
67
67
|
* @param {object} to contains style to animate and scenes index
|
|
68
68
|
* @param {string} action push|back
|
|
69
|
-
* @param {number} stepsBack number of steps back
|
|
69
|
+
* @param {number} [stepsBack] number of steps back, used by the back action only
|
|
70
70
|
*/
|
|
71
71
|
stateForAction({ to, scenes }, action, stepsBack) {
|
|
72
72
|
let rootIndex = to.index;
|
|
@@ -17,13 +17,14 @@ const fullWidthDimensions = {
|
|
|
17
17
|
type Props = {
|
|
18
18
|
children: any;
|
|
19
19
|
style: any;
|
|
20
|
-
pointerEvents
|
|
21
|
-
overlayStyle
|
|
22
|
-
animating
|
|
20
|
+
pointerEvents?: ViewProps["pointerEvents"];
|
|
21
|
+
overlayStyle?: ViewStyle;
|
|
22
|
+
animating?: boolean;
|
|
23
23
|
contentStyle: ViewStyle;
|
|
24
24
|
pathname: string;
|
|
25
25
|
isActive: boolean;
|
|
26
26
|
screenData: NavigationScreenData;
|
|
27
|
+
screenUniqueId?: string;
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
export function CurrentScreenContextProvider({
|