chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import { requestJson } from '@/lib/api/client/request'
import { oauthTokenContract } from '@/lib/api/contracts/selectors'
export interface OAuthTokenBundle {
accessToken: string
cloudId?: string
domain?: string
}
/**
* Returns the access token plus any provider-specific extras (e.g. `cloudId` for
* Atlassian service accounts whose tokens cannot call api.atlassian.com/oauth/token/accessible-resources).
*/
export async function fetchOAuthToken(
credentialId: string,
workflowId?: string
): Promise<OAuthTokenBundle | null> {
if (!credentialId) return null
const token = await requestJson(oauthTokenContract, {
body: { credentialId, workflowId },
})
if (!token.accessToken) return null
return {
accessToken: token.accessToken,
cloudId: token.cloudId,
domain: token.domain,
}
}
@@ -0,0 +1,90 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const airtableSelectors = {
'airtable.bases': {
key: 'airtable.bases',
contracts: [selectorContracts.airtableBasesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'airtable.bases',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'airtable.bases')
const data = await requestJson(selectorContracts.airtableBasesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.bases || []).map((base) => ({
id: base.id,
label: base.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'airtable.bases')
const data = await requestJson(selectorContracts.airtableBasesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
baseId: detailId,
},
signal,
})
const base = (data.bases || []).find((b) => b.id === detailId) ?? null
if (!base) return null
return { id: base.id, label: base.name }
},
},
'airtable.tables': {
key: 'airtable.tables',
contracts: [selectorContracts.airtableTablesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'airtable.tables',
context.oauthCredential ?? 'none',
context.baseId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.baseId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'airtable.tables')
if (!context.baseId) {
throw new Error('Missing base ID for airtable.tables selector')
}
const data = await requestJson(selectorContracts.airtableTablesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
baseId: context.baseId,
},
signal,
})
return (data.tables || []).map((table) => ({
id: table.id,
label: table.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'airtable.tables')
if (!context.baseId) return null
const data = await requestJson(selectorContracts.airtableTablesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
baseId: context.baseId,
},
signal,
})
const table = (data.tables || []).find((t) => t.id === detailId) ?? null
if (!table) return null
return { id: table.id, label: table.name }
},
},
} satisfies Record<Extract<SelectorKey, 'airtable.bases' | 'airtable.tables'>, SelectorDefinition>
@@ -0,0 +1,37 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const asanaSelectors = {
'asana.workspaces': {
key: 'asana.workspaces',
contracts: [selectorContracts.asanaWorkspacesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'asana.workspaces',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'asana.workspaces')
const data = await requestJson(selectorContracts.asanaWorkspacesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.workspaces || []).map((ws) => ({ id: ws.id, label: ws.name }))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'asana.workspaces')
const data = await requestJson(selectorContracts.asanaWorkspacesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const ws = (data.workspaces || []).find((w) => w.id === detailId) ?? null
if (!ws) return null
return { id: ws.id, label: ws.name }
},
},
} satisfies Record<Extract<SelectorKey, 'asana.workspaces'>, SelectorDefinition>
@@ -0,0 +1,73 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const attioSelectors = {
'attio.objects': {
key: 'attio.objects',
contracts: [selectorContracts.attioObjectsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'attio.objects',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'attio.objects')
const data = await requestJson(selectorContracts.attioObjectsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.objects || []).map((obj) => ({
id: obj.id,
label: obj.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'attio.objects')
const data = await requestJson(selectorContracts.attioObjectsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const obj = (data.objects || []).find((o) => o.id === detailId) ?? null
if (!obj) return null
return { id: obj.id, label: obj.name }
},
},
'attio.lists': {
key: 'attio.lists',
contracts: [selectorContracts.attioListsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'attio.lists',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'attio.lists')
const data = await requestJson(selectorContracts.attioListsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.lists || []).map((list) => ({
id: list.id,
label: list.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'attio.lists')
const data = await requestJson(selectorContracts.attioListsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const list = (data.lists || []).find((l) => l.id === detailId) ?? null
if (!list) return null
return { id: list.id, label: list.name }
},
},
} satisfies Record<Extract<SelectorKey, 'attio.objects' | 'attio.lists'>, SelectorDefinition>
@@ -0,0 +1,111 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const bigquerySelectors = {
'bigquery.datasets': {
key: 'bigquery.datasets',
contracts: [selectorContracts.bigQueryDatasetsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bigquery.datasets',
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.projectId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'bigquery.datasets')
if (!context.projectId) throw new Error('Missing project ID for bigquery.datasets selector')
const data = await requestJson(selectorContracts.bigQueryDatasetsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
projectId: context.projectId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
return (data.datasets || []).map((ds) => ({
id: ds.datasetReference.datasetId,
label: ds.friendlyName || ds.datasetReference.datasetId,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId || !context.projectId) return null
const credentialId = ensureCredential(context, 'bigquery.datasets')
const data = await requestJson(selectorContracts.bigQueryDatasetsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
projectId: context.projectId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
const ds =
(data.datasets || []).find((d) => d.datasetReference.datasetId === detailId) ?? null
if (!ds) return null
return {
id: ds.datasetReference.datasetId,
label: ds.friendlyName || ds.datasetReference.datasetId,
}
},
},
'bigquery.tables': {
key: 'bigquery.tables',
contracts: [selectorContracts.bigQueryTablesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bigquery.tables',
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
context.datasetId ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.oauthCredential && context.projectId && context.datasetId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'bigquery.tables')
if (!context.projectId) throw new Error('Missing project ID for bigquery.tables selector')
if (!context.datasetId) throw new Error('Missing dataset ID for bigquery.tables selector')
const data = await requestJson(selectorContracts.bigQueryTablesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
projectId: context.projectId,
datasetId: context.datasetId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
return (data.tables || []).map((t) => ({
id: t.tableReference.tableId,
label: t.friendlyName || t.tableReference.tableId,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId || !context.projectId || !context.datasetId) return null
const credentialId = ensureCredential(context, 'bigquery.tables')
const data = await requestJson(selectorContracts.bigQueryTablesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
projectId: context.projectId,
datasetId: context.datasetId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
const t = (data.tables || []).find((tbl) => tbl.tableReference.tableId === detailId) ?? null
if (!t) return null
return { id: t.tableReference.tableId, label: t.friendlyName || t.tableReference.tableId }
},
},
} satisfies Record<
Extract<SelectorKey, 'bigquery.datasets' | 'bigquery.tables'>,
SelectorDefinition
>
@@ -0,0 +1,76 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const calcomSelectors = {
'calcom.eventTypes': {
key: 'calcom.eventTypes',
contracts: [selectorContracts.calcomEventTypesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'calcom.eventTypes',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'calcom.eventTypes')
const data = await requestJson(selectorContracts.calcomEventTypesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.eventTypes || []).map((et) => ({
id: et.id,
label: et.title || et.slug,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'calcom.eventTypes')
const data = await requestJson(selectorContracts.calcomEventTypesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const et = (data.eventTypes || []).find((e) => e.id === detailId) ?? null
if (!et) return null
return { id: et.id, label: et.title || et.slug }
},
},
'calcom.schedules': {
key: 'calcom.schedules',
contracts: [selectorContracts.calcomSchedulesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'calcom.schedules',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'calcom.schedules')
const data = await requestJson(selectorContracts.calcomSchedulesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.schedules || []).map((s) => ({
id: s.id,
label: s.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'calcom.schedules')
const data = await requestJson(selectorContracts.calcomSchedulesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const s = (data.schedules || []).find((sc) => sc.id === detailId) ?? null
if (!s) return null
return { id: s.id, label: s.name }
},
},
} satisfies Record<
Extract<SelectorKey, 'calcom.eventTypes' | 'calcom.schedules'>,
SelectorDefinition
>
@@ -0,0 +1,94 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
function ensureAwsSelectorCredentials(context: SelectorQueryArgs['context'], key: SelectorKey) {
if (!context.awsAccessKeyId || !context.awsSecretAccessKey || !context.awsRegion) {
throw new Error(`Missing AWS credentials for selector ${key}`)
}
return {
accessKeyId: context.awsAccessKeyId,
secretAccessKey: context.awsSecretAccessKey,
region: context.awsRegion,
}
}
export const cloudwatchSelectors = {
'cloudwatch.logGroups': {
key: 'cloudwatch.logGroups',
contracts: [selectorContracts.cloudwatchLogGroupsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'cloudwatch.logGroups',
context.awsAccessKeyId ?? 'none',
context.awsRegion ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.awsAccessKeyId && context.awsSecretAccessKey && context.awsRegion),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const awsCredentials = ensureAwsSelectorCredentials(context, 'cloudwatch.logGroups')
const data = await requestJson(selectorContracts.cloudwatchLogGroupsSelectorContract, {
body: {
...awsCredentials,
prefix: search,
},
signal,
})
return (data.output?.logGroups || []).map((lg) => ({
id: lg.logGroupName,
label: lg.logGroupName,
}))
},
fetchById: async ({ detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
return { id: detailId, label: detailId }
},
},
'cloudwatch.logStreams': {
key: 'cloudwatch.logStreams',
contracts: [selectorContracts.cloudwatchLogStreamsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'cloudwatch.logStreams',
context.awsAccessKeyId ?? 'none',
context.awsRegion ?? 'none',
context.logGroupName ?? 'none',
],
enabled: ({ context }) =>
Boolean(
context.awsAccessKeyId &&
context.awsSecretAccessKey &&
context.awsRegion &&
context.logGroupName
),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const awsCredentials = ensureAwsSelectorCredentials(context, 'cloudwatch.logStreams')
if (!context.logGroupName) {
throw new Error('Missing log group name for cloudwatch.logStreams selector')
}
const data = await requestJson(selectorContracts.cloudwatchLogStreamsSelectorContract, {
body: {
...awsCredentials,
logGroupName: context.logGroupName,
prefix: search,
},
signal,
})
return (data.output?.logStreams || []).map((ls) => ({
id: ls.logStreamName,
label: ls.logStreamName,
}))
},
fetchById: async ({ detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
return { id: detailId, label: detailId }
},
},
} satisfies Record<
Extract<SelectorKey, 'cloudwatch.logGroups' | 'cloudwatch.logStreams'>,
SelectorDefinition
>
@@ -0,0 +1,134 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { fetchOAuthToken } from '@/hooks/selectors/helpers'
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
function formatConfluenceSpaceLabel(space: { name: string; key: string; status?: string }): string {
const base = `${space.name} (${space.key})`
return space.status === 'archived' ? `${base} — archived` : base
}
function toSpaceOption(space: { name: string; key: string; status?: string }): {
id: string
label: string
} {
return { id: space.key, label: formatConfluenceSpaceLabel(space) }
}
export const confluenceSelectors = {
'confluence.spaces': {
key: 'confluence.spaces',
contracts: [selectorContracts.confluenceSpacesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'confluence.spaces',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
/**
* Drives pagination through {@link useSelectorOptions}, which drains every
* page via this callback. No `fetchList` — the paged path supersedes it.
*/
fetchPage: async ({ context, cursor, signal }) => {
const credentialId = ensureCredential(context, 'confluence.spaces')
const domain = ensureDomain(context, 'confluence.spaces')
const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
domain,
cursor,
},
signal,
})
return {
items: (data.spaces || []).map(toSpaceOption),
nextCursor: data.nextCursor,
}
},
/**
* Resolves a single space label. Hits only the first page — the dropdown's
* `fetchPage` stream populates the options cache for spaces beyond page 1,
* and `useSelectorOptionMap` merges them in. Walking all pages here would
* double API load since the stream is already running in parallel.
*/
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'confluence.spaces')
const domain = ensureDomain(context, 'confluence.spaces')
const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
domain,
},
signal,
})
const space = (data.spaces || []).find((s) => s.key === detailId) ?? null
if (!space) return null
return toSpaceOption(space)
},
},
'confluence.pages': {
key: 'confluence.pages',
contracts: [
selectorContracts.confluencePagesSelectorContract,
selectorContracts.confluencePageSelectorContract,
],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'confluence.pages',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Confluence access token')
}
const data = await requestJson(selectorContracts.confluencePagesSelectorContract, {
body: {
domain,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
title: search,
},
signal,
})
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Confluence access token')
}
const data = await requestJson(selectorContracts.confluencePageSelectorContract, {
body: {
domain,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
pageId: detailId,
},
signal,
})
return { id: data.id, label: data.title }
},
},
} satisfies Record<
Extract<SelectorKey, 'confluence.spaces' | 'confluence.pages'>,
SelectorDefinition
>
@@ -0,0 +1,188 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const googleSelectors = {
'google.tasks.lists': {
key: 'google.tasks.lists',
contracts: [selectorContracts.googleTasksTaskListsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.tasks.lists',
context.oauthCredential ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.tasks.lists')
const data = await requestJson(selectorContracts.googleTasksTaskListsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
return (data.taskLists || []).map((tl) => ({ id: tl.id, label: tl.title }))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'google.tasks.lists')
const data = await requestJson(selectorContracts.googleTasksTaskListsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
const tl = (data.taskLists || []).find((t) => t.id === detailId) ?? null
if (!tl) return null
return { id: tl.id, label: tl.title }
},
},
'gmail.labels': {
key: 'gmail.labels',
contracts: [selectorContracts.gmailLabelsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'gmail.labels',
context.oauthCredential ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'gmail.labels')
const data = await requestJson(selectorContracts.gmailLabelsSelectorContract, {
query: {
credentialId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
return (data.labels || []).map((label) => ({
id: label.id,
label: label.name,
}))
},
},
'google.calendar': {
key: 'google.calendar',
contracts: [selectorContracts.googleCalendarSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.calendar',
context.oauthCredential ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.calendar')
const data = await requestJson(selectorContracts.googleCalendarSelectorContract, {
query: {
credentialId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
return (data.calendars || []).map((calendar) => ({
id: calendar.id,
label: calendar.summary,
}))
},
},
'google.drive': {
key: 'google.drive',
contracts: [
selectorContracts.googleDriveFilesSelectorContract,
selectorContracts.googleDriveFileSelectorContract,
],
staleTime: 15 * 1000,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'google.drive',
context.oauthCredential ?? 'none',
context.mimeType ?? 'any',
context.fileId ?? 'root',
search ?? '',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.drive')
const data = await requestJson(selectorContracts.googleDriveFilesSelectorContract, {
query: {
credentialId,
mimeType: context.mimeType,
parentId: context.fileId,
query: search,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'google.drive')
const data = await requestJson(selectorContracts.googleDriveFileSelectorContract, {
query: {
credentialId,
fileId: detailId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
const file = data.file
if (!file) return null
return { id: file.id, label: file.name }
},
},
'google.sheets': {
key: 'google.sheets',
contracts: [selectorContracts.googleSheetsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.sheets',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.sheets')
if (!context.spreadsheetId) {
throw new Error('Missing spreadsheet ID for google.sheets selector')
}
const data = await requestJson(selectorContracts.googleSheetsSelectorContract, {
query: {
credentialId,
spreadsheetId: context.spreadsheetId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
signal,
})
return (data.sheets || []).map((sheet) => ({
id: sheet.id,
label: sheet.name,
}))
},
},
} satisfies Record<
Extract<
SelectorKey,
'google.tasks.lists' | 'gmail.labels' | 'google.calendar' | 'google.drive' | 'google.sheets'
>,
SelectorDefinition
>
@@ -0,0 +1,134 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { fetchOAuthToken } from '@/hooks/selectors/helpers'
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const jiraSelectors = {
'jira.projects': {
key: 'jira.projects',
contracts: [
selectorContracts.jiraProjectsSelectorContract,
selectorContracts.jiraProjectSelectorContract,
],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.projects',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraProjectsSelectorContract, {
query: {
domain,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
query: search,
},
signal,
})
return (data.projects || []).map((project) => ({
id: project.id,
label: project.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraProjectSelectorContract, {
body: {
domain,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
projectId: detailId,
},
signal,
})
if (!data.project) return null
return {
id: data.project.id,
label: data.project.name,
}
},
},
'jira.issues': {
key: 'jira.issues',
contracts: [
selectorContracts.jiraIssuesSelectorContract,
selectorContracts.jiraIssueSelectorContract,
],
staleTime: 15 * 1000,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.issues',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
context.projectId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraIssuesSelectorContract, {
query: {
domain,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
projectId: context.projectId,
query: search,
},
signal,
})
const issues =
data.sections?.flatMap((section) =>
(section.issues || []).map((issue) => ({
id: issue.id || issue.key || '',
name: issue.summary || issue.key || '',
}))
) || []
return issues
.filter((issue) => issue.id)
.map((issue) => ({ id: issue.id, label: issue.name || issue.id }))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraIssueSelectorContract, {
body: {
domain,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
issueKeys: [detailId],
},
signal,
})
const issue = data.issues?.[0]
if (!issue) return null
return { id: issue.id, label: issue.name }
},
},
} satisfies Record<Extract<SelectorKey, 'jira.projects' | 'jira.issues'>, SelectorDefinition>
@@ -0,0 +1,104 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const jsmSelectors = {
'jsm.serviceDesks': {
key: 'jsm.serviceDesks',
contracts: [selectorContracts.jsmServiceDesksSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'jsm.serviceDesks',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jsm.serviceDesks')
const domain = ensureDomain(context, 'jsm.serviceDesks')
const data = await requestJson(selectorContracts.jsmServiceDesksSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
domain,
},
signal,
})
return (data.serviceDesks || []).map((sd) => ({
id: sd.id,
label: sd.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jsm.serviceDesks')
const domain = ensureDomain(context, 'jsm.serviceDesks')
const data = await requestJson(selectorContracts.jsmServiceDesksSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
domain,
},
signal,
})
const sd = (data.serviceDesks || []).find((s) => s.id === detailId) ?? null
if (!sd) return null
return { id: sd.id, label: sd.name }
},
},
'jsm.requestTypes': {
key: 'jsm.requestTypes',
contracts: [selectorContracts.jsmRequestTypesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'jsm.requestTypes',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
context.serviceDeskId ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.oauthCredential && context.domain && context.serviceDeskId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jsm.requestTypes')
const domain = ensureDomain(context, 'jsm.requestTypes')
if (!context.serviceDeskId) throw new Error('Missing serviceDeskId for jsm.requestTypes')
const data = await requestJson(selectorContracts.jsmRequestTypesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
domain,
serviceDeskId: context.serviceDeskId,
},
signal,
})
return (data.requestTypes || []).map((rt) => ({
id: rt.id,
label: rt.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jsm.requestTypes')
const domain = ensureDomain(context, 'jsm.requestTypes')
if (!context.serviceDeskId) return null
const data = await requestJson(selectorContracts.jsmRequestTypesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
domain,
serviceDeskId: context.serviceDeskId,
},
signal,
})
const rt = (data.requestTypes || []).find((r) => r.id === detailId) ?? null
if (!rt) return null
return { id: rt.id, label: rt.name }
},
},
} satisfies Record<
Extract<SelectorKey, 'jsm.serviceDesks' | 'jsm.requestTypes'>,
SelectorDefinition
>
@@ -0,0 +1,61 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureKnowledgeBase, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
const KNOWLEDGE_DOCUMENTS_PAGE_LIMIT = 100
export const knowledgeSelectors = {
'knowledge.documents': {
key: 'knowledge.documents',
contracts: [
selectorContracts.listKnowledgeSelectorDocumentsContract,
selectorContracts.getKnowledgeSelectorDocumentContract,
],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'knowledge.documents',
context.knowledgeBaseId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.knowledgeBaseId),
/**
* Drives pagination through {@link useSelectorOptions}, which drains every
* page via this callback. The `pagination.hasMore` flag from the route
* decides when to stop; `nextCursor` encodes the next `offset`.
*/
fetchPage: async ({ context, search, cursor, signal }) => {
const knowledgeBaseId = ensureKnowledgeBase(context)
const offset = cursor ? Number(cursor) : 0
const result = await requestJson(selectorContracts.listKnowledgeSelectorDocumentsContract, {
params: { id: knowledgeBaseId },
query: {
limit: KNOWLEDGE_DOCUMENTS_PAGE_LIMIT,
offset,
search,
},
signal,
})
const { pagination } = result.data
const nextOffset = pagination.offset + pagination.limit
return {
items: result.data.documents.map((doc) => ({
id: doc.id,
label: doc.filename,
})),
nextCursor: pagination.hasMore ? String(nextOffset) : undefined,
}
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const knowledgeBaseId = ensureKnowledgeBase(context)
const result = await requestJson(selectorContracts.getKnowledgeSelectorDocumentContract, {
params: { id: knowledgeBaseId, documentId: detailId },
query: { includeDisabled: 'true' },
signal,
})
return { id: result.data.id, label: result.data.filename }
},
},
} satisfies Record<Extract<SelectorKey, 'knowledge.documents'>, SelectorDefinition>
@@ -0,0 +1,59 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const linearSelectors = {
'linear.teams': {
key: 'linear.teams',
contracts: [selectorContracts.linearTeamsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.teams',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.teams')
const data = await requestJson(selectorContracts.linearTeamsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.teams || []).map((team) => ({
id: team.id,
label: team.name,
}))
},
},
'linear.projects': {
key: 'linear.projects',
contracts: [selectorContracts.linearProjectsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.projects',
context.oauthCredential ?? 'none',
context.teamId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.projects')
if (!context.teamId) {
throw new Error('Missing team ID for linear.projects selector')
}
const data = await requestJson(selectorContracts.linearProjectsSelectorContract, {
body: {
credential: credentialId,
teamId: context.teamId,
workflowId: context.workflowId,
},
signal,
})
return (data.projects || []).map((project) => ({
id: project.id,
label: project.name,
}))
},
},
} satisfies Record<Extract<SelectorKey, 'linear.teams' | 'linear.projects'>, SelectorDefinition>
@@ -0,0 +1,378 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const microsoftSelectors = {
'microsoft.planner.plans': {
key: 'microsoft.planner.plans',
contracts: [selectorContracts.microsoftPlannerPlansSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.planner.plans',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.planner.plans')
const data = await requestJson(selectorContracts.microsoftPlannerPlansSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.plans || []).map((plan) => ({ id: plan.id, label: plan.title }))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'microsoft.planner.plans')
const data = await requestJson(selectorContracts.microsoftPlannerPlansSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const plan = (data.plans || []).find((p) => p.id === detailId) ?? null
if (!plan) return null
return { id: plan.id, label: plan.title }
},
},
'outlook.folders': {
key: 'outlook.folders',
contracts: [selectorContracts.outlookFoldersSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'outlook.folders',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'outlook.folders')
const data = await requestJson(selectorContracts.outlookFoldersSelectorContract, {
query: { credentialId },
signal,
})
return (data.folders || []).map((folder) => ({
id: folder.id,
label: folder.name,
}))
},
},
'microsoft.teams': {
key: 'microsoft.teams',
contracts: [selectorContracts.microsoftTeamsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.teams',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.teams')
const data = await requestJson(selectorContracts.microsoftTeamsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.teams || []).map((team) => ({
id: team.id,
label: team.displayName,
}))
},
},
'microsoft.chats': {
key: 'microsoft.chats',
contracts: [selectorContracts.microsoftChatsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.chats',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.chats')
const data = await requestJson(selectorContracts.microsoftChatsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.chats || []).map((chat) => ({
id: chat.id,
label: chat.displayName,
}))
},
},
'microsoft.channels': {
key: 'microsoft.channels',
contracts: [selectorContracts.microsoftChannelsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.channels',
context.oauthCredential ?? 'none',
context.teamId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.channels')
if (!context.teamId) {
throw new Error('Missing team ID for microsoft.channels selector')
}
const data = await requestJson(selectorContracts.microsoftChannelsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
teamId: context.teamId,
},
signal,
})
return (data.channels || []).map((channel) => ({
id: channel.id,
label: channel.displayName,
}))
},
},
'microsoft.planner': {
key: 'microsoft.planner',
contracts: [selectorContracts.microsoftPlannerTasksSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.planner',
context.oauthCredential ?? 'none',
context.planId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.planId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.planner')
if (!context.planId) {
throw new Error('Missing plan ID for microsoft.planner selector')
}
const data = await requestJson(selectorContracts.microsoftPlannerTasksSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
planId: context.planId,
},
signal,
})
return (data.tasks || []).map((task) => ({
id: task.id,
label: task.title,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId || !context.planId) return null
const credentialId = ensureCredential(context, 'microsoft.planner')
const data = await requestJson(selectorContracts.microsoftPlannerTasksSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
planId: context.planId,
},
signal,
})
const task = (data.tasks || []).find((t) => t.id === detailId) ?? null
if (!task) return null
return { id: task.id, label: task.title }
},
},
'onedrive.files': {
key: 'onedrive.files',
contracts: [selectorContracts.onedriveFilesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.files',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.files')
const data = await requestJson(selectorContracts.onedriveFilesSelectorContract, {
query: { credentialId },
signal,
})
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'onedrive.folders': {
key: 'onedrive.folders',
contracts: [selectorContracts.onedriveFoldersSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.folders',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.folders')
const data = await requestJson(selectorContracts.onedriveFoldersSelectorContract, {
query: { credentialId },
signal,
})
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'microsoft.excel.sheets': {
key: 'microsoft.excel.sheets',
contracts: [selectorContracts.microsoftExcelSheetsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel.sheets',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
context.driveId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel.sheets')
if (!context.spreadsheetId) {
throw new Error('Missing spreadsheet ID for microsoft.excel.sheets selector')
}
const data = await requestJson(selectorContracts.microsoftExcelSheetsSelectorContract, {
query: {
credentialId,
spreadsheetId: context.spreadsheetId,
driveId: context.driveId,
workflowId: context.workflowId,
},
signal,
})
return (data.sheets || []).map((sheet) => ({
id: sheet.id,
label: sheet.name,
}))
},
},
'microsoft.excel.drives': {
key: 'microsoft.excel.drives',
contracts: [
selectorContracts.microsoftExcelDrivesSelectorContract,
selectorContracts.microsoftExcelDriveSelectorContract,
],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel.drives',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel.drives')
if (!context.siteId) {
throw new Error('Missing site ID for microsoft.excel.drives selector')
}
const data = await requestJson(selectorContracts.microsoftExcelDrivesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
siteId: context.siteId,
},
signal,
})
return data.drives.map((drive) => ({
id: drive.id,
label: drive.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId || !context.siteId) return null
const credentialId = ensureCredential(context, 'microsoft.excel.drives')
const data = await requestJson(selectorContracts.microsoftExcelDriveSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
siteId: context.siteId,
driveId: detailId,
},
signal,
})
const { drive } = data
if (!drive) return null
return { id: drive.id, label: drive.name }
},
},
'microsoft.excel': {
key: 'microsoft.excel',
contracts: [selectorContracts.microsoftFilesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel',
context.oauthCredential ?? 'none',
context.driveId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel')
const data = await requestJson(selectorContracts.microsoftFilesSelectorContract, {
query: {
credentialId,
query: search,
driveId: context.driveId,
workflowId: context.workflowId,
fileType: 'excel',
},
signal,
})
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'microsoft.word': {
key: 'microsoft.word',
contracts: [selectorContracts.microsoftFilesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.word',
context.oauthCredential ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.word')
const data = await requestJson(selectorContracts.microsoftFilesSelectorContract, {
query: {
credentialId,
query: search,
workflowId: context.workflowId,
fileType: 'word',
},
signal,
})
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
} satisfies Record<
Extract<
SelectorKey,
| 'microsoft.planner.plans'
| 'outlook.folders'
| 'microsoft.teams'
| 'microsoft.chats'
| 'microsoft.channels'
| 'microsoft.planner'
| 'onedrive.files'
| 'onedrive.folders'
| 'microsoft.excel.sheets'
| 'microsoft.excel.drives'
| 'microsoft.excel'
| 'microsoft.word'
>,
SelectorDefinition
>
@@ -0,0 +1,86 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const mondaySelectors = {
'monday.boards': {
key: 'monday.boards',
contracts: [selectorContracts.mondayBoardsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'monday.boards',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'monday.boards')
const data = await requestJson(selectorContracts.mondayBoardsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.boards || []).map((board) => ({
id: board.id,
label: board.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'monday.boards')
const data = await requestJson(selectorContracts.mondayBoardsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const board = (data.boards || []).find((b) => b.id === detailId) ?? null
if (!board) return null
return { id: board.id, label: board.name }
},
},
'monday.groups': {
key: 'monday.groups',
contracts: [selectorContracts.mondayGroupsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'monday.groups',
context.oauthCredential ?? 'none',
context.boardId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.boardId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'monday.groups')
if (!context.boardId) {
throw new Error('Missing board ID for monday.groups selector')
}
const data = await requestJson(selectorContracts.mondayGroupsSelectorContract, {
body: {
credential: credentialId,
boardId: context.boardId,
workflowId: context.workflowId,
},
signal,
})
return (data.groups || []).map((group) => ({
id: group.id,
label: group.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'monday.groups')
if (!context.boardId) return null
const data = await requestJson(selectorContracts.mondayGroupsSelectorContract, {
body: {
credential: credentialId,
boardId: context.boardId,
workflowId: context.workflowId,
},
signal,
})
const group = (data.groups || []).find((g) => g.id === detailId) ?? null
if (!group) return null
return { id: group.id, label: group.name }
},
},
} satisfies Record<Extract<SelectorKey, 'monday.boards' | 'monday.groups'>, SelectorDefinition>
@@ -0,0 +1,73 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const notionSelectors = {
'notion.databases': {
key: 'notion.databases',
contracts: [selectorContracts.notionDatabasesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'notion.databases',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'notion.databases')
const data = await requestJson(selectorContracts.notionDatabasesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.databases || []).map((db) => ({
id: db.id,
label: db.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'notion.databases')
const data = await requestJson(selectorContracts.notionDatabasesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const db = (data.databases || []).find((d) => d.id === detailId) ?? null
if (!db) return null
return { id: db.id, label: db.name }
},
},
'notion.pages': {
key: 'notion.pages',
contracts: [selectorContracts.notionPagesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'notion.pages',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'notion.pages')
const data = await requestJson(selectorContracts.notionPagesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.pages || []).map((page) => ({
id: page.id,
label: page.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'notion.pages')
const data = await requestJson(selectorContracts.notionPagesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const page = (data.pages || []).find((p) => p.id === detailId) ?? null
if (!page) return null
return { id: page.id, label: page.name }
},
},
} satisfies Record<Extract<SelectorKey, 'notion.databases' | 'notion.pages'>, SelectorDefinition>
@@ -0,0 +1,40 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const pipedriveSelectors = {
'pipedrive.pipelines': {
key: 'pipedrive.pipelines',
contracts: [selectorContracts.pipedrivePipelinesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'pipedrive.pipelines',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'pipedrive.pipelines')
const data = await requestJson(selectorContracts.pipedrivePipelinesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.pipelines || []).map((p) => ({
id: p.id,
label: p.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'pipedrive.pipelines')
const data = await requestJson(selectorContracts.pipedrivePipelinesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const p = (data.pipelines || []).find((pl) => pl.id === detailId) ?? null
if (!p) return null
return { id: p.id, label: p.name }
},
},
} satisfies Record<Extract<SelectorKey, 'pipedrive.pipelines'>, SelectorDefinition>
@@ -0,0 +1,24 @@
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
export const SELECTOR_STALE = 60 * 1000
export const ensureCredential = (context: SelectorContext, key: SelectorKey): string => {
if (!context.oauthCredential) {
throw new Error(`Missing credential for selector ${key}`)
}
return context.oauthCredential
}
export const ensureDomain = (context: SelectorContext, key: SelectorKey): string => {
if (!context.domain) {
throw new Error(`Missing domain for selector ${key}`)
}
return context.domain
}
export const ensureKnowledgeBase = (context: SelectorContext): string => {
if (!context.knowledgeBaseId) {
throw new Error('Missing knowledge base id')
}
return context.knowledgeBaseId
}
@@ -0,0 +1,89 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const sharepointSelectors = {
'sharepoint.lists': {
key: 'sharepoint.lists',
contracts: [selectorContracts.sharepointListsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sharepoint.lists',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'sharepoint.lists')
if (!context.siteId) throw new Error('Missing site ID for sharepoint.lists selector')
const data = await requestJson(selectorContracts.sharepointListsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
siteId: context.siteId,
},
signal,
})
return (data.lists || []).map((list) => ({ id: list.id, label: list.displayName }))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId || !context.siteId) return null
const credentialId = ensureCredential(context, 'sharepoint.lists')
const data = await requestJson(selectorContracts.sharepointListsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
siteId: context.siteId,
},
signal,
})
const list = (data.lists || []).find((l) => l.id === detailId) ?? null
if (!list) return null
return { id: list.id, label: list.displayName }
},
},
'sharepoint.sites': {
key: 'sharepoint.sites',
contracts: [selectorContracts.sharepointSitesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sharepoint.sites',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'sharepoint.sites')
const data = await requestJson(selectorContracts.sharepointSitesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
},
signal,
})
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'sharepoint.sites')
const data = await requestJson(selectorContracts.sharepointSitesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
},
signal,
})
const site = (data.files || []).find((f) => f.id === detailId) ?? null
if (!site) return null
return { id: site.id, label: site.name }
},
},
} satisfies Record<
Extract<SelectorKey, 'sharepoint.lists' | 'sharepoint.sites'>,
SelectorDefinition
>
@@ -0,0 +1,119 @@
import { getColumnId } from '@/lib/table/column-keys'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { getTableDetailQueryOptions } from '@/hooks/queries/tables'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getFolderPath } from '@/hooks/queries/utils/folder-tree'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import { selectorKeys } from '@/hooks/selectors/query-keys'
import type {
SelectorDefinition,
SelectorKey,
SelectorOption,
SelectorQueryArgs,
} from '@/hooks/selectors/types'
import type { WorkflowFolder } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/**
* Builds a label for a workflow option, appending the folder path when another
* workflow in the workspace shares the same display name. Avoids suffixing
* every workflow so the dropdown stays readable for the common case.
*/
function buildDisambiguatedLabel(
workflow: WorkflowMetadata,
duplicateNames: Set<string>,
folders: Record<string, WorkflowFolder>
): string {
const baseLabel = workflow.name || `Workflow ${workflow.id.slice(0, 8)}`
if (!duplicateNames.has(baseLabel)) return baseLabel
const folderPath = getFolderPath(workflow.folderId, folders)
return folderPath ? `${baseLabel} (${folderPath})` : `${baseLabel} (Root)`
}
function collectDuplicateNames(workflows: WorkflowMetadata[]): Set<string> {
const counts = new Map<string, number>()
for (const workflow of workflows) {
const label = workflow.name || `Workflow ${workflow.id.slice(0, 8)}`
counts.set(label, (counts.get(label) ?? 0) + 1)
}
const duplicates = new Set<string>()
for (const [label, count] of counts) {
if (count > 1) duplicates.add(label)
}
return duplicates
}
export const simSelectors = {
'sim.workflows': {
key: 'sim.workflows',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) =>
context.workspaceId
? selectorKeys.simWorkflows(context.workspaceId, context.excludeWorkflowId)
: [...selectorKeys.all, 'sim.workflows', 'none', context.excludeWorkflowId ?? 'none'],
enabled: ({ context }) => Boolean(context.workspaceId),
fetchList: async ({ context, signal }: SelectorQueryArgs): Promise<SelectorOption[]> => {
if (!context.workspaceId) return []
await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId))
const workflows = getWorkflows(context.workspaceId)
const folders = getFolderMap(context.workspaceId)
const duplicateNames = collectDuplicateNames(workflows)
return workflows
.filter((w) => w.id !== context.excludeWorkflowId)
.map((w) => ({
id: w.id,
label: buildDisambiguatedLabel(w, duplicateNames, folders),
}))
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchById: async ({
context,
detailId,
signal,
}: SelectorQueryArgs): Promise<SelectorOption | null> => {
if (!detailId || !context.workspaceId) return null
await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId))
const workflow = getWorkflowById(context.workspaceId, detailId)
if (!workflow) return null
const workflows = getWorkflows(context.workspaceId)
const folders = getFolderMap(context.workspaceId)
const duplicateNames = collectDuplicateNames(workflows)
return {
id: detailId,
label: buildDisambiguatedLabel(workflow, duplicateNames, folders),
}
},
},
'table.columns': {
key: 'table.columns',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
...selectorKeys.all,
'table.columns',
context.workspaceId ?? 'none',
context.tableId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.workspaceId && context.tableId),
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
if (!context.workspaceId || !context.tableId) return []
const table = await getQueryClient().ensureQueryData(
getTableDetailQueryOptions(context.workspaceId, context.tableId)
)
return (table.schema?.columns ?? [])
.filter((col) => col.unique)
.map((col) => ({ id: getColumnId(col), label: col.name }))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
if (!detailId || !context.workspaceId || !context.tableId) return null
const table = await getQueryClient().ensureQueryData(
getTableDetailQueryOptions(context.workspaceId, context.tableId)
)
const col = (table.schema?.columns ?? []).find((c) => getColumnId(c) === detailId)
return col ? { id: getColumnId(col), label: col.name } : null
},
},
} satisfies Record<Extract<SelectorKey, 'sim.workflows' | 'table.columns'>, SelectorDefinition>
@@ -0,0 +1,57 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const slackSelectors = {
'slack.channels': {
key: 'slack.channels',
contracts: [selectorContracts.slackChannelsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'slack.channels',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'slack.channels')
const data = await requestJson(selectorContracts.slackChannelsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
},
signal,
})
return (data.channels || []).map((channel) => ({
id: channel.id,
label: `#${channel.name}`,
}))
},
},
'slack.users': {
key: 'slack.users',
contracts: [selectorContracts.slackUsersSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'slack.users',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'slack.users')
const data = await requestJson(selectorContracts.slackUsersSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
},
signal,
})
return data.users.map((user) => ({
id: user.id,
label: user.real_name || user.name,
}))
},
},
} satisfies Record<Extract<SelectorKey, 'slack.channels' | 'slack.users'>, SelectorDefinition>
@@ -0,0 +1,39 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const trelloSelectors = {
'trello.boards': {
key: 'trello.boards',
contracts: [selectorContracts.trelloBoardsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'trello.boards',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'trello.boards')
const data = await requestJson(selectorContracts.trelloBoardsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.boards || [])
.filter((board) => !board.closed)
.map((board) => ({ id: board.id, label: board.name }))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'trello.boards')
const data = await requestJson(selectorContracts.trelloBoardsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const board = (data.boards || []).find((b) => b.id === detailId) ?? null
if (!board) return null
return { id: board.id, label: board.name }
},
},
} satisfies Record<Extract<SelectorKey, 'trello.boards'>, SelectorDefinition>
@@ -0,0 +1,30 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const wealthboxSelectors = {
'wealthbox.contacts': {
key: 'wealthbox.contacts',
contracts: [selectorContracts.wealthboxItemsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'wealthbox.contacts',
context.oauthCredential ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'wealthbox.contacts')
const data = await requestJson(selectorContracts.wealthboxItemsSelectorContract, {
query: { credentialId, type: 'contact', query: search ?? '' },
signal,
})
return (data.items || []).map((item) => ({
id: item.id,
label: item.name,
}))
},
},
} satisfies Record<Extract<SelectorKey, 'wealthbox.contacts'>, SelectorDefinition>
@@ -0,0 +1,94 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const webflowSelectors = {
'webflow.sites': {
key: 'webflow.sites',
contracts: [selectorContracts.webflowSitesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'webflow.sites',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.sites')
const data = await requestJson(selectorContracts.webflowSitesSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.sites || []).map((site) => ({
id: site.id,
label: site.name,
}))
},
},
'webflow.collections': {
key: 'webflow.collections',
contracts: [selectorContracts.webflowCollectionsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'webflow.collections',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.collections')
if (!context.siteId) {
throw new Error('Missing site ID for webflow.collections selector')
}
const data = await requestJson(selectorContracts.webflowCollectionsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
siteId: context.siteId,
},
signal,
})
return (data.collections || []).map((collection) => ({
id: collection.id,
label: collection.name,
}))
},
},
'webflow.items': {
key: 'webflow.items',
contracts: [selectorContracts.webflowItemsSelectorContract],
staleTime: 15 * 1000,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'webflow.items',
context.oauthCredential ?? 'none',
context.collectionId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.collectionId),
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.items')
if (!context.collectionId) {
throw new Error('Missing collection ID for webflow.items selector')
}
const data = await requestJson(selectorContracts.webflowItemsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
collectionId: context.collectionId,
search,
},
signal,
})
return (data.items || []).map((item) => ({
id: item.id,
label: item.name,
}))
},
},
} satisfies Record<
Extract<SelectorKey, 'webflow.sites' | 'webflow.collections' | 'webflow.items'>,
SelectorDefinition
>
@@ -0,0 +1,40 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const zoomSelectors = {
'zoom.meetings': {
key: 'zoom.meetings',
contracts: [selectorContracts.zoomMeetingsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'zoom.meetings',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'zoom.meetings')
const data = await requestJson(selectorContracts.zoomMeetingsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
return (data.meetings || []).map((m) => ({
id: m.id,
label: m.name || `Meeting ${m.id}`,
}))
},
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'zoom.meetings')
const data = await requestJson(selectorContracts.zoomMeetingsSelectorContract, {
body: { credential: credentialId, workflowId: context.workflowId },
signal,
})
const meeting = (data.meetings || []).find((m) => m.id === detailId) ?? null
if (!meeting) return null
return { id: meeting.id, label: meeting.name || `Meeting ${meeting.id}` }
},
},
} satisfies Record<Extract<SelectorKey, 'zoom.meetings'>, SelectorDefinition>
+7
View File
@@ -0,0 +1,7 @@
export const selectorKeys = {
all: ['selectors'] as const,
simWorkflowsPrefix: (workspaceId: string) =>
[...selectorKeys.all, 'sim.workflows', workspaceId] as const,
simWorkflows: (workspaceId: string, excludeWorkflowId?: string) =>
[...selectorKeys.simWorkflowsPrefix(workspaceId), excludeWorkflowId ?? 'none'] as const,
}
+150
View File
@@ -0,0 +1,150 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockEnsureQueryData, mockGetWorkflows, mockGetFolderMap } = vi.hoisted(() => ({
mockEnsureQueryData: vi.fn().mockResolvedValue(undefined),
mockGetWorkflows: vi.fn(),
mockGetFolderMap: vi.fn().mockReturnValue({}),
}))
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: vi.fn(() => ({
ensureQueryData: mockEnsureQueryData,
})),
}))
vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
getWorkflows: mockGetWorkflows,
getWorkflowById: vi.fn((workspaceId: string, workflowId: string) =>
mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId)
),
}))
vi.mock('@/hooks/queries/utils/folder-cache', () => ({
getFolderMap: mockGetFolderMap,
}))
vi.mock('@/hooks/queries/utils/workflow-list-query', () => ({
getWorkflowListQueryOptions: vi.fn((workspaceId: string) => ({
queryKey: ['workflows', 'list', workspaceId, 'active'],
})),
}))
import { getSelectorDefinition } from '@/hooks/selectors/registry'
describe('sim.workflows selector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnsureQueryData.mockResolvedValue(undefined)
mockGetWorkflows.mockReturnValue([
{ id: 'wf-1', name: 'Alpha Workflow', folderId: null },
{ id: 'wf-2', name: 'Bravo Workflow', folderId: null },
])
mockGetFolderMap.mockReturnValue({})
})
it('requires an explicit workspaceId in selector context', () => {
const definition = getSelectorDefinition('sim.workflows')
expect(definition.enabled?.({ key: 'sim.workflows', context: {} })).toBe(false)
expect(definition.staleTime).toBe(60_000)
expect(
definition.getQueryKey({
key: 'sim.workflows',
context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' },
})
).toEqual(['selectors', 'sim.workflows', 'ws-1', 'wf-2'])
})
it('reads workflow options from the scoped workflow cache', async () => {
const definition = getSelectorDefinition('sim.workflows')
const options = await definition.fetchList!({
key: 'sim.workflows',
context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' },
})
expect(mockEnsureQueryData).toHaveBeenCalledWith({
queryKey: ['workflows', 'list', 'ws-1', 'active'],
})
expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1')
expect(options).toEqual([{ id: 'wf-1', label: 'Alpha Workflow' }])
})
it('resolves workflow labels by id using the same workspace scope', async () => {
const definition = getSelectorDefinition('sim.workflows')
const option = await definition.fetchById?.({
key: 'sim.workflows',
context: { workspaceId: 'ws-1' },
detailId: 'wf-2',
})
expect(mockEnsureQueryData).toHaveBeenCalledWith({
queryKey: ['workflows', 'list', 'ws-1', 'active'],
})
expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1')
expect(option).toEqual({ id: 'wf-2', label: 'Bravo Workflow' })
})
it('disambiguates duplicate workflow names with their folder path', async () => {
mockGetWorkflows.mockReturnValue([
{ id: 'wf-root', name: 'Pipeline', folderId: null },
{ id: 'wf-eng', name: 'Pipeline', folderId: 'folder-eng' },
{ id: 'wf-eng-backend', name: 'Pipeline', folderId: 'folder-backend' },
{ id: 'wf-unique', name: 'Solo Workflow', folderId: 'folder-eng' },
])
mockGetFolderMap.mockReturnValue({
'folder-eng': {
id: 'folder-eng',
name: 'Engineering',
parentId: null,
workspaceId: 'ws-1',
},
'folder-backend': {
id: 'folder-backend',
name: 'Backend',
parentId: 'folder-eng',
workspaceId: 'ws-1',
},
})
const definition = getSelectorDefinition('sim.workflows')
const options = await definition.fetchList!({
key: 'sim.workflows',
context: { workspaceId: 'ws-1' },
})
const labelById = Object.fromEntries(options.map((o) => [o.id, o.label]))
expect(labelById['wf-root']).toBe('Pipeline (Root)')
expect(labelById['wf-eng']).toBe('Pipeline (Engineering)')
expect(labelById['wf-eng-backend']).toBe('Pipeline (Engineering / Backend)')
expect(labelById['wf-unique']).toBe('Solo Workflow')
})
it('disambiguates a single workflow lookup when its name has duplicates', async () => {
mockGetWorkflows.mockReturnValue([
{ id: 'wf-1', name: 'Pipeline', folderId: 'folder-a' },
{ id: 'wf-2', name: 'Pipeline', folderId: null },
])
mockGetFolderMap.mockReturnValue({
'folder-a': {
id: 'folder-a',
name: 'Alpha',
parentId: null,
workspaceId: 'ws-1',
},
})
const definition = getSelectorDefinition('sim.workflows')
const option = await definition.fetchById?.({
key: 'sim.workflows',
context: { workspaceId: 'ws-1' },
detailId: 'wf-1',
})
expect(option).toEqual({ id: 'wf-1', label: 'Pipeline (Alpha)' })
})
})
+103
View File
@@ -0,0 +1,103 @@
import { airtableSelectors } from '@/hooks/selectors/providers/airtable/selectors'
import { asanaSelectors } from '@/hooks/selectors/providers/asana/selectors'
import { attioSelectors } from '@/hooks/selectors/providers/attio/selectors'
import { bigquerySelectors } from '@/hooks/selectors/providers/bigquery/selectors'
import { calcomSelectors } from '@/hooks/selectors/providers/calcom/selectors'
import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors'
import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors'
import { googleSelectors } from '@/hooks/selectors/providers/google/selectors'
import { jiraSelectors } from '@/hooks/selectors/providers/jira/selectors'
import { jsmSelectors } from '@/hooks/selectors/providers/jsm/selectors'
import { knowledgeSelectors } from '@/hooks/selectors/providers/knowledge/selectors'
import { linearSelectors } from '@/hooks/selectors/providers/linear/selectors'
import { microsoftSelectors } from '@/hooks/selectors/providers/microsoft/selectors'
import { mondaySelectors } from '@/hooks/selectors/providers/monday/selectors'
import { notionSelectors } from '@/hooks/selectors/providers/notion/selectors'
import { pipedriveSelectors } from '@/hooks/selectors/providers/pipedrive/selectors'
import { sharepointSelectors } from '@/hooks/selectors/providers/sharepoint/selectors'
import { simSelectors } from '@/hooks/selectors/providers/sim/selectors'
import { slackSelectors } from '@/hooks/selectors/providers/slack/selectors'
import { trelloSelectors } from '@/hooks/selectors/providers/trello/selectors'
import { wealthboxSelectors } from '@/hooks/selectors/providers/wealthbox/selectors'
import { webflowSelectors } from '@/hooks/selectors/providers/webflow/selectors'
import { zoomSelectors } from '@/hooks/selectors/providers/zoom/selectors'
import type {
SelectorDefinition,
SelectorKey,
SelectorOption,
SelectorQueryArgs,
} from '@/hooks/selectors/types'
export const selectorRegistry = {
...airtableSelectors,
...asanaSelectors,
...attioSelectors,
...bigquerySelectors,
...calcomSelectors,
...confluenceSelectors,
...jsmSelectors,
...googleSelectors,
...microsoftSelectors,
...notionSelectors,
...pipedriveSelectors,
...sharepointSelectors,
...trelloSelectors,
...zoomSelectors,
...slackSelectors,
...wealthboxSelectors,
...jiraSelectors,
...mondaySelectors,
...linearSelectors,
...knowledgeSelectors,
...webflowSelectors,
...cloudwatchSelectors,
...simSelectors,
} satisfies Record<SelectorKey, SelectorDefinition>
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
const definition = selectorRegistry[key]
if (!definition) {
throw new Error(`Missing selector definition for ${key}`)
}
return definition
}
const MAX_LOAD_ALL_PAGES = 50
/**
* Loads the complete option list for a selector outside the React Query hook —
* for callers (search/replace, value resolution) that need every option in one
* call. Uses `fetchList` when defined, otherwise drains `fetchPage` (bounded by
* {@link MAX_LOAD_ALL_PAGES}). Returns an empty array for a selector that
* provides neither.
*/
export async function loadAllSelectorOptions(
definition: SelectorDefinition,
args: SelectorQueryArgs
): Promise<SelectorOption[]> {
if (definition.fetchList) {
return definition.fetchList(args)
}
if (definition.fetchPage) {
const items: SelectorOption[] = []
let cursor: string | undefined
for (let page = 0; page < MAX_LOAD_ALL_PAGES; page++) {
const { items: pageItems, nextCursor } = await definition.fetchPage({ ...args, cursor })
items.push(...pageItems)
cursor = nextCursor
if (!cursor) break
}
return items
}
return []
}
export function mergeOption(options: SelectorOption[], option?: SelectorOption | null) {
if (!option) return options
if (options.some((item) => item.id === option.id)) {
return options
}
return [option, ...options]
}
+23
View File
@@ -0,0 +1,23 @@
import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
export interface SelectorResolution {
key: SelectorKey | null
context: SelectorContext
allowSearch: boolean
}
export function resolveSelectorForSubBlock(
subBlock: SubBlockConfig,
context: SelectorContext
): SelectorResolution | null {
if (!subBlock.selectorKey) return null
return {
key: subBlock.selectorKey,
context: {
...context,
mimeType: subBlock.mimeType ?? context.mimeType,
},
allowSearch: subBlock.selectorAllowSearch ?? true,
}
}
+134
View File
@@ -0,0 +1,134 @@
import type React from 'react'
import type { QueryKey } from '@tanstack/react-query'
import type { AnyApiRouteContract } from '@/lib/api/contracts/types'
export type SelectorKey =
| 'airtable.bases'
| 'airtable.tables'
| 'asana.workspaces'
| 'attio.lists'
| 'attio.objects'
| 'bigquery.datasets'
| 'bigquery.tables'
| 'calcom.eventTypes'
| 'calcom.schedules'
| 'confluence.spaces'
| 'google.tasks.lists'
| 'jsm.requestTypes'
| 'jsm.serviceDesks'
| 'microsoft.planner.plans'
| 'notion.databases'
| 'notion.pages'
| 'pipedrive.pipelines'
| 'sharepoint.lists'
| 'trello.boards'
| 'zoom.meetings'
| 'slack.channels'
| 'slack.users'
| 'gmail.labels'
| 'outlook.folders'
| 'google.calendar'
| 'jira.issues'
| 'jira.projects'
| 'linear.projects'
| 'linear.teams'
| 'confluence.pages'
| 'microsoft.teams'
| 'microsoft.chats'
| 'microsoft.channels'
| 'wealthbox.contacts'
| 'onedrive.files'
| 'onedrive.folders'
| 'sharepoint.sites'
| 'microsoft.excel'
| 'microsoft.excel.drives'
| 'microsoft.excel.sheets'
| 'microsoft.word'
| 'microsoft.planner'
| 'google.drive'
| 'google.sheets'
| 'knowledge.documents'
| 'webflow.sites'
| 'webflow.collections'
| 'webflow.items'
| 'cloudwatch.logGroups'
| 'cloudwatch.logStreams'
| 'monday.boards'
| 'monday.groups'
| 'sim.workflows'
| 'table.columns'
export interface SelectorOption {
id: string
label: string
icon?: React.ComponentType<{ className?: string }>
meta?: Record<string, unknown>
}
export interface SelectorContext {
workspaceId?: string
workflowId?: string
oauthCredential?: string
serviceId?: string
domain?: string
teamId?: string
projectId?: string
knowledgeBaseId?: string
planId?: string
mimeType?: string
fileId?: string
siteId?: string
collectionId?: string
spreadsheetId?: string
driveId?: string
excludeWorkflowId?: string
baseId?: string
datasetId?: string
serviceDeskId?: string
impersonateUserEmail?: string
boardId?: string
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
logGroupName?: string
mcpServerId?: string
tableId?: string
}
export interface SelectorQueryArgs {
key: SelectorKey
context: SelectorContext
search?: string
detailId?: string
signal?: AbortSignal
}
export interface SelectorPage {
items: SelectorOption[]
nextCursor?: string
}
interface SelectorPageArgs extends SelectorQueryArgs {
cursor?: string
}
export interface SelectorDefinition {
key: SelectorKey
contracts?: readonly AnyApiRouteContract[]
getQueryKey: (args: SelectorQueryArgs) => QueryKey
/**
* Loads the full option list in a single call. Required unless `fetchPage` is
* defined, in which case the hook drives pagination through `fetchPage` and
* `fetchList` is never invoked — provide one or the other, not both.
*/
fetchList?: (args: SelectorQueryArgs) => Promise<SelectorOption[]>
/**
* Optional. When defined, the selector hook fetches one page at a time and
* auto-drains remaining pages so the dropdown populates progressively.
* Returns `{ items, nextCursor }`; `nextCursor: undefined` ends the stream.
*/
fetchPage?: (args: SelectorPageArgs) => Promise<SelectorPage>
fetchById?: (args: SelectorQueryArgs) => Promise<SelectorOption | null>
enabled?: (args: SelectorQueryArgs) => boolean
staleTime?: number
}
@@ -0,0 +1,195 @@
import { useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { usePersonalEnvironment } from '@/hooks/queries/environment'
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
import type {
SelectorKey,
SelectorOption,
SelectorPage,
SelectorQueryArgs,
} from '@/hooks/selectors/types'
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
search?: string
detailId?: string
enabled?: boolean
}
export interface SelectorOptionsResult {
data: SelectorOption[] | undefined
isLoading: boolean
isFetching: boolean
/**
* True while paginated selectors are draining remaining pages in the
* background. Always false for non-paginated selectors.
*/
isFetchingMore: boolean
/**
* True when the paginated selector still has more pages queued. Always false
* for non-paginated selectors.
*/
hasMore: boolean
/**
* True when the paginated drain stopped at {@link MAX_AUTO_DRAIN_PAGES} with
* pages still remaining, so the option list is a partial view. Always false
* for non-paginated selectors.
*/
truncated: boolean
error: Error | null
}
const logger = createLogger('SelectorQuery')
const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined }
/**
* Safety bound on the background auto-drain. Real dropdowns settle in a handful
* of pages; this only trips for pathological result sets and prevents an
* unbounded request loop when a provider keeps handing back cursors.
*/
const MAX_AUTO_DRAIN_PAGES = 50
export function useSelectorOptions(
key: SelectorKey,
args: SelectorHookArgs
): SelectorOptionsResult {
const definition = getSelectorDefinition(key)
const queryArgs: SelectorQueryArgs = {
key,
context: args.context,
search: args.search,
}
const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)
const supportsPagination = Boolean(definition.fetchPage)
const flatQuery = useQuery<SelectorOption[]>({
queryKey: definition.getQueryKey(queryArgs),
queryFn: ({ signal }) =>
definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]),
enabled: !supportsPagination && isEnabled,
staleTime: definition.staleTime ?? 30_000,
})
const pagedQuery = useInfiniteQuery<SelectorPage>({
queryKey: [...definition.getQueryKey(queryArgs), 'paged'],
queryFn: ({ pageParam, signal }) => {
if (!definition.fetchPage) return Promise.resolve(EMPTY_PAGE)
return definition.fetchPage({
...queryArgs,
cursor: pageParam as string | undefined,
signal,
})
},
getNextPageParam: (last) => last.nextCursor,
initialPageParam: undefined as string | undefined,
enabled: supportsPagination && isEnabled,
staleTime: definition.staleTime ?? 30_000,
})
const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery
const pageCount = pagedQuery.data?.pages.length ?? 0
const reachedDrainCap = pageCount >= MAX_AUTO_DRAIN_PAGES
useEffect(() => {
if (!supportsPagination) return
if (isError) return
if (reachedDrainCap) {
if (hasNextPage) {
logger.warn('Selector hit auto-drain cap; option list is truncated', {
key,
pages: pageCount,
})
}
return
}
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage()
}
}, [
supportsPagination,
hasNextPage,
isFetchingNextPage,
isError,
fetchNextPage,
reachedDrainCap,
pageCount,
key,
])
const pagedOptions = useMemo<SelectorOption[] | undefined>(() => {
if (!supportsPagination) return undefined
if (!pagedQuery.data) return undefined
return pagedQuery.data.pages.flatMap((page) => page.items)
}, [supportsPagination, pagedQuery.data])
if (supportsPagination) {
return {
data: pagedOptions,
isLoading: pagedQuery.isLoading,
isFetching: pagedQuery.isFetching,
isFetchingMore: pagedQuery.isFetchingNextPage,
hasMore: (pagedQuery.hasNextPage ?? false) && !reachedDrainCap,
truncated: reachedDrainCap && (pagedQuery.hasNextPage ?? false),
error: (pagedQuery.error as Error | null) ?? null,
}
}
return {
data: flatQuery.data,
isLoading: flatQuery.isLoading,
isFetching: flatQuery.isFetching,
isFetchingMore: false,
hasMore: false,
truncated: false,
error: (flatQuery.error as Error | null) ?? null,
}
}
export function useSelectorOptionDetail(
key: SelectorKey,
args: SelectorHookArgs & { detailId?: string }
) {
const { data: envVariables = {} } = usePersonalEnvironment()
const definition = getSelectorDefinition(key)
const resolvedDetailId = useMemo(() => {
if (!args.detailId) return undefined
if (isReference(args.detailId)) return undefined
if (isEnvVarReference(args.detailId)) {
const varName = extractEnvVarName(args.detailId)
return envVariables[varName]?.value || undefined
}
return args.detailId
}, [args.detailId, envVariables])
const queryArgs: SelectorQueryArgs = {
key,
context: args.context,
detailId: resolvedDetailId,
}
const hasRealDetailId = Boolean(resolvedDetailId)
const baseEnabled =
hasRealDetailId && definition.fetchById !== undefined
? definition.enabled
? definition.enabled(queryArgs)
: true
: false
const enabled = args.enabled ?? baseEnabled
const query = useQuery<SelectorOption | null>({
queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'],
queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }),
enabled,
staleTime: definition.staleTime ?? 300_000,
})
return query
}
export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) {
return useMemo(() => {
const merged = mergeOption(options, extra)
return new Map(merged.map((option) => [option.id, option]))
}, [options, extra])
}