@applicaster/zapp-react-native-utils 15.1.14 → 15.1.15-rc.0
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 +70 -16
- package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +25 -0
- package/actionsExecutor/actions/__tests__/loadItemsFromSource.test.ts +88 -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__/showToast.test.ts +2 -2
- package/actionsExecutor/actions/dismissBottomSheet.ts +23 -0
- package/actionsExecutor/actions/index.ts +4 -0
- package/actionsExecutor/actions/openBottomSheet.ts +329 -0
- package/actionsExecutor/actions/refreshComponent.ts +6 -0
- package/actionsExecutor/actions/sendCloudEvent.ts +6 -1
- package/actionsExecutor/actions/showToast.ts +2 -2
- package/actionsExecutor/consts.ts +23 -0
- package/actionsExecutor/types.ts +1 -1
- package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +5 -2
- package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +55 -2
- 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 +51 -20
- package/arrayUtils/__tests__/reorderByIds.test.ts +50 -0
- package/arrayUtils/index.ts +79 -0
- 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 +195 -0
- package/entryActions/aliasPresentation.ts +124 -0
- package/entryActions/buildEntryActions.ts +167 -0
- package/entryActions/index.ts +17 -0
- package/entryActions/types.ts +39 -0
- package/manifestUtils/defaultManifestConfigurations/generalContent.js +6 -0
- package/manifestUtils/defaultManifestConfigurations/player.js +22 -1
- 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 +235 -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 +415 -0
- package/modalState/__tests__/index.test.ts +30 -1
- package/modalState/__tests__/useBottomSheetContent.test.ts +349 -0
- package/modalState/__tests__/useModalStoreState.test.ts +142 -0
- package/modalState/index.ts +35 -83
- package/modalState/store.ts +109 -0
- package/modalState/types.ts +160 -0
- package/modalState/useBottomSheetContent.ts +109 -0
- package/package.json +2 -2
- package/playerUtils/__tests__/resolvePlayerActions.test.ts +138 -0
- package/playerUtils/index.ts +17 -1
- package/playerUtils/resolvePlayerActions.ts +71 -0
- package/reactHooks/actions/index.ts +67 -2
- package/reactHooks/navigation/__tests__/index.test.tsx +124 -1
- package/reactHooks/navigation/useRoute.ts +8 -4
- package/reactHooks/usePluginConfiguration.ts +6 -3
- 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/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
|
@@ -6,6 +6,7 @@ import { ActionsContext } from "@applicaster/zapp-react-native-ui-components/Con
|
|
|
6
6
|
import {
|
|
7
7
|
observeEntryState,
|
|
8
8
|
RegisteredActionValue,
|
|
9
|
+
uiActionsRegistry,
|
|
9
10
|
} from "../../uiActionsRegistrator";
|
|
10
11
|
import { reactHooksLogger } from "../logger";
|
|
11
12
|
|
|
@@ -34,7 +35,13 @@ export function useActions<T = unknown>(plugId: string): any {
|
|
|
34
35
|
if (!logged) {
|
|
35
36
|
logger.warning({
|
|
36
37
|
message: `useActions: Couldn't find an action for ${plugId} plugin`,
|
|
37
|
-
|
|
38
|
+
// Identifiers only. The action map holds every plugin's module and
|
|
39
|
+
// live React context, which is far too large for a log line and can
|
|
40
|
+
// carry whatever state those plugins hold.
|
|
41
|
+
data: {
|
|
42
|
+
plugId,
|
|
43
|
+
availableActions: Object.keys(context?.actions ?? {}),
|
|
44
|
+
},
|
|
38
45
|
});
|
|
39
46
|
|
|
40
47
|
setLogged(true);
|
|
@@ -80,7 +87,31 @@ export function useEntryActionState(
|
|
|
80
87
|
useEffect(() => {
|
|
81
88
|
if (!action) return undefined;
|
|
82
89
|
|
|
83
|
-
|
|
90
|
+
// An action's stream is third-party code. Subscribing with only a `next`
|
|
91
|
+
// handler lets RxJS rethrow an error notification asynchronously, where no
|
|
92
|
+
// error boundary catches it - one plugin faulting would take the app down
|
|
93
|
+
// rather than this one button. The button keeps its last known state.
|
|
94
|
+
let subscription;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
subscription = observeEntryState(action, entry).subscribe({
|
|
98
|
+
next: setState,
|
|
99
|
+
error: (error) =>
|
|
100
|
+
logger.warning({
|
|
101
|
+
message:
|
|
102
|
+
"useEntryActionState: the action's state stream failed - the button keeps its last state",
|
|
103
|
+
data: { entryId, error },
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logger.warning({
|
|
108
|
+
message:
|
|
109
|
+
"useEntryActionState: could not observe the action - the button keeps its initial state",
|
|
110
|
+
data: { entryId, error },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
84
115
|
|
|
85
116
|
return () => subscription.unsubscribe();
|
|
86
117
|
// eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
|
|
@@ -88,6 +119,18 @@ export function useEntryActionState(
|
|
|
88
119
|
|
|
89
120
|
const invokeAction = useCallback(
|
|
90
121
|
(options: InvokeArgsOptions = {}) => {
|
|
122
|
+
if (typeof action?.invokeAction !== "function") {
|
|
123
|
+
// A button was rendered and pressed, and the press goes nowhere. Only
|
|
124
|
+
// reachable on a user interaction, so it costs nothing on render. The
|
|
125
|
+
// action value carries no identifier, so the label is what locates the
|
|
126
|
+
// button that misbehaved.
|
|
127
|
+
logger.warning({
|
|
128
|
+
message:
|
|
129
|
+
"useEntryActionState: the pressed action has no invokeAction - the press did nothing",
|
|
130
|
+
data: { entryId, label: state?.label },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
91
134
|
action?.invokeAction?.(entry, { updateState: setState, ...options });
|
|
92
135
|
},
|
|
93
136
|
// eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
|
|
@@ -96,3 +139,25 @@ export function useEntryActionState(
|
|
|
96
139
|
|
|
97
140
|
return { state, invokeAction };
|
|
98
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A value that changes whenever a provider is added to or removed from
|
|
145
|
+
* `uiActionsRegistry`.
|
|
146
|
+
*
|
|
147
|
+
* Providers can register after the UI that wants them has rendered - the audio
|
|
148
|
+
* player registers playback speed only once its controller has state, and
|
|
149
|
+
* plugin-backed actions are bridged in from an effect. Put this in the
|
|
150
|
+
* dependency list of anything that resolves actions, so the list is re-read
|
|
151
|
+
* instead of being frozen at first paint.
|
|
152
|
+
*/
|
|
153
|
+
export function useRegistryRevision(): number {
|
|
154
|
+
const [revision, setRevision] = useState(0);
|
|
155
|
+
|
|
156
|
+
useEffect(
|
|
157
|
+
() =>
|
|
158
|
+
uiActionsRegistry.subscribe(() => setRevision((current) => current + 1)),
|
|
159
|
+
[]
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return revision;
|
|
163
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { PathnameContext } from "@applicaster/zapp-react-native-ui-components/Contexts/PathnameContext";
|
|
3
|
-
import { renderHook } from "@testing-library/react-
|
|
3
|
+
import { act, render, renderHook } from "@testing-library/react-native";
|
|
4
4
|
import { isNavBarVisible, useRoute } from "../";
|
|
5
5
|
import { Provider } from "react-redux";
|
|
6
6
|
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
|
|
|
@@ -45,6 +46,9 @@ const videoItem = {
|
|
|
45
46
|
type: { value: "video" },
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
const modalEntry = { id: "modal-entry-1" };
|
|
50
|
+
const modalPathname = `${ROUTE_TYPES.MODAL}/${rivers["river-general"].id}`;
|
|
51
|
+
|
|
48
52
|
const mockGetStackForPathname = jest.fn().mockReturnValue(homeStack);
|
|
49
53
|
|
|
50
54
|
const mainStackNavigator = {
|
|
@@ -168,6 +172,45 @@ const hookModalWrapper = ({ children }) => (
|
|
|
168
172
|
</Provider>
|
|
169
173
|
);
|
|
170
174
|
|
|
175
|
+
// Reports every render of useRoute so the tests below can assert on route
|
|
176
|
+
// transitions and on subscription behaviour, not just on a one-shot result.
|
|
177
|
+
function RouteProbe({ onRender }: { onRender: (route: any) => void }) {
|
|
178
|
+
onRender(useRoute());
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const openRiverModal = () =>
|
|
184
|
+
act(() => {
|
|
185
|
+
modalStore.getState().openModal({
|
|
186
|
+
item: rivers["river-general"] as any,
|
|
187
|
+
props: { entry: modalEntry },
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const renderRouteProbe = (pathname: string, onRender: (route: any) => void) => {
|
|
192
|
+
const tree = (currentPathname: string) => (
|
|
193
|
+
<Provider store={store}>
|
|
194
|
+
<ScreenDataContext.Provider value={homeStack.state}>
|
|
195
|
+
<NavigationContext.Provider value={videoModalNavigator}>
|
|
196
|
+
<PathnameContext.Provider value={currentPathname}>
|
|
197
|
+
<RouteProbe onRender={onRender} />
|
|
198
|
+
</PathnameContext.Provider>
|
|
199
|
+
</NavigationContext.Provider>
|
|
200
|
+
</ScreenDataContext.Provider>
|
|
201
|
+
</Provider>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const { rerender } = render(tree(pathname));
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
navigateTo: (nextPathname: string) =>
|
|
208
|
+
act(() => {
|
|
209
|
+
rerender(tree(nextPathname));
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
171
214
|
describe("navigation", () => {
|
|
172
215
|
describe("useRoute", () => {
|
|
173
216
|
describe("Main stack components", () => {
|
|
@@ -233,6 +276,86 @@ describe("navigation", () => {
|
|
|
233
276
|
);
|
|
234
277
|
});
|
|
235
278
|
});
|
|
279
|
+
|
|
280
|
+
describe("store subscriptions", () => {
|
|
281
|
+
afterEach(() => {
|
|
282
|
+
act(() => {
|
|
283
|
+
modalStore.getState().dismissModal();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Every hook in useRoute must run on every render. When the modal and
|
|
288
|
+
// hook-modal subscriptions lived inside the route branches, walking a
|
|
289
|
+
// single mounted component through the route types changed the hook
|
|
290
|
+
// count and React threw "Rendered more hooks than during the previous
|
|
291
|
+
// render".
|
|
292
|
+
it("keeps a stable hook order when the route type changes", () => {
|
|
293
|
+
let route: any;
|
|
294
|
+
|
|
295
|
+
const { navigateTo } = renderRouteProbe(homeStack.route, (current) => {
|
|
296
|
+
route = current;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(route.screenData.id).toEqual(entry.id);
|
|
300
|
+
expect(route.screenData.targetScreen.id).toEqual(screen.id);
|
|
301
|
+
|
|
302
|
+
openRiverModal();
|
|
303
|
+
navigateTo(modalPathname);
|
|
304
|
+
|
|
305
|
+
expect(route.pathname).toEqual(modalPathname);
|
|
306
|
+
expect(route.screenData.id).toEqual(rivers["river-general"].id);
|
|
307
|
+
|
|
308
|
+
navigateTo(hooksModalPathname);
|
|
309
|
+
|
|
310
|
+
expect(route.pathname).toEqual(hooksModalPathname);
|
|
311
|
+
|
|
312
|
+
expect(route.screenData.id).toEqual(
|
|
313
|
+
hookModalContextState.state.screenData.payload.id
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
navigateTo(videoModalPathname);
|
|
317
|
+
|
|
318
|
+
expect(route.pathname).toEqual(videoModalPathname);
|
|
319
|
+
expect(route.screenData.id).toEqual(videoItem.id);
|
|
320
|
+
|
|
321
|
+
expect(route.screenData.targetScreen.id).toEqual(
|
|
322
|
+
rivers["river-player"].id
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
navigateTo(homeStack.route);
|
|
326
|
+
|
|
327
|
+
expect(route.pathname).toEqual(homeStack.route);
|
|
328
|
+
expect(route.screenData.id).toEqual(entry.id);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("does not re-render a non-modal route when modal state changes", () => {
|
|
332
|
+
let renders = 0;
|
|
333
|
+
|
|
334
|
+
renderRouteProbe(homeStack.route, () => {
|
|
335
|
+
renders += 1;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const rendersBeforeOpen = renders;
|
|
339
|
+
|
|
340
|
+
openRiverModal();
|
|
341
|
+
|
|
342
|
+
expect(renders).toBe(rendersBeforeOpen);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("re-renders a modal route when modal state changes", () => {
|
|
346
|
+
let route: any;
|
|
347
|
+
|
|
348
|
+
renderRouteProbe(modalPathname, (current) => {
|
|
349
|
+
route = current;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(route.screenData).toEqual({});
|
|
353
|
+
|
|
354
|
+
openRiverModal();
|
|
355
|
+
|
|
356
|
+
expect(route.screenData.id).toEqual(rivers["river-general"].id);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
236
359
|
});
|
|
237
360
|
|
|
238
361
|
describe("isNavBarVisible", () => {
|
|
@@ -48,14 +48,18 @@ export const useRoute = (
|
|
|
48
48
|
"contentTypes",
|
|
49
49
|
]);
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
// useRoute runs in every cell, so both modal stores are subscribed to only on
|
|
52
|
+
// the routes that actually read them. Off those routes the selectors return a
|
|
53
|
+
// stable reference, which keeps an unrelated modal opening or closing from
|
|
54
|
+
// re-rendering the whole tree.
|
|
55
55
|
const hookModalScreenData = useHookModalScreenData(
|
|
56
56
|
!isHookModalPathname(pathname)
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
+
const modalState = useModalStoreState(!isModalPathname(pathname));
|
|
60
|
+
|
|
61
|
+
const modalScreenData = modalState.screen;
|
|
62
|
+
|
|
59
63
|
const videoModalScreenData =
|
|
60
64
|
navigator?.videoModalState?.item &&
|
|
61
65
|
legacyScreenData(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as R from "ramda";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
|
|
5
5
|
|
|
6
6
|
// importing this way prevents typescript packages importing from this
|
|
7
7
|
// file to go crazy reporting errors on JS
|
|
@@ -24,8 +24,11 @@ export function parsePluginConfiguration(plugin, manifest) {
|
|
|
24
24
|
return populateFields(plugin, manifest.custom_configuration_fields);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function usePluginConfiguration<T, S
|
|
28
|
-
|
|
27
|
+
export function usePluginConfiguration<T = any, S = Record<string, any>>(
|
|
28
|
+
identifier: string,
|
|
29
|
+
manifest?: any
|
|
30
|
+
) {
|
|
31
|
+
const plugins = usePlugins();
|
|
29
32
|
|
|
30
33
|
const plugin = useMemo(
|
|
31
34
|
() => R.find(R.propEq("identifier", identifier), plugins),
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseActionIdentifiers,
|
|
3
|
+
resolveActionIdentifiers,
|
|
4
|
+
} from "../resolveActionIdentifiers";
|
|
5
|
+
import { uiActionsRegistry } from "../registry";
|
|
6
|
+
|
|
7
|
+
const action = (identifier: string) =>
|
|
8
|
+
({ identifier, action: { invokeAction: jest.fn() } }) as any;
|
|
9
|
+
|
|
10
|
+
describe("parseActionIdentifiers", () => {
|
|
11
|
+
it("parses comma-separated identifiers", () => {
|
|
12
|
+
expect(parseActionIdentifiers(" a, b , ,c ")).toEqual(["a", "b", "c"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("parses identifier arrays", () => {
|
|
16
|
+
expect(parseActionIdentifiers([" a ", "", "b", " ", "c"])).toEqual([
|
|
17
|
+
"a",
|
|
18
|
+
"b",
|
|
19
|
+
"c",
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns empty array for nil config", () => {
|
|
24
|
+
expect(parseActionIdentifiers(null)).toEqual([]);
|
|
25
|
+
expect(parseActionIdentifiers(undefined)).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("resolveActionIdentifiers", () => {
|
|
30
|
+
const entry = { id: "entry-1" } as any;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest
|
|
34
|
+
.spyOn(uiActionsRegistry, "getActions")
|
|
35
|
+
.mockImplementation((identifier) => [action(identifier)] as any);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
jest.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("resolves through registry by default", () => {
|
|
43
|
+
const result = resolveActionIdentifiers({
|
|
44
|
+
identifiers: "favorites,share",
|
|
45
|
+
entry,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(result.map((item) => item.identifier)).toEqual([
|
|
49
|
+
"favorites",
|
|
50
|
+
"share",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
expect(uiActionsRegistry.getActions).toHaveBeenCalledWith(
|
|
54
|
+
"favorites",
|
|
55
|
+
entry
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(uiActionsRegistry.getActions).toHaveBeenCalledWith("share", entry);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("expands entry_actions through the caller, not the registry", () => {
|
|
62
|
+
const expanded = [action("entry_action_0"), action("entry_action_1")];
|
|
63
|
+
const expandEntryActions = jest.fn(() => expanded);
|
|
64
|
+
|
|
65
|
+
const unregister = uiActionsRegistry.registerAction("share", {
|
|
66
|
+
invokeAction: jest.fn(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = resolveActionIdentifiers({
|
|
71
|
+
identifiers: "entry_actions,share",
|
|
72
|
+
entry,
|
|
73
|
+
expandEntryActions,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(expandEntryActions).toHaveBeenCalledWith(entry);
|
|
77
|
+
|
|
78
|
+
expect(result.map((item) => item.identifier)).toEqual([
|
|
79
|
+
"entry_action_0",
|
|
80
|
+
"entry_action_1",
|
|
81
|
+
"share",
|
|
82
|
+
]);
|
|
83
|
+
} finally {
|
|
84
|
+
unregister();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("resolves nothing for entry_actions when the caller cannot expand it", () => {
|
|
89
|
+
expect(
|
|
90
|
+
resolveActionIdentifiers({ identifiers: "entry_actions", entry })
|
|
91
|
+
).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isActionAvailableFor, uiActionsRegistry } from "../registry";
|
|
2
|
+
|
|
3
|
+
const value = { invokeAction: jest.fn() };
|
|
4
|
+
|
|
5
|
+
describe("uiActionsRegistry.subscribe", () => {
|
|
6
|
+
it("fires on registration and on unregistration", () => {
|
|
7
|
+
const listener = jest.fn();
|
|
8
|
+
const unsubscribe = uiActionsRegistry.subscribe(listener);
|
|
9
|
+
|
|
10
|
+
const unregister = uiActionsRegistry.registerAction("late-action", value);
|
|
11
|
+
|
|
12
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
13
|
+
|
|
14
|
+
unregister();
|
|
15
|
+
|
|
16
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
17
|
+
|
|
18
|
+
unsubscribe();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("lets a listener see the action it was told about", () => {
|
|
22
|
+
let seen: string[] = [];
|
|
23
|
+
|
|
24
|
+
const unsubscribe = uiActionsRegistry.subscribe(() => {
|
|
25
|
+
seen = uiActionsRegistry
|
|
26
|
+
.getActions("late-action", { id: "e1" } as any)
|
|
27
|
+
.map((item) => item.identifier);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const unregister = uiActionsRegistry.registerAction("late-action", value);
|
|
31
|
+
|
|
32
|
+
expect(seen).toEqual(["late-action"]);
|
|
33
|
+
|
|
34
|
+
unregister();
|
|
35
|
+
unsubscribe();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("stops calling a listener once unsubscribed", () => {
|
|
39
|
+
const listener = jest.fn();
|
|
40
|
+
|
|
41
|
+
uiActionsRegistry.subscribe(listener)();
|
|
42
|
+
|
|
43
|
+
uiActionsRegistry.registerAction("another-action", value)();
|
|
44
|
+
|
|
45
|
+
expect(listener).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("survives a listener that unsubscribes itself while being notified", () => {
|
|
49
|
+
const other = jest.fn();
|
|
50
|
+
|
|
51
|
+
const unsubscribeOther = uiActionsRegistry.subscribe(other);
|
|
52
|
+
|
|
53
|
+
const unsubscribeSelf = uiActionsRegistry.subscribe(() => {
|
|
54
|
+
unsubscribeSelf();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(() =>
|
|
58
|
+
uiActionsRegistry.registerAction("self-removing", value)()
|
|
59
|
+
).not.toThrow();
|
|
60
|
+
|
|
61
|
+
expect(other).toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
unsubscribeOther();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("isActionAvailableFor", () => {
|
|
68
|
+
const entry = { id: "e1" } as any;
|
|
69
|
+
|
|
70
|
+
it("shows an action that states no opinion", () => {
|
|
71
|
+
expect(isActionAvailableFor({ invokeAction: jest.fn() }, entry)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it.each([
|
|
75
|
+
["isSupportDownloads"],
|
|
76
|
+
["isActionSupported"],
|
|
77
|
+
["isActionAvailable"],
|
|
78
|
+
])("honours %s", (predicate) => {
|
|
79
|
+
expect(
|
|
80
|
+
isActionAvailableFor(
|
|
81
|
+
{ invokeAction: jest.fn(), [predicate]: () => false } as any,
|
|
82
|
+
entry
|
|
83
|
+
)
|
|
84
|
+
).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("asks only the first predicate the action defines", () => {
|
|
88
|
+
const isActionAvailable = jest.fn(() => true);
|
|
89
|
+
|
|
90
|
+
expect(
|
|
91
|
+
isActionAvailableFor(
|
|
92
|
+
{
|
|
93
|
+
invokeAction: jest.fn(),
|
|
94
|
+
isSupportDownloads: () => false,
|
|
95
|
+
isActionAvailable,
|
|
96
|
+
} as any,
|
|
97
|
+
entry
|
|
98
|
+
)
|
|
99
|
+
).toBe(false);
|
|
100
|
+
|
|
101
|
+
expect(isActionAvailable).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("treats a missing action as unavailable-free", () => {
|
|
105
|
+
expect(isActionAvailableFor(undefined, entry)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { usableActionItems } from "../usableActionItems";
|
|
2
|
+
|
|
3
|
+
const action = (identifier: string) =>
|
|
4
|
+
({ identifier, action: { invokeAction: jest.fn() } }) as any;
|
|
5
|
+
|
|
6
|
+
describe("usableActionItems", () => {
|
|
7
|
+
it("keeps a usable list untouched, in order", () => {
|
|
8
|
+
const base = [action("share"), action("favorites")];
|
|
9
|
+
|
|
10
|
+
expect(usableActionItems(base).map((item) => item.identifier)).toEqual([
|
|
11
|
+
"share",
|
|
12
|
+
"favorites",
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps the first of a repeated identifier", () => {
|
|
17
|
+
const firstShare = action("share");
|
|
18
|
+
const secondShare = action("share");
|
|
19
|
+
|
|
20
|
+
const result = usableActionItems([
|
|
21
|
+
firstShare,
|
|
22
|
+
action("favorites"),
|
|
23
|
+
secondShare,
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
expect(result.map((item) => item.identifier)).toEqual([
|
|
27
|
+
"share",
|
|
28
|
+
"favorites",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
expect(result[0]).toBe(firstShare);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("drops items that cannot be invoked or have no identifier", () => {
|
|
35
|
+
const result = usableActionItems([
|
|
36
|
+
action("share"),
|
|
37
|
+
{ identifier: "blank" } as any,
|
|
38
|
+
{ identifier: "no-invoke", action: {} } as any,
|
|
39
|
+
{ action: { invokeAction: jest.fn() } } as any,
|
|
40
|
+
action("entry_action_0"),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
expect(result.map((item) => item.identifier)).toEqual([
|
|
44
|
+
"share",
|
|
45
|
+
"entry_action_0",
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("tolerates a missing list", () => {
|
|
50
|
+
expect(usableActionItems(undefined as any)).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|