@applicaster/zapp-react-native-utils 16.0.0-rc.80 → 16.0.0-rc.82
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/actionsExecutor/ActionExecutorContext.tsx +10 -1
- package/actionsExecutor/actions/__tests__/presentScreen.test.ts +127 -0
- package/actionsExecutor/actions/index.ts +2 -0
- package/actionsExecutor/actions/presentScreen.ts +78 -0
- package/actionsExecutor/consts.ts +1 -0
- package/configurationUtils/__tests__/manifestKeyParser.test.ts +21 -0
- package/configurationUtils/__tests__/modalBlocksParser.test.ts +153 -0
- package/configurationUtils/manifestKeyParser.ts +12 -1
- package/configurationUtils/modalBlocksParser.ts +118 -0
- package/package.json +2 -2
- package/reactHooks/navigation/__tests__/index.test.tsx +48 -0
- package/reactHooks/navigation/__tests__/usePresentScreen.test.ts +154 -0
- package/reactHooks/navigation/index.ts +2 -0
- package/reactHooks/navigation/usePresentScreen.ts +157 -0
- package/reactHooks/navigation/useRoute.ts +14 -1
|
@@ -9,7 +9,7 @@ import { ActionExecutionContext, ActionHandler } from "./types";
|
|
|
9
9
|
import { batchRemoveAllFromNamespaceForStorage } from "../zappFrameworkUtils/localStorageHelper";
|
|
10
10
|
import { sessionStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/SessionStorage";
|
|
11
11
|
|
|
12
|
-
import { useNavigation, useRivers } from "../reactHooks";
|
|
12
|
+
import { useNavigation, usePresentScreen, useRivers } from "../reactHooks";
|
|
13
13
|
import { useContentTypes } from "@applicaster/zapp-react-native-redux/hooks";
|
|
14
14
|
import { useSubscriberFor } from "../reactHooks/useSubscriberFor";
|
|
15
15
|
import { APP_EVENTS } from "../appUtils/events";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
localStorageRemoveAction,
|
|
26
26
|
localStorageSetAction,
|
|
27
27
|
localStorageToggleFlagAction,
|
|
28
|
+
createPresentScreenAction,
|
|
28
29
|
openBottomSheetAction,
|
|
29
30
|
refreshComponentAction,
|
|
30
31
|
screenSetVariableAction,
|
|
@@ -157,6 +158,7 @@ export function withActionExecutor(Component) {
|
|
|
157
158
|
const navigator = useNavigation();
|
|
158
159
|
const rivers = useRivers();
|
|
159
160
|
const contentTypes = useContentTypes();
|
|
161
|
+
const presentScreen = usePresentScreen();
|
|
160
162
|
|
|
161
163
|
const handlers = useMemo(() => {
|
|
162
164
|
return {
|
|
@@ -191,6 +193,13 @@ export function withActionExecutor(Component) {
|
|
|
191
193
|
);
|
|
192
194
|
}, [navigator, rivers, contentTypes]);
|
|
193
195
|
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
return _actionExecutor.registerAction(
|
|
198
|
+
ACTION_TYPES.PRESENT_SCREEN,
|
|
199
|
+
createPresentScreenAction(presentScreen)
|
|
200
|
+
);
|
|
201
|
+
}, [presentScreen]);
|
|
202
|
+
|
|
194
203
|
useEffect(() => {
|
|
195
204
|
return _actionExecutor.registerAction(
|
|
196
205
|
ACTION_TYPES.GO_BACK,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createPresentScreenAction } from "../presentScreen";
|
|
2
|
+
import { ActionResult } from "../../ActionExecutor";
|
|
3
|
+
|
|
4
|
+
const contextEntry = { id: "context-entry" } as any;
|
|
5
|
+
|
|
6
|
+
const succeeded = { success: true, cancelled: false };
|
|
7
|
+
|
|
8
|
+
const cancelled = { success: false, cancelled: true };
|
|
9
|
+
|
|
10
|
+
describe("presentScreenAction", () => {
|
|
11
|
+
let presentScreen: jest.Mock;
|
|
12
|
+
let presentScreenAction: ReturnType<typeof createPresentScreenAction>;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
presentScreen = jest.fn().mockResolvedValue(succeeded);
|
|
16
|
+
presentScreenAction = createPresentScreenAction(presentScreen);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("presents the screen named by typeMapping", async () => {
|
|
20
|
+
await presentScreenAction({
|
|
21
|
+
type: "presentScreen",
|
|
22
|
+
options: { typeMapping: "parent_lock" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(presentScreen).toHaveBeenCalledWith({
|
|
26
|
+
typeMapping: "parent_lock",
|
|
27
|
+
entry: undefined,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("fails without presenting anything when typeMapping is missing", async () => {
|
|
32
|
+
const result = await presentScreenAction({
|
|
33
|
+
type: "presentScreen",
|
|
34
|
+
options: {},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(result).toBe(ActionResult.Error);
|
|
38
|
+
expect(presentScreen).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("resolves the '@{entry/}' token to the entry from the execution context", async () => {
|
|
42
|
+
await presentScreenAction(
|
|
43
|
+
{
|
|
44
|
+
type: "presentScreen",
|
|
45
|
+
options: { typeMapping: "parent_lock", entry: "@{entry/}" },
|
|
46
|
+
},
|
|
47
|
+
{ entry: contextEntry }
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(presentScreen).toHaveBeenCalledWith({
|
|
51
|
+
typeMapping: "parent_lock",
|
|
52
|
+
entry: contextEntry,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("presents an entry supplied inline in the options", async () => {
|
|
57
|
+
const inlineEntry = { id: "inline-entry" } as any;
|
|
58
|
+
|
|
59
|
+
await presentScreenAction({
|
|
60
|
+
type: "presentScreen",
|
|
61
|
+
options: { typeMapping: "parent_lock", entry: inlineEntry },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(presentScreen).toHaveBeenCalledWith({
|
|
65
|
+
typeMapping: "parent_lock",
|
|
66
|
+
entry: inlineEntry,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("fails without presenting anything when the entry option is not an object", async () => {
|
|
71
|
+
const result = await presentScreenAction({
|
|
72
|
+
type: "presentScreen",
|
|
73
|
+
options: { typeMapping: "parent_lock", entry: "not-an-entry" as any },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result).toBe(ActionResult.Error);
|
|
77
|
+
expect(presentScreen).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("succeeds when the presented screen reports success", async () => {
|
|
81
|
+
presentScreen.mockResolvedValue(succeeded);
|
|
82
|
+
|
|
83
|
+
const result = await presentScreenAction({
|
|
84
|
+
type: "presentScreen",
|
|
85
|
+
options: { typeMapping: "parent_lock" },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result).toBe(ActionResult.Success);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("cancels the action chain when the presented screen is cancelled", async () => {
|
|
92
|
+
presentScreen.mockResolvedValue(cancelled);
|
|
93
|
+
|
|
94
|
+
const result = await presentScreenAction({
|
|
95
|
+
type: "presentScreen",
|
|
96
|
+
options: { typeMapping: "parent_lock" },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result).toBe(ActionResult.Cancel);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("cancels the action chain when the presented screen reports failure", async () => {
|
|
103
|
+
presentScreen.mockResolvedValue({ success: false, cancelled: false });
|
|
104
|
+
|
|
105
|
+
const result = await presentScreenAction({
|
|
106
|
+
type: "presentScreen",
|
|
107
|
+
options: { typeMapping: "parent_lock" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result).toBe(ActionResult.Cancel);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("errors when the screen could not be presented at all", async () => {
|
|
114
|
+
presentScreen.mockResolvedValue({
|
|
115
|
+
success: false,
|
|
116
|
+
cancelled: false,
|
|
117
|
+
error: "can not resolve screen type mapping: parent_lock",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = await presentScreenAction({
|
|
121
|
+
type: "presentScreen",
|
|
122
|
+
options: { typeMapping: "parent_lock" },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result).toBe(ActionResult.Error);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -27,6 +27,8 @@ export { screenToggleFlagAction } from "./screenToggleFlag";
|
|
|
27
27
|
|
|
28
28
|
export { createNavigateToScreenAction } from "./navigateToScreen";
|
|
29
29
|
|
|
30
|
+
export { createPresentScreenAction } from "./presentScreen";
|
|
31
|
+
|
|
30
32
|
export { createGoBackAction } from "./goBack";
|
|
31
33
|
|
|
32
34
|
export { createGoHomeAction } from "./goHome";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/// <reference types="@applicaster/applicaster-types" />
|
|
2
|
+
import { ActionResult } from "../ActionExecutor";
|
|
3
|
+
import { ActionHandler, ActionExecutionContext } from "../types";
|
|
4
|
+
import type { PresentScreenFn } from "../../reactHooks/navigation/usePresentScreen";
|
|
5
|
+
import { createLogger } from "../../logger";
|
|
6
|
+
|
|
7
|
+
const { log_error } = createLogger({
|
|
8
|
+
subsystem: "ActionExecutorContext",
|
|
9
|
+
category: "General",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options for the presentScreen action.
|
|
14
|
+
*/
|
|
15
|
+
export interface PresentScreenOptions {
|
|
16
|
+
/** The screen type to present (from content types mapping) */
|
|
17
|
+
typeMapping: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Entry to present the screen with. Can be:
|
|
21
|
+
* - "@{entry/}" to use the context entry
|
|
22
|
+
* - A ZappEntry object
|
|
23
|
+
* - undefined to present the screen without an entry
|
|
24
|
+
*/
|
|
25
|
+
entry?: "@{entry/}" | ZappEntry;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a present-screen action handler with the provided dependencies.
|
|
30
|
+
*
|
|
31
|
+
* Unlike navigateToScreen, this action does not return until the presented
|
|
32
|
+
* screen reports a result, so a screen that gates the flow (a parental lock,
|
|
33
|
+
* for example) can stop the remaining actions by being cancelled.
|
|
34
|
+
*
|
|
35
|
+
* @param presentScreen - Presents a screen as a modal and resolves with its result
|
|
36
|
+
* @returns An action handler that presents a screen and awaits its outcome
|
|
37
|
+
*/
|
|
38
|
+
export function createPresentScreenAction(
|
|
39
|
+
presentScreen: PresentScreenFn
|
|
40
|
+
): ActionHandler<PresentScreenOptions> {
|
|
41
|
+
return async function presentScreenAction(
|
|
42
|
+
action,
|
|
43
|
+
context?: ActionExecutionContext
|
|
44
|
+
): Promise<ActionResult> {
|
|
45
|
+
const typeMapping = action.options?.typeMapping;
|
|
46
|
+
|
|
47
|
+
if (!typeMapping) {
|
|
48
|
+
log_error("presentScreen: typeMapping option is missing");
|
|
49
|
+
|
|
50
|
+
return ActionResult.Error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const entrySource = action.options?.entry;
|
|
54
|
+
|
|
55
|
+
const entry = entrySource
|
|
56
|
+
? entrySource === "@{entry/}"
|
|
57
|
+
? context?.entry
|
|
58
|
+
: entrySource
|
|
59
|
+
: undefined;
|
|
60
|
+
|
|
61
|
+
if (entry && typeof entry !== "object") {
|
|
62
|
+
log_error(
|
|
63
|
+
`presentScreen: entry option is not an object, entry: ${entry}`
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return ActionResult.Error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = await presentScreen({ typeMapping, entry });
|
|
70
|
+
|
|
71
|
+
if (result.error) {
|
|
72
|
+
return ActionResult.Error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Anything short of success stops the remaining actions in the chain.
|
|
76
|
+
return result.success ? ActionResult.Success : ActionResult.Cancel;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -176,6 +176,27 @@ describe("getAllSpecificStyles", () => {
|
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
it("should normalize Studio focus alias onto focused state", () => {
|
|
180
|
+
const outStyles = {};
|
|
181
|
+
|
|
182
|
+
const configuration = {
|
|
183
|
+
button_style_focus_background_color: "#FF3B30",
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
getAllSpecificStyles({
|
|
187
|
+
componentName: "button",
|
|
188
|
+
subComponentName: "",
|
|
189
|
+
configuration,
|
|
190
|
+
outStyles,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(outStyles.focused).toEqual({
|
|
194
|
+
backgroundColor: "#FF3B30",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(outStyles.focus).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
179
200
|
it("should handle selected state", () => {
|
|
180
201
|
const outStyles = {};
|
|
181
202
|
|
|
@@ -104,4 +104,157 @@ describe("parseModalBlocksConfiguration", () => {
|
|
|
104
104
|
expect(result.button.title).toEqual({ default: {} });
|
|
105
105
|
expect(result.action.leadingIcon).toEqual({ default: {} });
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
it("should map Studio item.background.focus.* onto focused state", () => {
|
|
109
|
+
const result = parseModalBlocksConfiguration(
|
|
110
|
+
{
|
|
111
|
+
"item.background.default.background_color": "transparent",
|
|
112
|
+
"item.background.focus.background_color": "#FF3B30",
|
|
113
|
+
},
|
|
114
|
+
"playlist-experience"
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(result.item.background.default).toEqual(
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
backgroundColor: "transparent",
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.item.background.focused).toEqual(
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
backgroundColor: "#FF3B30",
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should accept Studio text_label1 keys without underscore before digit", () => {
|
|
131
|
+
const result = parseModalBlocksConfiguration(
|
|
132
|
+
{
|
|
133
|
+
"item.text_label1.font_color": "#00FF00",
|
|
134
|
+
"item.text_label1.font_size": 28,
|
|
135
|
+
"item.text_label2.font_color": "#00FFFF",
|
|
136
|
+
},
|
|
137
|
+
"playlist-experience"
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result.item.textLabel1.default).toEqual(
|
|
141
|
+
expect.objectContaining({
|
|
142
|
+
fontColor: "#00FF00",
|
|
143
|
+
fontSize: 28,
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(result.item.textLabel2.default).toEqual(
|
|
148
|
+
expect.objectContaining({
|
|
149
|
+
fontColor: "#00FFFF",
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should map button.create_new_playlist_asset onto leadingIcon.asset", () => {
|
|
155
|
+
const result = parseModalBlocksConfiguration(
|
|
156
|
+
{
|
|
157
|
+
"button.create_new_playlist_asset":
|
|
158
|
+
"https://placehold.co/24x24/FF00FF/FFFFFF/png?text=CN",
|
|
159
|
+
},
|
|
160
|
+
"playlist-experience"
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(result.button.leadingIcon.default).toEqual(
|
|
164
|
+
expect.objectContaining({
|
|
165
|
+
asset: "https://placehold.co/24x24/FF00FF/FFFFFF/png?text=CN",
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should map item.checkbox_asset and checked_checkbox_asset onto item.checkbox", () => {
|
|
171
|
+
const result = parseModalBlocksConfiguration(
|
|
172
|
+
{
|
|
173
|
+
"item.checkbox_asset":
|
|
174
|
+
"https://placehold.co/24x24/FF3B30/FFFFFF/png?text=CB",
|
|
175
|
+
"item.checked_checkbox_asset":
|
|
176
|
+
"https://placehold.co/24x24/FFD60A/000000/png?text=CK",
|
|
177
|
+
},
|
|
178
|
+
"playlist-experience"
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(result.item.checkbox.default).toEqual(
|
|
182
|
+
expect.objectContaining({
|
|
183
|
+
asset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=CB",
|
|
184
|
+
checkedAsset: "https://placehold.co/24x24/FFD60A/000000/png?text=CK",
|
|
185
|
+
})
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should map reorder/remove Studio assets onto item.actionButtons", () => {
|
|
190
|
+
const result = parseModalBlocksConfiguration(
|
|
191
|
+
{
|
|
192
|
+
"reorder_playlist_item.remove_from_playlist_asset":
|
|
193
|
+
"https://placehold.co/24x24/00FF00/000000/png?text=RM",
|
|
194
|
+
"reorder_playlist_item.reorder_handle_asset":
|
|
195
|
+
"https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
|
|
196
|
+
},
|
|
197
|
+
"playlist-experience"
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(result.item.actionButtons.default).toEqual(
|
|
201
|
+
expect.objectContaining({
|
|
202
|
+
removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
|
|
203
|
+
dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should fall back to flavor1 reorder/remove assets", () => {
|
|
209
|
+
const result = parseModalBlocksConfiguration(
|
|
210
|
+
{
|
|
211
|
+
"playlist_experience_assets_flavor1.remove_from_playlist_asset":
|
|
212
|
+
"https://placehold.co/24x24/00FF00/000000/png?text=RM",
|
|
213
|
+
"playlist_experience_assets_flavor1.reorder_handle_asset":
|
|
214
|
+
"https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
|
|
215
|
+
},
|
|
216
|
+
"playlist-experience"
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(result.item.actionButtons.default).toEqual(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
|
|
222
|
+
dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should fall back to flavor2 reorder/remove assets", () => {
|
|
228
|
+
const result = parseModalBlocksConfiguration(
|
|
229
|
+
{
|
|
230
|
+
"playlist_experience_assets_flavor2.remove_from_playlist_asset":
|
|
231
|
+
"https://placehold.co/24x24/00FF00/000000/png?text=RM",
|
|
232
|
+
"playlist_experience_assets_flavor2.reorder_handle_asset":
|
|
233
|
+
"https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
|
|
234
|
+
},
|
|
235
|
+
"playlist-experience"
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(result.item.actionButtons.default).toEqual(
|
|
239
|
+
expect.objectContaining({
|
|
240
|
+
removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
|
|
241
|
+
dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should ignore playlist-only asset keys for other theme plugins", () => {
|
|
247
|
+
const result = parseModalBlocksConfiguration(
|
|
248
|
+
{
|
|
249
|
+
"button.create_new_playlist_asset":
|
|
250
|
+
"https://placehold.co/24x24/FF00FF/FFFFFF/png?text=CN",
|
|
251
|
+
"playlist_experience_assets_flavor1.reorder_handle_asset":
|
|
252
|
+
"https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
|
|
253
|
+
},
|
|
254
|
+
"queue-action"
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(result.button.leadingIcon.default?.asset).toBeUndefined();
|
|
258
|
+
expect(result.item.actionButtons.default?.dragAsset).toBeUndefined();
|
|
259
|
+
});
|
|
107
260
|
});
|
|
@@ -11,11 +11,17 @@ const currentPlatform = platformSelect({
|
|
|
11
11
|
android_tv: "android",
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
// Do not change the order
|
|
14
|
+
// Do not change the order: focused_selected must be first, and "focus" must
|
|
15
|
+
// come *after* "focused". Matching uses startsWith(state + "_"), so putting
|
|
16
|
+
// "focus" first would steal focused_* / focused_selected_* keys. Adding
|
|
17
|
+
// "focus" here affects every getAllSpecificStyles consumer (not only modals);
|
|
18
|
+
// property names that themselves start with focus_ (e.g. focus_font_color as
|
|
19
|
+
// a non-state style) would also be classified as the focus state.
|
|
15
20
|
const states = [
|
|
16
21
|
"focused_selected",
|
|
17
22
|
"pressed",
|
|
18
23
|
"focused",
|
|
24
|
+
"focus",
|
|
19
25
|
"selected",
|
|
20
26
|
"default",
|
|
21
27
|
];
|
|
@@ -112,6 +118,11 @@ export const getAllSpecificStyles = ({
|
|
|
112
118
|
state = defaultKey;
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// Normalize Studio "focus" keys onto the runtime "focused" state bucket.
|
|
122
|
+
if (state === "focus") {
|
|
123
|
+
state = "focused";
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
if (!styleName) return;
|
|
116
127
|
|
|
117
128
|
const camelCaseKey = toCamelCase(styleName);
|
|
@@ -25,6 +25,10 @@ export type ModalBlocksConfig = {
|
|
|
25
25
|
buttonsContainer: Record<string, any>;
|
|
26
26
|
button1: Record<string, any>;
|
|
27
27
|
button2: Record<string, any>;
|
|
28
|
+
/** Studio item.checkbox_asset / item.checked_checkbox_asset */
|
|
29
|
+
checkbox?: Record<string, any>;
|
|
30
|
+
/** Studio reorder/remove icons for Edit Order rows */
|
|
31
|
+
actionButtons?: Record<string, any>;
|
|
28
32
|
};
|
|
29
33
|
nowPlayingItem?: {
|
|
30
34
|
background: Record<string, any>;
|
|
@@ -37,6 +41,8 @@ export type ModalBlocksConfig = {
|
|
|
37
41
|
buttonsContainer: Record<string, any>;
|
|
38
42
|
button1: Record<string, any>;
|
|
39
43
|
button2: Record<string, any>;
|
|
44
|
+
checkbox?: Record<string, any>;
|
|
45
|
+
actionButtons?: Record<string, any>;
|
|
40
46
|
};
|
|
41
47
|
button: {
|
|
42
48
|
background: Record<string, any>;
|
|
@@ -75,6 +81,8 @@ export const parseModalBlocksConfiguration = (
|
|
|
75
81
|
buttonsContainer: {},
|
|
76
82
|
button1: {},
|
|
77
83
|
button2: {},
|
|
84
|
+
checkbox: {},
|
|
85
|
+
actionButtons: {},
|
|
78
86
|
},
|
|
79
87
|
nowPlayingItem: {
|
|
80
88
|
background: {},
|
|
@@ -87,6 +95,8 @@ export const parseModalBlocksConfiguration = (
|
|
|
87
95
|
buttonsContainer: {},
|
|
88
96
|
button1: {},
|
|
89
97
|
button2: {},
|
|
98
|
+
checkbox: {},
|
|
99
|
+
actionButtons: {},
|
|
90
100
|
},
|
|
91
101
|
button: {
|
|
92
102
|
background: {},
|
|
@@ -152,8 +162,12 @@ export const parseModalBlocksConfiguration = (
|
|
|
152
162
|
"now_playing_container",
|
|
153
163
|
"now_playing_asset",
|
|
154
164
|
"now_playing_label",
|
|
165
|
+
// Underscored form (QB convention) and Studio/manifest-generator form
|
|
166
|
+
// without the underscore before the digit (text_label1).
|
|
155
167
|
"text_label_1",
|
|
156
168
|
"text_label_2",
|
|
169
|
+
"text_label1",
|
|
170
|
+
"text_label2",
|
|
157
171
|
"buttons_container",
|
|
158
172
|
"button_1",
|
|
159
173
|
"button_2",
|
|
@@ -167,6 +181,8 @@ export const parseModalBlocksConfiguration = (
|
|
|
167
181
|
"now_playing_label",
|
|
168
182
|
"text_label_1",
|
|
169
183
|
"text_label_2",
|
|
184
|
+
"text_label1",
|
|
185
|
+
"text_label2",
|
|
170
186
|
"buttons_container",
|
|
171
187
|
"button_1",
|
|
172
188
|
"button_2",
|
|
@@ -174,5 +190,107 @@ export const parseModalBlocksConfiguration = (
|
|
|
174
190
|
|
|
175
191
|
parseBlock("button", ["background", "title", "icons", "leading_icon"]);
|
|
176
192
|
|
|
193
|
+
// Playlist-experience Studio keys live in this shared parser because modal
|
|
194
|
+
// blocks are themed by themePluginId. Gate them so other plugins are not
|
|
195
|
+
// affected by playlist-only asset names.
|
|
196
|
+
const isPlaylistExperience =
|
|
197
|
+
normalizedComponentName === "playlist_experience";
|
|
198
|
+
|
|
199
|
+
// Studio uploads Create New icon as button.create_new_playlist_asset (not
|
|
200
|
+
// button.leading_icon.asset). Map it onto leadingIcon so AudioPlayerButton
|
|
201
|
+
// can override the hardcoded action-type asset.
|
|
202
|
+
const createNewPlaylistAsset = isPlaylistExperience
|
|
203
|
+
? configuration["button.create_new_playlist_asset"] ||
|
|
204
|
+
configuration[
|
|
205
|
+
`${normalizedComponentName}_button_create_new_playlist_asset`
|
|
206
|
+
]
|
|
207
|
+
: undefined;
|
|
208
|
+
|
|
209
|
+
if (createNewPlaylistAsset) {
|
|
210
|
+
if (!result.button.leadingIcon.default) {
|
|
211
|
+
result.button.leadingIcon.default = {};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
result.button.leadingIcon.default.asset = createNewPlaylistAsset;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Studio checkbox assets are top-level item.* keys (not a parseBlock
|
|
218
|
+
// sub-component). Map onto item.checkbox so multiSelect can render them.
|
|
219
|
+
const checkboxAsset =
|
|
220
|
+
configuration["item.checkbox_asset"] ||
|
|
221
|
+
configuration[`${normalizedComponentName}_item_checkbox_asset`];
|
|
222
|
+
|
|
223
|
+
const checkedCheckboxAsset =
|
|
224
|
+
configuration["item.checked_checkbox_asset"] ||
|
|
225
|
+
configuration[`${normalizedComponentName}_item_checked_checkbox_asset`];
|
|
226
|
+
|
|
227
|
+
if (checkboxAsset || checkedCheckboxAsset) {
|
|
228
|
+
if (!result.item.checkbox.default) {
|
|
229
|
+
result.item.checkbox.default = {};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (checkboxAsset) {
|
|
233
|
+
result.item.checkbox.default.asset = checkboxAsset;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (checkedCheckboxAsset) {
|
|
237
|
+
result.item.checkbox.default.checkedAsset = checkedCheckboxAsset;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Edit Order row icons: prefer reorder_playlist_item.*, then flavor1, then
|
|
242
|
+
// flavor2 playlist_experience_assets_* (playlist-experience theme only).
|
|
243
|
+
const removeAsset = isPlaylistExperience
|
|
244
|
+
? configuration["reorder_playlist_item.remove_from_playlist_asset"] ||
|
|
245
|
+
configuration[
|
|
246
|
+
`${normalizedComponentName}_reorder_playlist_item_remove_from_playlist_asset`
|
|
247
|
+
] ||
|
|
248
|
+
configuration[
|
|
249
|
+
"playlist_experience_assets_flavor1.remove_from_playlist_asset"
|
|
250
|
+
] ||
|
|
251
|
+
configuration[
|
|
252
|
+
`${normalizedComponentName}_assets_flavor1_remove_from_playlist_asset`
|
|
253
|
+
] ||
|
|
254
|
+
configuration[
|
|
255
|
+
"playlist_experience_assets_flavor2.remove_from_playlist_asset"
|
|
256
|
+
] ||
|
|
257
|
+
configuration[
|
|
258
|
+
`${normalizedComponentName}_assets_flavor2_remove_from_playlist_asset`
|
|
259
|
+
]
|
|
260
|
+
: undefined;
|
|
261
|
+
|
|
262
|
+
const dragAsset = isPlaylistExperience
|
|
263
|
+
? configuration["reorder_playlist_item.reorder_handle_asset"] ||
|
|
264
|
+
configuration[
|
|
265
|
+
`${normalizedComponentName}_reorder_playlist_item_reorder_handle_asset`
|
|
266
|
+
] ||
|
|
267
|
+
configuration[
|
|
268
|
+
"playlist_experience_assets_flavor1.reorder_handle_asset"
|
|
269
|
+
] ||
|
|
270
|
+
configuration[
|
|
271
|
+
`${normalizedComponentName}_assets_flavor1_reorder_handle_asset`
|
|
272
|
+
] ||
|
|
273
|
+
configuration[
|
|
274
|
+
"playlist_experience_assets_flavor2.reorder_handle_asset"
|
|
275
|
+
] ||
|
|
276
|
+
configuration[
|
|
277
|
+
`${normalizedComponentName}_assets_flavor2_reorder_handle_asset`
|
|
278
|
+
]
|
|
279
|
+
: undefined;
|
|
280
|
+
|
|
281
|
+
if (removeAsset || dragAsset) {
|
|
282
|
+
if (!result.item.actionButtons.default) {
|
|
283
|
+
result.item.actionButtons.default = {};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (removeAsset) {
|
|
287
|
+
result.item.actionButtons.default.removeAsset = removeAsset;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (dragAsset) {
|
|
291
|
+
result.item.actionButtons.default.dragAsset = dragAsset;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
177
295
|
return result;
|
|
178
296
|
};
|
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
|
+
"version": "16.0.0-rc.82",
|
|
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.
|
|
30
|
+
"@applicaster/applicaster-types": "16.0.0-rc.82",
|
|
31
31
|
"buffer": "^5.2.1",
|
|
32
32
|
"camelize": "^1.0.0",
|
|
33
33
|
"dayjs": "^1.11.10",
|
|
@@ -7,6 +7,7 @@ import configureMockStore from "redux-mock-store";
|
|
|
7
7
|
import { NavigationContext } from "@applicaster/zapp-react-native-ui-components/Contexts/NavigationContext";
|
|
8
8
|
import { ROUTE_TYPES } from "@applicaster/zapp-react-native-utils/navigationUtils/routeTypes";
|
|
9
9
|
import { ScreenDataContext } from "@applicaster/zapp-react-native-ui-components/Contexts/ScreenDataContext";
|
|
10
|
+
import { modalStore } from "@applicaster/zapp-react-native-utils/modalState";
|
|
10
11
|
|
|
11
12
|
const plugins = [];
|
|
12
13
|
|
|
@@ -156,6 +157,20 @@ const videoModalWrapper = ({ children }) => (
|
|
|
156
157
|
</Provider>
|
|
157
158
|
);
|
|
158
159
|
|
|
160
|
+
const modalEntry = { id: "modal-entry-1", type: { value: "video" } };
|
|
161
|
+
|
|
162
|
+
const modalPathname = `${ROUTE_TYPES.MODAL}/${rivers["river-general"].id}`;
|
|
163
|
+
|
|
164
|
+
const modalWrapper = ({ children }) => (
|
|
165
|
+
<Provider store={store}>
|
|
166
|
+
<NavigationContext.Provider value={mainStackNavigator}>
|
|
167
|
+
<PathnameContext.Provider value={modalPathname}>
|
|
168
|
+
{children}
|
|
169
|
+
</PathnameContext.Provider>
|
|
170
|
+
</NavigationContext.Provider>
|
|
171
|
+
</Provider>
|
|
172
|
+
);
|
|
173
|
+
|
|
159
174
|
const hookModalWrapper = ({ children }) => (
|
|
160
175
|
<Provider store={store}>
|
|
161
176
|
<NavigationContext.Provider value={hookModalNavigator}>
|
|
@@ -188,6 +203,39 @@ describe("navigation", () => {
|
|
|
188
203
|
});
|
|
189
204
|
});
|
|
190
205
|
|
|
206
|
+
describe("Modal components", () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
modalStore.getState().openModal({
|
|
209
|
+
item: rivers["river-general"] as any,
|
|
210
|
+
props: { entry: modalEntry },
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
modalStore.getState().dismissModal();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("should return a correct pathname", () => {
|
|
219
|
+
const { result } = renderHook(() => useRoute(), {
|
|
220
|
+
wrapper: modalWrapper,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(result.current.pathname).toEqual(modalPathname);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("should return correct screenData information", () => {
|
|
227
|
+
const { result } = renderHook(() => useRoute(), {
|
|
228
|
+
wrapper: modalWrapper,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(result.current.screenData.id).toEqual(modalEntry.id);
|
|
232
|
+
|
|
233
|
+
expect(result.current.screenData.targetScreen.id).toEqual(
|
|
234
|
+
rivers["river-general"].id
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
191
239
|
describe("Hook modal components", () => {
|
|
192
240
|
it("should return a correct pathname", () => {
|
|
193
241
|
const { result } = renderHook(() => useRoute(), {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { modalStore } from "../../../modalState";
|
|
2
|
+
import { isWeb } from "../../../reactUtils";
|
|
3
|
+
import { presentScreen } from "../usePresentScreen";
|
|
4
|
+
|
|
5
|
+
jest.mock("../../../reactUtils", () => ({
|
|
6
|
+
...jest.requireActual("../../../reactUtils"),
|
|
7
|
+
isWeb: jest.fn(() => false),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const river = { id: "river-parent-lock", type: "parent_lock" } as any;
|
|
11
|
+
|
|
12
|
+
const rivers = { "river-parent-lock": river };
|
|
13
|
+
|
|
14
|
+
const contentTypes = { parent_lock: { screen_id: "river-parent-lock" } };
|
|
15
|
+
|
|
16
|
+
const present = (args = {}) =>
|
|
17
|
+
presentScreen({ typeMapping: "parent_lock", rivers, contentTypes, ...args });
|
|
18
|
+
|
|
19
|
+
const modalState = () => modalStore.getState().modalState;
|
|
20
|
+
|
|
21
|
+
const screenCallback = () => modalState().props?.resultCallback as hookCallback;
|
|
22
|
+
|
|
23
|
+
describe("presentScreen", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
(isWeb as jest.Mock).mockReturnValue(false);
|
|
27
|
+
modalStore.getState().dismissModal();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("opens a modal with the river resolved from typeMapping", () => {
|
|
31
|
+
void present();
|
|
32
|
+
|
|
33
|
+
expect(modalState().visible).toBe(true);
|
|
34
|
+
expect(modalState().screen).toBe(river);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("resolves with success when the presented screen reports success", async () => {
|
|
38
|
+
const result = present();
|
|
39
|
+
|
|
40
|
+
screenCallback()({ success: true, payload: { verified: true } });
|
|
41
|
+
|
|
42
|
+
await expect(result).resolves.toMatchObject({
|
|
43
|
+
success: true,
|
|
44
|
+
cancelled: false,
|
|
45
|
+
payload: { verified: true },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("dismisses the modal once the presented screen reports a result", async () => {
|
|
50
|
+
const result = present();
|
|
51
|
+
|
|
52
|
+
screenCallback()({ success: true, payload: {} });
|
|
53
|
+
await result;
|
|
54
|
+
|
|
55
|
+
expect(modalState().visible).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("resolves as cancelled when the modal is dismissed without a result", async () => {
|
|
59
|
+
const result = present();
|
|
60
|
+
|
|
61
|
+
// What the hardware back button does: RN Modal's onRequestClose resets modal state.
|
|
62
|
+
modalStore.getState().dismissModal();
|
|
63
|
+
|
|
64
|
+
await expect(result).resolves.toMatchObject({
|
|
65
|
+
success: false,
|
|
66
|
+
cancelled: true,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("resolves as cancelled when the presented screen reports cancellation", async () => {
|
|
71
|
+
const result = present();
|
|
72
|
+
|
|
73
|
+
screenCallback()({ success: false, payload: {}, cancelled: true });
|
|
74
|
+
|
|
75
|
+
await expect(result).resolves.toMatchObject({
|
|
76
|
+
success: false,
|
|
77
|
+
cancelled: true,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("resolves as cancelled when another modal replaces the presented screen", async () => {
|
|
82
|
+
const result = present();
|
|
83
|
+
|
|
84
|
+
modalStore.getState().openModal({ item: { id: "other-modal" } as any });
|
|
85
|
+
|
|
86
|
+
await expect(result).resolves.toMatchObject({ cancelled: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("leaves the modal that replaced the presented screen open", async () => {
|
|
90
|
+
const result = present();
|
|
91
|
+
const other = { id: "other-modal" } as any;
|
|
92
|
+
|
|
93
|
+
modalStore.getState().openModal({ item: other });
|
|
94
|
+
await result;
|
|
95
|
+
|
|
96
|
+
expect(modalState().screen).toBe(other);
|
|
97
|
+
expect(modalState().visible).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ignores a result reported after the modal was already cancelled", async () => {
|
|
101
|
+
const result = present();
|
|
102
|
+
const callback = screenCallback();
|
|
103
|
+
|
|
104
|
+
modalStore.getState().dismissModal();
|
|
105
|
+
callback({ success: true, payload: {} });
|
|
106
|
+
|
|
107
|
+
await expect(result).resolves.toMatchObject({
|
|
108
|
+
success: false,
|
|
109
|
+
cancelled: true,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("hands the entry to the ModalProvider so it reaches ScreenDataContext", () => {
|
|
114
|
+
const entry = { id: "entry-1", type: { value: "video" } } as any;
|
|
115
|
+
|
|
116
|
+
void present({ entry });
|
|
117
|
+
|
|
118
|
+
expect(modalState().props?.entry).toBe(entry);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("omits the entry when the screen is presented without one", () => {
|
|
122
|
+
void present();
|
|
123
|
+
|
|
124
|
+
expect(modalState().props).not.toHaveProperty("entry");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("fails without opening a modal when typeMapping has no content type", async () => {
|
|
128
|
+
const result = await present({ typeMapping: "unmapped" });
|
|
129
|
+
|
|
130
|
+
expect(result).toMatchObject({ success: false, cancelled: false });
|
|
131
|
+
expect(result.error).toEqual(expect.any(String));
|
|
132
|
+
expect(modalState().visible).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("fails without opening a modal when the content type resolves to a missing river", async () => {
|
|
136
|
+
const result = await present({
|
|
137
|
+
contentTypes: { parent_lock: { screen_id: "gone" } },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result).toMatchObject({ success: false, cancelled: false });
|
|
141
|
+
expect(result.error).toEqual(expect.any(String));
|
|
142
|
+
expect(modalState().visible).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("fails without opening a modal on web, where no modal host exists", async () => {
|
|
146
|
+
(isWeb as jest.Mock).mockReturnValue(true);
|
|
147
|
+
|
|
148
|
+
const result = await present();
|
|
149
|
+
|
|
150
|
+
expect(result).toMatchObject({ success: false, cancelled: false });
|
|
151
|
+
expect(result.error).toEqual(expect.any(String));
|
|
152
|
+
expect(modalState().visible).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -22,6 +22,8 @@ import { useLayoutVersion } from "../layout";
|
|
|
22
22
|
|
|
23
23
|
export { useNavigation } from "./useNavigation";
|
|
24
24
|
|
|
25
|
+
export { usePresentScreen, presentScreen } from "./usePresentScreen";
|
|
26
|
+
|
|
25
27
|
export { useRoute } from "./useRoute";
|
|
26
28
|
|
|
27
29
|
export { usePathname } from "./usePathname";
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/// <reference types="@applicaster/applicaster-types" />
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
|
|
4
|
+
import { useContentTypes } from "@applicaster/zapp-react-native-redux/hooks";
|
|
5
|
+
|
|
6
|
+
import { modalStore } from "../../modalState";
|
|
7
|
+
import { isWeb } from "../../reactUtils";
|
|
8
|
+
import { createLogger } from "../../logger";
|
|
9
|
+
import { useRivers } from "../state";
|
|
10
|
+
|
|
11
|
+
const { log_error, log_info } = createLogger({
|
|
12
|
+
subsystem: "usePresentScreen",
|
|
13
|
+
category: "General",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export interface PresentScreenArgs {
|
|
17
|
+
/** The screen type to present (from content types mapping) */
|
|
18
|
+
typeMapping: string;
|
|
19
|
+
|
|
20
|
+
/** Entry to present the screen with. Omit to present the screen on its own. */
|
|
21
|
+
entry?: ZappEntry;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PresentScreenResult {
|
|
25
|
+
/** The presented screen completed successfully */
|
|
26
|
+
success: boolean;
|
|
27
|
+
|
|
28
|
+
/** The presented screen was dismissed without completing (e.g. the back button) */
|
|
29
|
+
cancelled: boolean;
|
|
30
|
+
|
|
31
|
+
/** Set when the screen could not be presented at all */
|
|
32
|
+
error?: string;
|
|
33
|
+
|
|
34
|
+
/** Whatever the presented screen reported back */
|
|
35
|
+
payload?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PresentScreenFn = (
|
|
39
|
+
args: PresentScreenArgs
|
|
40
|
+
) => Promise<PresentScreenResult>;
|
|
41
|
+
|
|
42
|
+
interface Dependencies {
|
|
43
|
+
rivers: Record<string, ZappRiver>;
|
|
44
|
+
contentTypes: ZappContentTypes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const failure = (error: string): Promise<PresentScreenResult> => {
|
|
48
|
+
log_error(`presentScreen: ${error}`);
|
|
49
|
+
|
|
50
|
+
return Promise.resolve({ success: false, cancelled: false, error });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Presents a screen as a modal and resolves once that screen reports a result
|
|
55
|
+
* or is dismissed.
|
|
56
|
+
*
|
|
57
|
+
* The screen is rendered by the ModalProvider through the regular ScreenResolver,
|
|
58
|
+
* so it receives the `resultCallback` we pass here as its `callback` prop — the
|
|
59
|
+
* same channel hook screens already use to report success and cancellation.
|
|
60
|
+
*/
|
|
61
|
+
export const presentScreen = ({
|
|
62
|
+
typeMapping,
|
|
63
|
+
entry,
|
|
64
|
+
rivers,
|
|
65
|
+
contentTypes,
|
|
66
|
+
}: PresentScreenArgs & Dependencies): Promise<PresentScreenResult> => {
|
|
67
|
+
if (!typeMapping) {
|
|
68
|
+
return failure("typeMapping option is missing");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The web ModalProvider renders nothing, so no result could ever arrive and
|
|
72
|
+
// awaiting one would stall the caller indefinitely.
|
|
73
|
+
if (isWeb()) {
|
|
74
|
+
return failure("screens can not be presented as modals on web");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const screenId = contentTypes?.[typeMapping]?.screen_id;
|
|
78
|
+
|
|
79
|
+
if (!screenId) {
|
|
80
|
+
return failure(`can not resolve screen type mapping: ${typeMapping}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const river = rivers?.[screenId];
|
|
84
|
+
|
|
85
|
+
if (!river) {
|
|
86
|
+
return failure(`can not resolve river for screen id: ${screenId}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return new Promise<PresentScreenResult>((resolve) => {
|
|
90
|
+
let settled = false;
|
|
91
|
+
let unsubscribe: () => void = () => {};
|
|
92
|
+
|
|
93
|
+
const settle = (result: PresentScreenResult) => {
|
|
94
|
+
if (settled) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
settled = true;
|
|
99
|
+
unsubscribe();
|
|
100
|
+
resolve(result);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const resultCallback = ({
|
|
104
|
+
success,
|
|
105
|
+
cancelled,
|
|
106
|
+
payload,
|
|
107
|
+
}: hookCallbackArgs) =>
|
|
108
|
+
settle({ success: !!success, cancelled: !!cancelled, payload });
|
|
109
|
+
|
|
110
|
+
const props: Record<string, unknown> = { resultCallback };
|
|
111
|
+
|
|
112
|
+
if (entry) {
|
|
113
|
+
// The ModalProvider turns this into the screen's ScreenDataContext and
|
|
114
|
+
// screenData, so the presented screen reads the entry exactly as it
|
|
115
|
+
// would when navigated to.
|
|
116
|
+
props.entry = entry;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
log_info(
|
|
120
|
+
`presentScreen: presenting screen type: ${typeMapping}${
|
|
121
|
+
entry ? ` with entry id: ${entry.id}` : ""
|
|
122
|
+
}`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
modalStore.getState().openModal({ item: river, props });
|
|
126
|
+
|
|
127
|
+
// Covers every dismissal we do not initiate ourselves: the hardware back
|
|
128
|
+
// button (via the Modal's onRequestClose), a swipe dismiss, another modal
|
|
129
|
+
// taking our slot, or dismissModal() called from anywhere else.
|
|
130
|
+
unsubscribe = modalStore.subscribe(({ modalState }) => {
|
|
131
|
+
if (!modalState.visible || modalState.screen !== river) {
|
|
132
|
+
settle({ success: false, cancelled: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}).then((result) => {
|
|
136
|
+
// Only take down our own modal — by now something else may already own the slot.
|
|
137
|
+
if (modalStore.getState().modalState.screen === river) {
|
|
138
|
+
modalStore.getState().dismissModal();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @returns a function presenting a screen as a modal, resolving with its result.
|
|
147
|
+
*/
|
|
148
|
+
export const usePresentScreen = (): PresentScreenFn => {
|
|
149
|
+
const rivers = useRivers();
|
|
150
|
+
const contentTypes = useContentTypes();
|
|
151
|
+
|
|
152
|
+
return useCallback(
|
|
153
|
+
(args: PresentScreenArgs) =>
|
|
154
|
+
presentScreen({ ...args, rivers, contentTypes }),
|
|
155
|
+
[rivers, contentTypes]
|
|
156
|
+
);
|
|
157
|
+
};
|
|
@@ -80,7 +80,20 @@ export const useRoute = (
|
|
|
80
80
|
// if path is hook grab screenData from screenData
|
|
81
81
|
|
|
82
82
|
if (isModalPathname(pathname)) {
|
|
83
|
-
|
|
83
|
+
if (!modalScreenData) {
|
|
84
|
+
return { screenData: {} as ZappEntry, pathname };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Mirror the pushed-screen shape ({ ...entry, targetScreen }) so a screen
|
|
88
|
+
// presented as a modal reads its entry the same way it would when
|
|
89
|
+
// navigated to, instead of only seeing the bare river.
|
|
90
|
+
const screenData = legacyScreenData(
|
|
91
|
+
{
|
|
92
|
+
screen: modalScreenData,
|
|
93
|
+
entry: modalState.props?.entry as NavigatorEntry,
|
|
94
|
+
},
|
|
95
|
+
plugins
|
|
96
|
+
);
|
|
84
97
|
|
|
85
98
|
return { screenData, pathname };
|
|
86
99
|
}
|