Skip to content

Commit c1253b6

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(webflow): paginate collection item selectors
1 parent 2598f44 commit c1253b6

10 files changed

Lines changed: 272 additions & 62 deletions

File tree

apps/docs/content/docs/integrations/webflow.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Integrates Webflow CMS into the workflow. Can create, get, list, update, or dele
3636

3737
### Webflow List Items
3838

39-
List all items from a Webflow CMS collection
39+
List items from a Webflow CMS collection
4040

4141
#### Input
4242

@@ -64,6 +64,7 @@ List all items from a Webflow CMS collection
6464
|`itemCount` | number | Number of items returned |
6565
|`offset` | number | Pagination offset |
6666
|`limit` | number | Maximum items per page |
67+
|`total` | number | Total number of matching items |
6768

6869
### Webflow Get Item
6970

apps/sim/lib/selectors/manifest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,9 @@ export const selectorManifest = {
304304
}),
305305
'webflow.items': providerSelector(['collectionId'], {
306306
readiness: { all: ['oauthCredential', 'collectionId'] },
307+
listMode: 'paginated',
307308
search: true,
309+
detail: true,
308310
staleTime: SEARCH_SELECTOR_STALE_TIME,
309311
}),
310312
'cloudwatch.logGroups': rawProviderSelector(
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockFetch, mockResolveSelectorOAuthAccessToken } = vi.hoisted(() => ({
7+
mockFetch: vi.fn(),
8+
mockResolveSelectorOAuthAccessToken: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/selectors/server/credentials', () => ({
12+
resolveSelectorOAuthAccessToken: mockResolveSelectorOAuthAccessToken,
13+
}))
14+
15+
import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values'
16+
import { webflowSelectorAttachments } from '@/lib/selectors/server/providers/webflow'
17+
import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types'
18+
19+
const collectionId = '680000000000000000000001'
20+
21+
function args(request: ExecuteServerSelectorArgs['request']): ExecuteServerSelectorArgs {
22+
return {
23+
selectorKey: 'webflow.items',
24+
context: { oauthCredential: 'credential-1', collectionId },
25+
request,
26+
scope: { kind: 'workspace', workspaceId: 'workspace-1' },
27+
workspaceId: 'workspace-1',
28+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
29+
requesterUserId: 'user-1',
30+
credential: { suppliedId: 'credential-1' },
31+
references: new Map(),
32+
protectedValues: createSelectorProtectedValues(),
33+
}
34+
}
35+
36+
describe('Webflow server selector adapter', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
vi.stubGlobal('fetch', mockFetch)
40+
mockResolveSelectorOAuthAccessToken.mockResolvedValue('server-only-token')
41+
})
42+
43+
afterAll(() => vi.unstubAllGlobals())
44+
45+
it('fetches one searched item page beyond the old 50-page boundary', async () => {
46+
const itemId = '680000000000000000001389'
47+
mockFetch.mockResolvedValueOnce(
48+
new Response(
49+
JSON.stringify({
50+
items: [{ id: itemId, fieldData: { name: 'Needle beyond fifty' } }],
51+
pagination: { limit: 100, offset: 5000, total: 5002 },
52+
}),
53+
{ status: 200 }
54+
)
55+
)
56+
57+
await expect(
58+
webflowSelectorAttachments['webflow.items'].execute(
59+
args({ kind: 'list', search: ' Needle ', cursor: '5000' })
60+
)
61+
).resolves.toEqual({
62+
kind: 'list',
63+
items: [{ id: itemId, label: 'Needle beyond fifty' }],
64+
nextCursor: '5001',
65+
})
66+
67+
const url = new URL(String(mockFetch.mock.calls[0]?.[0]))
68+
expect(url.pathname).toBe(`/v2/collections/${collectionId}/items`)
69+
expect(url.searchParams.get('limit')).toBe('100')
70+
expect(url.searchParams.get('offset')).toBe('5000')
71+
expect(url.searchParams.get('filter[name][contains]')).toBe('Needle')
72+
expect(mockFetch).toHaveBeenCalledTimes(1)
73+
})
74+
75+
it('hydrates saved items directly and treats a missing item as absent', async () => {
76+
const itemId = '680000000000000000001389'
77+
const missingItemId = '680000000000000000001390'
78+
mockFetch
79+
.mockResolvedValueOnce(
80+
new Response(JSON.stringify({ id: itemId, fieldData: { title: 'Saved item title' } }), {
81+
status: 200,
82+
})
83+
)
84+
.mockResolvedValueOnce(new Response(null, { status: 404 }))
85+
86+
await expect(
87+
webflowSelectorAttachments['webflow.items'].execute(args({ kind: 'detail', id: itemId }))
88+
).resolves.toEqual({
89+
kind: 'detail',
90+
item: { id: itemId, label: 'Saved item title' },
91+
})
92+
await expect(
93+
webflowSelectorAttachments['webflow.items'].execute(
94+
args({ kind: 'detail', id: missingItemId })
95+
)
96+
).resolves.toEqual({ kind: 'detail', item: null })
97+
98+
expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
99+
`https://api.webflow.com/v2/collections/${collectionId}/items/${itemId}`
100+
)
101+
expect(String(mockFetch.mock.calls[1]?.[0])).toBe(
102+
`https://api.webflow.com/v2/collections/${collectionId}/items/${missingItemId}`
103+
)
104+
expect(mockFetch).toHaveBeenCalledTimes(2)
105+
})
106+
})

apps/sim/lib/selectors/server/providers/webflow.ts

Lines changed: 130 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,41 @@ import {
77
SelectorOptionsUnavailableError,
88
} from '@/lib/selectors/server/errors'
99
import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results'
10-
import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http'
10+
import {
11+
fetchProviderJson,
12+
fetchProviderJsonWithStatus,
13+
} from '@/lib/selectors/server/providers/provider-http'
1114
import type {
1215
ExecuteServerSelectorArgs,
1316
ServerSelectorAttachmentMap,
1417
} from '@/lib/selectors/server/types'
18+
import { detailSelectorResult, listSelectorResult } from '@/lib/selectors/server/types'
1519
import type { SafeSelectorOption } from '@/lib/selectors/types'
1620

1721
type WebflowSelectorKey = Extract<
1822
ServerSelectorKey,
1923
'webflow.sites' | 'webflow.collections' | 'webflow.items'
2024
>
2125

22-
const WEBFLOW_MAX_ITEM_PAGES = 50
26+
const WEBFLOW_ITEM_PAGE_SIZE = 100
27+
28+
interface WebflowItem {
29+
id?: unknown
30+
fieldData?: {
31+
name?: unknown
32+
title?: unknown
33+
slug?: unknown
34+
}
35+
}
36+
37+
interface WebflowItemPage {
38+
items?: unknown
39+
pagination?: {
40+
limit?: unknown
41+
offset?: unknown
42+
total?: unknown
43+
}
44+
}
2345

2446
const credential = {
2547
kind: 'stored',
@@ -43,6 +65,34 @@ function requireWebflowId(value: string | undefined, name: string): string {
4365
return validation.sanitized ?? value
4466
}
4567

68+
function parseItemOffset(cursor: string | undefined): number {
69+
if (cursor === undefined) return 0
70+
if (!/^(0|[1-9]\d*)$/.test(cursor)) throw new SelectorContextUnavailableError()
71+
const offset = Number(cursor)
72+
if (!Number.isSafeInteger(offset)) throw new SelectorContextUnavailableError()
73+
return offset
74+
}
75+
76+
function projectItem(item: WebflowItem): SafeSelectorOption | null {
77+
if (typeof item.id !== 'string' || !item.id) return null
78+
const { name, title, slug } = item.fieldData ?? {}
79+
const label = [name, title, slug].find(
80+
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0
81+
)
82+
return { id: item.id, label: label ?? item.id }
83+
}
84+
85+
function requirePaginationNumber(value: unknown, options: { positive?: boolean } = {}): number {
86+
if (
87+
!Number.isSafeInteger(value) ||
88+
(value as number) < 0 ||
89+
(options.positive && (value as number) === 0)
90+
) {
91+
throw new SelectorOptionsUnavailableError()
92+
}
93+
return value as number
94+
}
95+
4696
async function listSites(args: ExecuteServerSelectorArgs): Promise<SafeSelectorOption[]> {
4797
const token = await tokenFor(args)
4898
const data = await fetchProviderJson<{
@@ -74,69 +124,94 @@ async function listCollections(args: ExecuteServerSelectorArgs): Promise<SafeSel
74124
}))
75125
}
76126

77-
async function listItems(
78-
args: ExecuteServerSelectorArgs
79-
): Promise<{ items: SafeSelectorOption[]; truncated: boolean }> {
127+
async function listItems(args: ExecuteServerSelectorArgs) {
128+
if (args.request.kind !== 'list') throw new SelectorContextUnavailableError()
129+
const offset = parseItemOffset(args.request.cursor)
80130
const collectionId = requireWebflowId(args.context.collectionId, 'collectionId')
81131
const token = await tokenFor(args)
82-
const items: Array<{
83-
id: string
84-
fieldData?: { name?: string; title?: string; slug?: string }
85-
}> = []
86-
let offset = 0
87-
let truncated = false
88-
for (let page = 0; page < WEBFLOW_MAX_ITEM_PAGES; page++) {
89-
const url = new URL(
90-
`https://api.webflow.com/v2/collections/${encodeURIComponent(collectionId)}/items`
91-
)
92-
url.searchParams.set('limit', '100')
93-
url.searchParams.set('offset', String(offset))
94-
const data = await fetchProviderJson<{
95-
items?: typeof items
96-
pagination?: { total?: number }
97-
}>(url, {
98-
headers: { Authorization: `Bearer ${token}`, accept: 'application/json' },
99-
signal: args.signal,
100-
redirect: 'error',
101-
})
102-
const pageItems = data.items ?? []
103-
const reportedTotal = data.pagination?.total
104-
if (reportedTotal !== undefined && (!Number.isInteger(reportedTotal) || reportedTotal < 0)) {
105-
throw new SelectorOptionsUnavailableError()
106-
}
107-
items.push(...pageItems)
108-
offset += pageItems.length
109-
if (reportedTotal !== undefined && items.length >= reportedTotal) {
110-
break
111-
}
112-
if (pageItems.length === 0) {
113-
if (reportedTotal !== undefined && items.length < reportedTotal) truncated = true
114-
break
115-
}
116-
if (page === WEBFLOW_MAX_ITEM_PAGES - 1) truncated = true
132+
const url = new URL(
133+
`https://api.webflow.com/v2/collections/${encodeURIComponent(collectionId)}/items`
134+
)
135+
url.searchParams.set('limit', String(WEBFLOW_ITEM_PAGE_SIZE))
136+
url.searchParams.set('offset', String(offset))
137+
const search = args.request.search?.trim()
138+
if (search) url.searchParams.set('filter[name][contains]', search)
139+
140+
const data = await fetchProviderJson<WebflowItemPage>(url, {
141+
headers: { Authorization: `Bearer ${token}`, accept: 'application/json' },
142+
signal: args.signal,
143+
redirect: 'error',
144+
})
145+
if (
146+
!data ||
147+
typeof data !== 'object' ||
148+
!Array.isArray(data.items) ||
149+
!data.pagination ||
150+
typeof data.pagination !== 'object' ||
151+
Array.isArray(data.pagination)
152+
) {
153+
throw new SelectorOptionsUnavailableError()
117154
}
118155

119-
const search = args.request.kind === 'list' ? args.request.search?.toLowerCase() : undefined
120-
return {
121-
items: items.flatMap((item) => {
122-
if (!item.id) return []
123-
const label = item.fieldData?.name || item.fieldData?.title || item.fieldData?.slug || item.id
124-
return search && !label.toLowerCase().includes(search) ? [] : [{ id: item.id, label }]
125-
}),
126-
truncated,
156+
const reportedLimit = requirePaginationNumber(data.pagination.limit, { positive: true })
157+
const reportedOffset = requirePaginationNumber(data.pagination.offset)
158+
const reportedTotal = requirePaginationNumber(data.pagination.total)
159+
if (
160+
reportedLimit > WEBFLOW_ITEM_PAGE_SIZE ||
161+
reportedOffset !== offset ||
162+
data.items.length > reportedLimit
163+
) {
164+
throw new SelectorOptionsUnavailableError()
165+
}
166+
if (data.items.length === 0 && reportedOffset < reportedTotal) {
167+
throw new SelectorOptionsUnavailableError()
127168
}
128-
}
129169

130-
async function executeItems(args: ExecuteServerSelectorArgs) {
131-
const { items, truncated } = await listItems(args)
132-
return flatSelectorResult(
133-
args.request,
170+
const nextOffset = reportedOffset + data.items.length
171+
if (
172+
!Number.isSafeInteger(nextOffset) ||
173+
(nextOffset <= reportedOffset && data.items.length > 0)
174+
) {
175+
throw new SelectorOptionsUnavailableError()
176+
}
177+
const items = data.items.flatMap((item) => {
178+
if (!item || typeof item !== 'object') return []
179+
const option = projectItem(item as WebflowItem)
180+
return option ? [option] : []
181+
})
182+
return listSelectorResult(
134183
items,
135-
false,
136-
truncated ? { truncated: { reason: 'provider-cap', pages: WEBFLOW_MAX_ITEM_PAGES } } : undefined
184+
data.items.length > 0 && nextOffset < reportedTotal ? String(nextOffset) : undefined
137185
)
138186
}
139187

188+
async function getItem(args: ExecuteServerSelectorArgs) {
189+
if (args.request.kind !== 'detail') throw new SelectorContextUnavailableError()
190+
const collectionId = requireWebflowId(args.context.collectionId, 'collectionId')
191+
const itemId = requireWebflowId(args.request.id, 'itemId')
192+
const token = await tokenFor(args)
193+
const result = await fetchProviderJsonWithStatus<WebflowItem>(
194+
`https://api.webflow.com/v2/collections/${encodeURIComponent(collectionId)}/items/${encodeURIComponent(itemId)}`,
195+
{
196+
headers: { Authorization: `Bearer ${token}`, accept: 'application/json' },
197+
signal: args.signal,
198+
redirect: 'error',
199+
},
200+
{ passthroughStatuses: [404] }
201+
)
202+
if (!result.ok) return detailSelectorResult(null)
203+
if (!result.data || typeof result.data !== 'object') {
204+
throw new SelectorOptionsUnavailableError()
205+
}
206+
const item = projectItem(result.data)
207+
if (!item || item.id !== itemId) throw new SelectorOptionsUnavailableError()
208+
return detailSelectorResult(item)
209+
}
210+
211+
function executeItems(args: ExecuteServerSelectorArgs) {
212+
return args.request.kind === 'detail' ? getItem(args) : listItems(args)
213+
}
214+
140215
export const webflowSelectorAttachments = {
141216
'webflow.sites': {
142217
credential,

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/generated/tool-outputs.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/webflow/list_items.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
export const webflowListItemsTool: ToolConfig<WebflowListItemsParams, WebflowListItemsResponse> = {
99
id: 'webflow_list_items',
1010
name: 'Webflow List Items',
11-
description: 'List all items from a Webflow CMS collection',
11+
description: 'List items from a Webflow CMS collection',
1212
version: '1.0.0',
1313

1414
oauth: {
@@ -78,8 +78,9 @@ export const webflowListItemsTool: ToolConfig<WebflowListItemsParams, WebflowLis
7878
items: data.items || [],
7979
metadata: {
8080
itemCount: (data.items || []).length,
81-
offset: data.offset,
82-
limit: data.limit,
81+
offset: data.pagination?.offset,
82+
limit: data.pagination?.limit,
83+
total: data.pagination?.total,
8384
},
8485
},
8586
}

apps/sim/tools/webflow/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const WEBFLOW_LIST_METADATA_OUTPUT_PROPERTIES = {
3232
itemCount: { type: 'number', description: 'Number of items returned' },
3333
offset: { type: 'number', description: 'Pagination offset', optional: true },
3434
limit: { type: 'number', description: 'Maximum items per page', optional: true },
35+
total: { type: 'number', description: 'Total number of matching items', optional: true },
3536
} as const satisfies Record<string, OutputProperty>
3637

3738
interface WebflowBaseParams {
@@ -51,6 +52,7 @@ interface WebflowListItemsOutput {
5152
itemCount: number
5253
offset?: number
5354
limit?: number
55+
total?: number
5456
}
5557
}
5658

0 commit comments

Comments
 (0)