@applicaster/zapp-react-native-utils 15.1.15 → 15.1.16-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.
@@ -77,6 +77,94 @@ describe("openBottomSheetAction inline items", () => {
77
77
  });
78
78
  });
79
79
 
80
+ it("opens an empty inline collection when dynamic collection options are present", async () => {
81
+ const result = await openBottomSheetAction({
82
+ type: "openBottomSheet",
83
+ options: {
84
+ header: { title: "Queue" },
85
+ content: {
86
+ items: [],
87
+ emptyMessage: "Queued items will be added here",
88
+ dynamicCollection: {
89
+ operations: ["remove"],
90
+ provider: "@{provider/queue}",
91
+ },
92
+ },
93
+ },
94
+ } as any);
95
+
96
+ expect(result).toBe(ActionResult.Success);
97
+
98
+ const [menu] = (modalOrchestrator.open as jest.Mock).mock.calls[0];
99
+
100
+ expect(menu.content.items).toEqual([]);
101
+ expect(menu.content.emptyMessage).toBe("Queued items will be added here");
102
+
103
+ expect(menu.content.dynamicCollection).toMatchObject({
104
+ operations: ["remove"],
105
+ provider: "@{provider/queue}",
106
+ });
107
+ });
108
+
109
+ it("accepts empty inline items when dynamic_collection_options live on extensions", async () => {
110
+ const result = await openBottomSheetAction({
111
+ type: "openBottomSheet",
112
+ options: {
113
+ content: {
114
+ items: [],
115
+ extensions: {
116
+ dynamic_collection_options: {
117
+ operations: "remove,reorder",
118
+ provider: "@{provider/offline}",
119
+ },
120
+ },
121
+ },
122
+ },
123
+ } as any);
124
+
125
+ expect(result).toBe(ActionResult.Success);
126
+
127
+ const [menu] = (modalOrchestrator.open as jest.Mock).mock.calls[0];
128
+
129
+ expect(menu.content.items).toEqual([]);
130
+
131
+ expect(menu.content.dynamicCollection).toMatchObject({
132
+ operations: ["remove", "reorder"],
133
+ provider: "@{provider/offline}",
134
+ });
135
+ });
136
+
137
+ it("returns an error for an empty items array with no source", async () => {
138
+ const result = await openBottomSheetAction({
139
+ type: "openBottomSheet",
140
+ options: {
141
+ content: { items: [] },
142
+ },
143
+ } as any);
144
+
145
+ expect(result).toBe(ActionResult.Error);
146
+ expect(modalOrchestrator.open).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("forwards content.emptyMessage onto the menu", async () => {
150
+ const result = await openBottomSheetAction({
151
+ type: "openBottomSheet",
152
+ options: {
153
+ header: { title: "Queue" },
154
+ content: {
155
+ items: [{ id: "t1", title: "Track 1" }],
156
+ emptyMessage: "Queued items will be added here",
157
+ },
158
+ },
159
+ } as any);
160
+
161
+ expect(result).toBe(ActionResult.Success);
162
+
163
+ const [menu] = (modalOrchestrator.open as jest.Mock).mock.calls[0];
164
+
165
+ expect(menu.content.emptyMessage).toBe("Queued items will be added here");
166
+ });
167
+
80
168
  it("pushes a new sheet by default even when another sheet is already open", async () => {
81
169
  (modalOrchestrator as any).stackSize = 1;
82
170
 
@@ -227,7 +227,18 @@ export async function openBottomSheetAction(
227
227
 
228
228
  let dynamicCollection: DynamicCollectionOptions | undefined;
229
229
 
230
- if (Array.isArray(inlineItems) && inlineItems.length > 0) {
230
+ const rawDynamicCollection =
231
+ content?.dynamicCollection ??
232
+ content?.extensions?.dynamic_collection_options;
233
+
234
+ // An empty items array is a valid starting state for a provider-backed or
235
+ // offline collection. A sheet with neither items nor a source still errors.
236
+ const hasDynamicCollection = Boolean(rawDynamicCollection);
237
+
238
+ if (
239
+ Array.isArray(inlineItems) &&
240
+ (inlineItems.length > 0 || hasDynamicCollection)
241
+ ) {
231
242
  log_info("openBottomSheet: using inline items from action options", {
232
243
  count: inlineItems.length,
233
244
  });
@@ -239,10 +250,7 @@ export async function openBottomSheetAction(
239
250
  entryToMenuItem(entry, behavior?.currentSelection ?? [])
240
251
  );
241
252
 
242
- dynamicCollection = parseDynamicCollectionOptions(
243
- content?.dynamicCollection ??
244
- content?.extensions?.dynamic_collection_options
245
- );
253
+ dynamicCollection = parseDynamicCollectionOptions(rawDynamicCollection);
246
254
  } else if (itemsUrl) {
247
255
  log_info(`openBottomSheet: loading items from source: ${itemsUrl}`);
248
256
 
@@ -280,6 +288,7 @@ export async function openBottomSheetAction(
280
288
  dynamicCollection,
281
289
  themePluginId,
282
290
  styleOverrides: styleOverrides ?? content?.styleOverrides,
291
+ emptyMessage: content?.emptyMessage,
283
292
  context,
284
293
  },
285
294
  };
@@ -0,0 +1,67 @@
1
+ import { getUpcomingQueue } from "../getUpcomingQueue";
2
+
3
+ describe("getUpcomingQueue", () => {
4
+ it("returns null when plugin is not provided or has no module", async () => {
5
+ expect(await getUpcomingQueue(undefined)).toBeNull();
6
+ expect(await getUpcomingQueue({} as any)).toBeNull();
7
+ });
8
+
9
+ it("returns null when QueueManager is not present on module", async () => {
10
+ const plugin = {
11
+ identifier: "queue-action",
12
+ module: {},
13
+ } as any;
14
+
15
+ expect(await getUpcomingQueue(plugin)).toBeNull();
16
+ });
17
+
18
+ it("calls setupQuickBrickQueue when QueueManager is not initialized", async () => {
19
+ const setupQuickBrickQueue = jest.fn();
20
+ const mockUpcoming = [{ id: "item_1" }];
21
+
22
+ const QueueManager = {
23
+ isInitialized: jest
24
+ .fn()
25
+ .mockReturnValueOnce(false)
26
+ .mockReturnValueOnce(true),
27
+ instance: {
28
+ getUpcomingQueue: jest.fn().mockResolvedValue(mockUpcoming),
29
+ },
30
+ };
31
+
32
+ const plugin = {
33
+ identifier: "queue-action",
34
+ module: {
35
+ QueueManager,
36
+ setupQuickBrickQueue,
37
+ },
38
+ } as any;
39
+
40
+ const result = await getUpcomingQueue(plugin);
41
+
42
+ expect(setupQuickBrickQueue).toHaveBeenCalled();
43
+ expect(result).toEqual(mockUpcoming);
44
+ });
45
+
46
+ it("returns upcoming items when QueueManager is already initialized", async () => {
47
+ const mockUpcoming = [{ id: "item_1" }, { id: "item_2" }];
48
+
49
+ const QueueManager = {
50
+ isInitialized: jest.fn().mockReturnValue(true),
51
+ instance: {
52
+ getUpcomingQueue: jest.fn().mockResolvedValue(mockUpcoming),
53
+ },
54
+ };
55
+
56
+ const plugin = {
57
+ identifier: "queue-action",
58
+ module: {
59
+ QueueManager,
60
+ },
61
+ } as any;
62
+
63
+ const result = await getUpcomingQueue(plugin);
64
+
65
+ expect(result).toEqual(mockUpcoming);
66
+ });
67
+ });
@@ -1,9 +1,22 @@
1
1
  export const getUpcomingQueue = async (
2
2
  plugin?: QuickBrickPlugin | ZappPlugin
3
3
  ): Promise<unknown[] | null> => {
4
- const QueueManager = (plugin as any)?.module?.QueueManager;
4
+ const module = (plugin as any)?.module;
5
+ const QueueManager = module?.QueueManager;
6
+ const setupQuickBrickQueue = module?.setupQuickBrickQueue;
5
7
 
6
- if (!QueueManager?.instance) {
8
+ if (!QueueManager) {
9
+ return null;
10
+ }
11
+
12
+ if (
13
+ !QueueManager.isInitialized?.() &&
14
+ typeof setupQuickBrickQueue === "function"
15
+ ) {
16
+ setupQuickBrickQueue();
17
+ }
18
+
19
+ if (!QueueManager.isInitialized?.()) {
7
20
  return null;
8
21
  }
9
22
 
@@ -205,56 +205,64 @@ describe("parseModalBlocksConfiguration", () => {
205
205
  );
206
206
  });
207
207
 
208
- it("should fall back to flavor1 reorder/remove assets", () => {
208
+ it("should parse section_header title and action_buttons independently of header", () => {
209
209
  const result = parseModalBlocksConfiguration(
210
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",
211
+ "section_header.title.font_color": "#ABCDEF",
212
+ "section_header.title.font_size": 13,
213
+ "section_header.title.ios.font_family": "SFProText-Bold",
214
+ "section_header.action_buttons.default.background_color":
215
+ "rgba(62, 62, 62, 1)",
216
+ "section_header.action_buttons.focus.background_color":
217
+ "rgba(78, 78, 78, 1)",
218
+ "section_header.action_buttons.default.font_color": "#FFFFFF",
219
+ "section_header.action_buttons.font_size": 14,
220
+ "section_header.action_buttons.border_radius": 16,
221
+ // Theme modal header — must not leak into sectionHeader
222
+ queue_action_style_header_title_default_fontColor: "#111111",
223
+ queue_action_style_header_title_default_fontSize: 22,
215
224
  },
216
- "playlist-experience"
225
+ "queue_action"
217
226
  );
218
227
 
219
- expect(result.item.actionButtons.default).toEqual(
228
+ expect(result.sectionHeader.title.default).toEqual(
220
229
  expect.objectContaining({
221
- removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
222
- dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
230
+ fontColor: "#ABCDEF",
231
+ fontSize: 13,
232
+ fontFamily: "SFProText-Bold",
223
233
  })
224
234
  );
225
- });
226
235
 
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
+ expect(result.sectionHeader.actionButtons.default).toEqual(
237
+ expect.objectContaining({
238
+ backgroundColor: "rgba(62, 62, 62, 1)",
239
+ fontColor: "#FFFFFF",
240
+ fontSize: 14,
241
+ borderRadius: 16,
242
+ })
236
243
  );
237
244
 
238
- expect(result.item.actionButtons.default).toEqual(
245
+ expect(result.sectionHeader.actionButtons.focused).toEqual(
239
246
  expect.objectContaining({
240
- removeAsset: "https://placehold.co/24x24/00FF00/000000/png?text=RM",
241
- dragAsset: "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH",
247
+ backgroundColor: "rgba(78, 78, 78, 1)",
248
+ })
249
+ );
250
+
251
+ expect(result.header.title.default).toEqual(
252
+ expect.objectContaining({
253
+ fontColor: "#111111",
254
+ fontSize: 22,
242
255
  })
243
256
  );
244
257
  });
245
258
 
246
- it("should ignore playlist-only asset keys for other theme plugins", () => {
259
+ it("should leave sectionHeader empty when keys are absent", () => {
247
260
  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"
261
+ { "item.text_label1.font_color": "#FFFFFF" },
262
+ "playlist-experience"
255
263
  );
256
264
 
257
- expect(result.button.leadingIcon.default?.asset).toBeUndefined();
258
- expect(result.item.actionButtons.default?.dragAsset).toBeUndefined();
265
+ expect(result.sectionHeader.title).toEqual({ default: {} });
266
+ expect(result.sectionHeader.actionButtons).toEqual({ default: {} });
259
267
  });
260
268
  });
@@ -7,6 +7,14 @@ export type ModalBlocksConfig = {
7
7
  closeButton: Record<string, any>;
8
8
  actionButtons: Record<string, any>;
9
9
  };
10
+ /**
11
+ * Queue Section Header (BB Variant 3) — shared by Next in Queue / Next Up.
12
+ * Separate from Theme modal `header` so top-title styles do not collide.
13
+ */
14
+ sectionHeader: {
15
+ title: Record<string, any>;
16
+ actionButtons: Record<string, any>;
17
+ };
10
18
  action: {
11
19
  background: Record<string, any>;
12
20
  title: Record<string, any>;
@@ -63,6 +71,10 @@ export const parseModalBlocksConfiguration = (
63
71
  closeButton: {},
64
72
  actionButtons: {},
65
73
  },
74
+ sectionHeader: {
75
+ title: {},
76
+ actionButtons: {},
77
+ },
66
78
  action: {
67
79
  background: {},
68
80
  title: {},
@@ -148,6 +160,8 @@ export const parseModalBlocksConfiguration = (
148
160
  "action_buttons",
149
161
  ]);
150
162
 
163
+ parseBlock("section_header", ["title", "action_buttons"]);
164
+
151
165
  parseBlock("action", [
152
166
  "background",
153
167
  "title",
@@ -44,6 +44,7 @@ function menuToModalArgs(
44
44
  title: menu.header?.title ?? menu.content.title,
45
45
  summary: menu.header?.subtitle,
46
46
  styleOverrides: menu.content.styleOverrides,
47
+ emptyMessage: menu.content.emptyMessage,
47
48
  onPress: onItemPress,
48
49
  viewModel,
49
50
  },
@@ -44,4 +44,20 @@ describe("ModalOrchestrator phase 3", () => {
44
44
  expect(props.title).toBe("Sleep Timer");
45
45
  expect(props.summary).toBe("remaining");
46
46
  });
47
+
48
+ it("forwards the queue empty message onto the sheet props", () => {
49
+ modalOrchestrator.open({
50
+ header: { title: "Queue" },
51
+ content: {
52
+ title: "Queue",
53
+ items: [],
54
+ emptyMessage: "Queued items will be added here",
55
+ },
56
+ });
57
+
58
+ const props = (modalStore.getState().modalState.screen as any)
59
+ ?.modalBottomSheetContentProps;
60
+
61
+ expect(props.emptyMessage).toBe("Queued items will be added here");
62
+ });
47
63
  });
@@ -118,6 +118,8 @@ export interface Content {
118
118
  dynamicCollection?: DynamicCollectionOptions;
119
119
  themePluginId?: string;
120
120
  styleOverrides?: Record<string, any>;
121
+ /** Shown under Next Up when the queue sheet has no upcoming items. */
122
+ emptyMessage?: string;
121
123
  context?: Record<string, any>;
122
124
  }
123
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "15.1.15",
3
+ "version": "15.1.16-rc.0",
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": "15.1.15",
30
+ "@applicaster/applicaster-types": "15.1.16-rc.0",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",