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,5 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dynamic-dropdown
|
|
2
2
|
|
|
3
|
-
This example integration
|
|
3
|
+
This example integration demonstrates how to create **dynamic dropdowns** (also known as dynamic choices) in Zapier integrations.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
374
|
+
nextPagingToken = result.nextPagingToken;
|
|
375
|
+
answer = await command.promptWithList(message, result.choices, {
|
|
231
376
|
useStderr: true,
|
|
232
377
|
});
|
|
233
378
|
}
|