zapier-platform-cli 18.2.3 → 18.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapier-platform-cli",
3
- "version": "18.2.3",
3
+ "version": "18.3.0",
4
4
  "description": "The CLI for managing integrations in Zapier Developer Platform.",
5
5
  "repository": "zapier/zapier-platform",
6
6
  "homepage": "https://platform.zapier.com/",
@@ -31,4 +31,4 @@ zapier scaffold trigger contact
31
31
  zapier scaffold create contact
32
32
  ```
33
33
 
34
- Find out more on the latest docs: https://github.com/zapier/zapier-platform/blob/main/packages/cli/README.md.
34
+ Find out more on the latest docs: https://docs.zapier.com/platform
@@ -1,5 +1,107 @@
1
- # The "dynamic-dropdown" Template
1
+ # dynamic-dropdown
2
2
 
3
- This example integration uses the [Star Wars API](https://swapi.info/) to provide a dynamic listing of choices for an `inputField` in a trigger.
3
+ This example integration demonstrates how to create **dynamic dropdowns** (also known as dynamic choices) in Zapier integrations.
4
4
 
5
- ![](https://cdn.zappy.app/d985065c5098089795d9b60c77791e12.png)
5
+ ## Dynamic Dropdown Patterns
6
+
7
+ There are two ways to implement dynamic dropdowns:
8
+
9
+ ### 1. Trigger-based (Legacy Pattern)
10
+
11
+ Uses a separate trigger to fetch choices. Reference it with the `dynamic` property:
12
+
13
+ ```javascript
14
+ {
15
+ key: 'species_id',
16
+ type: 'integer',
17
+ label: 'Species',
18
+ dynamic: 'species.id.name', // Format: "triggerKey.idField.labelField"
19
+ }
20
+ ```
21
+
22
+ The trigger (`species`) fetches data, and Zapier uses `id` for the value and `name` for the display label.
23
+
24
+ ### 2. Perform-based (New Pattern)
25
+
26
+ Uses a function to fetch choices directly. Define it with `choices.perform`:
27
+
28
+ ```javascript
29
+ {
30
+ key: 'planet_id',
31
+ type: 'integer',
32
+ label: 'Home Planet',
33
+ resource: 'planet', // Explicit resource linking (see below)
34
+ choices: {
35
+ perform: getPlanetChoices,
36
+ },
37
+ }
38
+ ```
39
+
40
+ #### Resource Linking
41
+
42
+ The `resource` property explicitly links an input field to a resource. This is particularly important for perform-based dropdowns since they don't have a `dynamic` property to derive the resource from.
43
+
44
+ ```javascript
45
+ {
46
+ key: 'spreadsheet_id',
47
+ resource: 'spreadsheet',
48
+ choices: { perform: getSpreadsheets },
49
+ }
50
+ ```
51
+
52
+ The perform function must return:
53
+
54
+ ```javascript
55
+ {
56
+ results: [
57
+ { id: '1', label: 'Tatooine' },
58
+ { id: '2', label: 'Alderaan' },
59
+ ],
60
+ paging_token: 'https://api.example.com/planets?page=2', // or null if no more pages
61
+ }
62
+ ```
63
+
64
+ #### Pagination Support
65
+
66
+ The perform function receives `bundle.meta.paging_token` for subsequent page requests:
67
+
68
+ ```javascript
69
+ const getPlanetChoices = async (z, bundle) => {
70
+ // First request: paging_token is undefined
71
+ // Subsequent requests: paging_token is the value you returned previously
72
+ const url = bundle.meta.paging_token || 'https://api.example.com/planets';
73
+
74
+ const response = await z.request({ url });
75
+
76
+ return {
77
+ results: response.data.results.map((item) => ({
78
+ id: item.id,
79
+ label: item.name,
80
+ })),
81
+ // Return null when there are no more pages
82
+ paging_token: response.data.next,
83
+ };
84
+ };
85
+ ```
86
+
87
+ ## This Example
88
+
89
+ This integration uses the [Star Wars API](https://swapi.dev/) to demonstrate:
90
+
91
+ - **Species dropdown** - Trigger-based pattern using the `species` trigger
92
+ - **Planet dropdown** - Perform-based pattern with pagination and explicit `resource` linking
93
+
94
+ ## Getting Started
95
+
96
+ ```bash
97
+ # Install dependencies
98
+ npm install
99
+
100
+ # Run tests
101
+ zapier test
102
+
103
+ # Push to Zapier
104
+ zapier push
105
+ ```
106
+
107
+ Find out more on the latest docs: https://docs.zapier.com/platform
@@ -1,5 +1,33 @@
1
1
  const { extractID } = require('../utils');
2
2
 
3
+ /**
4
+ * PERFORM-BASED choices WITH PAGINATION (NEW pattern)
5
+ * Fetches planets from the Star Wars API with pagination support.
6
+ *
7
+ * - bundle.meta.paging_token is a full URL from the previous response
8
+ * - Return paging_token as the API's next page URL (or null if no more pages)
9
+ *
10
+ * MUST return: { results: [...], paging_token: string|null }
11
+ */
12
+ const getPlanetChoices = async (z, bundle) => {
13
+ // paging_token is a full URL to the next page (from SWAPI's "next" field)
14
+ // First page: paging_token is undefined/null, use default URL
15
+ const url = bundle.meta.paging_token || 'https://swapi.dev/api/planets/';
16
+
17
+ const response = await z.request({ url });
18
+ const data = response.data;
19
+
20
+ // SWAPI returns: { results: [...], next: "url" or null }
21
+ return {
22
+ results: data.results.map((planet) => ({
23
+ id: extractID(planet.url),
24
+ label: planet.name,
25
+ })),
26
+ // Return SWAPI's next URL as our paging_token
27
+ paging_token: data.next,
28
+ };
29
+ };
30
+
3
31
  // Fetches a list of records from the endpoint
4
32
  const perform = async (z, bundle) => {
5
33
  // Ideally, we should poll through all the pages of results, but in this
@@ -23,6 +51,16 @@ const perform = async (z, bundle) => {
23
51
  });
24
52
  }
25
53
 
54
+ if (bundle.inputData.planet_id) {
55
+ // The Zap's setup has requested a specific home planet. Filter people by
56
+ // homeworld (SWAPI people have a homeworld URL).
57
+ peopleArray = peopleArray.filter((person) => {
58
+ if (!person.homeworld) return false;
59
+ const homeworldID = extractID(person.homeworld);
60
+ return homeworldID === bundle.inputData.planet_id;
61
+ });
62
+ }
63
+
26
64
  return peopleArray.map((person) => {
27
65
  person.id = extractID(person.url);
28
66
  return person;
@@ -39,13 +77,30 @@ module.exports = {
39
77
 
40
78
  operation: {
41
79
  inputFields: [
80
+ // TRIGGER-BASED dynamic dropdown (legacy pattern)
81
+ // Uses a separate trigger to fetch choices
42
82
  {
43
83
  key: 'species_id',
44
84
  type: 'integer',
45
- helpText: 'Species of person',
85
+ label: 'Species (trigger-based)',
86
+ helpText:
87
+ 'Filter by species. Uses trigger-based dynamic dropdown (dynamic: "species.id.name").',
46
88
  dynamic: 'species.id.name',
47
89
  altersDynamicFields: true,
48
90
  },
91
+ // PERFORM-BASED dynamic dropdown WITH PAGINATION (new pattern)
92
+ // Uses a function to fetch choices directly
93
+ {
94
+ key: 'planet_id',
95
+ type: 'integer',
96
+ label: 'Home Planet (perform-based)',
97
+ helpText:
98
+ 'Filter by home planet. Uses perform-based dynamic dropdown with pagination support.',
99
+ resource: 'planet', // Explicit resource linking for perform-based dropdowns
100
+ choices: {
101
+ perform: getPlanetChoices,
102
+ },
103
+ },
49
104
  ],
50
105
  perform,
51
106
  sample: {
@@ -1,7 +1,10 @@
1
1
  const _ = require('lodash');
2
2
 
3
3
  const { listAuthentications } = require('../../../utils/api');
4
+ const { startSpinner, endSpinner } = require('../../../utils/display');
4
5
  const { fetchChoices } = require('./remote');
6
+ const { localAppCommandWithRelayErrorHandler } = require('./relay');
7
+ const { customLogger } = require('./logger');
5
8
 
6
9
  /**
7
10
  * Formats a field definition for display in prompts.
@@ -121,6 +124,96 @@ const getDynamicDropdownChoices = async (
121
124
  }
122
125
  };
123
126
 
127
+ /**
128
+ * Checks whether a field uses perform-based dynamic choices (choices: { perform }).
129
+ * @param {Object} field - The field definition
130
+ * @returns {boolean} True if the field has perform-based choices
131
+ */
132
+ const isPerformBasedChoices = (field) =>
133
+ field.choices &&
134
+ typeof field.choices === 'object' &&
135
+ !Array.isArray(field.choices) &&
136
+ field.choices.perform !== undefined;
137
+
138
+ /**
139
+ * Fetches choices for a perform-based dynamic dropdown field.
140
+ * @param {import('../../ZapierBaseCommand')} command - The command instance
141
+ * @param {Object} context - The execution context
142
+ * @param {Object} field - The field definition with choices.perform
143
+ * @returns {Promise<{choices: Array<Object>, nextPagingToken: string|null}>}
144
+ */
145
+ const getPerformBasedChoices = async (command, context, field) => {
146
+ if (context.remote) {
147
+ const choices = (await fetchChoices(context, field.key)).map((c) => ({
148
+ name: `${c.label} (${c.value})`,
149
+ value: c.value,
150
+ }));
151
+ return { choices, nextPagingToken: null };
152
+ }
153
+
154
+ // Find the field's index in the action's inputFields array
155
+ const action =
156
+ context.appDefinition[context.actionTypePlural][context.actionKey];
157
+ const allInputFields = action.operation.inputFields || [];
158
+ const fieldIndex = allInputFields.findIndex(
159
+ (f) => f.key === field.key && f.choices && f.choices.perform,
160
+ );
161
+ if (fieldIndex === -1) {
162
+ throw new Error(
163
+ `Cannot find perform-based choices for field "${field.key}" in ` +
164
+ `${context.actionTypePlural}.${context.actionKey}.operation.inputFields.`,
165
+ );
166
+ }
167
+
168
+ const methodName = `${context.actionTypePlural}.${context.actionKey}.operation.inputFields.${fieldIndex}.choices.perform`;
169
+ const displayName = `${context.actionTypePlural}.${context.actionKey}.operation.inputFields[${fieldIndex}].choices.perform`;
170
+ const adverb = context.remote
171
+ ? 'remotely'
172
+ : context.authId
173
+ ? 'locally with relay'
174
+ : 'locally';
175
+ startSpinner(`Invoking ${displayName} ${adverb}`);
176
+ const result = await localAppCommandWithRelayErrorHandler({
177
+ command: 'execute',
178
+ method: methodName,
179
+ bundle: {
180
+ inputData: context.inputData,
181
+ inputDataRaw: context.inputData,
182
+ authData: context.authData,
183
+ meta: {
184
+ ...context.meta,
185
+ isFillingDynamicDropdown: true,
186
+ },
187
+ },
188
+ zcacheTestObj: context.zcacheTestObj,
189
+ cursorTestObj: context.cursorTestObj,
190
+ customLogger,
191
+ calledFromCliInvoke: true,
192
+ appId: context.appId,
193
+ deployKey: context.deployKey,
194
+ relayAuthenticationId: context.authId,
195
+ });
196
+ endSpinner();
197
+
198
+ // The perform function returns { results: [{ id, label }, ...], paging_token }
199
+ // or a plain array of { id, label } objects
200
+ let results, nextPagingToken;
201
+ if (Array.isArray(result)) {
202
+ results = result;
203
+ nextPagingToken = null;
204
+ } else {
205
+ results = result.results || [];
206
+ nextPagingToken = result.paging_token || null;
207
+ }
208
+
209
+ const choices = results.map((c) => ({
210
+ name: `${c.label || c.id} (${c.id})`,
211
+ value: String(c.id),
212
+ }));
213
+
214
+ return { choices, nextPagingToken };
215
+ };
216
+
124
217
  /**
125
218
  * Normalizes static choices into an array of { name, value } objects for
126
219
  * prompting.
@@ -150,18 +243,22 @@ const getStaticChoices = (choices) => {
150
243
  };
151
244
 
152
245
  /**
153
- * Gets choices for a dropdown field, handling both static and dynamic cases.
246
+ * Gets choices for a dropdown field, handling static, trigger-based dynamic,
247
+ * and perform-based dynamic cases.
154
248
  * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting
155
249
  * @param {Object} context - The execution context
156
250
  * @param {Object} field - The field definition
157
- * @param {Function} invokeAction - Function to invoke actions (used for dynamic dropdowns)
158
- * @returns {Promise<Array<Object>>} Array of choices formatted as { name, value } objects
251
+ * @param {Function} invokeAction - Function to invoke actions (used for trigger-based dynamic dropdowns)
252
+ * @param {Object} [pagingState] - Pagination state for perform-based choices
253
+ * @param {boolean} [pagingState.hasPreviousPage] - Whether there is a previous page
254
+ * @returns {Promise<{choices: Array<Object>, nextPagingToken: string|null}>}
159
255
  */
160
256
  const getStaticOrDynamicDropdownChoices = async (
161
257
  command,
162
258
  context,
163
259
  field,
164
260
  invokeAction,
261
+ pagingState,
165
262
  ) => {
166
263
  if (field.dynamic) {
167
264
  const choices = await getDynamicDropdownChoices(
@@ -181,9 +278,28 @@ const getStaticOrDynamicDropdownChoices = async (
181
278
  name: `>>> NEXT PAGE <<<`,
182
279
  value: '__next_page__',
183
280
  });
184
- return choices;
281
+ return { choices, nextPagingToken: null };
282
+ } else if (isPerformBasedChoices(field)) {
283
+ const { choices, nextPagingToken } = await getPerformBasedChoices(
284
+ command,
285
+ context,
286
+ field,
287
+ );
288
+ if (pagingState && pagingState.hasPreviousPage) {
289
+ choices.unshift({
290
+ name: `>>> PREVIOUS PAGE <<<`,
291
+ value: '__prev_page__',
292
+ });
293
+ }
294
+ if (nextPagingToken) {
295
+ choices.push({
296
+ name: `>>> NEXT PAGE <<<`,
297
+ value: '__next_page__',
298
+ });
299
+ }
300
+ return { choices, nextPagingToken };
185
301
  } else {
186
- return getStaticChoices(field.choices);
302
+ return { choices: getStaticChoices(field.choices), nextPagingToken: null };
187
303
  }
188
304
  };
189
305
 
@@ -199,35 +315,64 @@ const getStaticOrDynamicDropdownChoices = async (
199
315
  const promptForField = async (command, context, field, invokeAction) => {
200
316
  const message = formatFieldDisplay(field) + ':';
201
317
  if (field.dynamic || field.choices) {
318
+ const performBased = isPerformBasedChoices(field);
202
319
  let answer;
320
+
321
+ // Paging state for perform-based choices (token-based pagination)
322
+ const pagingTokenStack = [];
323
+ let currentPagingToken = null;
324
+ let nextPagingToken = null;
325
+
203
326
  while (
204
327
  !answer ||
205
328
  answer === '__next_page__' ||
206
329
  answer === '__prev_page__'
207
330
  ) {
208
- let page = 0;
209
- switch (answer) {
210
- case '__next_page__':
211
- page = (context.meta.page || 0) + 1;
212
- break;
213
- case '__prev_page__':
214
- page = Math.max((context.meta.page || 0) - 1, 0);
215
- break;
331
+ if (performBased) {
332
+ switch (answer) {
333
+ case '__next_page__':
334
+ pagingTokenStack.push(currentPagingToken);
335
+ currentPagingToken = nextPagingToken;
336
+ break;
337
+ case '__prev_page__':
338
+ currentPagingToken = pagingTokenStack.pop() || null;
339
+ break;
340
+ }
341
+ context = {
342
+ ...context,
343
+ meta: {
344
+ ...context.meta,
345
+ paging_token: currentPagingToken,
346
+ },
347
+ };
348
+ } else {
349
+ let page = 0;
350
+ switch (answer) {
351
+ case '__next_page__':
352
+ page = (context.meta.page || 0) + 1;
353
+ break;
354
+ case '__prev_page__':
355
+ page = Math.max((context.meta.page || 0) - 1, 0);
356
+ break;
357
+ }
358
+ context = {
359
+ ...context,
360
+ meta: {
361
+ ...context.meta,
362
+ page,
363
+ },
364
+ };
216
365
  }
217
- context = {
218
- ...context,
219
- meta: {
220
- ...context.meta,
221
- page,
222
- },
223
- };
224
- const choices = await getStaticOrDynamicDropdownChoices(
366
+
367
+ const result = await getStaticOrDynamicDropdownChoices(
225
368
  command,
226
369
  context,
227
370
  field,
228
371
  invokeAction,
372
+ { hasPreviousPage: pagingTokenStack.length > 0 },
229
373
  );
230
- answer = await command.promptWithList(message, choices, {
374
+ nextPagingToken = result.nextPagingToken;
375
+ answer = await command.promptWithList(message, result.choices, {
231
376
  useStderr: true,
232
377
  });
233
378
  }