Skip to content

Commit 3de10f6

Browse files
committed
fix(selectors): hydrate capped provider details directly
1 parent 9874289 commit 3de10f6

6 files changed

Lines changed: 226 additions & 24 deletions

File tree

apps/sim/lib/selectors/server/providers/monday.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,26 @@ describe('Monday server selector adapter', () => {
5252
items: [{ id: 'board-1', label: 'board-1' }],
5353
})
5454
})
55+
56+
it('hydrates a selected board through a direct ID lookup', async () => {
57+
mockFetch.mockResolvedValueOnce(
58+
new Response(JSON.stringify({ data: { boards: [{ id: '9001', name: 'Direct board' }] } }), {
59+
status: 200,
60+
})
61+
)
62+
63+
await expect(
64+
mondaySelectorAttachments['monday.boards'].execute({
65+
...listArgs(),
66+
request: { kind: 'detail', id: '9001' },
67+
})
68+
).resolves.toEqual({
69+
kind: 'detail',
70+
item: { id: '9001', label: 'Direct board' },
71+
})
72+
const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body)) as { query: string }
73+
expect(body.query).toContain('boards(ids: [9001])')
74+
expect(body.query).not.toContain('limit:')
75+
expect(mockFetch).toHaveBeenCalledTimes(1)
76+
})
5577
})

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import {
77
} from '@/lib/selectors/server/errors'
88
import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results'
99
import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http'
10-
import type {
11-
ExecuteServerSelectorArgs,
12-
ServerSelectorAttachmentMap,
10+
import {
11+
detailSelectorResult,
12+
type ExecuteServerSelectorArgs,
13+
type ServerSelectorAttachmentMap,
1314
} from '@/lib/selectors/server/types'
1415
import type { SafeSelectorOption } from '@/lib/selectors/types'
1516
import { MONDAY_API_URL, mondayHeaders } from '@/tools/monday/utils'
@@ -71,6 +72,28 @@ async function listBoards(args: ExecuteServerSelectorArgs) {
7172
return { items, truncated }
7273
}
7374

75+
async function getBoard(
76+
args: ExecuteServerSelectorArgs,
77+
boardId: string
78+
): Promise<SafeSelectorOption | null> {
79+
const validated = validateMondayNumericId(boardId, 'boardId')
80+
if (!validated.isValid) throw new SelectorContextUnavailableError()
81+
const token = await accessToken(args)
82+
const response = await fetchProviderJson<
83+
MondayResponse<{ boards?: Array<{ id: string; name?: string | null }> }>
84+
>(MONDAY_API_URL, {
85+
method: 'POST',
86+
headers: mondayHeaders(token),
87+
body: JSON.stringify({
88+
query: `{ boards(ids: [${validated.sanitized}]) { id name } }`,
89+
}),
90+
signal: args.signal,
91+
redirect: 'error',
92+
})
93+
const board = requireMondayData(response).boards?.[0]
94+
return board ? { id: boardId, label: board.name?.trim() || board.id } : null
95+
}
96+
7497
async function listGroups(args: ExecuteServerSelectorArgs): Promise<SafeSelectorOption[]> {
7598
const boardId = args.context.boardId
7699
if (!boardId) throw new SelectorContextUnavailableError()
@@ -99,11 +122,14 @@ export const mondaySelectorAttachments = {
99122
credential,
100123
destination: 'fixed',
101124
execute: async (args) => {
125+
if (args.request.kind === 'detail') {
126+
return detailSelectorResult(await getBoard(args, args.request.id))
127+
}
102128
const result = await listBoards(args)
103129
return flatSelectorResult(
104130
args.request,
105131
result.items,
106-
true,
132+
false,
107133
result.truncated ? { truncated: { reason: 'provider-cap', pages: MAX_PAGES } } : undefined
108134
)
109135
},

apps/sim/lib/selectors/server/providers/notion.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('Notion server selector adapter', () => {
4747
object: 'page',
4848
id: 'page-provider-id',
4949
properties: {
50-
title: { title: [{ plain_text: 'Planning' }] },
50+
Name: { type: 'title', title: [{ plain_text: 'Planning' }] },
5151
},
5252
}),
5353
{ status: 200 }
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 { sharepointSelectorAttachments } from '@/lib/selectors/server/providers/sharepoint'
17+
import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types'
18+
19+
function detailArgs(
20+
selectorKey: 'sharepoint.lists' | 'sharepoint.sites',
21+
id: string
22+
): ExecuteServerSelectorArgs {
23+
return {
24+
selectorKey,
25+
context: {
26+
oauthCredential: 'credential-1',
27+
...(selectorKey === 'sharepoint.lists' ? { siteId: 'contoso.sharepoint.com,site,web' } : {}),
28+
},
29+
request: { kind: 'detail', id },
30+
scope: { kind: 'workspace', workspaceId: 'workspace-1' },
31+
workspaceId: 'workspace-1',
32+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
33+
requesterUserId: 'user-1',
34+
credential: { suppliedId: 'credential-1' },
35+
references: new Map(),
36+
protectedValues: createSelectorProtectedValues(),
37+
}
38+
}
39+
40+
describe('SharePoint server selector adapter', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
vi.stubGlobal('fetch', mockFetch)
44+
mockResolveSelectorOAuthAccessToken.mockResolvedValue('server-only-token')
45+
})
46+
47+
afterAll(() => vi.unstubAllGlobals())
48+
49+
it('hydrates a selected list directly within its site', async () => {
50+
mockFetch.mockResolvedValueOnce(
51+
new Response(JSON.stringify({ id: 'list-1', displayName: 'Planning' }), { status: 200 })
52+
)
53+
54+
await expect(
55+
sharepointSelectorAttachments['sharepoint.lists'].execute(
56+
detailArgs('sharepoint.lists', 'list-1')
57+
)
58+
).resolves.toEqual({
59+
kind: 'detail',
60+
item: { id: 'list-1', label: 'Planning' },
61+
})
62+
expect(String(mockFetch.mock.calls[0]?.[0])).toContain(
63+
'/sites/contoso.sharepoint.com%2Csite%2Cweb/lists/list-1'
64+
)
65+
expect(mockFetch).toHaveBeenCalledTimes(1)
66+
})
67+
68+
it('hydrates a selected site directly by its compound ID', async () => {
69+
const siteId = 'contoso.sharepoint.com,site,web'
70+
mockFetch.mockResolvedValueOnce(
71+
new Response(JSON.stringify({ id: siteId, displayName: 'Engineering' }), { status: 200 })
72+
)
73+
74+
await expect(
75+
sharepointSelectorAttachments['sharepoint.sites'].execute(
76+
detailArgs('sharepoint.sites', siteId)
77+
)
78+
).resolves.toEqual({
79+
kind: 'detail',
80+
item: { id: siteId, label: 'Engineering' },
81+
})
82+
expect(String(mockFetch.mock.calls[0]?.[0])).toContain(
83+
'/sites/contoso.sharepoint.com%2Csite%2Cweb'
84+
)
85+
expect(mockFetch).toHaveBeenCalledTimes(1)
86+
})
87+
})

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

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1-
import { validateSharePointSiteId } from '@/lib/core/security/input-validation'
1+
import {
2+
validateMicrosoftGraphId,
3+
validateSharePointSiteId,
4+
} from '@/lib/core/security/input-validation'
25
import type { ServerSelectorKey } from '@/lib/selectors/manifest'
36
import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials'
47
import {
58
SelectorConnectionUnavailableError,
69
SelectorContextUnavailableError,
10+
SelectorOptionsUnavailableError,
711
} from '@/lib/selectors/server/errors'
812
import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results'
913
import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http'
10-
import type {
11-
ExecuteServerSelectorArgs,
12-
ServerSelectorAttachmentMap,
14+
import {
15+
detailSelectorResult,
16+
type ExecuteServerSelectorArgs,
17+
type ServerSelectorAttachmentMap,
1318
} from '@/lib/selectors/server/types'
19+
import type { SafeSelectorOption } from '@/lib/selectors/types'
1420
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
1521

1622
type SharePointSelectorKey = Extract<ServerSelectorKey, 'sharepoint.lists' | 'sharepoint.sites'>
@@ -58,18 +64,79 @@ async function drainGraph<T>(
5864
return { values, truncated: Boolean(nextUrl) }
5965
}
6066

67+
function requireSiteId(value: string | undefined): string {
68+
const validation = validateSharePointSiteId(value)
69+
if (!validation.isValid || !validation.sanitized) {
70+
throw new SelectorContextUnavailableError()
71+
}
72+
return validation.sanitized
73+
}
74+
75+
function requireListId(value: string): string {
76+
const trimmed = value.trim()
77+
const validation = validateMicrosoftGraphId(trimmed, 'listId')
78+
if (!validation.isValid || trimmed.length > 512) {
79+
throw new SelectorContextUnavailableError()
80+
}
81+
return trimmed
82+
}
83+
84+
async function getGraphDetail<T>(args: ExecuteServerSelectorArgs, url: string): Promise<T> {
85+
const token = await graphToken(args)
86+
return fetchProviderJson<T>(url, {
87+
headers: { Authorization: `Bearer ${token}` },
88+
signal: args.signal,
89+
redirect: 'error',
90+
})
91+
}
92+
93+
async function getList(
94+
args: ExecuteServerSelectorArgs,
95+
listId: string
96+
): Promise<SafeSelectorOption> {
97+
const siteId = requireSiteId(args.context.siteId)
98+
const requestedId = requireListId(listId)
99+
const list = await getGraphDetail<{ id?: string; displayName?: string | null }>(
100+
args,
101+
`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${encodeURIComponent(requestedId)}?$select=id,displayName,list`
102+
)
103+
const providerId = typeof list.id === 'string' ? list.id.trim() : ''
104+
const displayName = typeof list.displayName === 'string' ? list.displayName.trim() : ''
105+
const label = displayName || providerId
106+
if (!providerId || !label) throw new SelectorOptionsUnavailableError()
107+
return { id: requestedId, label }
108+
}
109+
110+
async function getSite(
111+
args: ExecuteServerSelectorArgs,
112+
siteId: string
113+
): Promise<SafeSelectorOption> {
114+
const requestedId = requireSiteId(siteId)
115+
const site = await getGraphDetail<{
116+
id?: string
117+
name?: string | null
118+
displayName?: string | null
119+
}>(
120+
args,
121+
`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(requestedId)}?$select=id,name,displayName,webUrl`
122+
)
123+
const providerId = typeof site.id === 'string' ? site.id.trim() : ''
124+
const displayName = typeof site.displayName === 'string' ? site.displayName.trim() : ''
125+
const name = typeof site.name === 'string' ? site.name.trim() : ''
126+
const label = displayName || name || providerId
127+
if (!providerId || !label) throw new SelectorOptionsUnavailableError()
128+
return { id: requestedId, label }
129+
}
130+
61131
async function listLists(args: ExecuteServerSelectorArgs) {
62-
const siteId = args.context.siteId
63-
if (!siteId) throw new SelectorContextUnavailableError()
64-
const validation = validateSharePointSiteId(siteId)
65-
if (!validation.isValid) throw new SelectorContextUnavailableError()
132+
const siteId = requireSiteId(args.context.siteId)
66133
const result = await drainGraph<{
67134
id: string
68135
displayName: string
69136
list?: { hidden?: boolean }
70137
}>(
71138
args,
72-
`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(validation.sanitized ?? siteId)}/lists?$select=id,displayName,description,webUrl,list&$top=999`
139+
`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists?$select=id,displayName,description,webUrl,list&$top=999`
73140
)
74141
return {
75142
items: result.values
@@ -95,11 +162,14 @@ export const sharepointSelectorAttachments = {
95162
credential: sharepointCredential,
96163
destination: 'fixed',
97164
execute: async (args) => {
165+
if (args.request.kind === 'detail') {
166+
return detailSelectorResult(await getList(args, args.request.id))
167+
}
98168
const result = await listLists(args)
99169
return flatSelectorResult(
100170
args.request,
101171
result.items,
102-
true,
172+
false,
103173
result.truncated
104174
? { truncated: { reason: 'provider-cap', pages: MAX_GRAPH_PAGES } }
105175
: undefined
@@ -110,11 +180,14 @@ export const sharepointSelectorAttachments = {
110180
credential: siteCredential,
111181
destination: 'fixed',
112182
execute: async (args) => {
183+
if (args.request.kind === 'detail') {
184+
return detailSelectorResult(await getSite(args, args.request.id))
185+
}
113186
const result = await listSites(args)
114187
return flatSelectorResult(
115188
args.request,
116189
result.items,
117-
true,
190+
false,
118191
result.truncated
119192
? { truncated: { reason: 'provider-cap', pages: MAX_GRAPH_PAGES } }
120193
: undefined

apps/sim/tools/notion/utils.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,10 @@ export function extractTitle(properties: Record<string, any>): string {
5252

5353
export function extractTitleFromItem(item: any): string {
5454
if (item.object === 'page') {
55-
// For pages, check properties first
56-
if (item.properties?.title?.title && Array.isArray(item.properties.title.title)) {
57-
const title = item.properties.title.title.map((t: any) => t.plain_text || '').join('')
58-
if (title) return title
59-
}
60-
// Fallback to page title
61-
return item.title || 'Untitled Page'
55+
const title = item.properties ? extractTitle(item.properties) : ''
56+
return title || item.title || 'Untitled Page'
6257
}
6358
if (item.object === 'database') {
64-
// For databases, get title from title array
6559
if (item.title && Array.isArray(item.title)) {
6660
return item.title.map((t: any) => t.plain_text || '').join('') || 'Untitled Database'
6761
}

0 commit comments

Comments
 (0)