connect.resolver.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import type {
  2. ConditionalValue,
  3. ConnectSchema,
  4. ConnectState,
  5. FieldOption,
  6. ResolvedField,
  7. ResolvedStep,
  8. StepDefinition,
  9. StepFieldValueMap,
  10. StepTree,
  11. } from './Connect.types'
  12. /**
  13. * The order in which state keys are checked during conditional value resolution.
  14. * Used for ConditionalValue (value-keyed) resolution, not for step trees.
  15. */
  16. const STATE_KEY_ORDER = [
  17. 'mode',
  18. 'framework',
  19. 'frameworkVariant',
  20. 'library',
  21. 'frameworkUi',
  22. 'orm',
  23. 'connectionMethod',
  24. 'connectionType',
  25. 'mcpClient',
  26. ] as const
  27. /**
  28. * Check if a value is a conditional object (has nested state keys or DEFAULT)
  29. */
  30. function isConditionalObject(value: unknown): value is Record<string, unknown> {
  31. return typeof value === 'object' && value !== null && !Array.isArray(value)
  32. }
  33. /**
  34. * Resolves a conditional value based on current state.
  35. * Walks the tree using stateKeys in order, falling back to DEFAULT at each level.
  36. *
  37. * Example: Given state { mode: 'mcp', mcpClient: 'codex' }
  38. * and stateKeys ['mode', 'framework', ..., 'mcpClient']
  39. *
  40. * 1. Look up 'mcp' (state.mode value) in tree -> found, continue
  41. * 2. At mcp subtree { codex: [...], DEFAULT: [...] }, skip irrelevant keys
  42. * until we find a key whose state value matches an entry in the object
  43. * 3. Look up 'codex' (state.mcpClient value) in that subtree -> found, return value
  44. * 4. If no state key matches, try DEFAULT at that level
  45. */
  46. export function resolveConditional<T>(
  47. value: ConditionalValue<T>,
  48. state: ConnectState,
  49. stateKeys: readonly string[] = STATE_KEY_ORDER
  50. ): T | undefined {
  51. // Base case: we've reached a leaf value (string, array, null, boolean, etc.)
  52. if (!isConditionalObject(value)) {
  53. return value as T
  54. }
  55. const conditionalObj = value as Record<string, ConditionalValue<T>>
  56. const objectKeys = Object.keys(conditionalObj).filter((k) => k !== 'DEFAULT')
  57. // Try each state key in order to find one that matches an entry in the object
  58. for (let i = 0; i < stateKeys.length; i++) {
  59. const currentKey = stateKeys[i]
  60. const stateValue = String(state[currentKey] ?? '')
  61. // If this state value matches a key in the conditional object, use it
  62. if (stateValue && objectKeys.includes(stateValue)) {
  63. const nextValue = conditionalObj[stateValue]
  64. // Continue resolving with remaining keys (after this one)
  65. return resolveConditional(nextValue, state, stateKeys.slice(i + 1))
  66. }
  67. }
  68. // No state key matched - use DEFAULT if available
  69. if (conditionalObj.DEFAULT !== undefined) {
  70. return resolveConditional(conditionalObj.DEFAULT, state, stateKeys)
  71. }
  72. return undefined
  73. }
  74. /**
  75. * Resolves the steps array based on current state.
  76. * Returns only steps that have non-null content.
  77. */
  78. export function resolveSteps(schema: ConnectSchema, state: ConnectState): ResolvedStep[] {
  79. const steps = resolveStepTree(schema.steps, state)
  80. if (steps.length === 0) return []
  81. return steps
  82. .map((step) => {
  83. const content = resolveConditional<string | null>(step.content, state)
  84. return {
  85. id: step.id,
  86. title: step.title,
  87. description: step.description,
  88. content: content ?? '',
  89. }
  90. })
  91. .filter((step) => step.content !== '' && step.content !== null)
  92. }
  93. /**
  94. * Resolves a step tree by evaluating field-specific branches in insertion order.
  95. * Each matching branch appends its steps to the final list.
  96. */
  97. function resolveStepTree(tree: StepTree, state: ConnectState): StepDefinition[] {
  98. if (Array.isArray(tree)) return tree
  99. if (!isConditionalObject(tree)) return []
  100. const resolved: StepDefinition[] = []
  101. for (const [fieldId, valueMap] of Object.entries(tree)) {
  102. if (fieldId === 'DEFAULT') continue
  103. if (!isConditionalObject(valueMap)) continue
  104. const branch = resolveStepBranch(valueMap as StepFieldValueMap, state[fieldId])
  105. if (!branch) continue
  106. resolved.push(...resolveStepTree(branch, state))
  107. }
  108. return resolved
  109. }
  110. function resolveStepBranch(
  111. valueMap: StepFieldValueMap,
  112. stateValue: ConnectState[keyof ConnectState] | undefined
  113. ): StepTree | undefined {
  114. const key = String(stateValue ?? '')
  115. if (key && Object.prototype.hasOwnProperty.call(valueMap, key)) {
  116. return valueMap[key]
  117. }
  118. if (valueMap.DEFAULT !== undefined) {
  119. return valueMap.DEFAULT
  120. }
  121. return undefined
  122. }
  123. /**
  124. * Gets the active fields for the current mode, filtering by dependsOn conditions.
  125. */
  126. export function getActiveFields(schema: ConnectSchema, state: ConnectState): ResolvedField[] {
  127. const currentMode = schema.modes.find((m) => m.id === state.mode)
  128. if (!currentMode) return []
  129. return currentMode.fields
  130. .map((fieldId) => schema.fields[fieldId])
  131. .filter((field): field is NonNullable<typeof field> => !!field)
  132. .filter((field) => {
  133. // Check dependsOn conditions
  134. if (!field.dependsOn) return true
  135. return Object.entries(field.dependsOn).every(([key, values]) => {
  136. const stateValue = String(state[key] ?? '')
  137. return values.includes(stateValue)
  138. })
  139. })
  140. .map((field) => ({
  141. ...field,
  142. resolvedOptions: resolveFieldOptions(field, state),
  143. }))
  144. }
  145. /**
  146. * Resolves field options based on current state.
  147. */
  148. function resolveFieldOptions(field: { options?: unknown }, state: ConnectState): FieldOption[] {
  149. if (!field.options) return []
  150. // Static options array
  151. if (Array.isArray(field.options)) {
  152. return field.options
  153. }
  154. // Reference to data source (handled elsewhere)
  155. if (
  156. typeof field.options === 'object' &&
  157. 'source' in field.options &&
  158. typeof field.options.source === 'string'
  159. ) {
  160. // This will be resolved by the component using getFieldOptionsFromSource
  161. return []
  162. }
  163. // Conditional options
  164. const resolved = resolveConditional<FieldOption[]>(
  165. field.options as ConditionalValue<FieldOption[]>,
  166. state
  167. )
  168. return resolved ?? []
  169. }
  170. /**
  171. * Gets default state for the schema, using first mode and default field values.
  172. */
  173. export function getDefaultState({ schema }: { schema: ConnectSchema }): ConnectState {
  174. const defaultMode = schema.modes[0]?.id ?? 'direct'
  175. const state: ConnectState = { mode: defaultMode }
  176. // Set default values for all fields
  177. Object.values(schema.fields).forEach((field) => {
  178. if (field.defaultValue !== undefined) {
  179. state[field.id] = field.defaultValue
  180. }
  181. })
  182. return state
  183. }
  184. /**
  185. * Resets dependent fields when a parent field changes.
  186. * For example, changing framework should reset frameworkVariant.
  187. */
  188. export function resetDependentFields(
  189. state: ConnectState,
  190. changedFieldId: string,
  191. schema: ConnectSchema
  192. ): ConnectState {
  193. const newState = { ...state }
  194. // Find fields that depend on the changed field
  195. Object.values(schema.fields).forEach((field) => {
  196. if (field.dependsOn && changedFieldId in field.dependsOn) {
  197. // Only reset if dependency conditions are no longer satisfied
  198. const dependencySatisfied = Object.entries(field.dependsOn).every(([key, values]) => {
  199. const stateValue = String(newState[key] ?? '')
  200. return values.includes(stateValue)
  201. })
  202. if (!dependencySatisfied) {
  203. delete newState[field.id]
  204. }
  205. }
  206. })
  207. // Special case: changing mode resets all mode-specific fields
  208. if (changedFieldId === 'mode') {
  209. const previousMode = schema.modes.find((m) => m.id !== state.mode)
  210. const currentMode = schema.modes.find((m) => m.id === state.mode)
  211. // Reset fields from previous mode that aren't in current mode
  212. previousMode?.fields.forEach((fieldId) => {
  213. if (!currentMode?.fields.includes(fieldId)) {
  214. delete newState[fieldId]
  215. }
  216. })
  217. }
  218. return newState
  219. }