@applicaster/zapp-react-native-ui-components 14.0.0-alpha.5594607030 → 14.0.0-alpha.5974411329

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.
@@ -217,6 +217,10 @@ export const useCellState = ({
217
217
  }): CellState => {
218
218
  const lastUpdate = useBehaviorUpdate(behavior);
219
219
  const router = useRoute();
220
+ /** Could be replaced with existing store from useScreenContextV2
221
+ * const screenContextV2 = useScreenContextV2();
222
+ const screenStateStore = screenContextV2._stateStore();
223
+ */
220
224
  const screenStateStore = useScreenStateStore();
221
225
 
222
226
  const _isSelected = useMemo(
@@ -0,0 +1,379 @@
1
+ import { renderHook, act } from "@testing-library/react-hooks";
2
+ import { BehaviorSubject } from "rxjs";
3
+ import { useLoadingState } from "../useLoadingState";
4
+
5
+ // Mock the useRefWithInitialValue hook
6
+ jest.mock(
7
+ "@applicaster/zapp-react-native-utils/reactHooks/state/useRefWithInitialValue",
8
+ () => ({
9
+ useRefWithInitialValue: jest.fn((initializer) => ({
10
+ current: initializer(),
11
+ })),
12
+ })
13
+ );
14
+
15
+ describe("useLoadingState", () => {
16
+ let onLoadDone: jest.Mock;
17
+
18
+ beforeEach(() => {
19
+ onLoadDone = jest.fn();
20
+ jest.clearAllMocks();
21
+ });
22
+
23
+ describe("initialization", () => {
24
+ it("should initialize with correct default state for zero components", () => {
25
+ const { result } = renderHook(() => useLoadingState(0, onLoadDone));
26
+
27
+ const initialState = result.current.loadingState.getValue();
28
+
29
+ expect(initialState).toEqual({
30
+ index: -1,
31
+ done: true,
32
+ waitForAllComponents: false,
33
+ });
34
+
35
+ expect(result.current.shouldShowLoadingError).toBe(false);
36
+ });
37
+
38
+ it("should initialize with correct default state for multiple components", () => {
39
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
40
+
41
+ const initialState = result.current.loadingState.getValue();
42
+
43
+ expect(initialState).toEqual({
44
+ index: -1,
45
+ done: false,
46
+ waitForAllComponents: false,
47
+ });
48
+
49
+ expect(result.current.shouldShowLoadingError).toBe(false);
50
+ });
51
+
52
+ it("should return a BehaviorSubject for loadingState", () => {
53
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
54
+
55
+ expect(result.current.loadingState).toBeInstanceOf(BehaviorSubject);
56
+ });
57
+ });
58
+
59
+ describe("arePreviousComponentsLoaded", () => {
60
+ it("should return true for index 0 (first component)", () => {
61
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
62
+
63
+ expect(result.current.arePreviousComponentsLoaded(0)).toBe(true);
64
+ });
65
+
66
+ it("should return false when previous components are not loaded", () => {
67
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
68
+
69
+ expect(result.current.arePreviousComponentsLoaded(1)).toBe(false);
70
+ expect(result.current.arePreviousComponentsLoaded(2)).toBe(false);
71
+ });
72
+
73
+ it("should return true when all previous components are loaded", () => {
74
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
75
+
76
+ act(() => {
77
+ result.current.onLoadFinished(0);
78
+ });
79
+
80
+ expect(result.current.arePreviousComponentsLoaded(1)).toBe(true);
81
+ expect(result.current.arePreviousComponentsLoaded(2)).toBe(false);
82
+
83
+ act(() => {
84
+ result.current.onLoadFinished(1);
85
+ });
86
+
87
+ expect(result.current.arePreviousComponentsLoaded(2)).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("onLoadFinished", () => {
92
+ it("should update component state and loading state when component finishes loading", () => {
93
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
94
+
95
+ act(() => {
96
+ result.current.onLoadFinished(0);
97
+ });
98
+
99
+ const state = result.current.loadingState.getValue();
100
+ expect(state.index).toBe(0);
101
+ expect(state.done).toBe(false);
102
+ });
103
+
104
+ it("should update index to highest loaded component", () => {
105
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
106
+
107
+ act(() => {
108
+ result.current.onLoadFinished(2);
109
+ });
110
+
111
+ let state = result.current.loadingState.getValue();
112
+ expect(state.index).toBe(2);
113
+
114
+ act(() => {
115
+ result.current.onLoadFinished(1);
116
+ });
117
+
118
+ state = result.current.loadingState.getValue();
119
+ expect(state.index).toBe(2); // Should remain 2, not decrease to 1
120
+ });
121
+
122
+ it("should mark as done when all components are loaded", () => {
123
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
124
+
125
+ act(() => {
126
+ result.current.onLoadFinished(0);
127
+ });
128
+
129
+ let state = result.current.loadingState.getValue();
130
+ expect(state.done).toBe(true); // True because arePreviousComponentsLoaded(1) returns true when component 0 is loaded
131
+ expect(onLoadDone).toHaveBeenCalledTimes(1);
132
+
133
+ act(() => {
134
+ result.current.onLoadFinished(1);
135
+ });
136
+
137
+ state = result.current.loadingState.getValue();
138
+ expect(state.done).toBe(true);
139
+ expect(onLoadDone).toHaveBeenCalledTimes(2); // Called again
140
+ });
141
+
142
+ it("should call onLoadDone when count is 0", () => {
143
+ const { result } = renderHook(() => useLoadingState(0, onLoadDone));
144
+
145
+ const state = result.current.loadingState.getValue();
146
+ expect(state.done).toBe(true);
147
+ // onLoadDone is not called on initialization for count 0, only when all components are loaded via dispatch
148
+ });
149
+
150
+ it("should handle loading components out of order", () => {
151
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
152
+
153
+ // Load component 2 first
154
+ act(() => {
155
+ result.current.onLoadFinished(2);
156
+ });
157
+
158
+ let state = result.current.loadingState.getValue();
159
+ expect(state.done).toBe(false);
160
+
161
+ // Load component 0
162
+ act(() => {
163
+ result.current.onLoadFinished(0);
164
+ });
165
+
166
+ state = result.current.loadingState.getValue();
167
+ expect(state.done).toBe(false);
168
+
169
+ // Load component 1 - should complete loading
170
+ act(() => {
171
+ result.current.onLoadFinished(1);
172
+ });
173
+
174
+ state = result.current.loadingState.getValue();
175
+ expect(state.done).toBe(true);
176
+ expect(onLoadDone).toHaveBeenCalledTimes(1);
177
+ });
178
+
179
+ it("should call onLoadDone again on subsequent dispatches", () => {
180
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
181
+
182
+ act(() => {
183
+ result.current.onLoadFinished(0);
184
+ result.current.onLoadFinished(1);
185
+ });
186
+
187
+ expect(onLoadDone).toHaveBeenCalledTimes(2); // Called for each dispatch when done is true
188
+
189
+ // Try loading again - onLoadDone will be called again because dispatch runs again
190
+ act(() => {
191
+ result.current.onLoadFinished(0);
192
+ });
193
+
194
+ expect(onLoadDone).toHaveBeenCalledTimes(3); // Will be called again
195
+ });
196
+ });
197
+
198
+ describe("onLoadFailed", () => {
199
+ it("should treat failed components as loaded when SHOULD_FAIL_ON_COMPONENT_LOADING is false", () => {
200
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
201
+
202
+ const error = new Error("Load failed");
203
+
204
+ act(() => {
205
+ result.current.onLoadFailed({ error, index: 0 });
206
+ });
207
+
208
+ const state = result.current.loadingState.getValue();
209
+ expect(state.index).toBe(0);
210
+ expect(result.current.shouldShowLoadingError).toBe(false);
211
+ });
212
+
213
+ it("should complete loading when all components fail", () => {
214
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
215
+
216
+ const error = new Error("Load failed");
217
+
218
+ act(() => {
219
+ result.current.onLoadFailed({ error, index: 0 });
220
+ result.current.onLoadFailed({ error, index: 1 });
221
+ });
222
+
223
+ const state = result.current.loadingState.getValue();
224
+ expect(state.done).toBe(true);
225
+ expect(onLoadDone).toHaveBeenCalledTimes(2); // Called for each failed component
226
+ });
227
+
228
+ it("should handle mixed success and failure", () => {
229
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone));
230
+
231
+ const error = new Error("Load failed");
232
+
233
+ act(() => {
234
+ result.current.onLoadFinished(0);
235
+ result.current.onLoadFailed({ error, index: 1 });
236
+ result.current.onLoadFinished(2);
237
+ });
238
+
239
+ const state = result.current.loadingState.getValue();
240
+ expect(state.done).toBe(true);
241
+ expect(onLoadDone).toHaveBeenCalledTimes(2); // Called when all components 0,1,2 are handled
242
+ });
243
+ });
244
+
245
+ describe("loading state observable", () => {
246
+ it("should emit state changes through BehaviorSubject", () => {
247
+ const { result } = renderHook(() => useLoadingState(3, onLoadDone)); // Use 3 components so loading component 0 doesn't complete everything
248
+ const mockSubscriber = jest.fn();
249
+
250
+ result.current.loadingState.subscribe(mockSubscriber);
251
+
252
+ act(() => {
253
+ result.current.onLoadFinished(0);
254
+ });
255
+
256
+ // Should have been called twice: initial state + update
257
+ expect(mockSubscriber).toHaveBeenCalledTimes(2);
258
+
259
+ expect(mockSubscriber).toHaveBeenLastCalledWith({
260
+ index: 0,
261
+ done: false, // Will be false because we need components 1 and 2 as well
262
+ waitForAllComponents: false,
263
+ });
264
+ });
265
+
266
+ it("should preserve waitForAllComponents flag in state updates", () => {
267
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
268
+
269
+ act(() => {
270
+ result.current.onLoadFinished(0);
271
+ });
272
+
273
+ const state = result.current.loadingState.getValue();
274
+ expect(state.waitForAllComponents).toBe(false);
275
+ });
276
+ });
277
+
278
+ describe("memoization", () => {
279
+ it("should return stable references for functions", () => {
280
+ const { result, rerender } = renderHook(() =>
281
+ useLoadingState(2, onLoadDone)
282
+ );
283
+
284
+ const firstRender = {
285
+ onLoadFinished: result.current.onLoadFinished,
286
+ onLoadFailed: result.current.onLoadFailed,
287
+ arePreviousComponentsLoaded: result.current.arePreviousComponentsLoaded,
288
+ };
289
+
290
+ rerender();
291
+
292
+ expect(result.current.onLoadFinished).toBe(firstRender.onLoadFinished);
293
+ expect(result.current.onLoadFailed).toBe(firstRender.onLoadFailed);
294
+
295
+ expect(result.current.arePreviousComponentsLoaded).toBe(
296
+ firstRender.arePreviousComponentsLoaded
297
+ );
298
+ });
299
+
300
+ it("should return stable function references (current behavior)", () => {
301
+ const { result, rerender } = renderHook(
302
+ ({ onLoadDone }) => useLoadingState(2, onLoadDone),
303
+ { initialProps: { onLoadDone } }
304
+ );
305
+
306
+ const firstResult = result.current;
307
+
308
+ const newOnLoadDone = jest.fn();
309
+ rerender({ onLoadDone: newOnLoadDone });
310
+
311
+ // Functions should remain the same due to empty dependency arrays (this is the current behavior)
312
+ expect(result.current.onLoadFinished).toBe(firstResult.onLoadFinished);
313
+ expect(result.current.onLoadFailed).toBe(firstResult.onLoadFailed);
314
+ });
315
+ });
316
+
317
+ describe("edge cases", () => {
318
+ it("should handle duplicate load finished calls gracefully", () => {
319
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
320
+
321
+ act(() => {
322
+ result.current.onLoadFinished(0);
323
+ result.current.onLoadFinished(0); // Duplicate call
324
+ });
325
+
326
+ const state = result.current.loadingState.getValue();
327
+ expect(state.index).toBe(0);
328
+ expect(state.done).toBe(true); // True because loading component 0 makes it done in a 2-component setup
329
+ });
330
+
331
+ it("should handle loading index greater than component count", () => {
332
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
333
+
334
+ act(() => {
335
+ result.current.onLoadFinished(5); // Index out of bounds
336
+ });
337
+
338
+ const state = result.current.loadingState.getValue();
339
+ expect(state.index).toBe(5);
340
+ expect(state.done).toBe(false); // Should not be done as not all components loaded
341
+ });
342
+
343
+ it("should handle negative indices", () => {
344
+ const { result } = renderHook(() => useLoadingState(2, onLoadDone));
345
+
346
+ act(() => {
347
+ result.current.onLoadFinished(-1);
348
+ });
349
+
350
+ const state = result.current.loadingState.getValue();
351
+ expect(state.index).toBe(-1); // Should remain -1
352
+ });
353
+ });
354
+
355
+ describe("component count changes", () => {
356
+ it("should handle changing component count", () => {
357
+ const { result, rerender } = renderHook(
358
+ ({ count }) => useLoadingState(count, onLoadDone),
359
+ { initialProps: { count: 2 } }
360
+ );
361
+
362
+ act(() => {
363
+ result.current.onLoadFinished(0);
364
+ });
365
+
366
+ // Change count
367
+ rerender({ count: 3 });
368
+
369
+ // The hook should work with the new count
370
+ act(() => {
371
+ result.current.onLoadFinished(1);
372
+ result.current.onLoadFinished(2);
373
+ });
374
+
375
+ const state = result.current.loadingState.getValue();
376
+ expect(state.done).toBe(true);
377
+ });
378
+ });
379
+ });
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { isNil, set, lensIndex, T, slice } from "ramda";
2
+ import { isNil, set, lensIndex, slice } from "ramda";
3
3
  import { BehaviorSubject } from "rxjs";
4
4
  import { useRefWithInitialValue } from "@applicaster/zapp-react-native-utils/reactHooks/state/useRefWithInitialValue";
5
5
 
@@ -53,7 +53,7 @@ export const useLoadingState = (
53
53
 
54
54
  const componentsBefore = slice(0, index, componentStateRef.current);
55
55
 
56
- return componentsBefore.every(T);
56
+ return componentsBefore.every(Boolean);
57
57
  }, []);
58
58
 
59
59
  const dispatch = React.useCallback(({ payload }) => {
@@ -36,7 +36,6 @@ export function CurrentScreenContextProvider({
36
36
  screenData: NavigationScreenData;
37
37
  }) {
38
38
  const { pathname, isActive = false, screenData } = props;
39
- console.log("CurrentScreenContextProvider", { screenData });
40
39
 
41
40
  const [initialScreenData, setInitialScreenData] = React.useState(screenData);
42
41
 
@@ -41,10 +41,37 @@ interface NavBarState {
41
41
 
42
42
  type ScreenContextType = {
43
43
  _navBarStore: UseBoundStore<StoreApi<NavBarStoreState>>;
44
+ _stateStore: UseBoundStore<StoreApi<StateStore>>;
44
45
  navBar: NavBarState;
45
46
  legacyFormatScreenData: LegacyNavigationScreenData | null;
46
47
  };
47
48
 
49
+ interface StateStore {
50
+ data: Record<string, any>;
51
+ setValue: (key: string, value: any) => void;
52
+ removeValue: (key: string) => void;
53
+ }
54
+
55
+ // possible replacement of screenStateStore, remove changes in this file if not needed
56
+ const createStateStore = () =>
57
+ create<StateStore>((set) => ({
58
+ data: {},
59
+ setValue: (key, value) =>
60
+ set((state) => ({
61
+ data: {
62
+ ...state.data,
63
+ [key]: value,
64
+ },
65
+ })),
66
+ removeValue: (key) =>
67
+ set((state) => {
68
+ const newData = { ...state.data };
69
+ delete newData[key];
70
+
71
+ return { data: newData };
72
+ }),
73
+ }));
74
+
48
75
  const createStore = () =>
49
76
  create<NavBarStoreState>((set) => ({
50
77
  title: "",
@@ -66,6 +93,7 @@ const createStore = () =>
66
93
  }));
67
94
 
68
95
  export const ScreenContext = createContext<ScreenContextType>({
96
+ _stateStore: createStateStore(),
69
97
  _navBarStore: createStore(),
70
98
  navBar: {
71
99
  visible: true,
@@ -103,6 +131,21 @@ export function ScreenContextProvider({
103
131
  null
104
132
  );
105
133
 
134
+ const screenStateRef = useRef<null | ReturnType<typeof createStateStore>>(
135
+ null
136
+ );
137
+
138
+ const getScreenState = useCallback(() => {
139
+ if (screenStateRef.current !== null) {
140
+ return screenStateRef.current;
141
+ }
142
+
143
+ const stateStore = createStateStore();
144
+ screenStateRef.current = stateStore;
145
+
146
+ return stateStore;
147
+ }, []);
148
+
106
149
  const getScreenNavBarState = useCallback(() => {
107
150
  if (screenNavBarStateRef.current !== null) {
108
151
  return screenNavBarStateRef.current;
@@ -163,6 +206,7 @@ export function ScreenContextProvider({
163
206
  value={useMemo(
164
207
  () => ({
165
208
  _navBarStore: getScreenNavBarState(),
209
+ _stateStore: getScreenState(),
166
210
  navBar: navBarState,
167
211
  legacyFormatScreenData: routeScreenData,
168
212
  }),
@@ -14,6 +14,7 @@ import {
14
14
  usePipesContexts,
15
15
  } from "./utils";
16
16
  import { useScreenResolvers } from "@applicaster/zapp-react-native-utils/actionsExecutor/screenResolver";
17
+ import { useScreenStateStore } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useScreenStateStore";
17
18
 
18
19
  type RiverProps = {
19
20
  dispatch: DispatchProp;
@@ -28,6 +29,7 @@ export function WithRiverFeedLoader(Component: ZappComponent) {
28
29
  const { screenData, pathname } = useRoute();
29
30
  const resolvers = useScreenResolvers();
30
31
  const pipesContexts = usePipesContexts(river.id, pathname);
32
+ const screenStateStore = useScreenStateStore();
31
33
 
32
34
  const componentsToLoad = ignoreComponentsWithClearCacheFlag(
33
35
  river?.ui_components || []
@@ -54,7 +56,9 @@ export function WithRiverFeedLoader(Component: ZappComponent) {
54
56
  nonEmptyDataSources,
55
57
  river?.id,
56
58
  props.dispatch,
57
- resolvers
59
+ resolvers,
60
+ screenStateStore,
61
+ pathname
58
62
  );
59
63
  }, []);
60
64
 
@@ -5,6 +5,7 @@ import {
5
5
  mapPromises,
6
6
  reducePromises,
7
7
  } from "@applicaster/zapp-react-native-utils/arrayUtils";
8
+ import { applyScreenRouteDefaults } from "@applicaster/zapp-react-native-redux/ZappPipes/feedProcessor";
8
9
 
9
10
  export { riverIsCurrentRoute, usePipesContexts } from "./usePipesContexts";
10
11
 
@@ -16,12 +17,23 @@ export async function loadDatasources(
16
17
  urls: string[][],
17
18
  riverId,
18
19
  dispatch,
19
- resolvers
20
+ resolvers,
21
+ screenStateStore,
22
+ pathname
20
23
  ) {
21
24
  return reducePromises<string, void>(
22
25
  mapPromises<string, void>((url) => {
23
26
  if (url) {
24
- return dispatch(loadPipesData(url, { riverId, resolvers }));
27
+ const onLoadCB = (data, _err) => {
28
+ if (data) {
29
+ // @TODO check if needed somewhere else
30
+ applyScreenRouteDefaults(data, screenStateStore, pathname);
31
+ }
32
+ };
33
+
34
+ return dispatch(
35
+ loadPipesData(url, { riverId, resolvers, callback: onLoadCB })
36
+ );
25
37
  }
26
38
  }),
27
39
  undefined,
@@ -14,8 +14,10 @@ import {
14
14
  } from "@applicaster/zapp-react-native-utils/reactHooks";
15
15
 
16
16
  import { ComponentDataSourceContext, ZappPipesDataProps } from "../types";
17
- import { isVerticalListOrGrid, subscribeForKeyChanges } from "../utils";
18
17
  import { useScreenStateStore } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useScreenStateStore";
18
+ import { isVerticalListOrGrid } from "../utils";
19
+ import { useFilter } from "../utils/useFilter";
20
+ import { subscribeForKeyChanges } from "@applicaster/zapp-pipes-v2-client";
19
21
 
20
22
  const FAVORITES_TYPE = "FAVOURITES";
21
23
 
@@ -211,10 +213,10 @@ export function UrlFeedResolver({
211
213
  }
212
214
  } else {
213
215
  return subscribeForKeyChanges({
214
- dataSourceUrl,
216
+ url: dataSourceUrl,
215
217
  pathname,
216
218
  screenStateStore,
217
- reloadData,
219
+ callback: reloadData,
218
220
  });
219
221
  }
220
222
  }, [dataSourceUrl, reloadData, pathname, screenStateStore]);
@@ -241,15 +243,26 @@ export function UrlFeedResolver({
241
243
  [isLast, component, loadNext]
242
244
  );
243
245
 
246
+ const applyItemFilter = useFilter({ url, loading, data, error }, component);
247
+
244
248
  const zappPipesDataProps = useMemo(() => {
245
249
  const pipeData = { url, loading, data, error };
246
250
 
247
251
  return {
248
- zappPipesData: applyItemLimit(pipeData, component),
252
+ zappPipesData: applyItemLimit(applyItemFilter(pipeData), component),
249
253
  reloadData,
250
254
  loadNextData,
251
255
  };
252
- }, [url, loading, data, error, component, reloadData, loadNextData]);
256
+ }, [
257
+ url,
258
+ loading,
259
+ data,
260
+ error,
261
+ component,
262
+ reloadData,
263
+ loadNextData,
264
+ applyItemFilter,
265
+ ]);
253
266
 
254
267
  return <>{children(zappPipesDataProps)}</>;
255
268
  }
@@ -1,45 +1,5 @@
1
- import {
2
- subscribeForUrlContextKeyChanges,
3
- subscribeForUrlScreenKeyChanges,
4
- } from "@applicaster/zapp-pipes-v2-client";
5
-
6
1
  const isVerticalList = (component) => component?.component_type === "list-qb";
7
2
  const isGrid = (component) => component?.component_type === "grid-qb";
8
3
 
9
4
  export const isVerticalListOrGrid = (component) =>
10
5
  isVerticalList(component) || isGrid(component);
11
-
12
- export const subscribeForKeyChanges = ({
13
- dataSourceUrl,
14
- pathname,
15
- screenStateStore,
16
- reloadData,
17
- }) => {
18
- const subscriptions = [];
19
-
20
- if (dataSourceUrl) {
21
- subscriptions.push(
22
- subscribeForUrlContextKeyChanges(dataSourceUrl, {}, reloadData)
23
- );
24
- }
25
-
26
- if (dataSourceUrl && pathname && screenStateStore) {
27
- subscriptions.push(
28
- subscribeForUrlScreenKeyChanges(
29
- dataSourceUrl,
30
- pathname,
31
- screenStateStore,
32
- {},
33
- reloadData
34
- )
35
- );
36
- }
37
-
38
- return () => {
39
- subscriptions.forEach((unsubscribe) => {
40
- if (typeof unsubscribe === "function") {
41
- unsubscribe();
42
- }
43
- });
44
- };
45
- };