Skip to content

Commit 27734f5

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
fix(webflow): paginate collection item selectors (#7337)
* fix(webflow): paginate collection item selectors * fix(webflow): handle optional pagination metadata --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent f2e20aa commit 27734f5

10 files changed

Lines changed: 302 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
@@ -311,7 +311,9 @@ export const selectorManifest = {
311311
}),
312312
'webflow.items': providerSelector(['collectionId'], {
313313
readiness: { all: ['oauthCredential', 'collectionId'] },
314+
listMode: 'paginated',
314315
search: true,
316+
detail: true,
315317
staleTime: SEARCH_SELECTOR_STALE_TIME,
316318
}),
317319
'cloudwatch.logGroups': rawProviderSelector(
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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('continues after a full page when optional pagination fields are omitted', async () => {
76+
const items = Array.from({ length: 100 }, (_, index) => ({
77+
id: index.toString(16).padStart(24, '0'),
78+
fieldData: { name: `Item ${index}` },
79+
}))
80+
mockFetch.mockResolvedValueOnce(
81+
new Response(JSON.stringify({ items, pagination: {} }), { status: 200 })
82+
)
83+
84+
const result = await webflowSelectorAttachments['webflow.items'].execute(
85+
args({ kind: 'list', cursor: '5000' })
86+
)
87+
88+
expect(result.kind).toBe('list')
89+
if (result.kind !== 'list') throw new Error('Expected a list selector result')
90+
expect(result.items).toHaveLength(100)
91+
expect(result.nextCursor).toBe('5100')
92+
expect(mockFetch).toHaveBeenCalledTimes(1)
93+
})
94+
95+
it('hydrates saved items directly and treats a missing item as absent', async () => {
96+
const itemId = '680000000000000000001389'
97+
const missingItemId = '680000000000000000001390'
98+
mockFetch
99+
.mockResolvedValueOnce(
100+
new Response(JSON.stringify({ id: itemId, fieldData: { title: 'Saved item title' } }), {
101+
status: 200,
102+
})
103+
)
104+
.mockResolvedValueOnce(new Response(null, { status: 404 }))
105+
106+
await expect(
107+
webflowSelectorAttachments['webflow.items'].execute(args({ kind: 'detail', id: itemId }))
108+
).resolves.toEqual({
109+
kind: 'detail',
110+
item: { id: itemId, label: 'Saved item title' },
111+
})
112+
await expect(
113+
webflowSelectorAttachments['webflow.items'].execute(
114+
args({ kind: 'detail', id: missingItemId })
115+
)
116+
).resolves.toEqual({ kind: 'detail', item: null })
117+
118+
expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
119+
`https://api.webflow.com/v2/collections/${collectionId}/items/${itemId}`
120+
)
121+
expect(String(mockFetch.mock.calls[1]?.[0])).toBe(
122+
`https://api.webflow.com/v2/collections/${collectionId}/items/${missingItemId}`
123+
)
124+
expect(mockFetch).toHaveBeenCalledTimes(2)
125+
})
126+
})

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

Lines changed: 140 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,104 @@ 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 =
157+
data.pagination.limit === undefined
158+
? WEBFLOW_ITEM_PAGE_SIZE
159+
: requirePaginationNumber(data.pagination.limit, { positive: true })
160+
const reportedOffset =
161+
data.pagination.offset === undefined ? offset : requirePaginationNumber(data.pagination.offset)
162+
const reportedTotal =
163+
data.pagination.total === undefined ? undefined : requirePaginationNumber(data.pagination.total)
164+
if (
165+
reportedLimit > WEBFLOW_ITEM_PAGE_SIZE ||
166+
reportedOffset !== offset ||
167+
data.items.length > reportedLimit
168+
) {
169+
throw new SelectorOptionsUnavailableError()
170+
}
171+
if (data.items.length === 0 && reportedTotal !== undefined && reportedOffset < reportedTotal) {
172+
throw new SelectorOptionsUnavailableError()
127173
}
128-
}
129174

130-
async function executeItems(args: ExecuteServerSelectorArgs) {
131-
const { items, truncated } = await listItems(args)
132-
return flatSelectorResult(
133-
args.request,
175+
const nextOffset = reportedOffset + data.items.length
176+
if (
177+
!Number.isSafeInteger(nextOffset) ||
178+
(nextOffset <= reportedOffset && data.items.length > 0)
179+
) {
180+
throw new SelectorOptionsUnavailableError()
181+
}
182+
const items = data.items.flatMap((item) => {
183+
if (!item || typeof item !== 'object') return []
184+
const option = projectItem(item as WebflowItem)
185+
return option ? [option] : []
186+
})
187+
return listSelectorResult(
134188
items,
135-
false,
136-
truncated ? { truncated: { reason: 'provider-cap', pages: WEBFLOW_MAX_ITEM_PAGES } } : undefined
189+
data.items.length > 0 &&
190+
(reportedTotal === undefined
191+
? data.items.length === reportedLimit
192+
: nextOffset < reportedTotal)
193+
? String(nextOffset)
194+
: undefined
137195
)
138196
}
139197

198+
async function getItem(args: ExecuteServerSelectorArgs) {
199+
if (args.request.kind !== 'detail') throw new SelectorContextUnavailableError()
200+
const collectionId = requireWebflowId(args.context.collectionId, 'collectionId')
201+
const itemId = requireWebflowId(args.request.id, 'itemId')
202+
const token = await tokenFor(args)
203+
const result = await fetchProviderJsonWithStatus<WebflowItem>(
204+
`https://api.webflow.com/v2/collections/${encodeURIComponent(collectionId)}/items/${encodeURIComponent(itemId)}`,
205+
{
206+
headers: { Authorization: `Bearer ${token}`, accept: 'application/json' },
207+
signal: args.signal,
208+
redirect: 'error',
209+
},
210+
{ passthroughStatuses: [404] }
211+
)
212+
if (!result.ok) return detailSelectorResult(null)
213+
if (!result.data || typeof result.data !== 'object') {
214+
throw new SelectorOptionsUnavailableError()
215+
}
216+
const item = projectItem(result.data)
217+
if (!item || item.id !== itemId) throw new SelectorOptionsUnavailableError()
218+
return detailSelectorResult(item)
219+
}
220+
221+
function executeItems(args: ExecuteServerSelectorArgs) {
222+
return args.request.kind === 'detail' ? getItem(args) : listItems(args)
223+
}
224+
140225
export const webflowSelectorAttachments = {
141226
'webflow.sites': {
142227
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.

0 commit comments

Comments
 (0)