@applicaster/zapp-react-native-utils 16.0.0-alpha.6861239588 → 16.0.0-alpha.6918106967
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/ActionExecutor.ts +40 -2
- package/actionsExecutor/ActionExecutorContext.tsx +96 -17
- package/actionsExecutor/__tests__/feedDecorator.test.ts +61 -0
- package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +25 -0
- package/actionsExecutor/actions/__tests__/goBack.test.ts +62 -0
- package/actionsExecutor/actions/__tests__/goHome.test.ts +19 -0
- package/actionsExecutor/actions/__tests__/loadItemsFromSource.test.ts +88 -0
- package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +133 -0
- package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +76 -0
- package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +212 -0
- package/actionsExecutor/actions/__tests__/presentScreen.test.ts +127 -0
- package/actionsExecutor/actions/__tests__/showToast.test.ts +88 -0
- package/actionsExecutor/actions/dismissBottomSheet.ts +23 -0
- package/actionsExecutor/actions/goBack.ts +60 -0
- package/actionsExecutor/actions/goHome.ts +31 -0
- package/actionsExecutor/actions/index.ts +10 -0
- package/actionsExecutor/actions/navigateToScreen.ts +18 -4
- package/actionsExecutor/actions/openBottomSheet.ts +325 -0
- package/actionsExecutor/actions/presentScreen.ts +78 -0
- package/actionsExecutor/actions/refreshComponent.ts +6 -0
- package/actionsExecutor/actions/sendCloudEvent.ts +6 -1
- package/actionsExecutor/actions/showToast.ts +64 -8
- package/actionsExecutor/consts.ts +26 -0
- package/actionsExecutor/feedDecorator.ts +6 -6
- package/actionsExecutor/types.ts +1 -1
- package/analyticsUtils/PlayerAnalyticsManager.ts +1 -1
- package/appUtils/HooksManager/index.ts +9 -0
- package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +143 -53
- package/appUtils/playerManager/OverlayObserver/__tests__/liveContent.test.ts +205 -0
- package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +49 -0
- package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.android.tv.ts +4 -0
- package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ios.tv.ts +4 -0
- package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ts +11 -0
- package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.web.ts +4 -0
- package/appUtils/playerManager/OverlayObserver/utils.ts +54 -20
- package/appUtils/playerManager/__tests__/playerContent.test.ts +403 -0
- package/appUtils/playerManager/__tests__/playerFactory.test.ts +1 -1
- package/appUtils/playerManager/__tests__/playerNative.test.ts +38 -0
- package/appUtils/playerManager/__tests__/usePlayerContent.test.tsx +64 -0
- package/appUtils/playerManager/{conts.ts → const.ts} +20 -0
- package/appUtils/playerManager/index.ts +1 -1
- package/appUtils/playerManager/player.ts +115 -3
- package/appUtils/playerManager/playerFactory.ts +1 -1
- package/appUtils/playerManager/playerNative.ts +6 -4
- package/appUtils/playerManager/usePlayerContent.tsx +43 -0
- package/appUtils/playerManager/usePlayerControllerSetup.tsx +1 -1
- package/appUtils/playerManager/userLanguagePreference.ts +1 -1
- package/arrayUtils/__tests__/{anyThruthy.test.ts → anyTruthy.test.ts} +8 -0
- package/arrayUtils/__tests__/arrayUtils.test.ts +165 -108
- package/arrayUtils/__tests__/isEmptyArray.test.ts +11 -0
- package/arrayUtils/__tests__/isFilledArray.test.ts +12 -0
- package/arrayUtils/__tests__/isFirst.test.ts +17 -0
- package/arrayUtils/__tests__/isIndexInRange.test.ts +14 -0
- package/arrayUtils/__tests__/isLast.test.ts +31 -0
- package/arrayUtils/__tests__/makeListOf.test.ts +31 -0
- package/arrayUtils/__tests__/makeListOfIndexes.test.ts +20 -0
- package/arrayUtils/__tests__/reorderByIds.test.ts +50 -0
- package/arrayUtils/__tests__/sample.test.ts +61 -0
- package/arrayUtils/index.ts +136 -20
- package/bottomSheet/__tests__/scrollContext.test.tsx +35 -0
- package/bottomSheet/index.ts +17 -0
- package/cellUtils/index.ts +30 -3
- package/configurationUtils/__tests__/manifestKeyParser.test.ts +21 -0
- package/configurationUtils/__tests__/modalBlocksParser.test.ts +260 -0
- package/configurationUtils/manifestKeyParser.ts +50 -10
- package/configurationUtils/modalBlocksParser.ts +296 -0
- package/entryActions/__tests__/buildEntryActions.test.ts +156 -0
- package/entryActions/aliasPresentation.ts +124 -0
- package/entryActions/buildEntryActions.ts +166 -0
- package/entryActions/index.ts +17 -0
- package/entryActions/types.ts +39 -0
- package/manifestUtils/__tests__/player.audioControls.test.js +158 -0
- package/manifestUtils/_internals/index.js +8 -3
- package/manifestUtils/defaultManifestConfigurations/player.js +349 -20
- package/manifestUtils/index.js +3 -0
- package/manifestUtils/modalBlocks.js +304 -0
- package/modalState/ContentViewModel.ts +113 -0
- package/modalState/EditableCollection.ts +17 -0
- package/modalState/EditableCollectionRegistry.ts +51 -0
- package/modalState/ModalOrchestrator.ts +211 -0
- package/modalState/RemoteEditableCollection.ts +227 -0
- package/modalState/__tests__/ContentViewModel.editable.test.ts +69 -0
- package/modalState/__tests__/ContentViewModel.test.ts +165 -0
- package/modalState/__tests__/EditableCollectionRegistry.test.ts +112 -0
- package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +47 -0
- package/modalState/__tests__/RemoteEditableCollection.test.ts +326 -0
- package/modalState/__tests__/index.test.ts +30 -1
- package/modalState/__tests__/useBottomSheetContent.test.ts +349 -0
- package/modalState/index.ts +35 -83
- package/modalState/store.ts +102 -0
- package/modalState/types.ts +159 -0
- package/modalState/useBottomSheetContent.ts +104 -0
- package/numberUtils/__tests__/isMinusZero.test.ts +20 -0
- package/numberUtils/__tests__/isZero.test.ts +27 -0
- package/numberUtils/__tests__/requireNumber.test.ts +50 -0
- package/numberUtils/__tests__/toNumber.test.ts +27 -0
- package/numberUtils/__tests__/toNumberWithDefault.test.ts +64 -0
- package/numberUtils/__tests__/toNumberWithDefaultZero.test.ts +48 -0
- package/numberUtils/index.ts +27 -8
- package/package.json +2 -2
- package/playerUtils/__tests__/contentDataKeys.test.ts +297 -0
- package/playerUtils/__tests__/resolvePlayerActions.test.ts +138 -0
- package/playerUtils/__tests__/resolvePlayerTitleSubtitle.test.ts +180 -0
- package/playerUtils/contentDataKeys.ts +134 -0
- package/playerUtils/index.ts +37 -16
- package/playerUtils/isAudioItem.ts +26 -0
- package/playerUtils/resolvePlayerActions.ts +71 -0
- package/playerUtils/resolvePlayerTitleSubtitle.ts +76 -0
- package/reactHooks/actions/index.ts +67 -2
- package/reactHooks/feed/__tests__/useInflatedUrl.test.tsx +25 -0
- package/reactHooks/index.ts +2 -0
- package/reactHooks/layout/index.ts +1 -1
- 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
- package/reactHooks/state/useComponentScreenState.ts +1 -1
- package/reactHooks/usePluginConfiguration.ts +4 -1
- package/reactHooks/utils/__tests__/index.test.js +22 -2
- package/reactHooks/utils/index.ts +11 -1
- package/stringUtils/__tests__/stringUtils.test.js +42 -0
- package/stringUtils/index.ts +2 -2
- package/uiActionsRegistrator/__tests__/resolveActionIdentifiers.test.ts +93 -0
- package/uiActionsRegistrator/__tests__/subscribe.test.ts +107 -0
- package/uiActionsRegistrator/__tests__/usableActionItems.test.ts +52 -0
- package/uiActionsRegistrator/index.ts +25 -203
- package/uiActionsRegistrator/registry.ts +335 -0
- package/uiActionsRegistrator/resolveActionIdentifiers.ts +100 -0
- package/uiActionsRegistrator/usableActionItems.ts +66 -0
- package/zappFrameworkUtils/__tests__/localStorageHelper.test.ts +146 -0
- package/zappFrameworkUtils/localStorageHelper.ts +19 -15
- package/appUtils/playerManager/usePlayerTitleSummaryOverlay.tsx +0 -56
- package/playerUtils/__tests__/getPlayerActionButtons.test.ts +0 -54
- package/playerUtils/getPlayerActionButtons.ts +0 -17
- /package/reactHooks/actions/__tests__/{index.test.js → index.test.tsx} +0 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { RemoteEditableCollection } from "../RemoteEditableCollection";
|
|
2
|
+
import { actionExecutor } from "../../actionsExecutor/ActionExecutor";
|
|
3
|
+
import type { Content, MenuItem } from "../types";
|
|
4
|
+
|
|
5
|
+
jest.mock("../../actionsExecutor/ActionExecutor", () => ({
|
|
6
|
+
actionExecutor: { handleAction: jest.fn() },
|
|
7
|
+
ActionResult: { Success: "success", Error: "error" },
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const handleAction = actionExecutor.handleAction as jest.Mock;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* BehaviorSubject emits its current value synchronously on subscribe, so this
|
|
14
|
+
* returns the latest items snapshot.
|
|
15
|
+
*/
|
|
16
|
+
function currentItems(col: RemoteEditableCollection): MenuItem[] {
|
|
17
|
+
let items: MenuItem[] = [];
|
|
18
|
+
const sub = col.items$.subscribe((i) => (items = i));
|
|
19
|
+
sub.unsubscribe();
|
|
20
|
+
|
|
21
|
+
return items;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const removeAction = { type: "sendCloudEvent", options: { foo: "bar" } };
|
|
25
|
+
|
|
26
|
+
const itemWithRemove: MenuItem = {
|
|
27
|
+
id: "1",
|
|
28
|
+
title: "A",
|
|
29
|
+
entryActions: [{ button: { alias: "remove_item" }, actions: [removeAction] }],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const itemPlain: MenuItem = { id: "2", title: "B" };
|
|
33
|
+
|
|
34
|
+
function makeCollection(
|
|
35
|
+
items: MenuItem[],
|
|
36
|
+
refetch = jest.fn().mockResolvedValue(undefined),
|
|
37
|
+
operations = ["remove", "reorder"]
|
|
38
|
+
) {
|
|
39
|
+
const content = {
|
|
40
|
+
items,
|
|
41
|
+
dynamicCollection: { operations, postUrl: "https://x/cloud-events" },
|
|
42
|
+
} as unknown as Content;
|
|
43
|
+
|
|
44
|
+
return { col: new RemoteEditableCollection(content, refetch), refetch };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("RemoteEditableCollection", () => {
|
|
48
|
+
beforeEach(() => jest.clearAllMocks());
|
|
49
|
+
|
|
50
|
+
it("exposes the declared operations and initial items", () => {
|
|
51
|
+
const { col } = makeCollection([itemWithRemove, itemPlain]);
|
|
52
|
+
|
|
53
|
+
expect(col.operations).toEqual(["remove", "reorder"]);
|
|
54
|
+
expect(currentItems(col)).toEqual([itemWithRemove, itemPlain]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("remove", () => {
|
|
58
|
+
it("optimistically removes, dispatches the aliased action, then refetches", async () => {
|
|
59
|
+
handleAction.mockResolvedValue("success");
|
|
60
|
+
const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
|
|
61
|
+
|
|
62
|
+
await col.remove(0);
|
|
63
|
+
|
|
64
|
+
expect(handleAction).toHaveBeenCalledWith(removeAction, {
|
|
65
|
+
entry: itemWithRemove,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(refetch).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(currentItems(col)).toEqual([itemPlain]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rolls back to the snapshot when the action dispatch fails", async () => {
|
|
73
|
+
handleAction.mockRejectedValue(new Error("network"));
|
|
74
|
+
const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
|
|
75
|
+
|
|
76
|
+
await col.remove(0);
|
|
77
|
+
|
|
78
|
+
// optimistic removal reverted
|
|
79
|
+
expect(currentItems(col)).toEqual([itemWithRemove, itemPlain]);
|
|
80
|
+
// refetch never reached on failure
|
|
81
|
+
expect(refetch).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lets the refetched server result replace the optimistic list", async () => {
|
|
85
|
+
handleAction.mockResolvedValue("success");
|
|
86
|
+
const serverItems: MenuItem[] = [{ id: "99", title: "server truth" }];
|
|
87
|
+
|
|
88
|
+
const refetch = jest.fn();
|
|
89
|
+
const { col } = makeCollection([itemWithRemove, itemPlain], refetch);
|
|
90
|
+
|
|
91
|
+
// Emulate ContentViewModel.refetch pushing authoritative items via sync()
|
|
92
|
+
refetch.mockImplementation(async () => {
|
|
93
|
+
col.sync(serverItems);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await col.remove(0);
|
|
97
|
+
|
|
98
|
+
expect(currentItems(col)).toEqual(serverItems);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("is a no-op for an out-of-range index", async () => {
|
|
102
|
+
const { col, refetch } = makeCollection([itemWithRemove]);
|
|
103
|
+
|
|
104
|
+
await col.remove(5);
|
|
105
|
+
|
|
106
|
+
expect(handleAction).not.toHaveBeenCalled();
|
|
107
|
+
expect(refetch).not.toHaveBeenCalled();
|
|
108
|
+
expect(currentItems(col)).toEqual([itemWithRemove]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("skips dispatch (no throw) when no matching alias exists", async () => {
|
|
112
|
+
const { col, refetch } = makeCollection([itemPlain, itemWithRemove]);
|
|
113
|
+
|
|
114
|
+
await col.remove(0); // itemPlain has no entryActions
|
|
115
|
+
|
|
116
|
+
expect(handleAction).not.toHaveBeenCalled();
|
|
117
|
+
// still optimistic-removes and refetches (server is source of truth)
|
|
118
|
+
expect(refetch).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(currentItems(col)).toEqual([itemWithRemove]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("move", () => {
|
|
124
|
+
it("optimistically reorders and dispatches reorder_item with itemIds", async () => {
|
|
125
|
+
handleAction.mockResolvedValue("success");
|
|
126
|
+
|
|
127
|
+
const itemA: MenuItem = {
|
|
128
|
+
id: "2",
|
|
129
|
+
title: "B",
|
|
130
|
+
entryActions: [
|
|
131
|
+
{ button: { alias: "reorder_item" }, actions: [removeAction] },
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const moved: MenuItem = {
|
|
136
|
+
id: "3",
|
|
137
|
+
title: "C",
|
|
138
|
+
entryActions: [
|
|
139
|
+
{ button: { alias: "reorder_item" }, actions: [removeAction] },
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const { col, refetch } = makeCollection([moved, itemA]);
|
|
144
|
+
|
|
145
|
+
await col.move(["2", "3"]);
|
|
146
|
+
|
|
147
|
+
expect(currentItems(col)).toEqual([itemA, moved]);
|
|
148
|
+
|
|
149
|
+
expect(handleAction).toHaveBeenCalledWith(
|
|
150
|
+
{
|
|
151
|
+
...removeAction,
|
|
152
|
+
options: {
|
|
153
|
+
...removeAction.options,
|
|
154
|
+
itemIds: ["2", "3"],
|
|
155
|
+
data: {
|
|
156
|
+
...removeAction.options?.data,
|
|
157
|
+
itemIds: ["2", "3"],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{ entry: itemA }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(refetch).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rolls back on failure", async () => {
|
|
168
|
+
handleAction.mockRejectedValue(new Error("nope"));
|
|
169
|
+
|
|
170
|
+
const itemA: MenuItem = {
|
|
171
|
+
id: "2",
|
|
172
|
+
title: "B",
|
|
173
|
+
entryActions: [
|
|
174
|
+
{ button: { alias: "reorder_item" }, actions: [removeAction] },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const moved: MenuItem = {
|
|
179
|
+
id: "3",
|
|
180
|
+
title: "C",
|
|
181
|
+
entryActions: [
|
|
182
|
+
{ button: { alias: "reorder_item" }, actions: [removeAction] },
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const { col } = makeCollection([moved, itemA]);
|
|
187
|
+
|
|
188
|
+
await col.move(["2", "3"]);
|
|
189
|
+
|
|
190
|
+
expect(currentItems(col)).toEqual([moved, itemA]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("is a no-op for empty or non-existent IDs", async () => {
|
|
194
|
+
const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
|
|
195
|
+
|
|
196
|
+
await col.move([]);
|
|
197
|
+
|
|
198
|
+
expect(refetch).not.toHaveBeenCalled();
|
|
199
|
+
expect(currentItems(col)).toEqual([itemWithRemove, itemPlain]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("sync", () => {
|
|
204
|
+
it("replaces the current items with the provided list", () => {
|
|
205
|
+
const { col } = makeCollection([itemWithRemove]);
|
|
206
|
+
const next: MenuItem[] = [{ id: "7", title: "next" }];
|
|
207
|
+
|
|
208
|
+
col.sync(next);
|
|
209
|
+
|
|
210
|
+
expect(currentItems(col)).toEqual(next);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("suppresses sync updates while a local mutation is in-flight", async () => {
|
|
214
|
+
let resolveAction: () => void = () => {};
|
|
215
|
+
|
|
216
|
+
handleAction.mockImplementation(
|
|
217
|
+
() =>
|
|
218
|
+
new Promise<void>((resolve) => {
|
|
219
|
+
resolveAction = resolve;
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const { col } = makeCollection([itemWithRemove, itemPlain]);
|
|
224
|
+
const staleServerItems: MenuItem[] = [{ id: "stale", title: "Stale" }];
|
|
225
|
+
|
|
226
|
+
// Initiate optimistic removal (mutation in-flight)
|
|
227
|
+
const removePromise = col.remove(0);
|
|
228
|
+
|
|
229
|
+
// Verify optimistic state applied locally
|
|
230
|
+
expect(currentItems(col)).toEqual([itemPlain]);
|
|
231
|
+
|
|
232
|
+
// Attempt sync while mutation is in-flight
|
|
233
|
+
col.sync(staleServerItems);
|
|
234
|
+
|
|
235
|
+
// Verify sync was suppressed and optimistic state remained
|
|
236
|
+
expect(currentItems(col)).toEqual([itemPlain]);
|
|
237
|
+
|
|
238
|
+
// Resolve the in-flight mutation
|
|
239
|
+
resolveAction();
|
|
240
|
+
await removePromise;
|
|
241
|
+
|
|
242
|
+
// After mutation completes, sync applies normally
|
|
243
|
+
const freshServerItems: MenuItem[] = [{ id: "fresh", title: "Fresh" }];
|
|
244
|
+
col.sync(freshServerItems);
|
|
245
|
+
|
|
246
|
+
expect(currentItems(col)).toEqual(freshServerItems);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("deleteCollection", () => {
|
|
251
|
+
it("optimistically clears items and calls refetch", async () => {
|
|
252
|
+
handleAction.mockResolvedValue("success");
|
|
253
|
+
const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
|
|
254
|
+
|
|
255
|
+
await col.deleteCollection();
|
|
256
|
+
|
|
257
|
+
expect(currentItems(col)).toEqual([]);
|
|
258
|
+
expect(refetch).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("dispatches aliased action when first item contains delete_collection alias", async () => {
|
|
262
|
+
handleAction.mockResolvedValue("success");
|
|
263
|
+
|
|
264
|
+
const deleteAction = {
|
|
265
|
+
type: "sendCloudEvent",
|
|
266
|
+
options: { subject: "delete_collection" },
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const itemWithDelete: MenuItem = {
|
|
270
|
+
id: "100",
|
|
271
|
+
title: "Collection Item",
|
|
272
|
+
entryActions: [
|
|
273
|
+
{
|
|
274
|
+
button: { alias: "delete_collection" },
|
|
275
|
+
actions: [deleteAction],
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const { col, refetch } = makeCollection([itemWithDelete]);
|
|
281
|
+
|
|
282
|
+
await col.deleteCollection();
|
|
283
|
+
|
|
284
|
+
expect(handleAction).toHaveBeenCalledWith(deleteAction, {
|
|
285
|
+
entry: itemWithDelete,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(refetch).toHaveBeenCalledTimes(1);
|
|
289
|
+
expect(currentItems(col)).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("add", () => {
|
|
294
|
+
it("dispatches actions from dynamicCollection.events.add and refetches", async () => {
|
|
295
|
+
handleAction.mockResolvedValue("success");
|
|
296
|
+
|
|
297
|
+
const addAction = {
|
|
298
|
+
type: "sendCloudEvent",
|
|
299
|
+
options: {
|
|
300
|
+
url: "https://x/cloud-events",
|
|
301
|
+
type: "com.applicaster.collection.create.v1",
|
|
302
|
+
subject: "create_collection",
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const content = {
|
|
307
|
+
items: [],
|
|
308
|
+
dynamicCollection: {
|
|
309
|
+
operations: ["add"],
|
|
310
|
+
postUrl: "https://x/cloud-events",
|
|
311
|
+
events: {
|
|
312
|
+
add: [addAction],
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
} as any;
|
|
316
|
+
|
|
317
|
+
const refetch = jest.fn().mockResolvedValue(undefined);
|
|
318
|
+
const col = new RemoteEditableCollection(content, refetch);
|
|
319
|
+
|
|
320
|
+
await col.add();
|
|
321
|
+
|
|
322
|
+
expect(handleAction).toHaveBeenCalledWith(addAction);
|
|
323
|
+
expect(refetch).toHaveBeenCalledTimes(1);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { modalStore } from "../";
|
|
1
|
+
import { modalStore, getMenuItemActions } from "../";
|
|
2
2
|
|
|
3
3
|
describe("Modal State", () => {
|
|
4
4
|
it("should have initial state", () => {
|
|
@@ -48,4 +48,33 @@ describe("Modal State", () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("getMenuItemActions", () => {
|
|
53
|
+
it("returns actions array when actions is provided", () => {
|
|
54
|
+
const actions = [{ type: "action1" }, { type: "action2" }];
|
|
55
|
+
const item = { id: "1", title: "Item", actions };
|
|
56
|
+
|
|
57
|
+
expect(getMenuItemActions(item)).toEqual(actions);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns [action] when single action is provided", () => {
|
|
61
|
+
const action = { type: "action1" };
|
|
62
|
+
const item = { id: "1", title: "Item", action };
|
|
63
|
+
|
|
64
|
+
expect(getMenuItemActions(item)).toEqual([action]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns action when action is passed as an array", () => {
|
|
68
|
+
const actions = [{ type: "action1" }];
|
|
69
|
+
const item = { id: "1", title: "Item", action: actions as any };
|
|
70
|
+
|
|
71
|
+
expect(getMenuItemActions(item)).toEqual(actions);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns empty array when no action or actions are provided", () => {
|
|
75
|
+
expect(getMenuItemActions({ id: "1", title: "Item" })).toEqual([]);
|
|
76
|
+
expect(getMenuItemActions(null)).toEqual([]);
|
|
77
|
+
expect(getMenuItemActions(undefined)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
51
80
|
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { renderHook, act, waitFor } from "@testing-library/react-native";
|
|
2
|
+
|
|
3
|
+
import { modalOrchestrator } from "../ModalOrchestrator";
|
|
4
|
+
import { MenuItem } from "../types";
|
|
5
|
+
import { useBottomSheetContent } from "../useBottomSheetContent";
|
|
6
|
+
import type { ContentState } from "../ContentViewModel";
|
|
7
|
+
|
|
8
|
+
jest.mock("../ModalOrchestrator", () => ({
|
|
9
|
+
isPresentSubMenuAction: jest.requireActual("../types").isPresentSubMenuAction,
|
|
10
|
+
modalOrchestrator: {
|
|
11
|
+
canGoBack: false,
|
|
12
|
+
back: jest.fn(),
|
|
13
|
+
close: jest.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
type MockViewModel = {
|
|
18
|
+
getState: jest.Mock<ContentState, []>;
|
|
19
|
+
subscribe: jest.Mock<(listener: (state: ContentState) => void) => () => void>;
|
|
20
|
+
refetch: jest.Mock<Promise<void>, []>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const createViewModel = (
|
|
24
|
+
initialState: ContentState
|
|
25
|
+
): MockViewModel & {
|
|
26
|
+
emit: (nextState: ContentState) => void;
|
|
27
|
+
} => {
|
|
28
|
+
let listener: ((state: ContentState) => void) | undefined;
|
|
29
|
+
const unsubscribe = jest.fn();
|
|
30
|
+
|
|
31
|
+
const viewModel: MockViewModel & { emit: (nextState: ContentState) => void } =
|
|
32
|
+
{
|
|
33
|
+
getState: jest.fn(() => initialState),
|
|
34
|
+
subscribe: jest.fn((nextListener) => {
|
|
35
|
+
listener = nextListener;
|
|
36
|
+
|
|
37
|
+
return unsubscribe;
|
|
38
|
+
}),
|
|
39
|
+
refetch: jest.fn().mockResolvedValue(undefined),
|
|
40
|
+
emit: (nextState) => {
|
|
41
|
+
listener?.(nextState);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return viewModel;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("useBottomSheetContent", () => {
|
|
49
|
+
const onPress = jest.fn().mockResolvedValue(undefined);
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
jest.clearAllMocks();
|
|
53
|
+
modalOrchestrator.canGoBack = false;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns initial state from the view model", () => {
|
|
57
|
+
const viewModel = createViewModel({
|
|
58
|
+
items: [{ title: "Item 1" }],
|
|
59
|
+
isLoading: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const { result } = renderHook(() =>
|
|
63
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(result.current.items).toEqual([{ title: "Item 1" }]);
|
|
67
|
+
expect(result.current.isLoading).toBe(false);
|
|
68
|
+
expect(result.current.error).toBeUndefined();
|
|
69
|
+
expect(result.current.showCentralLoading).toBe(false);
|
|
70
|
+
expect(result.current.canGoBack).toBe(false);
|
|
71
|
+
expect(viewModel.subscribe).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("computes showCentralLoading when loading with no items", () => {
|
|
75
|
+
const viewModel = createViewModel({
|
|
76
|
+
items: [],
|
|
77
|
+
isLoading: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const { result } = renderHook(() =>
|
|
81
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(result.current.showCentralLoading).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not show central loading when items exist while loading", () => {
|
|
88
|
+
const viewModel = createViewModel({
|
|
89
|
+
items: [{ title: "Item 1" }],
|
|
90
|
+
isLoading: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const { result } = renderHook(() =>
|
|
94
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(result.current.showCentralLoading).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("updates when the view model emits new state", () => {
|
|
101
|
+
const viewModel = createViewModel({
|
|
102
|
+
items: [],
|
|
103
|
+
isLoading: true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const { result } = renderHook(() =>
|
|
107
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
act(() => {
|
|
111
|
+
viewModel.emit({
|
|
112
|
+
items: [{ title: "Fetched Item" }],
|
|
113
|
+
isLoading: false,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.current.items).toEqual([{ title: "Fetched Item" }]);
|
|
118
|
+
expect(result.current.isLoading).toBe(false);
|
|
119
|
+
expect(result.current.showCentralLoading).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("exposes error state from the view model", () => {
|
|
123
|
+
const viewModel = createViewModel({
|
|
124
|
+
items: [],
|
|
125
|
+
isLoading: false,
|
|
126
|
+
error: "Network Error",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const { result } = renderHook(() =>
|
|
130
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(result.current.error).toBe("Network Error");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("unsubscribes from the view model on unmount", () => {
|
|
137
|
+
const viewModel = createViewModel({
|
|
138
|
+
items: [],
|
|
139
|
+
isLoading: false,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const { unmount } = renderHook(() =>
|
|
143
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const unsubscribeFn = viewModel.subscribe.mock.results[0]
|
|
147
|
+
.value as jest.Mock;
|
|
148
|
+
|
|
149
|
+
unmount();
|
|
150
|
+
|
|
151
|
+
expect(unsubscribeFn).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("handleItemPress", () => {
|
|
155
|
+
it("delegates to onPress and refetches for leaf items", async () => {
|
|
156
|
+
const viewModel = createViewModel({
|
|
157
|
+
items: [{ title: "Leaf" }],
|
|
158
|
+
isLoading: false,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const item: MenuItem = { title: "Leaf" };
|
|
162
|
+
|
|
163
|
+
const { result } = renderHook(() =>
|
|
164
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
await result.current.handleItemPress(item);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(onPress).toHaveBeenCalledWith(item);
|
|
172
|
+
expect(viewModel.refetch).toHaveBeenCalledTimes(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("does not refetch when the item opens a present submenu action", async () => {
|
|
176
|
+
const viewModel = createViewModel({
|
|
177
|
+
items: [{ title: "Parent" }],
|
|
178
|
+
isLoading: false,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const item: MenuItem = {
|
|
182
|
+
title: "Parent",
|
|
183
|
+
action: {
|
|
184
|
+
type: "presentSubMenu",
|
|
185
|
+
menu: {
|
|
186
|
+
content: {
|
|
187
|
+
title: "Submenu",
|
|
188
|
+
items: [{ title: "Child" }],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const { result } = renderHook(() =>
|
|
195
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await act(async () => {
|
|
199
|
+
await result.current.handleItemPress(item);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(onPress).toHaveBeenCalledWith(item);
|
|
203
|
+
expect(viewModel.refetch).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("awaits onPress before refetching", async () => {
|
|
207
|
+
const viewModel = createViewModel({
|
|
208
|
+
items: [{ title: "Leaf" }],
|
|
209
|
+
isLoading: false,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const item: MenuItem = { title: "Leaf" };
|
|
213
|
+
const callOrder: string[] = [];
|
|
214
|
+
|
|
215
|
+
onPress.mockImplementation(async () => {
|
|
216
|
+
callOrder.push("onPress");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
viewModel.refetch.mockImplementation(async () => {
|
|
220
|
+
callOrder.push("refetch");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const { result } = renderHook(() =>
|
|
224
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
await result.current.handleItemPress(item);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(callOrder).toEqual(["onPress", "refetch"]);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("goBackOrClose", () => {
|
|
236
|
+
it("calls back when the orchestrator can go back", () => {
|
|
237
|
+
modalOrchestrator.canGoBack = true;
|
|
238
|
+
|
|
239
|
+
const viewModel = createViewModel({
|
|
240
|
+
items: [],
|
|
241
|
+
isLoading: false,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const { result } = renderHook(() =>
|
|
245
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
act(() => {
|
|
249
|
+
result.current.goBackOrClose();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(modalOrchestrator.back).toHaveBeenCalledTimes(1);
|
|
253
|
+
expect(modalOrchestrator.close).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("calls close when the orchestrator cannot go back", () => {
|
|
257
|
+
modalOrchestrator.canGoBack = false;
|
|
258
|
+
|
|
259
|
+
const viewModel = createViewModel({
|
|
260
|
+
items: [],
|
|
261
|
+
isLoading: false,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const { result } = renderHook(() =>
|
|
265
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
act(() => {
|
|
269
|
+
result.current.goBackOrClose();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(modalOrchestrator.close).toHaveBeenCalledTimes(1);
|
|
273
|
+
expect(modalOrchestrator.back).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("exposes close from the modal orchestrator", () => {
|
|
278
|
+
const viewModel = createViewModel({
|
|
279
|
+
items: [],
|
|
280
|
+
isLoading: false,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const { result } = renderHook(() =>
|
|
284
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
act(() => {
|
|
288
|
+
result.current.close();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(modalOrchestrator.close).toHaveBeenCalledTimes(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("reflects canGoBack from the modal orchestrator", () => {
|
|
295
|
+
modalOrchestrator.canGoBack = true;
|
|
296
|
+
|
|
297
|
+
const viewModel = createViewModel({
|
|
298
|
+
items: [],
|
|
299
|
+
isLoading: false,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const { result } = renderHook(() =>
|
|
303
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress })
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(result.current.canGoBack).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("resubscribes when the view model instance changes", async () => {
|
|
310
|
+
const firstViewModel = createViewModel({
|
|
311
|
+
items: [{ title: "First" }],
|
|
312
|
+
isLoading: false,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const secondViewModel = createViewModel({
|
|
316
|
+
items: [{ title: "Second" }],
|
|
317
|
+
isLoading: false,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const { result, rerender } = renderHook(
|
|
321
|
+
({ viewModel }) =>
|
|
322
|
+
useBottomSheetContent({ viewModel: viewModel as any, onPress }),
|
|
323
|
+
{
|
|
324
|
+
initialProps: { viewModel: firstViewModel },
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
expect(result.current.items).toEqual([{ title: "First" }]);
|
|
329
|
+
|
|
330
|
+
rerender({ viewModel: secondViewModel });
|
|
331
|
+
|
|
332
|
+
expect(
|
|
333
|
+
firstViewModel.subscribe.mock.results[0].value
|
|
334
|
+
).toHaveBeenCalledTimes(1);
|
|
335
|
+
|
|
336
|
+
expect(secondViewModel.subscribe).toHaveBeenCalledTimes(1);
|
|
337
|
+
|
|
338
|
+
act(() => {
|
|
339
|
+
secondViewModel.emit({
|
|
340
|
+
items: [{ title: "Updated Second" }],
|
|
341
|
+
isLoading: false,
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await waitFor(() => {
|
|
346
|
+
expect(result.current.items).toEqual([{ title: "Updated Second" }]);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|