mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
refactor(sub-blocks): make a registered selector the single source for a remote option list, and close the fork-sync reconfiguration gap (#6878)
* fix(workspace-forking): stop double-labelling a custom block's inputs, and derive their controls from the canvas
Two problems with how a repointed custom block's inputs render in the sync modal.
The field title printed twice. The row wrapper already draws the label and its
required marker for every dependent field — `DependentFieldSelector` takes a
`title` only to phrase its placeholder and renders a bare combobox. The
custom-block branch used `ChipModalField`, which owns a label of its own, so every
input showed its name twice. It now renders bare controls like its sibling does.
The control was chosen by re-reading the raw field type instead of asking the
function that already answers this. `subBlockTypeForField` decides what a Start
field becomes on the canvas; the modal had a parallel switch that had already
drifted, rendering a `file[]` input — an upload on the canvas — as a plain text
box, which would write a bare string into a field expecting file references.
`subBlockTypeForField` is now exported and the modal derives from it, so the two
cannot disagree about what a field IS; the modal only decides how that kind draws.
A file input is explicitly `unsupported` rather than falling through: it renders
disabled, saying it is set in the workflow, instead of inviting a value that
cannot work. A test walks every type a Start field can declare and asserts the
modal's choice follows the canvas's, so a type added later surfaces here rather
than silently becoming a text box.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workspace-forking): resolve a custom block's inputs against the target environment, and stop a re-sync wiping its uploads
A repointed custom block's inputs are configured at sync time, but the modal
drew them as bare text fields against no environment at all:
- `{{SECRET}}` had no completion, and no way to know which secrets exist in the
workspace the value is written INTO.
- `<block.output>` had no completion. The canvas dropdown reads the workflow
open in the editor; on the fork settings page there is none, and the workflow
that matters is the target's.
- A `file[]` input has no control here (it is an upload on the canvas), so it
had no stored override — and the block was rebuilt from overrides alone, so
every sync silently dropped the target's uploaded files.
`WorkflowReferenceScope` lets a surface supply the workflow a reference resolves
against. Absent a provider, the hooks read the live editor stores exactly as
before, so the canvas is unchanged. The scope splits graph from values on
purpose: reachability cannot change with the text being typed, and the
validation hook runs in every reference-aware sub-block editor at once, so
subscribing it to live sub-block values would re-render all of them on every
keystroke. A test pins that split.
`replaceCustomBlockInputs` now seeds from the target block when it is ALREADY
the mapped type, layering the configured values on top. That keeps an input the
modal cannot offer a control for, and leaves a field the user simply did not
touch alone; a field they explicitly emptied stores `''`, which is an override
and still wins. Under a DIFFERENT current type nothing is carried over — those
values are keyed by another block's field ids, which is the orphaning this
function exists to prevent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workspace-forking): stop a required file input deadlocking Sync
Both PR bots flagged this and they were right. A repointed custom block's
`file[]` input renders as a disabled control — it is an upload on the canvas,
and there is nothing to type here — but the Sync gate still demanded a
non-empty value for every REQUIRED dependent. So a custom block with a required
file input turned Sync off permanently, while the field's own hint told the
user to go set it in a workflow they could only reach BY syncing.
`isForkSyncConfigurableField` is the one predicate for "can the modal put a
value in this field", used by the gate and by the per-kind status badge so the
two cannot disagree. Skipping the gate is only safe because the sync no longer
clears the field: the target keeps what it has, and a genuinely missing value is
still caught by the block's own required-field validation at run/deploy time —
the same fallback every other unconfigured required field already relies on.
Also gives the disabled control an `aria-label` (the row's visible label is a
sibling, not associated), closing the second review note.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(sub-blocks): make a registered selector the single source for a remote option list
`dropdown` and `combobox` could only load a remote list through a per-block
`fetchOptions(blockId)`, which resolves its credential by reading the live
workflow store. That works on the canvas and nowhere else — which is why the
fork sync modal cannot offer those fields, and why every one of those fetchers
turned out to be a hand-rolled duplicate of a selector that already exists
(`triggers/gmail/poller.ts` calls the very contract `gmail.labels` wraps).
Both controls now accept `selectorKey`, resolved through the registry inside
`useFetchedOptions`. Deliberately NOT a second code path: the registry is
presented through the same two function shapes the props already describe, so
the existing lifecycle — request-id guards, dependency-scope reset, label
hydration — is reused verbatim, and paginated selectors drain through the same
`loadAllSelectorOptions` that search/replace and value resolution already use.
`isDynamic` replaces the `fetchOptions &&` test the controls used to decide
whether the fetched list or the static `options` array is authoritative; that
question outlives the prop it was asking about.
No block or trigger changes yet, so nothing moves off `fetchOptions` in this
commit: subblock `type`, `multiSelect`, and the stored value shape are all
untouched and no existing workflow is affected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(triggers): move every credential-scoped option list onto a registered selector
Each of these `fetchOptions` resolved its credential with
`readSubBlockValue(blockId, 'triggerCredentials')` — a live-workflow-store read
— and then called the very selector contract a registered selector already
wraps. They were duplicates that only worked on the canvas.
Migrated: webflow sites/collections (x4 triggers), clickup workspaces, gmail
labels, outlook folders, and all six hubspot pickers. 425 lines of duplicated
fetch logic deleted.
The missing piece each one needed was `canonicalParamId: 'oauthCredential'` on
its credential subblock: `buildSelectorContextFromBlock` keys the context on a
subblock's CANONICAL id, so without it `context.oauthCredential` was never
populated and the block had no way to reach its credential except the store —
which is what forced the hand-rolled fetcher in the first place.
Five new hubspot selectors. `hubspot.pipelineStages` reads the pipelines
contract and narrows, because HubSpot returns stages inside the pipeline
payload rather than behind an endpoint of their own; sharing the one response
is also what keeps a stage list from ever describing a pipeline its sibling
picker is not showing. `objectType`/`customObjectTypeId`/`pipelineId` join
SelectorContext, and `resolveObjectType` keeps HubSpot's own `contact` default
so an untouched dropdown still lists properties for what it visibly shows.
Subblock `type`, `multiSelect`, and stored value shapes are unchanged, so
existing workflows are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(triggers): move the table trigger's column picker onto table.columns
`fetchTableColumns` resolved the workspace from the active-workflow store and
the table id by reading two subblocks by name, then refetched the table list to
find one table's schema. The registered `table.columns` selector takes both from
the context — `tableSelector`/`manualTableId` already carry
`canonicalParamId: 'tableId'`, so the canonical pair resolves on its own — and
reads the table detail query directly.
Deletes the helper and the four imports it was the only user of.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(managed-agent): move its four pickers onto registered selectors
All four read one route distinguished only by `resource`, with the credential
pulled from the store by name. They are now `managedAgent.agents` / `.vaults` /
`.memoryStores` / `.environments`, and `lib/managed-agents/subblock-options.ts`
is deleted entirely.
The environment filter (cloud vs self_hosted expose different fields, so mixing
them offers choices the rest of the form cannot honour) moves into the selector
with `environmentType` on the context.
Also decouples two things `canonicalParamId` was conflating. It is both a
block's serialized PARAM NAME and the key `buildSelectorContextFromBlock` reads,
so making this block's pickers resolvable appeared to require renaming its
shipped `credential` param to `oauthCredential` — a rename that would change the
serialized shape of every existing managed_agent block, and one that
`blocks.test.ts` correctly refused. A picker should not be able to force a param
rename, so the context now reads a credential off the subblock TYPE when no
canonical id supplied one. It only fills a gap: a block that declares
`canonicalParamId: 'oauthCredential'` has already resolved it, including the
basic/advanced active-member logic the type check cannot express.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(sub-blocks): delete fetchOptions — a sub-block's options are a selector or derived, never both
Completes the migration. `fetchOptions`/`fetchOptionById` are off `SubBlockConfig`,
off both controls, and out of `useFetchedOptions`, leaving exactly two ways a
sub-block gets its options:
selectorKey — a registered selector. The ONLY way to load a remote list.
Parameterized by an explicit SelectorContext, so it works on the
canvas, in the fork sync modal, and anywhere else.
options — a static array, or a pure function of the block's own values.
No I/O.
Reading the remaining callsites showed most of the "derived" ones were nothing of
the kind — they were workspace-scoped remote fetches wearing a local-looking
signature. Those became seven `workspace.*` selectors (credential providers,
credential groups + their per-group providers, secret names, raw secret names,
sandboxes, trigger types) plus `providers.openrouterEmbeddingModels`. Only the
agent block's three capability dropdowns were genuinely derived; `options` now
takes the block's values so they can say so directly. The parameter is optional,
so every existing zero-argument options function is untouched.
`imap.mailboxes` is the one selector whose account is typed rather than stored.
Its password is deliberately absent from the query key: a query key identifies a
resource, a credential authorizes access to it. `oauthCredential` is safe there
because it is only an id — a typed password is a secret, and keys are cached and
surfaced by devtools. Host, port, TLS and username already identify the mailbox
list uniquely; the password rides the body exactly as before.
`selectorExcludeSelf` replaces the one thing a shared `sim.workflows` selector
could not express. It is a declared flag rather than a blanket rule because the
answer differs per field: the Sim trigger never receives events about its own
workflow, while the Logs block legitimately reads the logs of the workflow it
runs in.
Deletes `lib/workflows/subblocks/options.ts` and `triggers/editor-state.ts`
entirely — every caller was a `fetchOptions` resolver. The live-registry test for
the trigger vocabulary moves to the selector that now owns it, keeping its
lazy-import cycle guarantee under test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(workspace-forking): make every fork-clearable sub-block reconfigurable at sync time, and lint that it stays so
`clearDependentsOnRemap` wipes every transitive dependent of a remapped parent,
and a credential mapped between environments changes value on EVERY sync — so a
dependent the sync modal could not offer was re-emptied on every push, with
nowhere to set it that stuck. Setting it in the target did not survive. 36 fields
were in that state.
The selector migration closed most of it; this closes the rest. The collector now
also emits plain text dependents (`short-input` / `long-input`), which need no
selector — just somewhere to type — and the modal's no-selector branch renders
them through the same control it already drew for custom-block inputs. It
deliberately does NOT emit the manual half of a selector-backed canonical pair:
that pair already represents the field once, and its manual member is verbatim by
policy, so offering both would show one concept twice and invite writing into the
inactive half.
`forkDependentControl` replaces the direct `customBlockInputControl` call in the
view, because `fieldType` now means two different things: a custom-block input
declares a Start FIELD type (`string`, `file[]`), while every other no-selector
dependent is a canvas SUB-BLOCK whose own type says it. They agreed by accident
before; now they are classified separately.
`check:fork-dependent-coverage` fails when a sub-block under a
credential/knowledge-base/table anchor is none of: selector-backed, a canonical
pair member, a preserved name-based type, or text. 656 dependents, zero
uncovered, no baseline — verified to fail by seeding a regression. Picked up
automatically by `check:audits` (all 30 green).
Documented in `/add-block`, `/add-trigger`, and `.claude/rules/sim-integrations.md`,
including the two rules the checks enforce: a secret never enters a selector's
query key, and a fork-clearable dependent must be reconfigurable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sub-blocks): stop selector-backed fields rendering undefined options, and pass derived values to ComboBox
Both found by Bugbot on the migration commit; both real, both mine.
A selector-backed field carries no static `options` — that is the point — but
`Dropdown` and `ComboBox` still read it on first paint, before any fetch resolves,
and `allOptions.map(...)` is unconditional. Every field moved to `selectorKey`
(Function sandboxes, Managed Agent pickers, OpenRouter embeddings, Logs
workflows, the migrated triggers) would throw on mount. The type said the prop was
required, so nothing caught it: the callsites pass `config.options`, which is
optional on `SubBlockConfig` and now genuinely absent.
Fixed on the controls rather than by restoring `options: []` to every migrated
sub-block: the absence is correct, so the component owns the default. `options`
is optional on both prop types and falls back to a shared empty array, which also
keeps a stable identity for the memo.
`ComboBox` never got the `options({ values })` wiring `Dropdown` received, so
agent's reasoning-effort, verbosity and thinking-level lists — all comboboxes —
silently stayed on their generic fallback instead of narrowing to the selected
model. Wired the same way, reading the block's own values from the store.
`selector-backed-subblocks.test.ts` pins the invariants against the real registry:
a named selector exists and can list, a selector-backed field never also declares
static options, and a field whose selector is gated on context declares the
`dependsOn` that rebuilds it. That last one immediately caught a third bug —
`clickup.triggerWorkspaceId` had no `dependsOn`, so its list would have loaded
once, empty, and never refetched once a credential was picked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(selectors): restore the credential-group provider label resolver, and probe getQueryKey for missing dependsOn
Two findings from a final adversarial pass over the migration, both the same
class as the three the review bots caught: something a `fetchOptions` sub-block
declared that its replacement selector quietly does not.
`credential-group.providerFilter` had a `fetchOptionById`;
`workspace.credentialGroupProviders` had no `fetchById`, so the canvas card
summarising several stored provider ids lost every label. The field is
multi-select, which is exactly when a label has to resolve without the full list.
The `dependsOn` assertion in `selector-backed-subblocks.test.ts` only probed
`enabled` against three hand-listed context fields, which is why it caught
`clickup.triggerWorkspaceId` and would have missed the rest. It now probes
`getQueryKey` as well — a selector's key names every context field its RESULT
depends on — and derives the sub-block-sourced set from
`SELECTOR_CONTEXT_FIELDS` rather than a literal. Verified by deleting a real
`dependsOn`: it fails naming the field and the fields it depends on.
Also checked and NOT changed: `display.ts` and the copilot dropdown validator
both guard `options` before use, so stripping `options: []` does not reach them.
The validator's behaviour does shift from "reject every value" (an empty
`validIds` array matched nothing) to "skip validation", which is a relaxation
rather than a regression. `function.sandboxId` kept its `dependsOn: ['language']`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: re-record the page-graph baseline after staging's growth consumed its allowance
CI's "Repo audits" step failed on `check:tool-registry-boundary`. Measured before
touching anything, because the reported growth (+32 and +42 modules on two
routes) looked like this branch had dragged the selector registry somewhere new.
It had not. Recording a baseline on clean `origin/staging` and diffing against
this branch attributes the growth precisely:
this branch: +1 to +4 modules per route, +35 total across 25 routes
staging: the rest
Staging's six merged commits landed both failing routes at exactly their
tolerance — knowledge/[id] at +31 of an allowed +31, layout at +41 of +41 — so
`check:tool-registry-boundary` passed there with nothing left over. This branch's
+1 tipped both past the line. The next PR to touch anything would have tripped it
just the same, whatever it contained.
The +1..+4 is the selector consolidation's real cost: `selectorRegistry` is one
static object, so a page reaching any selector reaches every provider, and this
branch adds four (hubspot, managed-agent, imap, workspace). That is the same cost
the 27 existing providers already impose, and it is what buys one option-list
mechanism that works off the canvas.
Also tried deferring the workspace provider's data-layer imports to fetch time.
Reverted: this checker follows dynamic imports, so the numbers did not move,
leaving only a Promise.all-of-imports shape that reads worse than the 27 sibling
providers it sits next to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workspace-forking): actually apply text dependents, and resolve labels against the full selector context
Both from Bugbot; both real, both mine.
**Text dependents never persisted.** `applyDependentOverrides` allowlisted
`dependsOn && selectorKey`, so the plain text fields the collector started
emitting were offered in the modal, stored, and gated on by the Sync button —
then dropped on apply. The field stayed wiped on every push and the typed value
went nowhere, which is the exact treadmill the feature existed to end.
The cause was the rule being written twice. `reconfigurableDependentIds` is now
the single definition of "a dependent the modal can offer AND the sync can write
back", used by the collector and by the apply side. A test asserts the two agree
by round-tripping through `applyDependentOverrides`, and fails against the old
allowlist.
**Provider labels stayed raw ids.** `useDynamicSubBlockOptionDisplayName` called
`fetchById` with a `workspaceId`-only context, which silently fails any selector
scoped by a sibling — `workspace.credentialGroupProviders` needs the group before
it can name a provider, so the `fetchById` restored last round returned null
every time. It now builds the block's real context with
`buildSelectorContextFromBlock`, the same one the canvas uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(queries): scope the sub-block label cache by the selector's own context
Follow-on to 1f423ab5, and a real gap in it. That commit taught `fetchById` to
read sibling context but left the React Query key at
`(workspaceId, blockId, subBlockId, optionId)`. A label resolved before its
sibling was set — `workspace.credentialGroupProviders` with no group picked,
which returns `null` — stayed cached under the same key and was reused once the
group WAS picked, so the card kept showing the raw id. Changing between two
groups collided the same way.
This is the repo's own React Query rule ("every identifier the queryFn forwards
into the fetch must appear in the queryKey"); `check:react-query` did not catch
it because the context is built in the hook rather than passed as a named arg.
The key now carries the selector's OWN `getQueryKey` for that context, rather
than a second hand-maintained list of context fields. The cache is scoped by
exactly what the selector reads, and stays correct if a selector's dependencies
change later. The context also became reactive (subscribed rather than read via
`getState()`), which is what lets the key move when the sibling does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9a621bcde7
commit
2ced737976
@@ -154,7 +154,7 @@ export const GET = withRouteHandler(
|
||||
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
|
||||
) ??
|
||||
readTargetDraftDependentValue(
|
||||
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId)?.subBlocks,
|
||||
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
field.subBlockKey
|
||||
),
|
||||
|
||||
+37
-18
@@ -14,7 +14,9 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,9 @@ const MIN_ZOOM = 0.1
|
||||
const MAX_ZOOM = 1
|
||||
const ZOOM_DURATION = 0
|
||||
|
||||
/** Shared empty list, so a selector-backed field with no static options keeps a stable identity. */
|
||||
const EMPTY_OPTIONS: ComboBoxOption[] = []
|
||||
|
||||
const CREATE_ACTION_LABEL: Record<NonNullable<SubBlockConfig['createAction']>, string> = {
|
||||
sandbox: 'Create Sandbox',
|
||||
}
|
||||
@@ -48,8 +53,12 @@ type ComboBoxOption =
|
||||
* Props for the ComboBox component
|
||||
*/
|
||||
interface ComboBoxProps {
|
||||
/** Available options for selection - can be static array or function that returns options */
|
||||
options: ComboBoxOption[] | (() => ComboBoxOption[])
|
||||
/**
|
||||
* Static options, or a function deriving them from the block's own values. Absent on a
|
||||
* selector-backed field, whose list comes from `selectorKey` instead — so this must never
|
||||
* be read without a default.
|
||||
*/
|
||||
options?: ComboBoxOption[] | ((params?: { values: Record<string, unknown> }) => ComboBoxOption[])
|
||||
/** Default value to use when no value is set */
|
||||
defaultValue?: string
|
||||
/** ID of the parent block */
|
||||
@@ -68,13 +77,10 @@ interface ComboBoxProps {
|
||||
placeholder?: string
|
||||
/** Configuration for the sub-block */
|
||||
config: SubBlockConfig
|
||||
/** Async function to fetch options dynamically */
|
||||
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
|
||||
/** Async function to fetch a single option's label by ID (for hydration) */
|
||||
fetchOptionById?: (
|
||||
blockId: string,
|
||||
optionId: string
|
||||
) => Promise<{ label: string; id: string } | null>
|
||||
/** Registered selector supplying the options. The canonical source for a remote list. */
|
||||
selectorKey?: SelectorKey
|
||||
/** Drop the hosting workflow from a `sim.workflows` list. */
|
||||
selectorExcludeSelf?: boolean
|
||||
/** Field dependencies that trigger option refetch when changed */
|
||||
dependsOn?: SubBlockConfig['dependsOn']
|
||||
}
|
||||
@@ -90,8 +96,8 @@ export const ComboBox = memo(function ComboBox({
|
||||
disabled,
|
||||
placeholder = 'Type or select an option...',
|
||||
config,
|
||||
fetchOptions,
|
||||
fetchOptionById,
|
||||
selectorKey,
|
||||
selectorExcludeSelf,
|
||||
dependsOn,
|
||||
}: ComboBoxProps) {
|
||||
const activeSearchTarget = useActiveSearchTarget()
|
||||
@@ -110,15 +116,27 @@ export const ComboBox = memo(function ComboBox({
|
||||
const { isModelUsable, isLoading: isPermissionLoading } = usePermissionConfig()
|
||||
|
||||
// Evaluate static options if provided as a function
|
||||
// Derived option lists read the block's own values (a model's valid reasoning efforts);
|
||||
// `dependsOn` already re-renders this control when one of those siblings changes.
|
||||
const activeWorkflowIdForValues = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const blockValues = useSubBlockStore((state) =>
|
||||
activeWorkflowIdForValues
|
||||
? state.workflowValues[activeWorkflowIdForValues]?.[blockId]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const staticOptions = useMemo(() => {
|
||||
const opts = typeof options === 'function' ? options() : options
|
||||
const opts =
|
||||
typeof options === 'function'
|
||||
? options({ values: blockValues ?? {} })
|
||||
: (options ?? EMPTY_OPTIONS)
|
||||
|
||||
if (subBlockId === 'model') {
|
||||
return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
|
||||
}
|
||||
|
||||
return opts
|
||||
}, [options, subBlockId, isModelUsable])
|
||||
}, [options, blockValues, subBlockId, isModelUsable])
|
||||
|
||||
const {
|
||||
fetchedOptions,
|
||||
@@ -126,12 +144,13 @@ export const ComboBox = memo(function ComboBox({
|
||||
fetchError,
|
||||
hydratedOption,
|
||||
missingOptionId,
|
||||
isDynamic,
|
||||
refetch: refetchOptions,
|
||||
} = useFetchedOptions({
|
||||
blockId,
|
||||
dependsOnFields,
|
||||
fetchOptions,
|
||||
fetchOptionById,
|
||||
selectorKey,
|
||||
selectorExcludeSelf,
|
||||
isPreview: Boolean(isPreview),
|
||||
disabled: Boolean(disabled),
|
||||
valueToHydrate: value as string | null | undefined,
|
||||
@@ -194,9 +213,9 @@ export const ComboBox = memo(function ComboBox({
|
||||
// Merge static and fetched options - fetched options take priority when available
|
||||
const evaluatedOptions = useMemo((): ComboBoxOption[] => {
|
||||
let opts: ComboBoxOption[] =
|
||||
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
|
||||
isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
|
||||
|
||||
if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
|
||||
if (subBlockId === 'model' && isDynamic && normalizedFetchedOptions.length > 0) {
|
||||
opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
|
||||
}
|
||||
|
||||
@@ -224,7 +243,7 @@ export const ComboBox = memo(function ComboBox({
|
||||
|
||||
return opts
|
||||
}, [
|
||||
fetchOptions,
|
||||
isDynamic,
|
||||
normalizedFetchedOptions,
|
||||
staticOptions,
|
||||
hydratedOption,
|
||||
|
||||
+32
-19
@@ -15,9 +15,15 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useOperationAccess } from '@/hooks/use-operation-access'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/** Shared empty list, so a selector-backed field with no static options keeps a stable identity. */
|
||||
const EMPTY_OPTIONS: DropdownOption[] = []
|
||||
|
||||
/** Selected-value badges shown before folding the rest into a "+N" badge. */
|
||||
const MAX_VISIBLE_MULTI_SELECT_BADGES = 2
|
||||
|
||||
@@ -39,8 +45,12 @@ type DropdownOption =
|
||||
* Props for the Dropdown component
|
||||
*/
|
||||
interface DropdownProps {
|
||||
/** Static options array or function that returns options */
|
||||
options: DropdownOption[] | (() => DropdownOption[])
|
||||
/**
|
||||
* Static options, or a function deriving them from the block's own values. Absent on a
|
||||
* selector-backed field, whose list comes from `selectorKey` instead — so this must never
|
||||
* be read without a default.
|
||||
*/
|
||||
options?: DropdownOption[] | ((params?: { values: Record<string, unknown> }) => DropdownOption[])
|
||||
/** Default value to select when no value is set */
|
||||
defaultValue?: string
|
||||
/** Unique identifier for the block */
|
||||
@@ -59,13 +69,10 @@ interface DropdownProps {
|
||||
placeholder?: string
|
||||
/** Enable multi-select mode */
|
||||
multiSelect?: boolean
|
||||
/** Async function to fetch options dynamically */
|
||||
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
|
||||
/** Async function to fetch a single option's label by ID (for hydration) */
|
||||
fetchOptionById?: (
|
||||
blockId: string,
|
||||
optionId: string
|
||||
) => Promise<{ label: string; id: string } | null>
|
||||
/** Registered selector supplying the options. The canonical source for a remote list. */
|
||||
selectorKey?: SelectorKey
|
||||
/** Drop the hosting workflow from a `sim.workflows` list. */
|
||||
selectorExcludeSelf?: boolean
|
||||
/** Field dependencies that trigger option refetch when changed */
|
||||
dependsOn?: SubBlockConfig['dependsOn']
|
||||
/** Enable search input in dropdown */
|
||||
@@ -94,8 +101,8 @@ export const Dropdown = memo(function Dropdown({
|
||||
disabled,
|
||||
placeholder = 'Select an option...',
|
||||
multiSelect = false,
|
||||
fetchOptions,
|
||||
fetchOptionById,
|
||||
selectorKey,
|
||||
selectorExcludeSelf,
|
||||
dependsOn,
|
||||
searchable = false,
|
||||
preserveLabelCase = false,
|
||||
@@ -136,21 +143,29 @@ export const Dropdown = memo(function Dropdown({
|
||||
: []
|
||||
: null
|
||||
|
||||
// Derived option lists read the block's own values (a model's valid reasoning efforts);
|
||||
// `dependsOn` already re-renders this control when one of those siblings changes.
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const blockValues = useSubBlockStore((state) =>
|
||||
activeWorkflowId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined
|
||||
)
|
||||
const evaluatedOptions = useMemo(() => {
|
||||
return typeof options === 'function' ? options() : options
|
||||
}, [options])
|
||||
if (typeof options === 'function') return options({ values: blockValues ?? {} })
|
||||
return options ?? EMPTY_OPTIONS
|
||||
}, [options, blockValues])
|
||||
|
||||
const {
|
||||
fetchedOptions,
|
||||
isLoadingOptions,
|
||||
fetchError,
|
||||
hydratedOption,
|
||||
isDynamic,
|
||||
refetch: refetchOptions,
|
||||
} = useFetchedOptions({
|
||||
blockId,
|
||||
dependsOnFields,
|
||||
fetchOptions,
|
||||
fetchOptionById,
|
||||
selectorKey,
|
||||
selectorExcludeSelf,
|
||||
isPreview: Boolean(isPreview),
|
||||
disabled: Boolean(disabled),
|
||||
valueToHydrate: singleValue,
|
||||
@@ -175,9 +190,7 @@ export const Dropdown = memo(function Dropdown({
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
let opts: DropdownOption[] =
|
||||
fetchOptions && normalizedFetchedOptions.length > 0
|
||||
? normalizedFetchedOptions
|
||||
: evaluatedOptions
|
||||
isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : evaluatedOptions
|
||||
|
||||
if (hydratedOption) {
|
||||
const alreadyPresent = opts.some((o) =>
|
||||
@@ -189,7 +202,7 @@ export const Dropdown = memo(function Dropdown({
|
||||
}
|
||||
|
||||
return opts
|
||||
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
|
||||
}, [isDynamic, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
|
||||
|
||||
/**
|
||||
* Operation IDs whose resolved tool is denied by the caller's permission
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ interface EnvVarDropdownProps {
|
||||
/** Maximum height for the dropdown */
|
||||
maxHeight?: string
|
||||
/** Reference to the input element for caret positioning */
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-62
@@ -11,9 +11,6 @@ import {
|
||||
PopoverSection,
|
||||
usePopoverContext,
|
||||
} from '@sim/emcn'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import {
|
||||
getEffectiveBlockOutputType,
|
||||
getOutputPathsFromSchema,
|
||||
@@ -28,19 +25,14 @@ import type {
|
||||
NestedTagChild,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/types'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useWorkflowReferenceScope } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const EMPTY_VARIABLES: Variable[] = []
|
||||
|
||||
/**
|
||||
* Context for sharing nested navigation state between components.
|
||||
* This enables unlimited nesting depth with a single back button.
|
||||
@@ -89,7 +81,7 @@ interface TagDropdownProps {
|
||||
/** Custom styles for positioning */
|
||||
style?: React.CSSProperties
|
||||
/** Reference to the input element for caret positioning */
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>
|
||||
}
|
||||
|
||||
interface TagComputationResult {
|
||||
@@ -197,16 +189,13 @@ const ensureRootTag = (tags: string[], rootTag: string): string[] => {
|
||||
const getOutputTypeForPath = (
|
||||
block: BlockState,
|
||||
blockConfig: BlockConfig | null,
|
||||
blockId: string,
|
||||
outputPath: string,
|
||||
mergedSubBlocksOverride?: Record<string, any>
|
||||
subBlocks: Record<string, any>
|
||||
): string => {
|
||||
if (block?.type === 'variables') {
|
||||
return 'any'
|
||||
}
|
||||
|
||||
const subBlocks =
|
||||
mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(block?.triggerMode && isTriggerCapable)
|
||||
|
||||
@@ -472,13 +461,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
const blockConfig = getBlock(block.type)
|
||||
const mergedSubBlocks = getMergedSubBlocks(group.blockId)
|
||||
|
||||
childType = getOutputTypeForPath(
|
||||
block,
|
||||
blockConfig || null,
|
||||
group.blockId,
|
||||
outputPath,
|
||||
mergedSubBlocks
|
||||
)
|
||||
childType = getOutputTypeForPath(block, blockConfig || null, outputPath, mergedSubBlocks)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -667,13 +650,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
const blockConfig = getBlock(block.type)
|
||||
const mergedSubBlocks = getMergedSubBlocks(group.blockId)
|
||||
|
||||
tagDescription = getOutputTypeForPath(
|
||||
block,
|
||||
blockConfig || null,
|
||||
group.blockId,
|
||||
outputPath,
|
||||
mergedSubBlocks
|
||||
)
|
||||
tagDescription = getOutputTypeForPath(block, blockConfig || null, outputPath, mergedSubBlocks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,16 +927,17 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
inputValueRef.current = inputValue
|
||||
cursorPositionRef.current = cursorPosition
|
||||
|
||||
const { blocks, edges, loops, parallels } = useWorkflowStore(
|
||||
useShallow((state) => ({
|
||||
blocks: state.blocks,
|
||||
edges: state.edges,
|
||||
loops: state.loops || {},
|
||||
parallels: state.parallels || {},
|
||||
}))
|
||||
)
|
||||
|
||||
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
// The workflow being referenced — the editor's own on the canvas, a supplied one on a
|
||||
// surface configuring a block that lives in another workflow (see `WorkflowReferenceScope`).
|
||||
const {
|
||||
blocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
workflowId,
|
||||
subBlockValues: workflowSubBlockValues,
|
||||
variables: workflowVariables,
|
||||
} = useWorkflowReferenceScope()
|
||||
const rawAccessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const combinedAccessiblePrefixes = useMemo(() => {
|
||||
@@ -967,10 +945,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
return new Set<string>(rawAccessiblePrefixes)
|
||||
}, [rawAccessiblePrefixes])
|
||||
|
||||
const workflowSubBlockValues = useSubBlockStore(
|
||||
(state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES
|
||||
)
|
||||
|
||||
const getMergedSubBlocks = useCallback(
|
||||
(targetBlockId: string): Record<string, any> => {
|
||||
const base = blocks[targetBlockId]?.subBlocks || {}
|
||||
@@ -984,18 +958,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
[blocks, workflowSubBlockValues]
|
||||
)
|
||||
|
||||
const workflowVariables = useStoreWithEqualityFn(
|
||||
useVariablesStore,
|
||||
useCallback(
|
||||
(state) =>
|
||||
workflowId
|
||||
? Object.values(state.variables).filter((variable) => variable.workflowId === workflowId)
|
||||
: EMPTY_VARIABLES,
|
||||
[workflowId]
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
|
||||
const searchTerm = useMemo(
|
||||
() => getTagSearchTerm(inputValue, cursorPosition),
|
||||
[inputValue, cursorPosition]
|
||||
@@ -1479,17 +1441,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const parts = tag.split('.')
|
||||
if (parts.length >= 3 && blockGroup) {
|
||||
const arrayFieldName = parts[1]
|
||||
const block = useWorkflowStore.getState().blocks[blockGroup.blockId]
|
||||
const block = blocks[blockGroup.blockId]
|
||||
const blockConfig = block ? (getBlock(block.type) ?? null) : null
|
||||
const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId)
|
||||
|
||||
const fieldType = getOutputTypeForPath(
|
||||
block,
|
||||
blockConfig,
|
||||
blockGroup.blockId,
|
||||
arrayFieldName,
|
||||
mergedSubBlocks
|
||||
)
|
||||
const fieldType = getOutputTypeForPath(block, blockConfig, arrayFieldName, mergedSubBlocks)
|
||||
|
||||
if (fieldType === 'file' || fieldType === 'file[]' || fieldType === 'array') {
|
||||
const blockName = parts[0]
|
||||
|
||||
+89
-5
@@ -2,8 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -20,8 +23,18 @@ interface UseFetchedOptionsProps {
|
||||
blockId: string
|
||||
/** Sibling subblock ids this list is scoped by; a change refetches. */
|
||||
dependsOnFields: string[]
|
||||
fetchOptions?: (blockId: string) => Promise<FetchedOption[]>
|
||||
fetchOptionById?: (blockId: string, optionId: string) => Promise<FetchedOption | null>
|
||||
/**
|
||||
* The registered selector supplying this control's options.
|
||||
*
|
||||
* This is the ONLY way a sub-block loads a remote list. A selector is parameterized by an
|
||||
* explicit {@link SelectorContext} built from the block's own values, so the same definition
|
||||
* serves the canvas, the fork sync modal, and any future surface. The alternative that used
|
||||
* to live here — a per-block `fetchOptions(blockId)` reading the live store — could only ever
|
||||
* work on the canvas, and was in every case a duplicate of a selector that already existed.
|
||||
*/
|
||||
selectorKey?: SelectorKey
|
||||
/** Drop the hosting workflow from the list — see `SubBlockConfig.selectorExcludeSelf`. */
|
||||
selectorExcludeSelf?: boolean
|
||||
isPreview: boolean
|
||||
disabled: boolean
|
||||
/**
|
||||
@@ -35,6 +48,13 @@ interface UseFetchedOptionsProps {
|
||||
|
||||
export interface UseFetchedOptionsResult {
|
||||
fetchedOptions: FetchedOption[]
|
||||
/**
|
||||
* Whether this control loads its options remotely at all. Controls use it to decide whether
|
||||
* `fetchedOptions` or the static `options` array is authoritative — a question they used to
|
||||
* answer by testing the `fetchOptions` prop, which stops being true once the source is a
|
||||
* `selectorKey` instead.
|
||||
*/
|
||||
isDynamic: boolean
|
||||
isLoadingOptions: boolean
|
||||
fetchError: string | null
|
||||
hydratedOption: FetchedOption | null
|
||||
@@ -61,8 +81,8 @@ function hasLocalOption(options: readonly LocalOption[], id: string): boolean {
|
||||
export function useFetchedOptions({
|
||||
blockId,
|
||||
dependsOnFields,
|
||||
fetchOptions,
|
||||
fetchOptionById,
|
||||
selectorKey,
|
||||
selectorExcludeSelf,
|
||||
isPreview,
|
||||
disabled,
|
||||
valueToHydrate,
|
||||
@@ -94,6 +114,69 @@ export function useFetchedOptions({
|
||||
isEqual
|
||||
)
|
||||
|
||||
/**
|
||||
* The block's live sub-block values merged over its persisted ones — the shape
|
||||
* `buildSelectorContextFromBlock` reads. Resolved at call time rather than memoized so a
|
||||
* selector always fetches against what the user has actually chosen, not a stale snapshot.
|
||||
*/
|
||||
const readSelectorContext = useCallback(() => {
|
||||
const block = useWorkflowStore.getState().blocks[blockId]
|
||||
if (!block?.type) return null
|
||||
const live = activeWorkflowId
|
||||
? (useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {})
|
||||
: {}
|
||||
const merged: Record<string, { value?: unknown }> = { ...(block.subBlocks ?? {}) }
|
||||
for (const [id, value] of Object.entries(live)) merged[id] = { ...merged[id], value }
|
||||
const context = buildSelectorContextFromBlock(block.type, merged, {
|
||||
workflowId: activeWorkflowId ?? undefined,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
canonicalModes: block.data?.canonicalModes,
|
||||
})
|
||||
if (selectorExcludeSelf && activeWorkflowId) context.excludeWorkflowId = activeWorkflowId
|
||||
return context
|
||||
}, [blockId, activeWorkflowId, workspaceId, selectorExcludeSelf])
|
||||
|
||||
const selectorDefinition = selectorKey ? getSelectorDefinition(selectorKey) : undefined
|
||||
|
||||
/**
|
||||
* A selector-backed control reuses this hook's whole lifecycle by presenting the registry
|
||||
* through the same two function shapes the props already describe — so there is one fetch
|
||||
* path, not a second system running alongside it.
|
||||
*
|
||||
* Memoized on `dependencyValues` so a changed parent (a newly picked credential) yields a
|
||||
* new identity and the scope reset below refetches, exactly as it does for a prop fetcher.
|
||||
*/
|
||||
const fetchOptions = useMemo(() => {
|
||||
if (!selectorDefinition) return undefined
|
||||
const definition = selectorDefinition
|
||||
return async (): Promise<FetchedOption[]> => {
|
||||
const context = readSelectorContext()
|
||||
if (!context) return []
|
||||
const args = { key: definition.key, context }
|
||||
// The selector's own readiness gate: an unset credential yields an empty list rather
|
||||
// than an error, which is how every other selector-backed control already behaves.
|
||||
if (definition.enabled && !definition.enabled(args)) return []
|
||||
// Shared with search/replace and value resolution, so a paginated selector drains the
|
||||
// same bounded way here as everywhere else instead of silently showing one page.
|
||||
const options = await loadAllSelectorOptions(definition, args)
|
||||
return options.map((option) => ({ id: option.id, label: option.label }))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- dependencyValues is the refetch scope
|
||||
}, [selectorDefinition, readSelectorContext, dependencyValues])
|
||||
|
||||
/** Label hydration for a stored id, from the same definition. */
|
||||
const fetchOptionById = useMemo(() => {
|
||||
const definition = selectorDefinition
|
||||
const fetchById = definition?.fetchById
|
||||
if (!definition || !fetchById) return undefined
|
||||
return async (_blockId: string, optionId: string, signal?: AbortSignal) => {
|
||||
const context = readSelectorContext()
|
||||
if (!context) return null
|
||||
const option = await fetchById({ key: definition.key, context, detailId: optionId, signal })
|
||||
return option ? { id: option.id, label: option.label } : null
|
||||
}
|
||||
}, [selectorDefinition, readSelectorContext])
|
||||
|
||||
const [fetchedOptions, setFetchedOptions] = useState<FetchedOption[]>([])
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
@@ -120,7 +203,7 @@ export function useFetchedOptions({
|
||||
setIsLoadingOptions(true)
|
||||
setFetchError(null)
|
||||
try {
|
||||
const options = await fetchOptions(blockId)
|
||||
const options = await fetchOptions()
|
||||
if (requestId !== fetchRequestIdRef.current) return
|
||||
setFetchedOptions(options)
|
||||
} catch (error) {
|
||||
@@ -229,6 +312,7 @@ export function useFetchedOptions({
|
||||
|
||||
return {
|
||||
fetchedOptions,
|
||||
isDynamic: Boolean(fetchOptions),
|
||||
isLoadingOptions,
|
||||
fetchError,
|
||||
hydratedOption,
|
||||
|
||||
+4
-4
@@ -680,8 +680,8 @@ function SubBlockComponent({
|
||||
previewValue={previewValue}
|
||||
disabled={isDisabled}
|
||||
multiSelect={config.multiSelect}
|
||||
fetchOptions={config.fetchOptions}
|
||||
fetchOptionById={config.fetchOptionById}
|
||||
selectorKey={config.selectorKey}
|
||||
selectorExcludeSelf={config.selectorExcludeSelf}
|
||||
dependsOn={config.dependsOn}
|
||||
searchable={config.searchable}
|
||||
preserveLabelCase={config.preserveLabelCase}
|
||||
@@ -715,8 +715,8 @@ function SubBlockComponent({
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
config={config}
|
||||
fetchOptions={config.fetchOptions}
|
||||
fetchOptionById={config.fetchOptionById}
|
||||
selectorKey={config.selectorKey}
|
||||
selectorExcludeSelf={config.selectorExcludeSelf}
|
||||
dependsOn={config.dependsOn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import {
|
||||
buildWorkflowReferenceScope,
|
||||
type WorkflowReferenceScope,
|
||||
WorkflowReferenceScopeProvider,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const WORKFLOW_ID = 'wf-1'
|
||||
|
||||
function block(id: string, name: string): BlockState {
|
||||
return { id, name, type: 'agent', subBlocks: {} } as unknown as BlockState
|
||||
}
|
||||
|
||||
/** A → B → C, so only A is upstream of B and only A/B are upstream of C. */
|
||||
const GRAPH = {
|
||||
blocks: { a: block('a', 'Alpha'), b: block('b', 'Bravo'), c: block('c', 'Charlie') },
|
||||
edges: [
|
||||
{ id: 'e1', source: 'a', target: 'b' },
|
||||
{ id: 'e2', source: 'b', target: 'c' },
|
||||
],
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
result: () => Set<string> | undefined
|
||||
renderCount: () => number
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function renderPrefixes(blockId: string | undefined, scope?: WorkflowReferenceScope): Harness {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
let latest: Set<string> | undefined
|
||||
let renders = 0
|
||||
|
||||
function Probe() {
|
||||
renders += 1
|
||||
latest = useAccessibleReferencePrefixes(blockId)
|
||||
return null
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
scope ? (
|
||||
<WorkflowReferenceScopeProvider scope={scope}>
|
||||
<Probe />
|
||||
</WorkflowReferenceScopeProvider>
|
||||
) : (
|
||||
<Probe />
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
result: () => latest,
|
||||
renderCount: () => renders,
|
||||
unmount: () => act(() => root.unmount()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAccessibleReferencePrefixes', () => {
|
||||
beforeEach(() => {
|
||||
useWorkflowStore.setState({ ...GRAPH, loops: {}, parallels: {} })
|
||||
useWorkflowRegistry.setState({ activeWorkflowId: WORKFLOW_ID })
|
||||
useSubBlockStore.setState({ workflowValues: { [WORKFLOW_ID]: {} } })
|
||||
})
|
||||
|
||||
it('offers only the referencing block and its ancestors on the canvas', () => {
|
||||
const harness = renderPrefixes('c')
|
||||
const prefixes = harness.result()
|
||||
expect(prefixes?.has(normalizeName('Alpha'))).toBe(true)
|
||||
expect(prefixes?.has(normalizeName('Bravo'))).toBe(true)
|
||||
expect(prefixes?.has(normalizeName('Charlie'))).toBe(true)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('excludes a block that is downstream of the referencing one', () => {
|
||||
const harness = renderPrefixes('a')
|
||||
expect(harness.result()?.has(normalizeName('Bravo'))).toBe(false)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('does not re-render when a sub-block VALUE changes', () => {
|
||||
// Reachability cannot change with the text being typed, and this hook runs in every
|
||||
// reference-aware sub-block editor at once. Subscribing it to the sub-block store would
|
||||
// re-render all of them on every keystroke anywhere in the workflow.
|
||||
const harness = renderPrefixes('c')
|
||||
const before = harness.renderCount()
|
||||
act(() => {
|
||||
useSubBlockStore.setState({ workflowValues: { [WORKFLOW_ID]: { a: { prompt: 'typing' } } } })
|
||||
})
|
||||
expect(harness.renderCount()).toBe(before)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('still re-renders when the graph itself changes', () => {
|
||||
const harness = renderPrefixes('c')
|
||||
const before = harness.renderCount()
|
||||
act(() => {
|
||||
useWorkflowStore.setState({
|
||||
blocks: { ...GRAPH.blocks, a: block('a', 'Renamed') },
|
||||
})
|
||||
})
|
||||
expect(harness.renderCount()).toBeGreaterThan(before)
|
||||
expect(harness.result()?.has(normalizeName('Renamed'))).toBe(true)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('resolves against a supplied graph instead of the editor’s', () => {
|
||||
const scope = buildWorkflowReferenceScope({
|
||||
workflowId: 'other-wf',
|
||||
blocks: { x: block('x', 'Extract'), y: block('y', 'Load') },
|
||||
edges: [{ id: 'e', source: 'x', target: 'y' }],
|
||||
referencingBlockId: 'y',
|
||||
})
|
||||
const harness = renderPrefixes('y', scope)
|
||||
const prefixes = harness.result()
|
||||
expect(prefixes?.has(normalizeName('Extract'))).toBe(true)
|
||||
// The editor's own workflow must not leak in.
|
||||
expect(prefixes?.has(normalizeName('Alpha'))).toBe(false)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('offers every block when the referencing one is absent from the supplied graph', () => {
|
||||
// A block this sync is about to ADD has no position in the target workflow yet; the
|
||||
// ancestor walk would return nothing and suggest nothing at all.
|
||||
const scope = buildWorkflowReferenceScope({
|
||||
workflowId: 'other-wf',
|
||||
blocks: { x: block('x', 'Extract'), y: block('y', 'Load') },
|
||||
edges: [{ id: 'e', source: 'x', target: 'y' }],
|
||||
referencingBlockId: 'not-in-this-workflow',
|
||||
})
|
||||
const harness = renderPrefixes('not-in-this-workflow', scope)
|
||||
const prefixes = harness.result()
|
||||
expect(prefixes?.has(normalizeName('Extract'))).toBe(true)
|
||||
expect(prefixes?.has(normalizeName('Load'))).toBe(true)
|
||||
harness.unmount()
|
||||
})
|
||||
})
|
||||
+16
-14
@@ -1,29 +1,31 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
|
||||
import { useWorkflowReferenceGraph } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export function useAccessibleReferencePrefixes(blockId?: string | null): Set<string> | undefined {
|
||||
const { blocks, edges, loops, parallels } = useWorkflowStore(
|
||||
useShallow((state) => ({
|
||||
blocks: state.blocks,
|
||||
edges: state.edges,
|
||||
loops: state.loops || {},
|
||||
parallels: state.parallels || {},
|
||||
}))
|
||||
)
|
||||
// The GRAPH only — this runs on every keystroke in every reference-aware sub-block editor,
|
||||
// and reachability cannot change with the values being typed.
|
||||
const { blocks, edges, loops, parallels, unrestricted } = useWorkflowReferenceGraph()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!blockId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
|
||||
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
|
||||
const accessibleIds = new Set<string>(ancestorIds)
|
||||
const accessibleIds = new Set<string>()
|
||||
if (unrestricted) {
|
||||
// The referencing block is not in this graph, so there is no path to walk. Every block
|
||||
// is offered instead of none — see `WorkflowReferenceScope.unrestricted`.
|
||||
Object.keys(blocks).forEach((id) => accessibleIds.add(id))
|
||||
} else {
|
||||
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
|
||||
BlockPathCalculator.findAllPathNodes(graphEdges, blockId).forEach((id) =>
|
||||
accessibleIds.add(id)
|
||||
)
|
||||
}
|
||||
accessibleIds.add(blockId)
|
||||
|
||||
Object.values(loops as Record<string, Loop>).forEach((loop) => {
|
||||
@@ -46,5 +48,5 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
|
||||
SYSTEM_REFERENCE_PREFIXES.forEach((prefix) => prefixes.add(prefix))
|
||||
|
||||
return prefixes
|
||||
}, [blockId, blocks, edges, loops, parallels])
|
||||
}, [blockId, blocks, edges, loops, parallels, unrestricted])
|
||||
}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const EMPTY_VARIABLES: Variable[] = []
|
||||
const EMPTY_BLOCKS: Record<string, BlockState> = {}
|
||||
const EMPTY_EDGES: Edge[] = []
|
||||
const EMPTY_LOOPS: Record<string, Loop> = {}
|
||||
const EMPTY_PARALLELS: Record<string, Parallel> = {}
|
||||
|
||||
/**
|
||||
* The workflow a `<block.output>` autocomplete resolves against.
|
||||
*
|
||||
* On the canvas this is the workflow open in the editor, read from the live stores — which is
|
||||
* why every reference surface used to read those stores directly. Off-canvas there IS no open
|
||||
* workflow, so a surface that configures a block belonging to some OTHER workflow (the fork
|
||||
* sync modal configuring a custom block in a target workspace's workflow) supplies the graph
|
||||
* itself. Both paths then produce identical, position-aware suggestions.
|
||||
*/
|
||||
/**
|
||||
* The graph half of a scope: everything a block's REACHABILITY depends on.
|
||||
*
|
||||
* Split out from the rest because it is what the reference-validation path reads, on every
|
||||
* keystroke, from several sub-block editors at once. Widening that read to the whole scope
|
||||
* would subscribe those editors to live sub-block values — which change on every keystroke
|
||||
* anywhere in the workflow — and re-render them for edits that cannot affect reachability.
|
||||
*/
|
||||
export interface WorkflowReferenceGraph {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
/**
|
||||
* Offer every block in the graph rather than only the referencing block's ancestors.
|
||||
*
|
||||
* The ancestor walk needs the referencing block to BE in the graph. A surface configuring a
|
||||
* block the graph does not contain yet — the fork sync modal reaching a block this sync is
|
||||
* about to add — would otherwise resolve to an empty ancestor set and suggest nothing at all.
|
||||
* Showing the whole workflow is the honest degradation: over-offering a reference the user
|
||||
* can see is wrong beats offering none.
|
||||
*/
|
||||
unrestricted?: boolean
|
||||
}
|
||||
|
||||
export interface WorkflowReferenceScope extends WorkflowReferenceGraph {
|
||||
/** Scopes workflow-level variables; `null` when no workflow is in scope. */
|
||||
workflowId: string | null
|
||||
/**
|
||||
* Unsaved sub-block edits layered over `blocks`, so a reference reflects what the user is
|
||||
* typing rather than what was last persisted. Empty off-canvas: nothing is being edited there.
|
||||
*/
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
variables: Variable[]
|
||||
}
|
||||
|
||||
const WorkflowReferenceScopeContext = createContext<WorkflowReferenceScope | null>(null)
|
||||
|
||||
interface WorkflowReferenceScopeProviderProps {
|
||||
scope: WorkflowReferenceScope
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every reference autocomplete inside at a workflow other than the one open in the
|
||||
* editor. Canvas surfaces mount no provider and keep reading the live stores.
|
||||
*/
|
||||
export function WorkflowReferenceScopeProvider({
|
||||
scope,
|
||||
children,
|
||||
}: WorkflowReferenceScopeProviderProps) {
|
||||
return (
|
||||
<WorkflowReferenceScopeContext.Provider value={scope}>
|
||||
{children}
|
||||
</WorkflowReferenceScopeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph a reference resolves against: a supplied scope's when one is provided, the live
|
||||
* editor's otherwise.
|
||||
*
|
||||
* The live read runs either way — hooks cannot be called conditionally — which costs nothing
|
||||
* off-canvas, where the workflow store holds no workflow and never updates. It subscribes to
|
||||
* exactly the workflow-store slice the canvas always did, and nothing else.
|
||||
*/
|
||||
export function useWorkflowReferenceGraph(): WorkflowReferenceGraph {
|
||||
const provided = useContext(WorkflowReferenceScopeContext)
|
||||
const live = useWorkflowStore(
|
||||
useShallow((state) => ({
|
||||
blocks: state.blocks,
|
||||
edges: state.edges,
|
||||
loops: state.loops || EMPTY_LOOPS,
|
||||
parallels: state.parallels || EMPTY_PARALLELS,
|
||||
}))
|
||||
)
|
||||
return provided ?? live
|
||||
}
|
||||
|
||||
/** The editor's own workflow-scoped values, straight from the live stores. */
|
||||
function useLiveWorkflowValues(): Omit<WorkflowReferenceScope, keyof WorkflowReferenceGraph> {
|
||||
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const subBlockValues = useSubBlockStore(
|
||||
(state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES
|
||||
)
|
||||
const variables = useStoreWithEqualityFn(
|
||||
useVariablesStore,
|
||||
useCallback(
|
||||
(state) =>
|
||||
workflowId
|
||||
? Object.values(state.variables).filter((variable) => variable.workflowId === workflowId)
|
||||
: EMPTY_VARIABLES,
|
||||
[workflowId]
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
return { workflowId, subBlockValues, variables }
|
||||
}
|
||||
|
||||
/**
|
||||
* The full scope, for surfaces that render the tag list itself (block outputs need live
|
||||
* sub-block values to type their fields; workflow variables are their own tag group).
|
||||
*/
|
||||
export function useWorkflowReferenceScope(): WorkflowReferenceScope {
|
||||
const provided = useContext(WorkflowReferenceScopeContext)
|
||||
const graph = useWorkflowReferenceGraph()
|
||||
const live = useLiveWorkflowValues()
|
||||
const values = provided ?? live
|
||||
return useMemo(
|
||||
() => ({
|
||||
...graph,
|
||||
workflowId: values.workflowId,
|
||||
subBlockValues: values.subBlockValues,
|
||||
variables: values.variables,
|
||||
}),
|
||||
[graph, values.workflowId, values.subBlockValues, values.variables]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A scope for a workflow loaded outside the editor. `unrestricted` is derived rather than
|
||||
* asked for: it is exactly the case where the referencing block is absent from the graph, and
|
||||
* making the caller work that out invites getting it wrong.
|
||||
*/
|
||||
export function buildWorkflowReferenceScope(params: {
|
||||
workflowId: string | null
|
||||
blocks: Record<string, BlockState> | undefined
|
||||
edges: Edge[] | undefined
|
||||
loops?: Record<string, Loop>
|
||||
parallels?: Record<string, Parallel>
|
||||
variables?: Variable[]
|
||||
/** The block whose position filters the suggestions. */
|
||||
referencingBlockId?: string
|
||||
}): WorkflowReferenceScope {
|
||||
const blocks = params.blocks ?? EMPTY_BLOCKS
|
||||
return {
|
||||
workflowId: params.workflowId,
|
||||
blocks,
|
||||
edges: params.edges ?? EMPTY_EDGES,
|
||||
loops: params.loops ?? EMPTY_LOOPS,
|
||||
parallels: params.parallels ?? EMPTY_PARALLELS,
|
||||
subBlockValues: EMPTY_SUBBLOCK_VALUES,
|
||||
variables: params.variables ?? EMPTY_VARIABLES,
|
||||
unrestricted: !params.referencingBlockId || !blocks[params.referencingBlockId],
|
||||
}
|
||||
}
|
||||
@@ -904,7 +904,7 @@ describe.concurrent('Blocks Module', () => {
|
||||
(sb) => sb.id === 'model' && sb.condition?.value === provider
|
||||
)
|
||||
if (provider === 'openrouter') {
|
||||
expect(modelSubBlock?.fetchOptions).toBeTypeOf('function')
|
||||
expect(modelSubBlock?.selectorKey).toBeTypeOf('string')
|
||||
} else {
|
||||
expect(
|
||||
Array.isArray(modelSubBlock?.options) ? modelSubBlock.options.length : 0
|
||||
|
||||
+25
-109
@@ -25,8 +25,6 @@ import {
|
||||
isAutoModel,
|
||||
supportsTemperature,
|
||||
} from '@/providers/models'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('AgentBlock')
|
||||
@@ -176,49 +174,19 @@ Return ONLY the JSON array.`,
|
||||
title: 'Reasoning Effort',
|
||||
type: 'combobox',
|
||||
placeholder: 'Type or select reasoning effort...',
|
||||
options: [
|
||||
{ label: 'auto', id: 'auto' },
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
],
|
||||
dependsOn: ['model'],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
options: (params) => {
|
||||
const autoOption = { label: 'auto', id: 'auto' }
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) {
|
||||
return [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]
|
||||
const blockValues = workflowValues?.[blockId]
|
||||
const modelValue = blockValues?.model as string
|
||||
|
||||
if (!modelValue) {
|
||||
return [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const fallback = [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
const modelValue = params?.values.model
|
||||
if (typeof modelValue !== 'string' || !modelValue) return fallback
|
||||
const validOptions = getReasoningEffortValuesForModel(modelValue)
|
||||
if (!validOptions) {
|
||||
return [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
if (!validOptions) return fallback
|
||||
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
|
||||
},
|
||||
mode: 'advanced',
|
||||
@@ -229,49 +197,19 @@ Return ONLY the JSON array.`,
|
||||
title: 'Verbosity',
|
||||
type: 'combobox',
|
||||
placeholder: 'Type or select verbosity...',
|
||||
options: [
|
||||
{ label: 'auto', id: 'auto' },
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
],
|
||||
dependsOn: ['model'],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
options: (params) => {
|
||||
const autoOption = { label: 'auto', id: 'auto' }
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) {
|
||||
return [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]
|
||||
const blockValues = workflowValues?.[blockId]
|
||||
const modelValue = blockValues?.model as string
|
||||
|
||||
if (!modelValue) {
|
||||
return [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const fallback = [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
const modelValue = params?.values.model
|
||||
if (typeof modelValue !== 'string' || !modelValue) return fallback
|
||||
const validOptions = getVerbosityValuesForModel(modelValue)
|
||||
if (!validOptions) {
|
||||
return [
|
||||
autoOption,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
if (!validOptions) return fallback
|
||||
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
|
||||
},
|
||||
mode: 'advanced',
|
||||
@@ -282,36 +220,14 @@ Return ONLY the JSON array.`,
|
||||
title: 'Thinking Level',
|
||||
type: 'combobox',
|
||||
placeholder: 'Type or select thinking level...',
|
||||
options: [
|
||||
{ label: 'none', id: 'none' },
|
||||
{ label: 'minimal', id: 'minimal' },
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
{ label: 'max', id: 'max' },
|
||||
],
|
||||
dependsOn: ['model'],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
options: (params) => {
|
||||
const noneOption = { label: 'none', id: 'none' }
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) {
|
||||
return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }]
|
||||
}
|
||||
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]
|
||||
const blockValues = workflowValues?.[blockId]
|
||||
const modelValue = blockValues?.model as string
|
||||
|
||||
if (!modelValue) {
|
||||
return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }]
|
||||
}
|
||||
|
||||
const fallback = [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }]
|
||||
const modelValue = params?.values.model
|
||||
if (typeof modelValue !== 'string' || !modelValue) return fallback
|
||||
const validOptions = getThinkingLevelsForModel(modelValue)
|
||||
if (!validOptions) {
|
||||
return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }]
|
||||
}
|
||||
|
||||
if (!validOptions) return fallback
|
||||
return [noneOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
|
||||
},
|
||||
mode: 'advanced',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { QueryClient } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
|
||||
import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
@@ -57,7 +59,8 @@ vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
|
||||
import { CredentialGroupBlock } from '@/blocks/blocks/credential-group'
|
||||
|
||||
const WORKSPACE_LIST_KEY = credentialGroupKeys.list('workspace-1')
|
||||
const WORKSPACE_ID = 'workspace-1'
|
||||
const WORKSPACE_LIST_KEY = credentialGroupKeys.list(WORKSPACE_ID)
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
@@ -94,10 +97,13 @@ describe('credential group dynamic option resolution', () => {
|
||||
})
|
||||
|
||||
it('keeps the shared workspace credential-group list alive when one option resolution is cancelled', async () => {
|
||||
const subBlock = getCredentialGroupSubBlock()
|
||||
const fetchOptionById = subBlock.fetchOptionById
|
||||
const fetchOptions = subBlock.fetchOptions
|
||||
if (!fetchOptionById || !fetchOptions) throw new Error('credentialGroup resolvers are missing')
|
||||
const definition = getSelectorDefinition(
|
||||
getCredentialGroupSubBlock().selectorKey as SelectorKey
|
||||
)
|
||||
const context = { workspaceId: WORKSPACE_ID }
|
||||
const fetchOptionById = (_b: string, id: string, signal?: AbortSignal) =>
|
||||
definition.fetchById?.({ key: definition.key, context, detailId: id, signal })
|
||||
const fetchOptions = () => definition.fetchList?.({ key: definition.key, context })
|
||||
|
||||
const controller = new AbortController()
|
||||
const optionResolution = Promise.resolve(
|
||||
@@ -106,7 +112,7 @@ describe('credential group dynamic option resolution', () => {
|
||||
|
||||
await waitForRequestCount(1)
|
||||
|
||||
const sharedListConsumer = fetchOptions('block-1')
|
||||
const sharedListConsumer = fetchOptions()
|
||||
|
||||
controller.abort()
|
||||
await Promise.resolve()
|
||||
@@ -122,9 +128,16 @@ describe('credential group dynamic option resolution', () => {
|
||||
})
|
||||
|
||||
it('still cancels the underlying request when React Query cancels the shared list query', async () => {
|
||||
const subBlock = getCredentialGroupSubBlock()
|
||||
const fetchOptionById = subBlock.fetchOptionById
|
||||
if (!fetchOptionById) throw new Error('credentialGroup fetchOptionById is missing')
|
||||
const definition = getSelectorDefinition(
|
||||
getCredentialGroupSubBlock().selectorKey as SelectorKey
|
||||
)
|
||||
const fetchOptionById = (_b: string, id: string, signal?: AbortSignal) =>
|
||||
definition.fetchById?.({
|
||||
key: definition.key,
|
||||
context: { workspaceId: WORKSPACE_ID },
|
||||
detailId: id,
|
||||
signal,
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const optionResolution = Promise.resolve(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GridOffset } from '@sim/emcn/icons'
|
||||
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
|
||||
import {
|
||||
type CanonicalGroup,
|
||||
resolveActiveCanonicalValue,
|
||||
@@ -163,23 +162,11 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
|
||||
id: 'credentialGroup',
|
||||
title: 'Credential Group',
|
||||
type: 'dropdown',
|
||||
options: [],
|
||||
selectorKey: 'workspace.credentialGroups',
|
||||
required: { field: 'operation', value: [...GROUP_OPERATIONS] },
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'credentialGroupId',
|
||||
condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
|
||||
fetchOptions: async () => {
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
return groups
|
||||
.filter((group) => group.status === 'active')
|
||||
.map((group) => ({ label: group.name, id: group.id }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchOptionById: async (_blockId: string, optionId: string) => {
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === optionId)
|
||||
return group ? { label: group.name, id: group.id } : null
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'manualCredentialGroup',
|
||||
@@ -203,44 +190,14 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
|
||||
id: 'providerFilter',
|
||||
title: 'Provider',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'workspace.credentialGroupProviders',
|
||||
multiSelect: true,
|
||||
emptyIsValid: true,
|
||||
options: [],
|
||||
required: false,
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'credentialProviderIds',
|
||||
dependsOn: ['credentialGroupId'],
|
||||
condition: { field: 'operation', value: 'list_credentials' },
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
|
||||
if (!credentialGroupId) return []
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === credentialGroupId)
|
||||
if (!group) return []
|
||||
return group.options
|
||||
.filter((option) => option.status === 'active')
|
||||
.map((option) => {
|
||||
const service = getCredentialGroupProviderService(option.provider)
|
||||
return { id: service.providerId, label: service.name }
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
|
||||
if (!credentialGroupId) return null
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === credentialGroupId)
|
||||
const option = group?.options.find(
|
||||
(candidate) =>
|
||||
candidate.status === 'active' &&
|
||||
getCredentialGroupProviderService(candidate.provider).providerId === optionId
|
||||
)
|
||||
if (!option) return null
|
||||
return {
|
||||
id: optionId,
|
||||
label: getCredentialGroupProviderService(option.provider).name,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'manualProviderIds',
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { CredentialIcon } from '@/components/icons'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
|
||||
import {
|
||||
fetchWorkspaceCredentialList,
|
||||
WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
} from '@/hooks/queries/utils/fetch-workspace-credentials'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface CredentialBlockOutput {
|
||||
success: boolean
|
||||
@@ -65,37 +57,9 @@ export const CredentialBlock: BlockConfig<CredentialBlockOutput> = {
|
||||
id: 'providerFilter',
|
||||
title: 'Provider',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'workspace.credentialProviders',
|
||||
multiSelect: true,
|
||||
options: [],
|
||||
condition: { field: 'operation', value: 'list' },
|
||||
fetchOptions: async () => {
|
||||
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
|
||||
if (!workspaceId) return []
|
||||
|
||||
const credentials = await getQueryClient().fetchQuery({
|
||||
queryKey: workspaceCredentialKeys.list(workspaceId),
|
||||
queryFn: () => fetchWorkspaceCredentialList(workspaceId),
|
||||
staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
})
|
||||
|
||||
const seen = new Set<string>()
|
||||
const options: Array<{ label: string; id: string }> = []
|
||||
|
||||
for (const cred of credentials) {
|
||||
if (cred.type === 'oauth' && cred.providerId && !seen.has(cred.providerId)) {
|
||||
seen.add(cred.providerId)
|
||||
const serviceConfig = getServiceConfigByProviderId(cred.providerId)
|
||||
options.push({ label: serviceConfig?.name ?? cred.providerId, id: cred.providerId })
|
||||
}
|
||||
}
|
||||
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchOptionById: async (_blockId: string, optionId: string) => {
|
||||
const serviceConfig = getServiceConfigByProviderId(optionId)
|
||||
const label = serviceConfig?.name ?? optionId
|
||||
return { label, id: optionId }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
const { mockFetchQuery } = vi.hoisted(() => ({
|
||||
mockFetchQuery: vi.fn(),
|
||||
@@ -157,8 +159,9 @@ describe('Embeddings block', () => {
|
||||
(subBlock) => conditionProvider(subBlock) === 'openrouter'
|
||||
)
|
||||
expect(openRouterModels?.type).toBe('combobox')
|
||||
expect(openRouterModels?.options).toEqual([])
|
||||
expect(optionIds(await openRouterModels?.fetchOptions?.('block-1'))).toEqual(OPENROUTER_MODELS)
|
||||
const definition = getSelectorDefinition(openRouterModels?.selectorKey as SelectorKey)
|
||||
const options = await definition.fetchList?.({ key: definition.key, context: {} })
|
||||
expect(optionIds(options)).toEqual(OPENROUTER_MODELS)
|
||||
expect(mockFetchQuery).toHaveBeenCalledOnce()
|
||||
|
||||
expect(
|
||||
|
||||
@@ -16,23 +16,14 @@ import {
|
||||
normalizeOpenRouterEmbeddingModelId,
|
||||
} from '@/lib/embeddings/openrouter-models'
|
||||
import type { EmbeddingTaskType } from '@/lib/embeddings/types'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { BlockConfig, BlockMeta, SubBlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { providerModelsQueryOptions } from '@/hooks/queries/providers'
|
||||
import type { EmbeddingsResponse } from '@/tools/embeddings/types'
|
||||
|
||||
export const EMBEDDING_BLOCK_PROVIDERS = [...EMBEDDING_CATALOG_PROVIDERS, 'openrouter'] as const
|
||||
|
||||
type EmbeddingBlockProvider = (typeof EMBEDDING_BLOCK_PROVIDERS)[number]
|
||||
|
||||
async function fetchOpenRouterEmbeddingModelOptions() {
|
||||
const { models } = await getQueryClient().fetchQuery(
|
||||
providerModelsQueryOptions('openrouter-embeddings')
|
||||
)
|
||||
return models.map((model) => ({ label: model, id: model }))
|
||||
}
|
||||
|
||||
const TOOL_ID_BY_PROVIDER: Record<EmbeddingBlockProvider, string> = {
|
||||
openai: 'embeddings_openai',
|
||||
openrouter: 'embeddings_openrouter',
|
||||
@@ -81,8 +72,7 @@ MODEL_SUB_BLOCKS.push({
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'combobox',
|
||||
options: [],
|
||||
fetchOptions: fetchOpenRouterEmbeddingModelOptions,
|
||||
selectorKey: 'providers.openrouterEmbeddingModels',
|
||||
value: () => DEFAULT_OPENROUTER_EMBEDDING_MODEL,
|
||||
condition: { field: 'provider', value: 'openrouter' },
|
||||
dependsOn: ['provider'],
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { CodeIcon } from '@/components/icons'
|
||||
import { isSandboxesEnabled } from '@/lib/core/config/env-flags'
|
||||
import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages'
|
||||
import {
|
||||
fetchWorkspaceSandboxOption,
|
||||
fetchWorkspaceSandboxOptions,
|
||||
fetchWorkspaceSecretNameOptions,
|
||||
} from '@/lib/workflows/subblocks/options'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { CodeExecutionOutput } from '@/tools/function/types'
|
||||
|
||||
@@ -107,6 +102,7 @@ try {
|
||||
id: 'sandboxId',
|
||||
title: 'Sandbox',
|
||||
type: 'combobox',
|
||||
selectorKey: 'workspace.sandboxes',
|
||||
mode: 'advanced',
|
||||
searchable: true,
|
||||
// Empty means the default image — the picker must never auto-select for us.
|
||||
@@ -120,9 +116,6 @@ try {
|
||||
placeholder: 'Default image',
|
||||
description:
|
||||
'Sim sandbox dependencies, system packages, and managed CLIs available to this block. Shell can use Sim sandboxes from either language. Manage them in Settings > Sandboxes. Leaving this empty runs on the default image.',
|
||||
options: [],
|
||||
fetchOptions: (blockId) => fetchWorkspaceSandboxOptions(blockId),
|
||||
fetchOptionById: (blockId, optionId) => fetchWorkspaceSandboxOption(blockId, optionId),
|
||||
},
|
||||
{
|
||||
id: 'secretScope',
|
||||
@@ -143,6 +136,7 @@ try {
|
||||
id: 'mountedSecrets',
|
||||
title: 'Secrets',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'workspace.secretNames',
|
||||
context: 'tool-input',
|
||||
paramVisibility: 'user-only',
|
||||
multiSelect: true,
|
||||
@@ -152,10 +146,8 @@ try {
|
||||
* so the picker must preserve the displayed casing.
|
||||
*/
|
||||
preserveLabelCase: true,
|
||||
options: [],
|
||||
condition: { field: 'secretScope', value: 'selected' },
|
||||
placeholder: 'Select secrets this tool can read',
|
||||
fetchOptions: () => fetchWorkspaceSecretNameOptions(),
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { Library } from '@sim/emcn/icons'
|
||||
import {
|
||||
fetchTriggerTypeOptions,
|
||||
fetchWorkspaceWorkflowOptions,
|
||||
} from '@/lib/workflows/subblocks/options'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
export const LogsBlock: BlockConfig = {
|
||||
@@ -371,14 +367,13 @@ export const LogsV2Block: BlockConfig = {
|
||||
id: 'workflowSelector',
|
||||
title: 'Workflows',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'sim.workflows',
|
||||
multiSelect: true,
|
||||
options: [],
|
||||
placeholder: 'All workflows',
|
||||
description: 'Only include runs of these workflows. Leave empty for all.',
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'workflowIds',
|
||||
condition: { field: 'operation', value: 'query' },
|
||||
fetchOptions: () => fetchWorkspaceWorkflowOptions(),
|
||||
},
|
||||
{
|
||||
id: 'manualWorkflowIds',
|
||||
@@ -408,14 +403,13 @@ export const LogsV2Block: BlockConfig = {
|
||||
id: 'triggerSelector',
|
||||
title: 'Triggers',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'workspace.triggerTypes',
|
||||
multiSelect: true,
|
||||
options: [],
|
||||
placeholder: 'All triggers',
|
||||
description: 'Only include runs started this way. Leave empty for all.',
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'triggers',
|
||||
condition: { field: 'operation', value: 'query' },
|
||||
fetchOptions: () => fetchTriggerTypeOptions(),
|
||||
},
|
||||
{
|
||||
id: 'manualTriggers',
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { ClaudeIcon } from '@/components/icons'
|
||||
import {
|
||||
fetchManagedAgentAgentOptions,
|
||||
fetchManagedAgentEnvironmentOptions,
|
||||
fetchManagedAgentMemoryStoreOptions,
|
||||
fetchManagedAgentVaultOptions,
|
||||
} from '@/lib/managed-agents/subblock-options'
|
||||
import type { BlockConfig, BlockMeta } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
|
||||
@@ -264,6 +258,7 @@ export const ManagedAgentBlock: BlockConfig = {
|
||||
id: 'environmentType',
|
||||
title: 'Environment type',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'environmentType',
|
||||
required: true,
|
||||
condition: forOperations(SESSION_STARTING_OPERATIONS),
|
||||
options: [
|
||||
@@ -278,25 +273,23 @@ export const ManagedAgentBlock: BlockConfig = {
|
||||
id: 'agent',
|
||||
title: 'Agent',
|
||||
type: 'combobox',
|
||||
selectorKey: 'managedAgent.agents',
|
||||
required: true,
|
||||
placeholder: 'Select an agent from your Claude workspace…',
|
||||
commandSearchable: true,
|
||||
options: [],
|
||||
dependsOn: ['credential'],
|
||||
condition: forOperations(SESSION_STARTING_OPERATIONS),
|
||||
fetchOptions: fetchManagedAgentAgentOptions,
|
||||
},
|
||||
{
|
||||
id: 'environment',
|
||||
title: 'Environment',
|
||||
type: 'combobox',
|
||||
selectorKey: 'managedAgent.environments',
|
||||
required: true,
|
||||
placeholder: 'Select an environment…',
|
||||
commandSearchable: true,
|
||||
options: [],
|
||||
dependsOn: ['credential', 'environmentType'],
|
||||
condition: forOperations(SESSION_STARTING_OPERATIONS),
|
||||
fetchOptions: fetchManagedAgentEnvironmentOptions,
|
||||
},
|
||||
{
|
||||
id: 'userMessage',
|
||||
@@ -407,15 +400,14 @@ export const ManagedAgentBlock: BlockConfig = {
|
||||
id: 'vaults',
|
||||
title: 'Credential vaults',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'managedAgent.vaults',
|
||||
required: false,
|
||||
mode: 'advanced',
|
||||
placeholder: 'Optional — pick zero or more OAuth vaults',
|
||||
searchable: true,
|
||||
multiSelect: true,
|
||||
options: [],
|
||||
dependsOn: ['credential'],
|
||||
condition: forOperations(SESSION_STARTING_OPERATIONS),
|
||||
fetchOptions: fetchManagedAgentVaultOptions,
|
||||
},
|
||||
{
|
||||
id: 'vaultsAck',
|
||||
@@ -431,11 +423,11 @@ export const ManagedAgentBlock: BlockConfig = {
|
||||
id: 'memoryStoreId',
|
||||
title: 'Memory store',
|
||||
type: 'combobox',
|
||||
selectorKey: 'managedAgent.memoryStores',
|
||||
required: false,
|
||||
mode: 'advanced',
|
||||
placeholder: 'Optional — pick a memory store',
|
||||
commandSearchable: true,
|
||||
options: [],
|
||||
dependsOn: ['credential'],
|
||||
// Cloud only: memory stores attach as `resources[]`, which self-hosted
|
||||
// rejects. A self-hosted worker that uses a store reads its id from a
|
||||
@@ -444,7 +436,6 @@ export const ManagedAgentBlock: BlockConfig = {
|
||||
field: 'environmentType',
|
||||
value: 'cloud',
|
||||
}),
|
||||
fetchOptions: fetchManagedAgentMemoryStoreOptions,
|
||||
},
|
||||
{
|
||||
id: 'memoryAccess',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Blimp } from '@sim/emcn'
|
||||
import { fetchWorkspaceRawSecretNameOptions } from '@/lib/workflows/subblocks/options'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
@@ -99,14 +98,13 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
|
||||
id: 'mountedSecrets',
|
||||
title: 'Secrets',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'workspace.rawSecretNames',
|
||||
mode: 'advanced',
|
||||
hideFromCopilot: true,
|
||||
multiSelect: true,
|
||||
searchable: true,
|
||||
preserveLabelCase: true,
|
||||
options: [],
|
||||
condition: { field: 'secretScope', value: 'selected' },
|
||||
fetchOptions: () => fetchWorkspaceRawSecretNameOptions(),
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
|
||||
@@ -96,7 +96,13 @@ export function assembleCustomBlockInputMapping(params: Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** Map a Start input field type to the editor sub-block type used to collect it. */
|
||||
function subBlockTypeForField(fieldType: string): SubBlockType {
|
||||
/**
|
||||
* The sub-block a Start input field becomes on the canvas. Exported so any surface that has to
|
||||
* render or reason about a custom block's inputs derives the field's KIND from here instead of
|
||||
* re-deriving it — the fork sync modal renders its own controls but must agree with this about
|
||||
* what each field is.
|
||||
*/
|
||||
export function subBlockTypeForField(fieldType: string): SubBlockType {
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return 'switch'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.unmock('@/blocks/registry')
|
||||
|
||||
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
/**
|
||||
* Guards the two invariants a selector-backed sub-block relies on, both of which broke silently
|
||||
* when `fetchOptions` was replaced by `selectorKey`.
|
||||
*
|
||||
* A selector-backed field carries no static `options` — that is the point — so every consumer
|
||||
* has to tolerate the absence. The controls read `options` on first paint, before any fetch
|
||||
* resolves, and an unguarded `.map` there takes out the whole editor for that block.
|
||||
*/
|
||||
/**
|
||||
* Context fields supplied by a sibling SUB-BLOCK value. `workspaceId` / `workflowId` come from
|
||||
* the surface itself and `excludeWorkflowId` from a flag, so none of them can be declared as a
|
||||
* `dependsOn` and none belong here.
|
||||
*/
|
||||
const SUB_BLOCK_SOURCED = new Set(
|
||||
[...SELECTOR_CONTEXT_FIELDS].filter(
|
||||
(field) => field !== 'workspaceId' && field !== 'workflowId' && field !== 'excludeWorkflowId'
|
||||
)
|
||||
)
|
||||
|
||||
describe('selector-backed sub-blocks', () => {
|
||||
const selectorBacked = getAllBlocks().flatMap((block) =>
|
||||
((block.subBlocks ?? []) as Array<Record<string, any>>)
|
||||
.filter((sub) => sub.selectorKey)
|
||||
.map((sub) => ({ block: block.type, sub }))
|
||||
)
|
||||
|
||||
it('covers a meaningful number of fields', () => {
|
||||
// A guard on the guard: if the registry ever stops resolving, the assertions below would
|
||||
// pass vacuously over an empty list.
|
||||
expect(selectorBacked.length).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('names a selector that is actually registered and can list', () => {
|
||||
for (const { block, sub } of selectorBacked) {
|
||||
const definition = getSelectorDefinition(sub.selectorKey as SelectorKey)
|
||||
expect(
|
||||
Boolean(definition.fetchList || definition.fetchPage),
|
||||
`${block}.${sub.id} points at ${sub.selectorKey}, which can neither list nor page`
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('never also declares a static options array', () => {
|
||||
// Two sources for one list. The controls prefer fetched options only when non-empty, so a
|
||||
// leftover array would show through whenever the fetch is empty or still in flight.
|
||||
for (const { block, sub } of selectorBacked) {
|
||||
expect(
|
||||
sub.options,
|
||||
`${block}.${sub.id} declares both selectorKey and options`
|
||||
).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('declares dependsOn for every sub-block-sourced context field its selector reads', () => {
|
||||
// A selector's `getQueryKey` names every context field its RESULT depends on, and `enabled`
|
||||
// names what it is gated on. Both are probed rather than listing fields by hand, which is
|
||||
// what let `clickup.triggerWorkspaceId` ship without a `dependsOn`.
|
||||
//
|
||||
// The declaration is what makes the list refetch: `useFetchedOptions` resets its fetch scope
|
||||
// on `dependsOn` values changing. Without it the list loads once — before the credential is
|
||||
// picked, or against the old language — and never reloads.
|
||||
for (const { block, sub } of selectorBacked) {
|
||||
const definition = getSelectorDefinition(sub.selectorKey as SelectorKey)
|
||||
const probed = new Set<string>()
|
||||
const context = new Proxy({} as Record<string, unknown>, {
|
||||
get: (_target, property) => {
|
||||
if (typeof property === 'string') probed.add(property)
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const args = { key: definition.key, context }
|
||||
definition.getQueryKey(args)
|
||||
definition.enabled?.(args)
|
||||
|
||||
const needed = [...probed].filter((field) => SUB_BLOCK_SOURCED.has(field))
|
||||
if (needed.length === 0) continue
|
||||
|
||||
const dependsOn = sub.dependsOn
|
||||
const declared = new Set<string>(
|
||||
Array.isArray(dependsOn)
|
||||
? dependsOn
|
||||
: [...(dependsOn?.all ?? []), ...(dependsOn?.any ?? [])]
|
||||
)
|
||||
expect(
|
||||
declared.size > 0,
|
||||
`${block}.${sub.id} uses ${sub.selectorKey}, whose result depends on ${needed.join(', ')}, but declares no dependsOn — its list would never refetch`
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
+17
-11
@@ -329,7 +329,15 @@ export interface SubBlockConfig {
|
||||
defaultChecked?: boolean
|
||||
description?: string
|
||||
}[]
|
||||
| (() => {
|
||||
/**
|
||||
* Options DERIVED from the block's own values — no I/O. Receives the block's current
|
||||
* sub-block values so a list can narrow to a sibling's selection (the reasoning efforts a
|
||||
* chosen model actually supports). A remote list is never expressed here: it belongs to a
|
||||
* registered selector via `selectorKey`, which works off-canvas too.
|
||||
*
|
||||
* Existing zero-argument option functions keep working unchanged.
|
||||
*/
|
||||
| ((params?: { values: Record<string, unknown> }) => {
|
||||
label: string
|
||||
id: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
@@ -439,6 +447,14 @@ export interface SubBlockConfig {
|
||||
allowServiceAccounts?: boolean
|
||||
// Selector properties — declarative mapping to a SelectorKey
|
||||
selectorKey?: SelectorKey
|
||||
/**
|
||||
* Drop the workflow this block lives in from a `sim.workflows` list.
|
||||
*
|
||||
* A declared flag rather than a blanket rule, because "can this reference itself" differs by
|
||||
* field: the Sim trigger never receives events about its own workflow, while the Logs block
|
||||
* legitimately reads the logs of the workflow it runs in.
|
||||
*/
|
||||
selectorExcludeSelf?: boolean
|
||||
selectorAllowSearch?: boolean
|
||||
// File selector specific properties
|
||||
mimeType?: string
|
||||
@@ -490,16 +506,6 @@ export interface SubBlockConfig {
|
||||
dependsOn?: string[] | { all?: string[]; any?: string[] }
|
||||
// Copyable-text specific: Use webhook URL from webhook management hook
|
||||
useWebhookUrl?: boolean
|
||||
// Dropdown/Combobox: Function to fetch options dynamically
|
||||
// Works with both 'dropdown' (select-only) and 'combobox' (editable with expression support)
|
||||
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
|
||||
// Dropdown/Combobox: Function to fetch a single option's label by ID (for hydration)
|
||||
// Called when component mounts with a stored value to display the correct label before options load
|
||||
fetchOptionById?: (
|
||||
blockId: string,
|
||||
optionId: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<{ label: string; id: string } | null>
|
||||
/**
|
||||
* tool-input only: tool categories the consuming block cannot execute. They
|
||||
* stay visible in the picker but are greyed out with a tooltip rather than
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { subBlockTypeForField } from '@/blocks/custom/build-config'
|
||||
import {
|
||||
CUSTOM_BLOCK_BOOLEAN_FALSE,
|
||||
CUSTOM_BLOCK_BOOLEAN_TRUE,
|
||||
CUSTOM_BLOCK_BOOLEAN_UNSET,
|
||||
customBlockBooleanOptions,
|
||||
customBlockInputControl,
|
||||
isForkSyncConfigurableField,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control'
|
||||
|
||||
describe('customBlockInputControl', () => {
|
||||
@@ -21,12 +23,59 @@ describe('customBlockInputControl', () => {
|
||||
expect(customBlockInputControl('number')).toBe('input')
|
||||
})
|
||||
|
||||
it('refuses to offer a file input rather than rendering a text box for it', () => {
|
||||
// `file[]` is an upload on the canvas. A text box would write a plain string into a field
|
||||
// that expects file references — worse than not offering it, because it looks configured.
|
||||
expect(customBlockInputControl('file[]')).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('stays in step with the canvas mapping for every declared field type', () => {
|
||||
// The union a Start field can declare. Deriving from `subBlockTypeForField` means a type
|
||||
// added there surfaces here instead of silently falling through to a text box — which is
|
||||
// exactly how `file[]` came to be mis-rendered.
|
||||
const byCanvasKind = {
|
||||
switch: 'switch',
|
||||
code: 'textarea',
|
||||
'file-upload': 'unsupported',
|
||||
} as const
|
||||
|
||||
for (const fieldType of ['string', 'number', 'boolean', 'object', 'array', 'file[]']) {
|
||||
const canvasKind = subBlockTypeForField(fieldType) as keyof typeof byCanvasKind
|
||||
expect(customBlockInputControl(fieldType)).toBe(byCanvasKind[canvasKind] ?? 'input')
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to a plain input for an unknown or absent type', () => {
|
||||
expect(customBlockInputControl('something-new')).toBe('input')
|
||||
expect(customBlockInputControl(undefined)).toBe('input')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isForkSyncConfigurableField', () => {
|
||||
it('excludes a custom block’s file input from the Sync gate', () => {
|
||||
// The modal renders it disabled, so a REQUIRED one could never be satisfied: Sync would
|
||||
// stay off forever while the hint told the user to set it in a workflow they can only
|
||||
// reach by syncing. The sync no longer clears the field, so skipping the gate is safe.
|
||||
expect(isForkSyncConfigurableField({ parentKind: 'custom-block', fieldType: 'file[]' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('still gates every custom-block input that HAS a control', () => {
|
||||
for (const fieldType of ['string', 'number', 'boolean', 'object', 'array']) {
|
||||
expect(isForkSyncConfigurableField({ parentKind: 'custom-block', fieldType })).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves every other parent kind gated', () => {
|
||||
// Only custom-block inputs classify their own control here; a selector-backed dependent
|
||||
// is always configurable.
|
||||
for (const parentKind of ['credential', 'knowledge-base', 'table'] as const) {
|
||||
expect(isForkSyncConfigurableField({ parentKind, fieldType: 'file[]' })).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('customBlockBooleanOptions', () => {
|
||||
it('lets an OPTIONAL flag return to the workflow default', () => {
|
||||
// Without this a single click permanently pins the flag: a two-segment switch has no
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
|
||||
import { subBlockTypeForField } from '@/blocks/custom/build-config'
|
||||
|
||||
/**
|
||||
* Which control the sync modal renders for a repointed custom block's input, derived from the
|
||||
* field type its Start block declares.
|
||||
* Which control the sync modal renders for a repointed custom block's input.
|
||||
*
|
||||
* Mirrors `subBlockTypeForField` in `@/blocks/custom/build-config`, which decides the same thing
|
||||
* for the canvas — a field the user configures here must read and behave the way it will once
|
||||
* the block is open in the editor.
|
||||
* Derived from `subBlockTypeForField` — the same function that decides what the field becomes
|
||||
* on the canvas — rather than re-reading the raw field type. The modal cannot reuse the canvas
|
||||
* sub-block renderer (that one is bound to the workflow store, by workflow and block id), so it
|
||||
* draws its own controls; taking the field's KIND from one place is what stops the two drifting
|
||||
* when a field type is added. Re-deriving it is how `file[]` came to render as a text box.
|
||||
*
|
||||
* `unsupported` is a real outcome, not a fallback: a `file[]` input is an upload on the canvas,
|
||||
* and there is nothing meaningful to type for it here. A text box would write a plain string
|
||||
* into a field that expects file references.
|
||||
*/
|
||||
export type CustomBlockInputControl = 'switch' | 'textarea' | 'input'
|
||||
export type CustomBlockInputControl = 'switch' | 'textarea' | 'input' | 'unsupported'
|
||||
|
||||
export function customBlockInputControl(fieldType: string | undefined): CustomBlockInputControl {
|
||||
switch (fieldType) {
|
||||
// Stored as a real boolean on the canvas (its sub-block is a `switch`), so it must be
|
||||
// toggled here rather than typed — a text field would persist the string `'true'`.
|
||||
case 'boolean':
|
||||
switch (subBlockTypeForField(fieldType ?? '')) {
|
||||
// Stored as a real boolean on the canvas, so it must be toggled rather than typed — a text
|
||||
// field would persist the string `'true'`.
|
||||
case 'switch':
|
||||
return 'switch'
|
||||
// Authored as JSON and parsed by the executor before the child receives it.
|
||||
case 'object':
|
||||
case 'array':
|
||||
// A JSON editor on the canvas. The modal has no editor, but the value is the same JSON
|
||||
// string either way and the executor parses it before the child receives it.
|
||||
case 'code':
|
||||
return 'textarea'
|
||||
case 'file-upload':
|
||||
return 'unsupported'
|
||||
default:
|
||||
return 'input'
|
||||
}
|
||||
@@ -58,3 +68,46 @@ const BOOLEAN_OPTIONAL_OPTIONS = [
|
||||
export function customBlockBooleanOptions(required: boolean) {
|
||||
return required ? BOOLEAN_VALUE_OPTIONS : BOOLEAN_OPTIONAL_OPTIONS
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown in place of a control for a field the sync modal cannot configure. States what the
|
||||
* sync does rather than what the modal can't: the target's uploaded files are carried across
|
||||
* untouched (see `replaceCustomBlockInputs`), so the field is safe to leave alone.
|
||||
*/
|
||||
export const CUSTOM_BLOCK_UNSUPPORTED_HINT = 'Uploaded in the workflow — kept as configured there'
|
||||
|
||||
/**
|
||||
* Whether the sync modal can actually put a value in this field.
|
||||
*
|
||||
* The Sync gate demands a value for every REQUIRED dependent, which is right for a field the
|
||||
* user can fill and a deadlock for one they cannot: a required `file[]` renders as a disabled
|
||||
* control, so the gate could never be satisfied and Sync stayed off forever — with the hint
|
||||
* telling the user to go set it in a workflow they could not reach.
|
||||
*
|
||||
* Excluding it is safe because the sync no longer clears it. The target keeps whatever it has
|
||||
* (see `replaceCustomBlockInputs`), and a genuinely missing value is still caught by the
|
||||
* block's own required-field validation at run/deploy time — the same fallback every other
|
||||
* unconfigured required field relies on.
|
||||
*/
|
||||
export function isForkSyncConfigurableField(
|
||||
field: Pick<ForkDependentReconfig, 'parentKind' | 'fieldType' | 'selectorKey'>
|
||||
): boolean {
|
||||
return forkDependentControl(field) !== 'unsupported'
|
||||
}
|
||||
|
||||
/**
|
||||
* The control the sync modal renders for one dependent field.
|
||||
*
|
||||
* `fieldType` means two different things depending on where the field came from, so the two
|
||||
* are classified separately rather than by one lookup that happens to agree:
|
||||
* - a **custom-block input** declares a Start FIELD type (`string`, `boolean`, `file[]`),
|
||||
* which {@link customBlockInputControl} maps through the canvas's own mapping;
|
||||
* - every other no-selector dependent is a canvas SUB-BLOCK, whose own type says it.
|
||||
*/
|
||||
export function forkDependentControl(
|
||||
field: Pick<ForkDependentReconfig, 'parentKind' | 'fieldType' | 'selectorKey'>
|
||||
): CustomBlockInputControl | 'selector' {
|
||||
if (field.selectorKey) return 'selector'
|
||||
if (field.parentKind === 'custom-block') return customBlockInputControl(field.fieldType)
|
||||
return field.fieldType === 'long-input' ? 'textarea' : 'input'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
buildWorkflowReferenceScope,
|
||||
WorkflowReferenceScopeProvider,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope'
|
||||
import { ReferenceInput } from '@/ee/workspace-forking/components/fork-sync/reference-input'
|
||||
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||
|
||||
interface CustomBlockInputFieldProps {
|
||||
field: ForkDependentReconfig
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
/** The workspace being written into — scopes the `{{secret}}` suggestions. */
|
||||
targetWorkspaceId: string
|
||||
/** JSON-valued (`object` / `array`) fields get the multi-line editor. */
|
||||
multiline?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One text-valued input of a repointed custom block, with the canvas's own `{{secret}}` and
|
||||
* `<block.output>` autocompletes pointed at the environment the value is being written into.
|
||||
*
|
||||
* The suggestions come from the TARGET side on purpose. A value configured here is applied to
|
||||
* the target workspace's copy of the workflow, so it has to resolve there: a secret the user
|
||||
* can see in the workspace they are looking at may not exist in the other one, and a block
|
||||
* reference means whatever the hosting workflow calls that block.
|
||||
*
|
||||
* The hosting workflow is fetched by id rather than read from the editor stores, which hold
|
||||
* the workflow open on the canvas — on this page, none.
|
||||
*/
|
||||
export function CustomBlockInputField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
targetWorkspaceId,
|
||||
multiline,
|
||||
}: CustomBlockInputFieldProps) {
|
||||
const { data: hostWorkflow } = useWorkflowState(field.targetWorkflowId)
|
||||
|
||||
const scope = useMemo(
|
||||
() =>
|
||||
buildWorkflowReferenceScope({
|
||||
workflowId: field.targetWorkflowId,
|
||||
blocks: hostWorkflow?.blocks,
|
||||
edges: hostWorkflow?.edges,
|
||||
loops: hostWorkflow?.loops,
|
||||
parallels: hostWorkflow?.parallels,
|
||||
referencingBlockId: field.targetBlockId,
|
||||
}),
|
||||
[field.targetWorkflowId, field.targetBlockId, hostWorkflow]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkflowReferenceScopeProvider scope={scope}>
|
||||
<ReferenceInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
multiline={multiline}
|
||||
placeholder={multiline ? `Enter ${field.title} as JSON` : `Enter ${field.title}`}
|
||||
workspaceId={targetWorkspaceId}
|
||||
blockId={field.targetBlockId}
|
||||
aria-label={field.title}
|
||||
/>
|
||||
</WorkflowReferenceScopeProvider>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ChevronDown,
|
||||
Chip,
|
||||
ChipCombobox,
|
||||
ChipModalField,
|
||||
ChipInput,
|
||||
ChipSwitch,
|
||||
CollapsibleCard,
|
||||
cn,
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
|
||||
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
import {
|
||||
CUSTOM_BLOCK_UNSUPPORTED_HINT,
|
||||
customBlockBooleanOptions,
|
||||
customBlockInputControl,
|
||||
forkDependentControl,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control'
|
||||
import { CustomBlockInputField } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-field'
|
||||
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
|
||||
import {
|
||||
applyDependentRepick,
|
||||
@@ -219,35 +221,60 @@ function DependentSelector({
|
||||
: effectiveDependentValue(f, state, parentChanged)
|
||||
const baselineValueFor = (f: ForkDependentReconfig) => effectiveValueIn(f, {})
|
||||
const effectiveValue = (f: ForkDependentReconfig) => effectiveValueIn(f, reconfig)
|
||||
if (isCustomBlockInput) {
|
||||
// Not a selector: there is no parent resource to browse and no options to fetch, just the
|
||||
// target block's own declared input. Rendered as a plain field so the user types the value
|
||||
// the repointed block should run with. Structured types get a textarea because their value
|
||||
// is JSON, matching how `subBlockTypeForField` renders them on the canvas.
|
||||
// A dependent with no selector has no parent resource to browse and no options to fetch —
|
||||
// just a value to type. That is every custom-block input, and also a plain text field under
|
||||
// a remapped credential (a Jira issue type, a Notion block id), which the sync clears on
|
||||
// every push and so must be re-settable here.
|
||||
if (!field.selectorKey) {
|
||||
// Renders a BARE control, like `DependentFieldSelector` does — the row wrapper above
|
||||
// already draws the field's label and required marker, so a labelled `ChipModalField`
|
||||
// printed the title twice.
|
||||
const setValue = (value: string) =>
|
||||
setReconfig((current) => ({ ...current, [dependentKey(field)]: value }))
|
||||
const value = effectiveValue(field)
|
||||
const shared = { title: field.title, required: field.required }
|
||||
switch (customBlockInputControl(field.fieldType)) {
|
||||
switch (forkDependentControl(field)) {
|
||||
case 'switch':
|
||||
return (
|
||||
<ChipModalField {...shared} type='custom'>
|
||||
<ChipSwitch
|
||||
options={customBlockBooleanOptions(field.required)}
|
||||
// Passed through unmapped: an unset field is `''`, which matches neither
|
||||
// segment, so the switch renders with nothing selected. Coercing it to False
|
||||
// would show a required flag as configured while the Sync gate still reads it
|
||||
// as empty — the display-versus-gate split this whole carve-out exists to avoid.
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
aria-label={field.title}
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipSwitch
|
||||
options={customBlockBooleanOptions(field.required)}
|
||||
// Passed through unmapped: an unset field is `''`, which matches neither segment,
|
||||
// so the switch renders with nothing selected. Coercing it to False would show a
|
||||
// required flag as configured while the Sync gate still reads it as empty.
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
aria-label={field.title}
|
||||
/>
|
||||
)
|
||||
case 'textarea':
|
||||
return <ChipModalField {...shared} type='textarea' value={value} onChange={setValue} />
|
||||
return (
|
||||
<CustomBlockInputField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
targetWorkspaceId={workspaceId}
|
||||
multiline
|
||||
/>
|
||||
)
|
||||
case 'unsupported':
|
||||
return (
|
||||
<ChipInput
|
||||
className='w-full'
|
||||
value=''
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
placeholder={CUSTOM_BLOCK_UNSUPPORTED_HINT}
|
||||
aria-label={field.title}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <ChipModalField {...shared} type='input' value={value} onChange={setValue} />
|
||||
return (
|
||||
<CustomBlockInputField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
targetWorkspaceId={workspaceId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { ChipInput, ChipTextarea } from '@sim/emcn'
|
||||
import {
|
||||
checkEnvVarTrigger,
|
||||
EnvVarDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import {
|
||||
checkTagTrigger,
|
||||
TagDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
|
||||
interface ReferenceInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
/** Multi-line editor, for the JSON-valued (`object` / `array`) fields. */
|
||||
multiline?: boolean
|
||||
/**
|
||||
* Workspace whose secrets `{{` offers. This is the workspace the value is written INTO, so
|
||||
* the suggestions are the ones that will actually resolve at run time — a secret from the
|
||||
* workspace the user happens to be looking at would not.
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Block the value belongs to. Positions the `<` suggestions within its workflow, which is
|
||||
* supplied by the enclosing `WorkflowReferenceScopeProvider` rather than the live editor.
|
||||
*/
|
||||
blockId: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A value field that resolves `{{SECRET}}` and `<block.output>` the same way the canvas does.
|
||||
*
|
||||
* Both dropdowns are the canvas components verbatim — the same trigger helpers, the same
|
||||
* caret anchoring, the same insertion semantics — so a reference authored here reads and
|
||||
* behaves identically to one authored on the block itself. Only their DATA differs, and that
|
||||
* is the point: secrets come from the workspace being written into, and block outputs from
|
||||
* the workflow that will host the block, neither of which is the one on screen.
|
||||
*/
|
||||
export function ReferenceInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
multiline = false,
|
||||
workspaceId,
|
||||
blockId,
|
||||
'aria-label': ariaLabel,
|
||||
}: ReferenceInputProps) {
|
||||
// One ref for both branches: the dropdowns anchor to whichever element is mounted, and only
|
||||
// ever call `focus`/`setSelectionRange`, which input and textarea share.
|
||||
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
|
||||
const closeDropdowns = () => {
|
||||
setShowEnvVars(false)
|
||||
setShowTags(false)
|
||||
setSearchTerm('')
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const next = event.target.value
|
||||
const cursor = event.target.selectionStart ?? next.length
|
||||
onChange(next)
|
||||
setCursorPosition(cursor)
|
||||
|
||||
const envVar = checkEnvVarTrigger(next, cursor)
|
||||
setShowEnvVars(envVar.show)
|
||||
setSearchTerm(envVar.show ? envVar.searchTerm : '')
|
||||
|
||||
const tag = checkTagTrigger(next, cursor)
|
||||
setShowTags(tag.show)
|
||||
if (!tag.show) setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dropdown rewrites the whole value, so the caret has to be put back explicitly — the
|
||||
* field is controlled and would otherwise land at the end, mid-reference.
|
||||
*/
|
||||
const applySelection = (next: string, nextCursor: number) => {
|
||||
onChange(next)
|
||||
setCursorPosition(nextCursor)
|
||||
closeDropdowns()
|
||||
requestAnimationFrame(() => {
|
||||
const element = inputRef.current
|
||||
if (!element) return
|
||||
element.focus()
|
||||
element.setSelectionRange(nextCursor, nextCursor)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && (showEnvVars || showTags)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeDropdowns()
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately no `onBlur` close: both dropdowns commit on `onMouseDown`, and a blur handler
|
||||
// races that even with the items' `preventDefault`. They close on select, Escape, or an
|
||||
// outside press routed through the popover's own dismissal.
|
||||
const shared = {
|
||||
value,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
placeholder,
|
||||
'aria-label': ariaLabel,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative w-full'>
|
||||
{multiline ? (
|
||||
<ChipTextarea
|
||||
{...shared}
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
className='w-full'
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<ChipInput
|
||||
{...shared}
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
className='w-full'
|
||||
/>
|
||||
)}
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={applySelection}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
inputRef={inputRef}
|
||||
onClose={closeDropdowns}
|
||||
/>
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={applySelection}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={value}
|
||||
cursorPosition={cursorPosition}
|
||||
inputRef={inputRef}
|
||||
onClose={closeDropdowns}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
forkVisibleCopyables,
|
||||
isForkRequiredComplete,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
import { isForkSyncConfigurableField } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control'
|
||||
import {
|
||||
type DependentReconfigState,
|
||||
dependentKey,
|
||||
@@ -561,6 +562,9 @@ export function useForkSync(params: {
|
||||
// instead, so it's skipped here.
|
||||
const reconfigComplete = dependentReconfigs.every((field) => {
|
||||
if (!field.required) return true
|
||||
// A field the modal renders no control for can never satisfy this gate — see
|
||||
// `isForkSyncConfigurableField`.
|
||||
if (!isForkSyncConfigurableField(field)) return true
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) return true
|
||||
const resolution = resolutionFor(parent)
|
||||
@@ -574,6 +578,9 @@ export function useForkSync(params: {
|
||||
const reconfigPendingByKind = new Set<MappableMappingKind>()
|
||||
for (const field of dependentReconfigs) {
|
||||
if (!field.required) continue
|
||||
// Mirrors the Sync gate above: a field it cannot block on must not make the kind's badge
|
||||
// read "Needs setup" forever either.
|
||||
if (!isForkSyncConfigurableField(field)) continue
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) continue
|
||||
const resolution = resolutionFor(parent)
|
||||
|
||||
@@ -262,8 +262,21 @@ export async function loadWorkflowNameRegistry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched read of the current DRAFT subBlocks for a set of (replace) target
|
||||
* workflows, keyed `workflowId -> blockId -> subBlocks`. One query for the whole
|
||||
* One target block as it stands BEFORE this sync overwrites it.
|
||||
*
|
||||
* The `type` rides along with the sub-blocks because a custom block's inputs are only
|
||||
* meaningful under the type that declared them: the same field id on a different block is a
|
||||
* different workflow's field. {@link replaceCustomBlockInputs} uses the pair to decide whether
|
||||
* the target's own values may be kept.
|
||||
*/
|
||||
export interface ForkTargetDraftBlock {
|
||||
type: string
|
||||
subBlocks: SubBlockRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched read of the current DRAFT blocks for a set of (replace) target
|
||||
* workflows, keyed `workflowId -> blockId -> block`. One query for the whole
|
||||
* promote so the locked apply phase doesn't do N per-workflow loads; called
|
||||
* pre-write so it reflects the target state the user configured before this sync
|
||||
* overwrites it. Promote uses it to detect required dependents the sync left empty
|
||||
@@ -272,13 +285,14 @@ export async function loadWorkflowNameRegistry(
|
||||
export async function loadTargetDraftSubBlocks(
|
||||
executor: DbOrTx,
|
||||
workflowIds: string[]
|
||||
): Promise<Map<string, Map<string, SubBlockRecord>>> {
|
||||
const byWorkflow = new Map<string, Map<string, SubBlockRecord>>()
|
||||
): Promise<Map<string, Map<string, ForkTargetDraftBlock>>> {
|
||||
const byWorkflow = new Map<string, Map<string, ForkTargetDraftBlock>>()
|
||||
if (workflowIds.length === 0) return byWorkflow
|
||||
const rows = await executor
|
||||
.select({
|
||||
workflowId: workflowBlocks.workflowId,
|
||||
blockId: workflowBlocks.id,
|
||||
blockType: workflowBlocks.type,
|
||||
subBlocks: workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
@@ -286,10 +300,13 @@ export async function loadTargetDraftSubBlocks(
|
||||
for (const row of rows) {
|
||||
let blocks = byWorkflow.get(row.workflowId)
|
||||
if (!blocks) {
|
||||
blocks = new Map<string, SubBlockRecord>()
|
||||
blocks = new Map<string, ForkTargetDraftBlock>()
|
||||
byWorkflow.set(row.workflowId, blocks)
|
||||
}
|
||||
blocks.set(row.blockId, (row.subBlocks ?? {}) as SubBlockRecord)
|
||||
blocks.set(row.blockId, {
|
||||
type: row.blockType,
|
||||
subBlocks: (row.subBlocks ?? {}) as SubBlockRecord,
|
||||
})
|
||||
}
|
||||
return byWorkflow
|
||||
}
|
||||
@@ -373,12 +390,13 @@ export interface CopyWorkflowStateParams {
|
||||
*/
|
||||
transformBlockType?: (blockType: string, block: { id: string; name: string }) => string
|
||||
/**
|
||||
* The target workflow's current draft subBlocks (block id -> subBlocks), for
|
||||
* The target workflow's current draft blocks (block id -> type + subBlocks), for
|
||||
* `replace` mode only. When present, required dependents that the sync left empty
|
||||
* (the parent change cleared and the stored mapping didn't fill) are reported in
|
||||
* {@link CopyWorkflowResult.needsConfiguration}.
|
||||
* {@link CopyWorkflowResult.needsConfiguration}, and a custom block keeps the target's own
|
||||
* values for inputs the sync modal cannot configure (see {@link replaceCustomBlockInputs}).
|
||||
*/
|
||||
targetCurrentBlocks?: Map<string, SubBlockRecord>
|
||||
targetCurrentBlocks?: Map<string, ForkTargetDraftBlock>
|
||||
/**
|
||||
* Per-block (block id -> subBlock key -> value) stored dependent values applied last,
|
||||
* after the reference transform cleared the source's, so the stored mapping is the sole
|
||||
@@ -545,7 +563,7 @@ export async function copyWorkflowStateIntoTarget(
|
||||
block.type,
|
||||
newBlockId,
|
||||
block.name,
|
||||
targetCurrent,
|
||||
targetCurrent.subBlocks,
|
||||
subBlocks,
|
||||
activeCanonicalModes
|
||||
)
|
||||
@@ -562,7 +580,7 @@ export async function copyWorkflowStateIntoTarget(
|
||||
// wholesale because ALL of a custom block's inputs are reconfigurable, not a `dependsOn`
|
||||
// subset. `applyDependentOverrides` above is a no-op for them: it allowlists on
|
||||
// `dependsOn` + `selectorKey`, which no custom-block input has.
|
||||
subBlocks = replaceCustomBlockInputs(subBlocks, blockOverrides, nextBlockType)
|
||||
subBlocks = replaceCustomBlockInputs(subBlocks, blockOverrides, nextBlockType, targetCurrent)
|
||||
}
|
||||
|
||||
newBlocks[newBlockId] = {
|
||||
|
||||
@@ -359,6 +359,72 @@ describe('collectForkDependentReconfigs', () => {
|
||||
expect(sheet?.context.spreadsheetId).toBe('ss-src')
|
||||
})
|
||||
|
||||
it('offers a plain text dependent of a remapped credential', () => {
|
||||
// `clearDependentsOnRemap` wipes it on EVERY sync (a credential mapped across environments
|
||||
// changes value each time), so a text field the modal never offered was re-emptied on every
|
||||
// push with nowhere to set it that stuck.
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'issueType',
|
||||
title: 'Issue Type',
|
||||
type: 'short-input',
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('jira', { credential: { value: 'cred-src' }, issueType: { value: 'Bug' } }),
|
||||
],
|
||||
])
|
||||
const fields = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(fields).toHaveLength(1)
|
||||
expect(fields[0]).toMatchObject({ subBlockKey: 'issueType', fieldType: 'short-input' })
|
||||
expect(fields[0].selectorKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not offer the manual half of a selector-backed canonical pair', () => {
|
||||
// The pair's selector member already represents the field, and the manual member is
|
||||
// verbatim by policy — offering both shows one concept twice and invites writing into the
|
||||
// inactive half.
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'projectId',
|
||||
title: 'Project',
|
||||
type: 'project-selector',
|
||||
canonicalParamId: 'projectId',
|
||||
mode: 'basic',
|
||||
selectorKey: 'jira.projects',
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
{
|
||||
id: 'manualProjectId',
|
||||
title: 'Project ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'projectId',
|
||||
mode: 'advanced',
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('jira', { credential: { value: 'cred-src' }, projectId: { value: 'PROJ' } }),
|
||||
],
|
||||
])
|
||||
const keys = collectForkDependentReconfigs([replaceItem], states, resolve).map(
|
||||
(field) => field.subBlockKey
|
||||
)
|
||||
expect(keys).toContain('projectId')
|
||||
expect(keys).not.toContain('manualProjectId')
|
||||
})
|
||||
|
||||
it('uses the persisted canonical mode when building a dependent selector context', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block
|
||||
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
|
||||
import {
|
||||
createCanonicalModeGates,
|
||||
reconfigurableDependentIds,
|
||||
scanWorkflowReferences,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -130,6 +131,9 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
|
||||
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
|
||||
const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes)
|
||||
const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg]))
|
||||
// Shared with `applyDependentOverrides`, so what the modal offers is exactly what the sync
|
||||
// can write back — the two encoded this rule separately once and drifted.
|
||||
const reconfigurableIds = reconfigurableDependentIds(config.subBlocks)
|
||||
// A field could hang off two anchors (or be reachable via two paths); emit it once.
|
||||
const seen = new Set<string>()
|
||||
|
||||
@@ -167,7 +171,16 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
|
||||
|
||||
for (const clear of getTransitiveSubBlockDependents(config.subBlocks, [anchorCfg.id])) {
|
||||
const dependent = configById.get(clear.subBlockId)
|
||||
if (!dependent?.id || !dependent.selectorKey) continue
|
||||
// A dependent is offered when the modal can actually render a control for it: a
|
||||
// registered selector, or a plain text field. Anything else is skipped and the
|
||||
// fork-dependent-coverage check keeps that set empty — see
|
||||
// `scripts/check-fork-dependent-coverage.ts`.
|
||||
//
|
||||
// Text fields matter as much as selectors here. `clearDependentsOnRemap` wipes every
|
||||
// transitive dependent of a remapped parent on EVERY sync (a credential mapped across
|
||||
// environments changes value each time), so a field the modal never offered was
|
||||
// re-emptied on every push and could not be fixed by setting it in the target either.
|
||||
if (!dependent?.id || !reconfigurableIds.has(dependent.id)) continue
|
||||
// Skip fields gated off by their `condition` - a selector under a now-inactive
|
||||
// operation (e.g. a move-only label while the block reads) isn't in play. We do
|
||||
// NOT require a source value: an active selector the source left empty is still
|
||||
@@ -233,7 +246,9 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
|
||||
targetBlockId: resolveTargetBlockId(),
|
||||
blockName,
|
||||
subBlockKey: makeSubBlockKey(dependent.id),
|
||||
selectorKey: dependent.selectorKey,
|
||||
...(dependent.selectorKey
|
||||
? { selectorKey: dependent.selectorKey }
|
||||
: { fieldType: dependent.type }),
|
||||
title: makeTitle(dependent),
|
||||
...(toolName ? { toolName } : {}),
|
||||
...(dependencyScope ? { dependencyScope } : {}),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import {
|
||||
applyDependentOverrides,
|
||||
customBlockInputStorageKey,
|
||||
type ForkReferenceResolver,
|
||||
reconfigurableDependentIds,
|
||||
remapForkBlockType,
|
||||
replaceCustomBlockInputs,
|
||||
scanWorkflowReferences,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
@@ -117,3 +122,153 @@ describe('scanWorkflowReferences with custom blocks', () => {
|
||||
expect(scan.unmapped).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceCustomBlockInputs target carry-over', () => {
|
||||
/** The source block's inputs, keyed by the SOURCE block's field ids. */
|
||||
const sourceSubBlocks = {
|
||||
workflowId: { value: 'wf-prod' },
|
||||
invoice: { value: 'from prod' },
|
||||
}
|
||||
const key = (fieldType: string, fieldId: string) =>
|
||||
customBlockInputStorageKey(UAT_BLOCK, fieldType, fieldId)
|
||||
|
||||
it('keeps an input the modal cannot configure when the target is already this block', () => {
|
||||
// A `file[]` input is an upload on the canvas, so it never has a stored override. Before
|
||||
// the carry-over every sync rebuilt the block without it and silently dropped the files.
|
||||
const result = replaceCustomBlockInputs(
|
||||
sourceSubBlocks,
|
||||
new Map([[key('string', 'vendor'), 'Acme']]),
|
||||
UAT_BLOCK,
|
||||
{
|
||||
type: UAT_BLOCK,
|
||||
subBlocks: {
|
||||
attachments: { value: ['uat-file-1'] },
|
||||
vendor: { value: 'stale' },
|
||||
},
|
||||
}
|
||||
)
|
||||
expect(result.attachments).toEqual({ value: ['uat-file-1'] })
|
||||
// A configured value still wins over the target's own.
|
||||
expect(result.vendor).toEqual({ value: 'Acme' })
|
||||
})
|
||||
|
||||
it('carries nothing over when the target currently holds a DIFFERENT custom block', () => {
|
||||
// The target's field ids describe another workflow's Start fields; keeping them is exactly
|
||||
// the orphaning this function exists to prevent.
|
||||
const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK, {
|
||||
type: 'custom_block_someotherxyz',
|
||||
subBlocks: { attachments: { value: ['other-file'] } },
|
||||
})
|
||||
expect(result.attachments).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries nothing over for a non-custom target block', () => {
|
||||
const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK, {
|
||||
type: 'agent',
|
||||
subBlocks: { systemPrompt: { value: 'hello' } },
|
||||
})
|
||||
expect(result.systemPrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lets an explicitly emptied field clear the target value', () => {
|
||||
// `''` is a stored override, not an absent one — so clearing a field in the modal is a
|
||||
// real edit rather than a silent no-op.
|
||||
const result = replaceCustomBlockInputs(
|
||||
sourceSubBlocks,
|
||||
new Map([[key('string', 'vendor'), '']]),
|
||||
UAT_BLOCK,
|
||||
{ type: UAT_BLOCK, subBlocks: { vendor: { value: 'previous' } } }
|
||||
)
|
||||
expect(result.vendor).toEqual({ value: '' })
|
||||
})
|
||||
|
||||
it('takes reserved wiring from the source, never the target', () => {
|
||||
// `workflowId`/`inputMapping` are recomputed by the serializer; the target's copy is stale
|
||||
// the moment the mapping changes.
|
||||
const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK, {
|
||||
type: UAT_BLOCK,
|
||||
subBlocks: { workflowId: { value: 'wf-stale-uat' } },
|
||||
})
|
||||
expect(result.workflowId).toEqual({ value: 'wf-prod' })
|
||||
})
|
||||
|
||||
it('still drops the source block-keyed inputs with no target to carry over', () => {
|
||||
const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK)
|
||||
expect(result.invoice).toBeUndefined()
|
||||
expect(result.workflowId).toEqual({ value: 'wf-prod' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconfigurableDependentIds', () => {
|
||||
const SUBS = [
|
||||
{ id: 'credential', type: 'oauth-input' },
|
||||
{ id: 'issueType', type: 'short-input', dependsOn: ['credential'] },
|
||||
{ id: 'notes', type: 'long-input', dependsOn: ['credential'] },
|
||||
{
|
||||
id: 'labelId',
|
||||
type: 'file-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
{
|
||||
id: 'projectId',
|
||||
type: 'project-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'jira.projects',
|
||||
canonicalParamId: 'projectId',
|
||||
},
|
||||
{
|
||||
id: 'manualProjectId',
|
||||
type: 'short-input',
|
||||
dependsOn: ['credential'],
|
||||
canonicalParamId: 'projectId',
|
||||
},
|
||||
{ id: 'watchColumns', type: 'dropdown', dependsOn: ['credential'] },
|
||||
{ id: 'standalone', type: 'short-input' },
|
||||
]
|
||||
|
||||
it('offers selector-backed and plain text dependents', () => {
|
||||
const allowed = reconfigurableDependentIds(SUBS)
|
||||
expect([...allowed].sort()).toEqual(['issueType', 'labelId', 'notes', 'projectId'])
|
||||
})
|
||||
|
||||
it('excludes the manual half of a selector-backed canonical pair', () => {
|
||||
expect(reconfigurableDependentIds(SUBS).has('manualProjectId')).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes a dependent the modal can render no control for', () => {
|
||||
// A `dropdown` with no selector has options to fetch and no way to fetch them here.
|
||||
expect(reconfigurableDependentIds(SUBS).has('watchColumns')).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes a field that depends on nothing', () => {
|
||||
expect(reconfigurableDependentIds(SUBS).has('standalone')).toBe(false)
|
||||
})
|
||||
|
||||
it('is the SAME set the sync actually writes back', () => {
|
||||
// The collector offers these and `applyDependentOverrides` writes them. Encoding the rule
|
||||
// twice is what let text dependents be collected, stored, gated on — then silently dropped
|
||||
// on apply, leaving the field wiped on every push with nowhere for the value to go.
|
||||
vi.mocked(getBlock).mockReturnValue({ type: 'jira', subBlocks: SUBS } as never)
|
||||
const applied = applyDependentOverrides(
|
||||
{
|
||||
issueType: { value: 'old' },
|
||||
labelId: { value: 'old' },
|
||||
manualProjectId: { value: 'keep-me' },
|
||||
watchColumns: { value: 'keep-me' },
|
||||
},
|
||||
'jira',
|
||||
new Map([
|
||||
['issueType', 'Bug'],
|
||||
['labelId', 'LABEL_1'],
|
||||
['manualProjectId', 'hacked'],
|
||||
['watchColumns', 'hacked'],
|
||||
])
|
||||
)
|
||||
expect(applied.issueType).toEqual({ value: 'Bug' })
|
||||
expect(applied.labelId).toEqual({ value: 'LABEL_1' })
|
||||
// Not offered, so not writable — an override naming one must not slip through.
|
||||
expect(applied.manualProjectId).toEqual({ value: 'keep-me' })
|
||||
expect(applied.watchColumns).toEqual({ value: 'keep-me' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -299,16 +299,37 @@ export function parseCustomBlockInputStorageKey(key: string): ParsedCustomBlockI
|
||||
* skipped, so re-pointing a block twice never carries the first target's values into the
|
||||
* second. Reserved wiring (`workflowId`/`inputMapping`) is preserved untouched: those are
|
||||
* computed value-fns the serializer recomputes and never carries forward.
|
||||
*
|
||||
* `targetCurrent` is the block the sync is about to overwrite. When it is ALREADY the mapped
|
||||
* type — the normal state of every sync after the one that set the mapping — its own values
|
||||
* seed the result and the configured ones are layered on top. That is what stops a re-sync
|
||||
* wiping an input the modal cannot offer a control for: a `file[]` field is an upload on the
|
||||
* canvas, so it is only ever set there, and rebuilding the block from the stored overrides
|
||||
* alone would blank it every single time. It also means a field the user simply left alone in
|
||||
* the modal keeps the target's value rather than being cleared; a field they explicitly
|
||||
* emptied stores `''`, which is an override and still wins.
|
||||
*
|
||||
* The type equality check is the whole safety property. Under a DIFFERENT current type the
|
||||
* target's values are keyed by another block's field ids, which is exactly the orphaning this
|
||||
* function exists to prevent — so nothing is carried over.
|
||||
*/
|
||||
export function replaceCustomBlockInputs(
|
||||
subBlocks: SubBlockRecord,
|
||||
values: ReadonlyMap<string, string> | undefined,
|
||||
targetType: string
|
||||
targetType: string,
|
||||
targetCurrent?: { type: string; subBlocks: SubBlockRecord }
|
||||
): SubBlockRecord {
|
||||
const next: SubBlockRecord = {}
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (RESERVED_PARAMS.has(key)) next[key] = subBlock
|
||||
}
|
||||
if (targetCurrent?.type === targetType) {
|
||||
for (const [key, subBlock] of Object.entries(targetCurrent.subBlocks)) {
|
||||
// Reserved wiring is taken from the SOURCE block above: it is recomputed by the
|
||||
// serializer, and the target's copy is stale the moment the mapping changes.
|
||||
if (!RESERVED_PARAMS.has(key)) next[key] = subBlock
|
||||
}
|
||||
}
|
||||
for (const [key, value] of values ?? []) {
|
||||
const parsed = parseCustomBlockInputStorageKey(key)
|
||||
if (!parsed || parsed.targetType !== targetType) continue
|
||||
@@ -1608,6 +1629,48 @@ function applyNestedToolOverrides(
|
||||
* set a parent/credential field (bypassing mapping validation) or inject a bogus subblock.
|
||||
* Returns a new record only when something applied.
|
||||
*/
|
||||
/** Sub-block types the fork sync modal renders as a free-text field rather than a picker. */
|
||||
export const TEXT_DEPENDENT_TYPES = new Set<string>(['short-input', 'long-input'])
|
||||
|
||||
/**
|
||||
* The dependents of a remapped parent that the sync modal can offer AND the sync can apply.
|
||||
*
|
||||
* ONE definition on purpose. The collector and the apply side each encoded this rule separately
|
||||
* and drifted the moment text fields were added: they were collected, stored, and gated on by
|
||||
* the Sync button, then dropped here because the allowlist still demanded a `selectorKey`. The
|
||||
* field stayed wiped on every push and the typed value went nowhere.
|
||||
*
|
||||
* A text member of a canonical pair whose basic side is a selector is excluded: the pair is
|
||||
* already represented by its selector member, and the manual member is verbatim by policy.
|
||||
*/
|
||||
export function reconfigurableDependentIds(
|
||||
subBlocks: ReadonlyArray<{
|
||||
id?: string
|
||||
type?: string
|
||||
dependsOn?: unknown
|
||||
selectorKey?: string
|
||||
canonicalParamId?: string
|
||||
}>
|
||||
): Set<string> {
|
||||
const canonicalWithSelector = new Set(
|
||||
subBlocks
|
||||
.filter((cfg) => cfg.canonicalParamId && cfg.selectorKey)
|
||||
.map((cfg) => cfg.canonicalParamId)
|
||||
)
|
||||
const allowed = new Set<string>()
|
||||
for (const cfg of subBlocks) {
|
||||
if (!cfg.id || !cfg.dependsOn) continue
|
||||
if (cfg.selectorKey) {
|
||||
allowed.add(cfg.id)
|
||||
continue
|
||||
}
|
||||
if (!TEXT_DEPENDENT_TYPES.has(cfg.type ?? '')) continue
|
||||
if (cfg.canonicalParamId && canonicalWithSelector.has(cfg.canonicalParamId)) continue
|
||||
allowed.add(cfg.id)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
export function applyDependentOverrides(
|
||||
subBlocks: SubBlockRecord,
|
||||
blockType: string,
|
||||
@@ -1616,12 +1679,10 @@ export function applyDependentOverrides(
|
||||
const config = getBlock(blockType)
|
||||
if (!config || overrides.size === 0) return subBlocks
|
||||
|
||||
const allowedTopLevel = new Set<string>()
|
||||
const allowedTopLevel = reconfigurableDependentIds(config.subBlocks)
|
||||
const toolInputIds = new Set<string>()
|
||||
for (const cfg of config.subBlocks) {
|
||||
if (!cfg.id) continue
|
||||
if (cfg.dependsOn && cfg.selectorKey) allowedTopLevel.add(cfg.id)
|
||||
if (cfg.type === 'tool-input') toolInputIds.add(cfg.id)
|
||||
if (cfg.id && cfg.type === 'tool-input') toolInputIds.add(cfg.id)
|
||||
}
|
||||
|
||||
const nestedByTool = new Map<string, Array<{ index: number; paramId: string; value: string }>>()
|
||||
|
||||
@@ -5,8 +5,28 @@ import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSelectorDefinition } = vi.hoisted(() => ({
|
||||
mockGetSelectorDefinition: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/selectors/registry', () => ({
|
||||
getSelectorDefinition: mockGetSelectorDefinition,
|
||||
}))
|
||||
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options'
|
||||
import {
|
||||
dynamicSubBlockOptionKeys,
|
||||
useDynamicSubBlockOptionDisplayName,
|
||||
} from '@/hooks/queries/dynamic-subblock-options'
|
||||
import type { SelectorDefinition, SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
/** Any registered key; the hook only uses it to look the definition up. */
|
||||
const SELECTOR_KEY = 'workspace.credentialGroups' as SelectorKey
|
||||
|
||||
function mockDefinition(definition: Partial<SelectorDefinition>) {
|
||||
mockGetSelectorDefinition.mockReturnValue(definition as SelectorDefinition)
|
||||
}
|
||||
|
||||
interface HookHarness<T> {
|
||||
result: () => T
|
||||
@@ -54,16 +74,16 @@ describe('useDynamicSubBlockOptionDisplayName', () => {
|
||||
})
|
||||
|
||||
it('hydrates a stored dynamic dropdown id to its label', async () => {
|
||||
const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({
|
||||
id: optionId,
|
||||
const fetchById = vi.fn(async ({ detailId }: { detailId?: string }) => ({
|
||||
id: detailId as string,
|
||||
label: 'Customer support accounts',
|
||||
}))
|
||||
mockDefinition({ key: SELECTOR_KEY, getQueryKey: () => [SELECTOR_KEY], fetchById })
|
||||
const subBlock = {
|
||||
id: 'credentialGroup',
|
||||
title: 'Credential Group',
|
||||
type: 'dropdown',
|
||||
options: [],
|
||||
fetchOptionById,
|
||||
selectorKey: SELECTOR_KEY,
|
||||
} satisfies SubBlockConfig
|
||||
|
||||
const hook = renderHookWithClient(() =>
|
||||
@@ -78,21 +98,23 @@ describe('useDynamicSubBlockOptionDisplayName', () => {
|
||||
|
||||
await waitForResult(() => expect(hook.result()).toBe('Customer support accounts'))
|
||||
|
||||
expect(fetchOptionById).toHaveBeenCalledWith('block-1', 'group-uuid', expect.any(AbortSignal))
|
||||
expect(fetchById).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detailId: 'group-uuid', context: { workspaceId: 'workspace-1' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('summarizes every selected dynamic option without dropping ids', async () => {
|
||||
const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({
|
||||
id: optionId,
|
||||
label: optionId === 'gmail' ? 'Gmail' : 'Slack',
|
||||
const fetchById = vi.fn(async ({ detailId }: { detailId?: string }) => ({
|
||||
id: detailId as string,
|
||||
label: detailId === 'gmail' ? 'Gmail' : 'Slack',
|
||||
}))
|
||||
mockDefinition({ key: SELECTOR_KEY, getQueryKey: () => [SELECTOR_KEY], fetchById })
|
||||
const subBlock = {
|
||||
id: 'providerFilter',
|
||||
title: 'Provider',
|
||||
type: 'dropdown',
|
||||
options: [],
|
||||
multiSelect: true,
|
||||
fetchOptionById,
|
||||
selectorKey: SELECTOR_KEY,
|
||||
} satisfies SubBlockConfig
|
||||
|
||||
const hook = renderHookWithClient(() =>
|
||||
@@ -107,4 +129,22 @@ describe('useDynamicSubBlockOptionDisplayName', () => {
|
||||
|
||||
await waitForResult(() => expect(hook.result()).toBe('Gmail, Slack'))
|
||||
})
|
||||
|
||||
it('re-resolves a label when the sibling its selector depends on changes', () => {
|
||||
// The bug: `fetchById` reads sibling context, but the cache key did not, so a label
|
||||
// resolved before a credential group was picked (null) stayed cached after it was, and the
|
||||
// card kept showing the raw id. The key now carries the selector's OWN query key, which
|
||||
// names every context field its result depends on.
|
||||
const keyFor = (credentialGroupId?: string) =>
|
||||
dynamicSubBlockOptionKeys.detail('workspace-1', 'block-1', 'providerFilter', 'gmail', [
|
||||
'selectors',
|
||||
'workspace.credentialGroupProviders',
|
||||
'workspace-1',
|
||||
credentialGroupId ?? 'none',
|
||||
])
|
||||
|
||||
expect(keyFor(undefined)).not.toEqual(keyFor('group-1'))
|
||||
expect(keyFor('group-1')).not.toEqual(keyFor('group-2'))
|
||||
expect(keyFor('group-1')).toEqual(keyFor('group-1'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context'
|
||||
import { summarizeNames } from '@/lib/workflows/subblocks/display'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000
|
||||
|
||||
export const dynamicSubBlockOptionKeys = {
|
||||
all: ['dynamic-subblock-options'] as const,
|
||||
details: () => [...dynamicSubBlockOptionKeys.all, 'detail'] as const,
|
||||
detail: (workspaceId?: string, blockId?: string, subBlockId?: string, optionId?: string) =>
|
||||
/**
|
||||
* `selectorScope` is the selector's OWN query key for this context — every context field its
|
||||
* result depends on, named by the selector rather than restated here. Without it a label
|
||||
* resolved under an empty or previous sibling (no credential group picked yet) stays cached
|
||||
* and is reused once the sibling is set, so the card keeps showing a raw id or a stale name.
|
||||
*/
|
||||
detail: (
|
||||
workspaceId?: string,
|
||||
blockId?: string,
|
||||
subBlockId?: string,
|
||||
optionId?: string,
|
||||
selectorScope: readonly unknown[] = []
|
||||
) =>
|
||||
[
|
||||
...dynamicSubBlockOptionKeys.details(),
|
||||
workspaceId ?? '',
|
||||
blockId ?? '',
|
||||
subBlockId ?? '',
|
||||
optionId ?? '',
|
||||
...selectorScope,
|
||||
] as const,
|
||||
}
|
||||
|
||||
@@ -44,18 +63,67 @@ export function useDynamicSubBlockOptionDisplayName({
|
||||
value,
|
||||
}: UseDynamicSubBlockOptionDisplayNameArgs): string | null {
|
||||
const optionIds = useMemo(() => getResolvableOptionIds(value), [value])
|
||||
const fetchOptionById = subBlock?.fetchOptionById
|
||||
const canResolve = Boolean(blockId && fetchOptionById && optionIds.length > 0)
|
||||
// Label resolution follows the option source: a selector's own `fetchById`. There is no
|
||||
// per-block resolver any more, so a selector without one simply renders the raw id.
|
||||
const definition = subBlock?.selectorKey ? getSelectorDefinition(subBlock.selectorKey) : undefined
|
||||
const fetchById = definition?.fetchById
|
||||
|
||||
/**
|
||||
* The block's own values, the same context the canvas builds. A `workspaceId`-only context
|
||||
* silently fails every selector scoped by a sibling — `workspace.credentialGroupProviders`
|
||||
* needs the group before it can name a provider, so the card fell back to raw ids.
|
||||
*/
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const block = useWorkflowStore((state) => (blockId ? state.blocks[blockId] : undefined))
|
||||
const liveValues = useSubBlockStore((state) =>
|
||||
activeWorkflowId && blockId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined
|
||||
)
|
||||
|
||||
const resolverContext = useMemo((): SelectorContext => {
|
||||
if (!block?.type) return { workspaceId }
|
||||
const merged: Record<string, { value?: unknown }> = { ...(block.subBlocks ?? {}) }
|
||||
for (const [id, value] of Object.entries(liveValues ?? {})) {
|
||||
merged[id] = { ...merged[id], value }
|
||||
}
|
||||
return buildSelectorContextFromBlock(block.type, merged, {
|
||||
workflowId: activeWorkflowId ?? undefined,
|
||||
workspaceId,
|
||||
canonicalModes: block.data?.canonicalModes,
|
||||
})
|
||||
}, [block, liveValues, activeWorkflowId, workspaceId])
|
||||
|
||||
/**
|
||||
* The selector's own key for this context. Reusing it means the cache is scoped by exactly
|
||||
* what the selector reads — no second list of context fields to keep in step, and it stays
|
||||
* correct when a selector's dependencies change.
|
||||
*/
|
||||
const selectorScope = useMemo(
|
||||
() =>
|
||||
definition ? definition.getQueryKey({ key: definition.key, context: resolverContext }) : [],
|
||||
[definition, resolverContext]
|
||||
)
|
||||
const canResolve = Boolean(blockId && fetchById && optionIds.length > 0)
|
||||
|
||||
const queries = useQueries({
|
||||
queries: canResolve
|
||||
? optionIds.map((optionId) => ({
|
||||
queryKey: dynamicSubBlockOptionKeys.detail(workspaceId, blockId, subBlock?.id, optionId),
|
||||
queryKey: dynamicSubBlockOptionKeys.detail(
|
||||
workspaceId,
|
||||
blockId,
|
||||
subBlock?.id,
|
||||
optionId,
|
||||
selectorScope as readonly unknown[]
|
||||
),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!blockId || !fetchOptionById) {
|
||||
if (!blockId || !fetchById || !definition) {
|
||||
throw new Error('Dynamic subblock option resolver is required')
|
||||
}
|
||||
return fetchOptionById(blockId, optionId, signal)
|
||||
return fetchById({
|
||||
key: definition.key,
|
||||
context: resolverContext,
|
||||
detailId: optionId,
|
||||
signal,
|
||||
})
|
||||
},
|
||||
staleTime: DYNAMIC_SUBBLOCK_OPTION_STALE_TIME,
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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 {
|
||||
SelectorContext,
|
||||
SelectorDefinition,
|
||||
SelectorKey,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
|
||||
/**
|
||||
* HubSpot's default CRM object. A picker renders before the user has touched the object-type
|
||||
* dropdown, and the dropdown itself already displays `contact` — so resolving to nothing there
|
||||
* would render every dependent picker empty against a control that visibly shows a selection.
|
||||
*/
|
||||
const DEFAULT_OBJECT_TYPE = 'contact'
|
||||
|
||||
/**
|
||||
* The object type a picker is scoped to.
|
||||
*
|
||||
* `custom` is an indirection rather than a type: the real id lives in a sibling field, and
|
||||
* until that is filled in there is no object to query. Returning `null` for that case keeps
|
||||
* the dependent pickers empty instead of querying HubSpot for an object called "custom".
|
||||
*/
|
||||
function resolveObjectType(context: SelectorContext): string | null {
|
||||
const selected = context.objectType ?? DEFAULT_OBJECT_TYPE
|
||||
if (selected !== 'custom') return selected
|
||||
const customId = context.customObjectTypeId?.trim()
|
||||
return customId ? customId : null
|
||||
}
|
||||
|
||||
export const hubspotSelectors = {
|
||||
'hubspot.properties': {
|
||||
key: 'hubspot.properties',
|
||||
contracts: [selectorContracts.hubspotPropertiesSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'hubspot.properties',
|
||||
context.oauthCredential ?? 'none',
|
||||
resolveObjectType(context) ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(context.oauthCredential) && resolveObjectType(context) !== null,
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'hubspot.properties')
|
||||
const objectType = resolveObjectType(context)
|
||||
if (!objectType) return []
|
||||
const data = await requestJson(selectorContracts.hubspotPropertiesSelectorContract, {
|
||||
query: { credentialId, objectType },
|
||||
signal,
|
||||
})
|
||||
return data.properties.map((property) => ({ id: property.id, label: property.name }))
|
||||
},
|
||||
},
|
||||
'hubspot.lists': {
|
||||
key: 'hubspot.lists',
|
||||
contracts: [selectorContracts.hubspotListsSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'hubspot.lists',
|
||||
context.oauthCredential ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'hubspot.lists')
|
||||
const data = await requestJson(selectorContracts.hubspotListsSelectorContract, {
|
||||
query: { credentialId },
|
||||
signal,
|
||||
})
|
||||
return data.lists.map((list) => ({ id: list.id, label: list.name }))
|
||||
},
|
||||
},
|
||||
'hubspot.pipelines': {
|
||||
key: 'hubspot.pipelines',
|
||||
contracts: [selectorContracts.hubspotPipelinesSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'hubspot.pipelines',
|
||||
context.oauthCredential ?? 'none',
|
||||
resolveObjectType(context) ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(context.oauthCredential) && resolveObjectType(context) !== null,
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'hubspot.pipelines')
|
||||
const objectType = resolveObjectType(context)
|
||||
if (!objectType) return []
|
||||
const data = await requestJson(selectorContracts.hubspotPipelinesSelectorContract, {
|
||||
query: { credentialId, objectType },
|
||||
signal,
|
||||
})
|
||||
return data.pipelines.map((pipeline) => ({ id: pipeline.id, label: pipeline.name }))
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Stages live INSIDE the pipelines payload rather than behind an endpoint of their own, so
|
||||
* this reads the same contract and narrows to the selected pipeline. Sharing HubSpot's one
|
||||
* response is also why both selectors stay in step — a stage list can never describe a
|
||||
* pipeline the sibling picker is not showing.
|
||||
*/
|
||||
'hubspot.pipelineStages': {
|
||||
key: 'hubspot.pipelineStages',
|
||||
contracts: [selectorContracts.hubspotPipelinesSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'hubspot.pipelineStages',
|
||||
context.oauthCredential ?? 'none',
|
||||
resolveObjectType(context) ?? 'none',
|
||||
context.pipelineId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(context.oauthCredential && context.pipelineId) && resolveObjectType(context) !== null,
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'hubspot.pipelineStages')
|
||||
const objectType = resolveObjectType(context)
|
||||
if (!objectType || !context.pipelineId) return []
|
||||
const data = await requestJson(selectorContracts.hubspotPipelinesSelectorContract, {
|
||||
query: { credentialId, objectType },
|
||||
signal,
|
||||
})
|
||||
const pipeline = data.pipelines.find((entry) => entry.id === context.pipelineId)
|
||||
return (pipeline?.stages ?? []).map((stage) => ({ id: stage.id, label: stage.label }))
|
||||
},
|
||||
},
|
||||
'hubspot.owners': {
|
||||
key: 'hubspot.owners',
|
||||
contracts: [selectorContracts.hubspotOwnersSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'hubspot.owners',
|
||||
context.oauthCredential ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'hubspot.owners')
|
||||
const data = await requestJson(selectorContracts.hubspotOwnersSelectorContract, {
|
||||
query: { credentialId },
|
||||
signal,
|
||||
})
|
||||
return data.owners.map((owner) => ({ id: owner.id, label: owner.name }))
|
||||
},
|
||||
},
|
||||
} satisfies Partial<Record<SelectorKey, SelectorDefinition>>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap'
|
||||
import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
|
||||
|
||||
export const imapSelectors = {
|
||||
/**
|
||||
* Mailboxes on a self-described IMAP server. Unlike every other selector here the account is
|
||||
* not a stored credential the server can resolve by id — the user types the connection in,
|
||||
* so the parameters travel on the context.
|
||||
*
|
||||
* **The password is deliberately absent from the query key.** A query key identifies a
|
||||
* resource; a credential authorizes access to it. `oauthCredential` is safe in a key because
|
||||
* it is only an id, but a typed password is a secret, and React Query keys are held in cache
|
||||
* and surfaced by devtools. Host, port, TLS and username already identify the mailbox list
|
||||
* uniquely — the password only proves the caller may read it, and it rides the request body
|
||||
* exactly as it did before.
|
||||
*
|
||||
* The consequence is intentional: correcting a wrong password re-runs the request (the
|
||||
* previous attempt failed and cached nothing), while changing ONLY the password on an
|
||||
* otherwise identical connection reuses the cached list, which is the same list.
|
||||
*/
|
||||
'imap.mailboxes': {
|
||||
key: 'imap.mailboxes',
|
||||
contracts: [imapMailboxesContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'imap.mailboxes',
|
||||
context.host ?? 'none',
|
||||
context.port ?? 'default',
|
||||
context.secure ?? 'default',
|
||||
context.username ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.host && context.username && context.password),
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
if (!context.host || !context.username || !context.password) return []
|
||||
const data = await requestJson(imapMailboxesContract, {
|
||||
body: {
|
||||
host: context.host,
|
||||
port: context.port,
|
||||
secure: context.secure,
|
||||
username: context.username,
|
||||
password: context.password,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return data.mailboxes.map((mailbox) => ({ id: mailbox.path, label: mailbox.name }))
|
||||
},
|
||||
},
|
||||
} satisfies Partial<Record<SelectorKey, SelectorDefinition>>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
listManagedAgentOptionsContract,
|
||||
type ManagedAgentResource,
|
||||
} from '@/lib/api/contracts/managed-agents'
|
||||
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
import type {
|
||||
SelectorDefinition,
|
||||
SelectorKey,
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
|
||||
/**
|
||||
* All four Managed Agent pickers read one route, distinguished only by `resource`. The route
|
||||
* decrypts the selected Claude Platform credential server-side, so the API key never reaches
|
||||
* the browser — which is why these cannot fall back to a plain client fetch.
|
||||
*/
|
||||
async function listResource(
|
||||
key: SelectorKey,
|
||||
resource: ManagedAgentResource,
|
||||
{ context, signal }: SelectorQueryArgs
|
||||
): Promise<SelectorOption[]> {
|
||||
const credentialId = ensureCredential(context, key)
|
||||
const { options } = await requestJson(listManagedAgentOptionsContract, {
|
||||
query: { credentialId, resource },
|
||||
signal,
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
function resourceSelector(key: SelectorKey, resource: ManagedAgentResource): SelectorDefinition {
|
||||
return {
|
||||
key,
|
||||
contracts: [listManagedAgentOptionsContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
key,
|
||||
context.oauthCredential ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: (args: SelectorQueryArgs) => listResource(key, resource, args),
|
||||
}
|
||||
}
|
||||
|
||||
export const managedAgentSelectors = {
|
||||
'managedAgent.agents': resourceSelector('managedAgent.agents', 'agents'),
|
||||
'managedAgent.vaults': resourceSelector('managedAgent.vaults', 'vaults'),
|
||||
'managedAgent.memoryStores': resourceSelector('managedAgent.memoryStores', 'memory-stores'),
|
||||
/**
|
||||
* Environments are filtered to the selected deployment mode: cloud and self-hosted expose
|
||||
* different fields (self-hosted rejects `resources`), so mixing them offers choices the rest
|
||||
* of the form cannot honour. An option whose type the API leaves unset is kept either way.
|
||||
*/
|
||||
'managedAgent.environments': {
|
||||
key: 'managedAgent.environments',
|
||||
contracts: [listManagedAgentOptionsContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'managedAgent.environments',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.environmentType ?? 'any',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async (args: SelectorQueryArgs) => {
|
||||
const options = await listResource('managedAgent.environments', 'environments', args)
|
||||
const mode = args.context.environmentType
|
||||
if (mode !== 'cloud' && mode !== 'self_hosted') return options
|
||||
return options.filter((option) => {
|
||||
const type = (option as { type?: string }).type
|
||||
return type === undefined || type === mode
|
||||
})
|
||||
},
|
||||
},
|
||||
} satisfies Partial<Record<SelectorKey, SelectorDefinition>>
|
||||
@@ -0,0 +1,258 @@
|
||||
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
|
||||
import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options'
|
||||
import { fetchWorkspaceEnvironment } from '@/lib/environment/api'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment'
|
||||
import { getSandboxListQueryOptions } from '@/hooks/queries/sandboxes'
|
||||
import {
|
||||
CREDENTIAL_GROUP_LIST_STALE_TIME,
|
||||
credentialGroupKeys,
|
||||
fetchCredentialGroupList,
|
||||
} from '@/hooks/queries/utils/credential-group-queries'
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
|
||||
import {
|
||||
fetchWorkspaceCredentialList,
|
||||
WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
} from '@/hooks/queries/utils/fetch-workspace-credentials'
|
||||
import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
import type {
|
||||
SelectorContext,
|
||||
SelectorDefinition,
|
||||
SelectorKey,
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
|
||||
/**
|
||||
* Workspace-scoped option lists: things a block picks from its OWN workspace rather than from
|
||||
* a third-party account. They are selectors for the same reason the credential-scoped ones
|
||||
* are — a per-block fetcher reading the active-workspace store only works on the canvas — but
|
||||
* their context key is `workspaceId` instead of `oauthCredential`.
|
||||
*/
|
||||
|
||||
function workspaceCredentials(workspaceId: string) {
|
||||
return getQueryClient().fetchQuery({
|
||||
queryKey: workspaceCredentialKeys.list(workspaceId),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
fetchWorkspaceCredentialList(workspaceId, signal),
|
||||
staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
function credentialGroups(workspaceId: string) {
|
||||
return getQueryClient().fetchQuery({
|
||||
queryKey: credentialGroupKeys.list(workspaceId),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
fetchCredentialGroupList(workspaceId, signal),
|
||||
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
function workspaceScoped(
|
||||
key: SelectorKey,
|
||||
fetchList: (workspaceId: string, context: SelectorContext) => Promise<SelectorOption[]>,
|
||||
extraKey?: (context: SelectorContext) => string
|
||||
): SelectorDefinition {
|
||||
return {
|
||||
key,
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
key,
|
||||
context.workspaceId ?? 'none',
|
||||
...(extraKey ? [extraKey(context)] : []),
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.workspaceId),
|
||||
fetchList: ({ context }: SelectorQueryArgs) =>
|
||||
context.workspaceId ? fetchList(context.workspaceId, context) : Promise.resolve([]),
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceSelectors = {
|
||||
/** Distinct OAuth providers the workspace holds a credential for. */
|
||||
'workspace.credentialProviders': {
|
||||
...workspaceScoped('workspace.credentialProviders', async (workspaceId) => {
|
||||
const credentials = await workspaceCredentials(workspaceId)
|
||||
const seen = new Set<string>()
|
||||
const options: SelectorOption[] = []
|
||||
for (const credential of credentials) {
|
||||
if (credential.type !== 'oauth' || !credential.providerId) continue
|
||||
if (seen.has(credential.providerId)) continue
|
||||
seen.add(credential.providerId)
|
||||
const service = getServiceConfigByProviderId(credential.providerId)
|
||||
options.push({ id: credential.providerId, label: service?.name ?? credential.providerId })
|
||||
}
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}),
|
||||
// Resolves a stored provider id with no list fetch at all — the service registry is local.
|
||||
fetchById: async ({ detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
const service = getServiceConfigByProviderId(detailId)
|
||||
return { id: detailId, label: service?.name ?? detailId }
|
||||
},
|
||||
},
|
||||
'workspace.credentialGroups': {
|
||||
...workspaceScoped('workspace.credentialGroups', async (workspaceId) => {
|
||||
const groups = await credentialGroups(workspaceId)
|
||||
return groups
|
||||
.filter((group) => group.status === 'active')
|
||||
.map((group) => ({ id: group.id, label: group.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}),
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!context.workspaceId || !detailId) return null
|
||||
const group = (await credentialGroups(context.workspaceId)).find(
|
||||
(candidate) => candidate.id === detailId
|
||||
)
|
||||
return group ? { id: group.id, label: group.name } : null
|
||||
},
|
||||
},
|
||||
/** Providers represented inside ONE credential group, for its per-provider filter. */
|
||||
'workspace.credentialGroupProviders': {
|
||||
...workspaceScoped(
|
||||
'workspace.credentialGroupProviders',
|
||||
async (workspaceId, context) => {
|
||||
if (!context.credentialGroupId) return []
|
||||
const group = (await credentialGroups(workspaceId)).find(
|
||||
(candidate) => candidate.id === context.credentialGroupId
|
||||
)
|
||||
if (!group) return []
|
||||
return group.options
|
||||
.filter((option) => option.status === 'active')
|
||||
.map((option) => {
|
||||
const service = getCredentialGroupProviderService(option.provider)
|
||||
return { id: service.providerId, label: service.name }
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
(context) => context.credentialGroupId ?? 'none'
|
||||
),
|
||||
/**
|
||||
* Resolves one stored provider id to its service name. The field is multi-select, so the
|
||||
* canvas card summarises several stored ids at once and needs each label before (or
|
||||
* without) the full list — which is what `useDynamicSubBlockOptionDisplayName` asks for.
|
||||
*/
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!context.workspaceId || !context.credentialGroupId || !detailId) return null
|
||||
const group = (await credentialGroups(context.workspaceId)).find(
|
||||
(candidate) => candidate.id === context.credentialGroupId
|
||||
)
|
||||
const option = group?.options.find(
|
||||
(candidate) =>
|
||||
candidate.status === 'active' &&
|
||||
getCredentialGroupProviderService(candidate.provider).providerId === detailId
|
||||
)
|
||||
if (!option) return null
|
||||
const service = getCredentialGroupProviderService(option.provider)
|
||||
return { id: service.providerId, label: service.name }
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Secret NAMES the workspace can resolve. Names only — values stay server-side and are
|
||||
* injected at execution. Both halves come from the one workspace-environment response, the
|
||||
* client mirror of `getEffectiveDecryptedEnv`, so this picker and the `{{VAR}}` autocomplete
|
||||
* can never disagree about what exists.
|
||||
*/
|
||||
'workspace.secretNames': workspaceScoped('workspace.secretNames', async (workspaceId) => {
|
||||
const environment = await getQueryClient().fetchQuery({
|
||||
queryKey: environmentKeys.workspace(workspaceId),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
fetchWorkspaceEnvironment(workspaceId, signal),
|
||||
staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME,
|
||||
})
|
||||
const names = new Set<string>([
|
||||
...Object.keys(environment?.workspace ?? {}),
|
||||
...Object.keys(environment?.personal ?? {}),
|
||||
])
|
||||
return [...names].sort().map((name) => ({ id: name, label: name }))
|
||||
}),
|
||||
/** Only the secret names the current actor may mount as PLAINTEXT into Copilot code. */
|
||||
'workspace.rawSecretNames': workspaceScoped('workspace.rawSecretNames', async (workspaceId) => {
|
||||
const credentials = await workspaceCredentials(workspaceId)
|
||||
return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name }))
|
||||
}),
|
||||
/**
|
||||
* Sandboxes a Function block can run in, narrowed to the language its sibling selects — a
|
||||
* Python block must never be offered an npm sandbox. `shell` runs anywhere.
|
||||
*/
|
||||
'workspace.sandboxes': {
|
||||
...workspaceScoped(
|
||||
'workspace.sandboxes',
|
||||
async (workspaceId, context) => {
|
||||
const { sandboxes } = await getQueryClient().fetchQuery(
|
||||
getSandboxListQueryOptions(workspaceId)
|
||||
)
|
||||
const language = context.language
|
||||
return sandboxes
|
||||
.filter((sandbox) => !language || language === 'shell' || sandbox.language === language)
|
||||
.map((sandbox) => ({ id: sandbox.id, label: sandbox.name }))
|
||||
},
|
||||
(context) => context.language ?? 'any'
|
||||
),
|
||||
/**
|
||||
* A selection left over from before a language switch is still shown, flagged rather than
|
||||
* hidden: returning `null` would drop the field to its placeholder while the value stayed
|
||||
* stored and stayed fatal at execution — cleared-looking, still broken, nothing to point at.
|
||||
*/
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!context.workspaceId || !detailId) return null
|
||||
const { sandboxes } = await getQueryClient().fetchQuery(
|
||||
getSandboxListQueryOptions(context.workspaceId)
|
||||
)
|
||||
const sandbox = sandboxes.find((candidate) => candidate.id === detailId)
|
||||
if (!sandbox) return null
|
||||
const option = { id: sandbox.id, label: sandbox.name }
|
||||
const language = context.language
|
||||
if ((language === 'python' || language === 'javascript') && sandbox.language !== language) {
|
||||
return { ...option, label: `${option.label} · wrong language for this block` }
|
||||
}
|
||||
return option
|
||||
},
|
||||
},
|
||||
/**
|
||||
* The trigger vocabulary the Logs page filter offers, so the Logs block and that page name a
|
||||
* run's origin identically. Workspace-independent, but registered here because it is the
|
||||
* Logs block's list.
|
||||
*
|
||||
* The registry is reached lazily: `getTriggerOptions` reads the block and trigger registries,
|
||||
* and importing it eagerly from a module block definitions import would close a cycle.
|
||||
* Entries sharing a label merge into one comma-joined id, because the filter is a
|
||||
* comma-separated list end to end and two identical rows would be unselectable apart.
|
||||
*/
|
||||
'workspace.triggerTypes': {
|
||||
key: 'workspace.triggerTypes',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: () => ['selectors', 'workspace.triggerTypes'],
|
||||
fetchList: async () => {
|
||||
const { getTriggerOptions } = await import('@/lib/logs/get-trigger-options')
|
||||
const valuesByLabel = new Map<string, string[]>()
|
||||
for (const option of getTriggerOptions()) {
|
||||
const values = valuesByLabel.get(option.label)
|
||||
if (values) values.push(option.value)
|
||||
else valuesByLabel.set(option.label, [option.value])
|
||||
}
|
||||
return Array.from(valuesByLabel, ([label, values]) => ({ id: values.join(','), label }))
|
||||
},
|
||||
},
|
||||
} satisfies Partial<Record<SelectorKey, SelectorDefinition>>
|
||||
|
||||
/**
|
||||
* The OpenRouter embedding catalog. Workspace-independent — the list is the same for everyone
|
||||
* — but a selector rather than a static array because it is fetched, and a fetched list has to
|
||||
* be reachable from every surface, not just the canvas.
|
||||
*/
|
||||
export const providerSelectors = {
|
||||
'providers.openrouterEmbeddingModels': {
|
||||
key: 'providers.openrouterEmbeddingModels',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: () => ['selectors', 'providers.openrouterEmbeddingModels'],
|
||||
fetchList: async () => {
|
||||
const { providerModelsQueryOptions } = await import('@/hooks/queries/providers')
|
||||
const { models } = await getQueryClient().fetchQuery(
|
||||
providerModelsQueryOptions('openrouter-embeddings')
|
||||
)
|
||||
return models.map((model: string) => ({ id: model, label: model }))
|
||||
},
|
||||
},
|
||||
} satisfies Partial<Record<SelectorKey, SelectorDefinition>>
|
||||
@@ -8,10 +8,13 @@ import { clickupSelectors } from '@/hooks/selectors/providers/clickup/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 { hubspotSelectors } from '@/hooks/selectors/providers/hubspot/selectors'
|
||||
import { imapSelectors } from '@/hooks/selectors/providers/imap/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 { managedAgentSelectors } from '@/hooks/selectors/providers/managed-agent/selectors'
|
||||
import { microsoftSelectors } from '@/hooks/selectors/providers/microsoft/selectors'
|
||||
import { mondaySelectors } from '@/hooks/selectors/providers/monday/selectors'
|
||||
import { netsuiteSelectors } from '@/hooks/selectors/providers/netsuite/selectors'
|
||||
@@ -24,6 +27,10 @@ import { snowflakeSelectors } from '@/hooks/selectors/providers/snowflake/select
|
||||
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 {
|
||||
providerSelectors,
|
||||
workspaceSelectors,
|
||||
} from '@/hooks/selectors/providers/workspace/selectors'
|
||||
import { zohoDeskSelectors } from '@/hooks/selectors/providers/zoho-desk/selectors'
|
||||
import { zoomSelectors } from '@/hooks/selectors/providers/zoom/selectors'
|
||||
import type {
|
||||
@@ -43,6 +50,11 @@ export const selectorRegistry = {
|
||||
...confluenceSelectors,
|
||||
...jsmSelectors,
|
||||
...googleSelectors,
|
||||
...hubspotSelectors,
|
||||
...managedAgentSelectors,
|
||||
...imapSelectors,
|
||||
...workspaceSelectors,
|
||||
...providerSelectors,
|
||||
...microsoftSelectors,
|
||||
...notionSelectors,
|
||||
...pipedriveSelectors,
|
||||
|
||||
+9
-3
@@ -2,15 +2,21 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fetchTriggerTypeOptions } from '@/lib/workflows/subblocks/options'
|
||||
import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
|
||||
/**
|
||||
* Exercises the real block and trigger registries rather than a mock: the
|
||||
* fetcher reaches them through a lazy import specifically to avoid an
|
||||
* selector reaches them through a lazy import specifically to avoid an
|
||||
* initialization cycle, and a mocked test cannot show that the import resolves
|
||||
* or that the registry is populated by the time the dropdown asks for options.
|
||||
*/
|
||||
describe('fetchTriggerTypeOptions against the real registry', () => {
|
||||
const fetchTriggerTypeOptions = () =>
|
||||
getSelectorDefinition('workspace.triggerTypes').fetchList!({
|
||||
key: 'workspace.triggerTypes',
|
||||
context: {},
|
||||
})
|
||||
|
||||
describe('workspace.triggerTypes against the real registry', () => {
|
||||
it('resolves the lazy import into a populated list of unique labels', async () => {
|
||||
const options = await fetchTriggerTypeOptions()
|
||||
|
||||
@@ -20,6 +20,15 @@ export type SelectorKey =
|
||||
| 'clickup.lists'
|
||||
| 'confluence.spaces'
|
||||
| 'google.tasks.lists'
|
||||
| 'managedAgent.agents'
|
||||
| 'managedAgent.environments'
|
||||
| 'managedAgent.vaults'
|
||||
| 'managedAgent.memoryStores'
|
||||
| 'hubspot.lists'
|
||||
| 'hubspot.owners'
|
||||
| 'hubspot.pipelines'
|
||||
| 'hubspot.pipelineStages'
|
||||
| 'hubspot.properties'
|
||||
| 'jsm.requestTypes'
|
||||
| 'jsm.serviceDesks'
|
||||
| 'microsoft.planner.plans'
|
||||
@@ -76,6 +85,15 @@ export type SelectorKey =
|
||||
| 'monday.groups'
|
||||
| 'sim.workflows'
|
||||
| 'table.columns'
|
||||
| 'workspace.credentialProviders'
|
||||
| 'workspace.credentialGroups'
|
||||
| 'workspace.credentialGroupProviders'
|
||||
| 'workspace.secretNames'
|
||||
| 'workspace.rawSecretNames'
|
||||
| 'workspace.sandboxes'
|
||||
| 'workspace.triggerTypes'
|
||||
| 'imap.mailboxes'
|
||||
| 'providers.openrouterEmbeddingModels'
|
||||
|
||||
export interface SelectorOption {
|
||||
id: string
|
||||
@@ -125,6 +143,34 @@ export interface SelectorContext {
|
||||
orgId?: string
|
||||
/** Bitbucket Cloud workspace slug that scopes repository discovery. */
|
||||
workspaceSlug?: string
|
||||
/**
|
||||
* HubSpot CRM object the pickers are scoped to (`contact` | `deal` | … | `custom`). Left
|
||||
* unset until the user picks one; the selectors apply HubSpot's own `contact` default so an
|
||||
* untouched dropdown still lists properties for what it visibly shows.
|
||||
*/
|
||||
objectType?: string
|
||||
/** HubSpot custom object type id (e.g. `2-12345`), used when `objectType` is `custom`. */
|
||||
customObjectTypeId?: string
|
||||
/** HubSpot pipeline whose stages a stage picker enumerates. */
|
||||
pipelineId?: string
|
||||
/**
|
||||
* Managed Agent deployment mode (`cloud` | `self_hosted`). The two expose different fields,
|
||||
* so an environment list is filtered to the selected mode rather than mixing them.
|
||||
*/
|
||||
environmentType?: string
|
||||
/** Credential group whose per-provider filter a picker enumerates. */
|
||||
credentialGroupId?: string
|
||||
/** Function block runtime (`python` | `javascript` | `shell`), scoping the sandbox list. */
|
||||
language?: string
|
||||
/**
|
||||
* IMAP connection parameters. `imapPassword` is a raw secret, so unlike `oauthCredential` —
|
||||
* which is only an id — it must NEVER appear in a query key; see `imap.mailboxes`.
|
||||
*/
|
||||
host?: string
|
||||
port?: string
|
||||
secure?: string
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
listManagedAgentOptionsContract,
|
||||
type ManagedAgentOption,
|
||||
type ManagedAgentResource,
|
||||
} from '@/lib/api/contracts/managed-agents'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
/**
|
||||
* `fetchOptions` helpers for the Managed Agent block's dropdowns. Each reads
|
||||
* the block's selected Claude Platform `credential` and calls the list route,
|
||||
* which decrypts the credential's key server-side — the API key never touches
|
||||
* the browser.
|
||||
*/
|
||||
|
||||
function readSubBlockValue(blockId: string, key: string): string | null {
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) return null
|
||||
const value = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId]?.[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
async function fetchOptions(
|
||||
blockId: string,
|
||||
resource: ManagedAgentResource
|
||||
): Promise<ManagedAgentOption[]> {
|
||||
const credentialId = readSubBlockValue(blockId, 'credential')
|
||||
if (!credentialId) return []
|
||||
try {
|
||||
const { options } = await requestJson(listManagedAgentOptionsContract, {
|
||||
query: { credentialId, resource },
|
||||
})
|
||||
return options
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchManagedAgentAgentOptions(blockId: string): Promise<ManagedAgentOption[]> {
|
||||
return fetchOptions(blockId, 'agents')
|
||||
}
|
||||
|
||||
export async function fetchManagedAgentEnvironmentOptions(
|
||||
blockId: string
|
||||
): Promise<ManagedAgentOption[]> {
|
||||
const options = await fetchOptions(blockId, 'environments')
|
||||
// Filter to the selected environment type so cloud/self-hosted stay separate
|
||||
// (self-hosted rejects `resources`, so the two modes expose different fields).
|
||||
// Options with an unknown type are kept as a safety net.
|
||||
const mode = readSubBlockValue(blockId, 'environmentType')
|
||||
if (mode !== 'cloud' && mode !== 'self_hosted') return options
|
||||
return options.filter((option) => option.type === undefined || option.type === mode)
|
||||
}
|
||||
|
||||
export function fetchManagedAgentVaultOptions(blockId: string): Promise<ManagedAgentOption[]> {
|
||||
return fetchOptions(blockId, 'vaults')
|
||||
}
|
||||
|
||||
export function fetchManagedAgentMemoryStoreOptions(
|
||||
blockId: string
|
||||
): Promise<ManagedAgentOption[]> {
|
||||
return fetchOptions(blockId, 'memory-stores')
|
||||
}
|
||||
@@ -42,6 +42,17 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
|
||||
'database',
|
||||
'schema',
|
||||
'workspaceSlug',
|
||||
'objectType',
|
||||
'customObjectTypeId',
|
||||
'pipelineId',
|
||||
'environmentType',
|
||||
'credentialGroupId',
|
||||
'language',
|
||||
'host',
|
||||
'port',
|
||||
'secure',
|
||||
'username',
|
||||
'password',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -91,5 +102,26 @@ export function buildSelectorContextFromBlock(
|
||||
setField(subBlockId, subBlock?.value)
|
||||
}
|
||||
|
||||
// A credential field IS the oauth credential, whatever the block calls its subblock. Most
|
||||
// blocks say so with `canonicalParamId: 'oauthCredential'`, but that id is also the block's
|
||||
// serialized param name — so requiring it would mean renaming a shipped block's param just
|
||||
// to make its pickers resolvable, which is not a rename any picker should be able to force.
|
||||
// Reading it off the subblock TYPE keeps the two decisions independent.
|
||||
//
|
||||
// Only fills a gap: a block that does declare the canonical id has already set it above,
|
||||
// including the basic/advanced active-member resolution this loop cannot express.
|
||||
if (!context.oauthCredential) {
|
||||
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
|
||||
if (blockConfig.subBlocks.find((cfg) => cfg.id === subBlockId)?.type !== 'oauth-input') {
|
||||
continue
|
||||
}
|
||||
const value = subBlock?.value
|
||||
if (typeof value === 'string' && value) {
|
||||
context.oauthCredential = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockFetchQuery, mockGetSubBlockValue, mockTriggerOptions } = vi.hoisted(() => ({
|
||||
mockFetchQuery: vi.fn(),
|
||||
mockGetSubBlockValue: vi.fn(),
|
||||
mockTriggerOptions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
getQueryClient: () => ({ fetchQuery: mockFetchQuery }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/sandboxes', () => ({
|
||||
getSandboxListQueryOptions: (workspaceId: string) => ({
|
||||
queryKey: ['sandboxes', 'list', workspaceId],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/registry/store', () => ({
|
||||
useWorkflowRegistry: {
|
||||
getState: () => ({ hydration: { workspaceId: 'workspace-1' } }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/get-trigger-options', () => ({
|
||||
getTriggerOptions: () => mockTriggerOptions(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/subblock/store', () => ({
|
||||
useSubBlockStore: {
|
||||
getState: () => ({ getValue: mockGetSubBlockValue }),
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchTriggerTypeOptions,
|
||||
fetchWorkspaceSandboxOption,
|
||||
fetchWorkspaceSandboxOptions,
|
||||
} from '@/lib/workflows/subblocks/options'
|
||||
|
||||
const SANDBOXES = [
|
||||
{ id: 'sandbox-python', name: 'Data tools', language: 'python' },
|
||||
{ id: 'sandbox-javascript', name: 'Node tools', language: 'javascript' },
|
||||
]
|
||||
|
||||
describe('workspace sandbox options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchQuery.mockResolvedValue({ sandboxes: SANDBOXES })
|
||||
})
|
||||
|
||||
it('lists both dependency ecosystems by their workspace-unique names for Shell blocks', async () => {
|
||||
mockGetSubBlockValue.mockReturnValue('shell')
|
||||
|
||||
await expect(fetchWorkspaceSandboxOptions('block-1')).resolves.toEqual([
|
||||
{ id: 'sandbox-python', label: 'Data tools' },
|
||||
{ id: 'sandbox-javascript', label: 'Node tools' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps language-specific block lists filtered and name-only', async () => {
|
||||
mockGetSubBlockValue.mockReturnValue('python')
|
||||
|
||||
await expect(fetchWorkspaceSandboxOptions('block-1')).resolves.toEqual([
|
||||
{ id: 'sandbox-python', label: 'Data tools' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses workspace-unique names when the sibling language is unavailable', async () => {
|
||||
mockGetSubBlockValue.mockReturnValue(undefined)
|
||||
|
||||
await expect(fetchWorkspaceSandboxOptions('synthetic-block-1')).resolves.toEqual([
|
||||
{ id: 'sandbox-python', label: 'Data tools' },
|
||||
{ id: 'sandbox-javascript', label: 'Node tools' },
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates a stored Shell selection by name', async () => {
|
||||
mockGetSubBlockValue.mockReturnValue('shell')
|
||||
|
||||
await expect(fetchWorkspaceSandboxOption('block-1', 'sandbox-python')).resolves.toEqual({
|
||||
id: 'sandbox-python',
|
||||
label: 'Data tools',
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates a synthetic single option by name', async () => {
|
||||
mockGetSubBlockValue.mockReturnValue(undefined)
|
||||
|
||||
await expect(
|
||||
fetchWorkspaceSandboxOption('synthetic-block-1', 'sandbox-javascript')
|
||||
).resolves.toEqual({
|
||||
id: 'sandbox-javascript',
|
||||
label: 'Node tools',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps mismatched persisted selections visible for language-specific blocks', async () => {
|
||||
mockGetSubBlockValue.mockReturnValue('python')
|
||||
|
||||
await expect(fetchWorkspaceSandboxOption('block-1', 'sandbox-javascript')).resolves.toEqual({
|
||||
id: 'sandbox-javascript',
|
||||
label: 'Node tools · wrong language for this block',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchTriggerTypeOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('merges values that share a label into one comma-joined option', async () => {
|
||||
mockTriggerOptions.mockReturnValue([
|
||||
{ value: 'api', label: 'API', color: '#2563eb' },
|
||||
{ value: 'copilot', label: 'Sim agent', color: '#ec4899' },
|
||||
{ value: 'mothership', label: 'Sim agent', color: '#ec4899' },
|
||||
{ value: 'slack', label: 'Slack', color: '#611f69' },
|
||||
])
|
||||
|
||||
await expect(fetchTriggerTypeOptions()).resolves.toEqual([
|
||||
{ id: 'api', label: 'API' },
|
||||
{ id: 'copilot,mothership', label: 'Sim agent' },
|
||||
{ id: 'slack', label: 'Slack' },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves registry order so core trigger types lead the list', async () => {
|
||||
mockTriggerOptions.mockReturnValue([
|
||||
{ value: 'manual', label: 'Manual', color: '#6b7280' },
|
||||
{ value: 'api', label: 'API', color: '#2563eb' },
|
||||
{ value: 'airtable', label: 'Airtable', color: '#181d1f' },
|
||||
])
|
||||
|
||||
await expect(fetchTriggerTypeOptions()).resolves.toEqual([
|
||||
{ id: 'manual', label: 'Manual' },
|
||||
{ id: 'api', label: 'API' },
|
||||
{ id: 'airtable', label: 'Airtable' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,172 +0,0 @@
|
||||
import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options'
|
||||
import { fetchWorkspaceEnvironment } from '@/lib/environment/api'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment'
|
||||
import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes'
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
|
||||
import {
|
||||
fetchWorkspaceCredentialList,
|
||||
WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
} from '@/hooks/queries/utils/fetch-workspace-credentials'
|
||||
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface SubBlockOption {
|
||||
label: string
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the active workspace's workflows for multi-select subblocks
|
||||
* (`fetchOptions`). Set `excludeActiveWorkflow` for surfaces where selecting
|
||||
* the current workflow is meaningless (e.g. the Sim trigger never receives
|
||||
* events about itself).
|
||||
*/
|
||||
export async function fetchWorkspaceWorkflowOptions(options?: {
|
||||
excludeActiveWorkflow?: boolean
|
||||
}): Promise<SubBlockOption[]> {
|
||||
const registry = useWorkflowRegistry.getState()
|
||||
const workspaceId = registry.hydration.workspaceId
|
||||
if (!workspaceId) return []
|
||||
|
||||
const workflows = await getQueryClient().fetchQuery(
|
||||
getWorkflowListQueryOptions(workspaceId, 'active')
|
||||
)
|
||||
|
||||
return workflows
|
||||
.filter(
|
||||
(workflow) => !options?.excludeActiveWorkflow || workflow.id !== registry.activeWorkflowId
|
||||
)
|
||||
.map((workflow) => ({ id: workflow.id, label: workflow.name }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the active workspace's secret NAMES for the Function block's secret-scope
|
||||
* picker. Names only — values stay server-side and are injected at execution, the
|
||||
* same discipline the copilot's workspace context uses.
|
||||
*
|
||||
* Both halves come from the single workspace-environment response, which is the
|
||||
* client-side mirror of `getEffectiveDecryptedEnv` — the resolver the executor
|
||||
* injects from. Its `personal` slice is NOT the caller's raw personal variables:
|
||||
* under credential filtering it also carries personal secrets other members have
|
||||
* shared into the workspace, so reading `/api/environment` instead would hide
|
||||
* names the code can genuinely resolve. This is the same pair the `{{VAR}}`
|
||||
* autocomplete lists, so the picker and the editor never disagree.
|
||||
*
|
||||
* Values are masked to `''` for non-admin viewers; only the keys are used here.
|
||||
*/
|
||||
export async function fetchWorkspaceSecretNameOptions(): Promise<SubBlockOption[]> {
|
||||
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
|
||||
if (!workspaceId) return []
|
||||
|
||||
const environment = await getQueryClient().fetchQuery({
|
||||
queryKey: environmentKeys.workspace(workspaceId),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
fetchWorkspaceEnvironment(workspaceId, signal),
|
||||
staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME,
|
||||
})
|
||||
|
||||
// Workspace entries shadow personal ones at execution (`getEffectiveDecryptedEnv`
|
||||
// spreads workspace last), but a name-only list just de-duplicates the union.
|
||||
const names = new Set<string>([
|
||||
...Object.keys(environment?.workspace ?? {}),
|
||||
...Object.keys(environment?.personal ?? {}),
|
||||
])
|
||||
return [...names].sort().map((name) => ({ id: name, label: name }))
|
||||
}
|
||||
|
||||
/** Loads only secret names the current actor may mount as plaintext into Copilot code. */
|
||||
export async function fetchWorkspaceRawSecretNameOptions(): Promise<SubBlockOption[]> {
|
||||
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
|
||||
if (!workspaceId) return []
|
||||
|
||||
const credentials = await getQueryClient().fetchQuery({
|
||||
queryKey: workspaceCredentialKeys.list(workspaceId),
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
fetchWorkspaceCredentialList(workspaceId, signal),
|
||||
staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME,
|
||||
})
|
||||
|
||||
return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name }))
|
||||
}
|
||||
|
||||
/** Sandbox names are workspace-unique, so the picker needs no language suffix. */
|
||||
function toSandboxOption(sandbox: { id: string; name: string }): SubBlockOption {
|
||||
return { id: sandbox.id, label: sandbox.name }
|
||||
}
|
||||
|
||||
async function loadWorkspaceSandboxes(): Promise<SandboxListResponse['sandboxes']> {
|
||||
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
|
||||
if (!workspaceId) {
|
||||
throw new Error('Workspace sandbox options are unavailable until workspace hydration completes')
|
||||
}
|
||||
const data = await getQueryClient().fetchQuery(getSandboxListQueryOptions(workspaceId))
|
||||
return data.sandboxes
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the sandboxes a Function block can run in, scoped to the language its
|
||||
* sibling `language` subblock selects — a Python block must never be offered an
|
||||
* npm sandbox. The block re-fetches when `language` changes (`dependsOn`).
|
||||
*/
|
||||
export async function fetchWorkspaceSandboxOptions(blockId: string): Promise<SubBlockOption[]> {
|
||||
const language = useSubBlockStore.getState().getValue(blockId, 'language')
|
||||
const sandboxes = await loadWorkspaceSandboxes()
|
||||
return sandboxes
|
||||
.filter((sandbox) => !language || language === 'shell' || sandbox.language === language)
|
||||
.map(toSandboxOption)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates a stored sandbox id to its label before the option list loads.
|
||||
*
|
||||
* A selection left over from before a language switch is still shown, flagged
|
||||
* rather than hidden. Returning `null` here would drop the field back to its
|
||||
* "Default image" placeholder while the value stayed stored
|
||||
* and stayed fatal at execution — the field would read as cleared and the run
|
||||
* would still fail, with nothing to point at. Labelling it is what lets the
|
||||
* author see what to fix.
|
||||
*/
|
||||
export async function fetchWorkspaceSandboxOption(
|
||||
blockId: string,
|
||||
optionId: string
|
||||
): Promise<SubBlockOption | null> {
|
||||
const language = useSubBlockStore.getState().getValue(blockId, 'language')
|
||||
const sandboxes = await loadWorkspaceSandboxes()
|
||||
const sandbox = sandboxes.find((candidate) => candidate.id === optionId)
|
||||
if (!sandbox) return null
|
||||
|
||||
const option = toSandboxOption(sandbox)
|
||||
if ((language === 'python' || language === 'javascript') && sandbox.language !== language) {
|
||||
return { ...option, label: `${option.label} · wrong language for this block` }
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the trigger vocabulary the Logs page filter offers — the core trigger
|
||||
* types plus one entry per registered webhook provider — for the Logs block's
|
||||
* trigger filter, so both surfaces name a run's origin identically.
|
||||
*
|
||||
* The registry is reached lazily: `getTriggerOptions` reads the block and trigger
|
||||
* registries, and importing it at module scope from a module that block
|
||||
* definitions themselves import would close an initialization cycle.
|
||||
*
|
||||
* Entries sharing a label are merged into one option whose id is the comma-joined
|
||||
* set of values (`copilot,mothership` for "Sim agent"). The filter is a
|
||||
* comma-separated list end to end, so a merged id selects every value behind the
|
||||
* label instead of offering two identical rows.
|
||||
*/
|
||||
export async function fetchTriggerTypeOptions(): Promise<SubBlockOption[]> {
|
||||
const { getTriggerOptions } = await import('@/lib/logs/get-trigger-options')
|
||||
|
||||
const valuesByLabel = new Map<string, string[]>()
|
||||
for (const option of getTriggerOptions()) {
|
||||
const values = valuesByLabel.get(option.label)
|
||||
if (values) values.push(option.value)
|
||||
else valuesByLabel.set(option.label, [option.value])
|
||||
}
|
||||
|
||||
return Array.from(valuesByLabel, ([label, values]) => ({ id: values.join(','), label }))
|
||||
}
|
||||
@@ -1,32 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/clickup'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { clickupSetupInstructions } from '@/triggers/clickup/utils'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
|
||||
const logger = createLogger('ClickUpTriggerSubBlocks')
|
||||
|
||||
async function fetchWorkspaceOptions(
|
||||
blockId: string
|
||||
): Promise<Array<{ id: string; label: string }>> {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null
|
||||
if (!credentialId) {
|
||||
throw new Error('No ClickUp credential selected')
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(clickupWorkspacesSelectorContract, {
|
||||
body: { credential: credentialId },
|
||||
})
|
||||
return (data.workspaces ?? []).map((workspace) => ({
|
||||
id: workspace.id,
|
||||
label: workspace.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching ClickUp workspaces:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the shared subBlocks for a ClickUp trigger: OAuth credentials, the
|
||||
@@ -40,6 +13,7 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[
|
||||
id: 'triggerCredentials',
|
||||
title: 'ClickUp Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
serviceId: 'clickup',
|
||||
requiredScopes: [],
|
||||
mode: 'trigger',
|
||||
@@ -50,17 +24,13 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[
|
||||
id: 'triggerWorkspaceId',
|
||||
title: 'Workspace',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'clickup.workspaces',
|
||||
dependsOn: ['triggerCredentials'],
|
||||
placeholder: 'Select a workspace',
|
||||
description: 'The ClickUp Workspace the webhook is registered in',
|
||||
required: true,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: { field: 'selectedTriggerId', value: triggerId },
|
||||
fetchOptions: fetchWorkspaceOptions,
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const workspaces = await fetchWorkspaceOptions(blockId)
|
||||
return workspaces.find((workspace) => workspace.id === optionId) ?? null
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggerSpaceId',
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Editor-state readers for trigger sub-block option resolvers.
|
||||
*
|
||||
* Trigger definitions are a definition layer: `@/triggers` must not reach `@/blocks`
|
||||
* through a static import, because block configs spread `getTrigger(...).subBlocks` at
|
||||
* module scope. A static edge makes the two barrels mutually recursive, and whichever
|
||||
* one an entry point reaches first wins — enter through `@/triggers` and `getTrigger()`
|
||||
* runs before `TRIGGER_REGISTRY` is initialized, throwing
|
||||
* `ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization`.
|
||||
*
|
||||
* The Zustand stores below sit on the far side of that edge (`subblock/store` imports
|
||||
* `@/blocks`), so they are loaded with a dynamic `import()`. Dynamic imports resolve at
|
||||
* call time rather than during module evaluation, so they carry no initialization-order
|
||||
* obligation. Every caller is an editor-side `fetchOptions`/`fetchOptionById` resolver
|
||||
* that already runs asynchronously, long after both registries are built.
|
||||
*
|
||||
* `scripts/check-trigger-block-cycle.ts` fails the build if a static edge reappears.
|
||||
*/
|
||||
|
||||
/** The value the user has entered for `subBlockId` on `blockId` in the open workflow. */
|
||||
export async function readSubBlockValue(blockId: string, subBlockId: string): Promise<unknown> {
|
||||
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
|
||||
return useSubBlockStore.getState().getValue(blockId, subBlockId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored sub-block value for `blockId`, for resolvers that read several fields at
|
||||
* once. Returns `undefined` when the block has no stored values yet.
|
||||
*/
|
||||
export async function readBlockValues(
|
||||
blockId: string
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const [{ useSubBlockStore }, { useWorkflowRegistry }] = await Promise.all([
|
||||
import('@/stores/workflows/subblock/store'),
|
||||
import('@/stores/workflows/registry/store'),
|
||||
])
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) return undefined
|
||||
return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId]
|
||||
}
|
||||
|
||||
/** The active workspace's workflows, for trigger sub-blocks that select other workflows. */
|
||||
export async function readWorkspaceWorkflowOptions(options?: {
|
||||
excludeActiveWorkflow?: boolean
|
||||
}): Promise<Array<{ label: string; id: string }>> {
|
||||
const { fetchWorkspaceWorkflowOptions } = await import('@/lib/workflows/subblocks/options')
|
||||
return fetchWorkspaceWorkflowOptions(options)
|
||||
}
|
||||
|
||||
/** The workflow and workspace the editor currently has open. */
|
||||
export async function readActiveWorkflowContext(): Promise<{
|
||||
activeWorkflowId: string | null
|
||||
workspaceId: string | null
|
||||
}> {
|
||||
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
|
||||
const state = useWorkflowRegistry.getState()
|
||||
return {
|
||||
activeWorkflowId: state.activeWorkflowId,
|
||||
workspaceId: state.hydration.workspaceId,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { GmailIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
const logger = createLogger('GmailPollingTrigger')
|
||||
@@ -21,6 +18,7 @@ export const gmailPollingTrigger: TriggerConfig = {
|
||||
id: 'triggerCredentials',
|
||||
title: 'Credentials',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
description: 'This trigger requires google email credentials to access your account.',
|
||||
serviceId: 'gmail',
|
||||
requiredScopes: [],
|
||||
@@ -32,32 +30,11 @@ export const gmailPollingTrigger: TriggerConfig = {
|
||||
title: 'Gmail Labels to Monitor',
|
||||
canvasNoun: 'a label',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'gmail.labels',
|
||||
multiSelect: true,
|
||||
placeholder: 'Select Gmail labels to monitor for new emails',
|
||||
description: 'Choose which Gmail labels to monitor. Leave empty to monitor all emails.',
|
||||
required: false,
|
||||
options: [], // Will be populated dynamically from user's Gmail labels
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) {
|
||||
// Return a sentinel to prevent infinite retry loops when credential is missing
|
||||
throw new Error('No Gmail credential selected')
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(gmailLabelsSelectorContract, {
|
||||
query: { credentialId },
|
||||
})
|
||||
return data.labels.map((label) => ({
|
||||
id: label.id,
|
||||
label: label.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Gmail labels:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
mode: 'trigger',
|
||||
},
|
||||
|
||||
@@ -1,44 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { HubspotIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
hubspotListsSelectorContract,
|
||||
hubspotOwnersSelectorContract,
|
||||
hubspotPipelinesSelectorContract,
|
||||
hubspotPropertiesSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/hubspot'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
const logger = createLogger('HubSpotPollingTrigger')
|
||||
|
||||
/**
|
||||
* Resolves the effective object type from the subblock store. `getValue` returns `null`
|
||||
* for fields the user hasn't interacted with yet, so we fall back to the dropdown's
|
||||
* default ('contact') — otherwise the cascading property selectors render empty on
|
||||
* first render even when the dropdown visibly shows "contact".
|
||||
*/
|
||||
async function resolveSelectedObjectType(blockId: string): Promise<string | null> {
|
||||
const objectType = (await readSubBlockValue(blockId, 'objectType')) as string | null
|
||||
const customId = (await readSubBlockValue(blockId, 'customObjectTypeId')) as string | null
|
||||
const selected = objectType ?? 'contact'
|
||||
if (selected === 'custom') {
|
||||
const trimmed = customId?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
async function fetchHubSpotProperties(blockId: string, objectType: string) {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null
|
||||
if (!credentialId) throw new Error('No HubSpot credential selected')
|
||||
const data = await requestJson(hubspotPropertiesSelectorContract, {
|
||||
query: { credentialId, objectType },
|
||||
})
|
||||
return data.properties.map((p) => ({ id: p.id, label: p.name }))
|
||||
}
|
||||
|
||||
export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'hubspot_poller',
|
||||
name: 'HubSpot CRM Trigger',
|
||||
@@ -54,6 +20,7 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'triggerCredentials',
|
||||
title: 'HubSpot Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
description: 'Connect a HubSpot account so Sim can poll your CRM on your behalf.',
|
||||
serviceId: 'hubspot',
|
||||
requiredScopes: getScopesForService('hubspot'),
|
||||
@@ -65,6 +32,7 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'objectType',
|
||||
title: 'Object Type',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'objectType',
|
||||
description: 'What you want to watch.',
|
||||
options: [
|
||||
{ label: 'Contact', id: 'contact' },
|
||||
@@ -82,6 +50,7 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'customObjectTypeId',
|
||||
title: 'Custom Object Type ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'customObjectTypeId',
|
||||
description:
|
||||
'HubSpot custom object type ID (e.g. "2-12345"). Find it in HubSpot Settings → Objects → Custom Objects.',
|
||||
placeholder: '2-12345',
|
||||
@@ -93,24 +62,9 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'listId',
|
||||
title: 'List',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'hubspot.lists',
|
||||
description: 'The HubSpot list to watch for new members.',
|
||||
placeholder: 'Select a list',
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) throw new Error('No HubSpot credential selected')
|
||||
try {
|
||||
const data = await requestJson(hubspotListsSelectorContract, {
|
||||
query: { credentialId },
|
||||
})
|
||||
return data.lists.map((l) => ({ id: l.id, label: l.name }))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching HubSpot lists:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
required: { field: 'objectType', value: 'list_membership' },
|
||||
mode: 'trigger',
|
||||
@@ -137,19 +91,9 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'targetPropertyName',
|
||||
title: 'Property to Watch',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'hubspot.properties',
|
||||
description: 'Fires only when this specific property changes value on a record.',
|
||||
placeholder: 'Select a property',
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const resolved = await resolveSelectedObjectType(blockId)
|
||||
if (!resolved) throw new Error('Select an object type first')
|
||||
try {
|
||||
return await fetchHubSpotProperties(blockId, resolved)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching HubSpot properties:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials', 'objectType', 'customObjectTypeId'],
|
||||
required: { field: 'eventType', value: 'property_changed' },
|
||||
mode: 'trigger',
|
||||
@@ -163,21 +107,11 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'properties',
|
||||
title: 'Properties to Fetch',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'hubspot.properties',
|
||||
multiSelect: true,
|
||||
description:
|
||||
'Properties to include on each record. Leave empty to use sensible defaults. Sim always includes the timestamps it needs internally.',
|
||||
placeholder: 'Select properties (optional)',
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const resolved = await resolveSelectedObjectType(blockId)
|
||||
if (!resolved) return []
|
||||
try {
|
||||
return await fetchHubSpotProperties(blockId, resolved)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching HubSpot properties:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials', 'objectType', 'customObjectTypeId'],
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
@@ -187,25 +121,10 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'pipelineId',
|
||||
title: 'Pipeline (optional)',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'pipelineId',
|
||||
selectorKey: 'hubspot.pipelines',
|
||||
description: 'Restrict to a single pipeline.',
|
||||
placeholder: 'All pipelines',
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const objectType = (await resolveSelectedObjectType(blockId)) ?? 'contact'
|
||||
if (!credentialId) throw new Error('No HubSpot credential selected')
|
||||
try {
|
||||
const data = await requestJson(hubspotPipelinesSelectorContract, {
|
||||
query: { credentialId, objectType },
|
||||
})
|
||||
return data.pipelines.map((p) => ({ id: p.id, label: p.name }))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching HubSpot pipelines:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials', 'objectType'],
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
@@ -215,28 +134,9 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'stageId',
|
||||
title: 'Stage (optional)',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'hubspot.pipelineStages',
|
||||
description: 'Restrict to a single stage within the selected pipeline.',
|
||||
placeholder: 'All stages',
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const objectType = (await resolveSelectedObjectType(blockId)) ?? 'contact'
|
||||
const pipelineId = (await readSubBlockValue(blockId, 'pipelineId')) as string | null
|
||||
if (!credentialId) throw new Error('No HubSpot credential selected')
|
||||
if (!pipelineId) return []
|
||||
try {
|
||||
const data = await requestJson(hubspotPipelinesSelectorContract, {
|
||||
query: { credentialId, objectType },
|
||||
})
|
||||
const pipeline = data.pipelines.find((p) => p.id === pipelineId)
|
||||
return (pipeline?.stages ?? []).map((s) => ({ id: s.id, label: s.label }))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching HubSpot stages:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials', 'objectType', 'pipelineId'],
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
@@ -246,24 +146,9 @@ export const hubspotPollingTrigger: TriggerConfig = {
|
||||
id: 'ownerId',
|
||||
title: 'Owner (optional)',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'hubspot.owners',
|
||||
description: 'Restrict to records owned by a specific HubSpot user.',
|
||||
placeholder: 'Any owner',
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) throw new Error('No HubSpot credential selected')
|
||||
try {
|
||||
const data = await requestJson(hubspotOwnersSelectorContract, {
|
||||
query: { credentialId },
|
||||
})
|
||||
return data.owners.map((o) => ({ id: o.id, label: o.name }))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching HubSpot owners:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { MailServerIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
const logger = createLogger('ImapPollingTrigger')
|
||||
@@ -72,45 +69,12 @@ export const imapPollingTrigger: TriggerConfig = {
|
||||
title: 'Mailboxes to Monitor',
|
||||
canvasNoun: 'a mailbox',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'imap.mailboxes',
|
||||
multiSelect: true,
|
||||
placeholder: 'Select mailboxes to monitor',
|
||||
description:
|
||||
'Choose which mailbox/folder(s) to monitor for new emails. Leave empty to monitor INBOX.',
|
||||
required: false,
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const [host, port, secure, username, password] = await Promise.all([
|
||||
readSubBlockValue(blockId, 'host') as Promise<string | null>,
|
||||
readSubBlockValue(blockId, 'port') as Promise<string | null>,
|
||||
readSubBlockValue(blockId, 'secure') as Promise<boolean | null>,
|
||||
readSubBlockValue(blockId, 'username') as Promise<string | null>,
|
||||
readSubBlockValue(blockId, 'password') as Promise<string | null>,
|
||||
])
|
||||
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('Please enter IMAP server, username, and password first')
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await requestJson(imapMailboxesContract, {
|
||||
body: {
|
||||
host,
|
||||
port: port ?? undefined,
|
||||
secure: secure ?? undefined,
|
||||
username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
|
||||
return data.mailboxes.map((mailbox) => ({
|
||||
id: mailbox.path,
|
||||
label: mailbox.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching IMAP mailboxes:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['host', 'port', 'secure', 'username', 'password'],
|
||||
mode: 'trigger',
|
||||
},
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { OutlookIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
const logger = createLogger('OutlookPollingTrigger')
|
||||
@@ -21,6 +18,7 @@ export const outlookPollingTrigger: TriggerConfig = {
|
||||
id: 'triggerCredentials',
|
||||
title: 'Credentials',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
description: 'This trigger requires outlook credentials to access your account.',
|
||||
serviceId: 'outlook',
|
||||
requiredScopes: [],
|
||||
@@ -32,31 +30,11 @@ export const outlookPollingTrigger: TriggerConfig = {
|
||||
title: 'Outlook Folders to Monitor',
|
||||
canvasNoun: 'a folder',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'outlook.folders',
|
||||
multiSelect: true,
|
||||
placeholder: 'Select Outlook folders to monitor for new emails',
|
||||
description: 'Choose which Outlook folders to monitor. Leave empty to monitor all emails.',
|
||||
required: false,
|
||||
options: [], // Will be populated dynamically
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) {
|
||||
throw new Error('No Outlook credential selected')
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(outlookFoldersSelectorContract, {
|
||||
query: { credentialId },
|
||||
})
|
||||
return data.folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
label: folder.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Outlook folders:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
mode: 'trigger',
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SIM_TRIGGER_PROVIDER,
|
||||
SIM_WORKSPACE_EVENT_TRIGGER_ID,
|
||||
} from '@/lib/workspace-events/constants'
|
||||
import { readWorkspaceWorkflowOptions } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
export const simWorkspaceEventTrigger: TriggerConfig = {
|
||||
@@ -44,14 +43,14 @@ export const simWorkspaceEventTrigger: TriggerConfig = {
|
||||
id: 'workflowIds',
|
||||
title: 'Workflows',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'sim.workflows',
|
||||
selectorExcludeSelf: true,
|
||||
multiSelect: true,
|
||||
options: [],
|
||||
placeholder: 'All workflows',
|
||||
description: 'Only fire for these workflows. Leave empty to watch every workflow.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
// A subscriber never receives events about itself, so exclude it.
|
||||
fetchOptions: () => readWorkspaceWorkflowOptions({ excludeActiveWorkflow: true }),
|
||||
},
|
||||
{
|
||||
id: 'consecutiveFailures',
|
||||
|
||||
@@ -1,38 +1,6 @@
|
||||
import { Table } from '@sim/emcn/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listTablesContract } from '@/lib/api/contracts/tables'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { tableKeys } from '@/hooks/queries/utils/table-keys'
|
||||
import { readActiveWorkflowContext, readBlockValues } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
async function fetchTableColumns(blockId: string): Promise<Array<{ label: string; id: string }>> {
|
||||
const { activeWorkflowId, workspaceId } = await readActiveWorkflowContext()
|
||||
if (!activeWorkflowId || !workspaceId) return []
|
||||
|
||||
const blockValues = await readBlockValues(blockId)
|
||||
const tableId = (blockValues?.tableSelector as string) || (blockValues?.manualTableId as string)
|
||||
if (!tableId) return []
|
||||
|
||||
const tables = await getQueryClient().fetchQuery({
|
||||
queryKey: tableKeys.list(workspaceId),
|
||||
queryFn: async ({ signal }): Promise<TableDefinition[]> => {
|
||||
const response = await requestJson(listTablesContract, {
|
||||
query: { workspaceId, scope: 'active' },
|
||||
signal,
|
||||
})
|
||||
return (response.data.tables ?? []) as TableDefinition[]
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const table = tables.find((t: TableDefinition) => t.id === tableId)
|
||||
if (!table?.schema?.columns) return []
|
||||
|
||||
return table.schema.columns.map((col) => ({ id: col.name, label: col.name }))
|
||||
}
|
||||
|
||||
export const tableNewRowTrigger: TriggerConfig = {
|
||||
id: 'table_new_row',
|
||||
name: 'Table Trigger',
|
||||
@@ -79,15 +47,14 @@ export const tableNewRowTrigger: TriggerConfig = {
|
||||
id: 'watchColumns',
|
||||
title: 'Watch Columns',
|
||||
type: 'dropdown',
|
||||
selectorKey: 'table.columns',
|
||||
multiSelect: true,
|
||||
options: [],
|
||||
placeholder: 'All columns',
|
||||
description: 'Only fire when these columns change. Leave empty to fire on any update.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: 'update' },
|
||||
dependsOn: { any: ['tableSelector', 'manualTableId'] },
|
||||
fetchOptions: fetchTableColumns,
|
||||
},
|
||||
{
|
||||
id: 'includeHeaders',
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { WebflowIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
webflowCollectionsSelectorContract,
|
||||
webflowSitesSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/webflow'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '../types'
|
||||
|
||||
const logger = createLogger('webflow-collection-item-changed-trigger')
|
||||
@@ -24,6 +18,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
|
||||
id: 'triggerCredentials',
|
||||
title: 'Credentials',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
description: 'This trigger requires webflow credentials to access your account.',
|
||||
serviceId: 'webflow',
|
||||
requiredScopes: [],
|
||||
@@ -38,108 +33,32 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
|
||||
id: 'triggerSiteId',
|
||||
title: 'Site',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'siteId',
|
||||
selectorKey: 'webflow.sites',
|
||||
placeholder: 'Select a site',
|
||||
description: 'The Webflow site to monitor',
|
||||
required: true,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'webflow_collection_item_changed',
|
||||
},
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) {
|
||||
throw new Error('No Webflow credential selected')
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId },
|
||||
})
|
||||
return (data.sites ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Webflow sites:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) return null
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId, siteId: optionId },
|
||||
})
|
||||
const site = data.sites?.find((s) => s.id === optionId)
|
||||
if (site) {
|
||||
return { id: site.id, label: site.name }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
},
|
||||
{
|
||||
id: 'triggerCollectionId',
|
||||
title: 'Collection',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'collectionId',
|
||||
selectorKey: 'webflow.collections',
|
||||
placeholder: 'Select a collection (optional)',
|
||||
description: 'Optionally filter to monitor only a specific collection',
|
||||
required: false,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'webflow_collection_item_changed',
|
||||
},
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null
|
||||
if (!credentialId || !siteId) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(webflowCollectionsSelectorContract, {
|
||||
body: { credential: credentialId, siteId },
|
||||
})
|
||||
return (data.collections ?? []).map((collection) => ({
|
||||
id: collection.id,
|
||||
label: collection.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Webflow collections:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null
|
||||
if (!credentialId || !siteId) return null
|
||||
try {
|
||||
const data = await requestJson(webflowCollectionsSelectorContract, {
|
||||
body: { credential: credentialId, siteId },
|
||||
})
|
||||
const collection = data.collections?.find((c) => c.id === optionId)
|
||||
if (collection) {
|
||||
return { id: collection.id, label: collection.name }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials', 'triggerSiteId'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { WebflowIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
webflowCollectionsSelectorContract,
|
||||
webflowSitesSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/webflow'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '../types'
|
||||
|
||||
const logger = createLogger('webflow-collection-item-created-trigger')
|
||||
@@ -39,6 +33,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
|
||||
id: 'triggerCredentials',
|
||||
title: 'Credentials',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
description: 'This trigger requires webflow credentials to access your account.',
|
||||
serviceId: 'webflow',
|
||||
requiredScopes: [],
|
||||
@@ -53,108 +48,32 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
|
||||
id: 'triggerSiteId',
|
||||
title: 'Site',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'siteId',
|
||||
selectorKey: 'webflow.sites',
|
||||
placeholder: 'Select a site',
|
||||
description: 'The Webflow site to monitor',
|
||||
required: true,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'webflow_collection_item_created',
|
||||
},
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) {
|
||||
throw new Error('No Webflow credential selected')
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId },
|
||||
})
|
||||
return (data.sites ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Webflow sites:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) return null
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId, siteId: optionId },
|
||||
})
|
||||
const site = data.sites?.find((s) => s.id === optionId)
|
||||
if (site) {
|
||||
return { id: site.id, label: site.name }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
},
|
||||
{
|
||||
id: 'triggerCollectionId',
|
||||
title: 'Collection',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'collectionId',
|
||||
selectorKey: 'webflow.collections',
|
||||
placeholder: 'Select a collection (optional)',
|
||||
description: 'Optionally filter to monitor only a specific collection',
|
||||
required: false,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'webflow_collection_item_created',
|
||||
},
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null
|
||||
if (!credentialId || !siteId) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(webflowCollectionsSelectorContract, {
|
||||
body: { credential: credentialId, siteId },
|
||||
})
|
||||
return (data.collections ?? []).map((collection) => ({
|
||||
id: collection.id,
|
||||
label: collection.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Webflow collections:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null
|
||||
if (!credentialId || !siteId) return null
|
||||
try {
|
||||
const data = await requestJson(webflowCollectionsSelectorContract, {
|
||||
body: { credential: credentialId, siteId },
|
||||
})
|
||||
const collection = data.collections?.find((c) => c.id === optionId)
|
||||
if (collection) {
|
||||
return { id: collection.id, label: collection.name }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials', 'triggerSiteId'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { WebflowIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
webflowCollectionsSelectorContract,
|
||||
webflowSitesSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/webflow'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '../types'
|
||||
|
||||
const logger = createLogger('webflow-collection-item-deleted-trigger')
|
||||
@@ -24,6 +18,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
|
||||
id: 'triggerCredentials',
|
||||
title: 'Credentials',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
description: 'This trigger requires webflow credentials to access your account.',
|
||||
serviceId: 'webflow',
|
||||
requiredScopes: [],
|
||||
@@ -38,108 +33,32 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
|
||||
id: 'triggerSiteId',
|
||||
title: 'Site',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'siteId',
|
||||
selectorKey: 'webflow.sites',
|
||||
placeholder: 'Select a site',
|
||||
description: 'The Webflow site to monitor',
|
||||
required: true,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'webflow_collection_item_deleted',
|
||||
},
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) {
|
||||
throw new Error('No Webflow credential selected')
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId },
|
||||
})
|
||||
return (data.sites ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Webflow sites:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) return null
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId, siteId: optionId },
|
||||
})
|
||||
const site = data.sites?.find((s) => s.id === optionId)
|
||||
if (site) {
|
||||
return { id: site.id, label: site.name }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
},
|
||||
{
|
||||
id: 'triggerCollectionId',
|
||||
title: 'Collection',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'collectionId',
|
||||
selectorKey: 'webflow.collections',
|
||||
placeholder: 'Select a collection (optional)',
|
||||
description: 'Optionally filter to monitor only a specific collection',
|
||||
required: false,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'webflow_collection_item_deleted',
|
||||
},
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null
|
||||
if (!credentialId || !siteId) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(webflowCollectionsSelectorContract, {
|
||||
body: { credential: credentialId, siteId },
|
||||
})
|
||||
return (data.collections ?? []).map((collection) => ({
|
||||
id: collection.id,
|
||||
label: collection.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Webflow collections:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null
|
||||
if (!credentialId || !siteId) return null
|
||||
try {
|
||||
const data = await requestJson(webflowCollectionsSelectorContract, {
|
||||
body: { credential: credentialId, siteId },
|
||||
})
|
||||
const collection = data.collections?.find((c) => c.id === optionId)
|
||||
if (collection) {
|
||||
return { id: collection.id, label: collection.name }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials', 'triggerSiteId'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { WebflowIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { webflowSitesSelectorContract } from '@/lib/api/contracts/selectors/webflow'
|
||||
import { readSubBlockValue } from '@/triggers/editor-state'
|
||||
import type { TriggerConfig } from '../types'
|
||||
|
||||
const logger = createLogger('webflow-form-submission-trigger')
|
||||
@@ -21,6 +18,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
|
||||
id: 'triggerCredentials',
|
||||
title: 'Credentials',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
description: 'This trigger requires webflow credentials to access your account.',
|
||||
serviceId: 'webflow',
|
||||
requiredScopes: ['forms:read'],
|
||||
@@ -35,53 +33,16 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
|
||||
id: 'triggerSiteId',
|
||||
title: 'Site',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'siteId',
|
||||
selectorKey: 'webflow.sites',
|
||||
placeholder: 'Select a site',
|
||||
description: 'The Webflow site to monitor',
|
||||
required: true,
|
||||
options: [],
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'webflow_form_submission',
|
||||
},
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) {
|
||||
throw new Error('No Webflow credential selected')
|
||||
}
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId },
|
||||
})
|
||||
return (data.sites ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Webflow sites:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
|
||||
| string
|
||||
| null
|
||||
if (!credentialId) return null
|
||||
try {
|
||||
const data = await requestJson(webflowSitesSelectorContract, {
|
||||
body: { credential: credentialId, siteId: optionId },
|
||||
})
|
||||
const site = data.sites?.find((s) => s.id === optionId)
|
||||
if (site) {
|
||||
return { id: site.id, label: site.name }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
dependsOn: ['triggerCredentials'],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user