|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { type Dispatch, type SetStateAction, useMemo, useState } from 'react' |
| 4 | +import { ChevronDown } from '@/components/emcn' |
| 5 | +import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork' |
| 6 | +import { cn } from '@/lib/core/utils/cn' |
| 7 | +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' |
| 8 | +import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector' |
| 9 | +import { |
| 10 | + dependentKey, |
| 11 | + effectiveDependentValue, |
| 12 | +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value' |
| 13 | +import type { SelectorKey } from '@/hooks/selectors/types' |
| 14 | + |
| 15 | +interface ReconfigBlock { |
| 16 | + targetBlockId: string |
| 17 | + blockName: string |
| 18 | + fields: ForkDependentReconfig[] |
| 19 | +} |
| 20 | + |
| 21 | +/** Group a workflow's dependent fields by their block, sorted by block name. */ |
| 22 | +function groupByBlock(fields: ForkDependentReconfig[]): ReconfigBlock[] { |
| 23 | + const byBlock = new Map<string, ReconfigBlock>() |
| 24 | + for (const field of fields) { |
| 25 | + let block = byBlock.get(field.targetBlockId) |
| 26 | + if (!block) { |
| 27 | + block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] } |
| 28 | + byBlock.set(field.targetBlockId, block) |
| 29 | + } |
| 30 | + block.fields.push(field) |
| 31 | + } |
| 32 | + return Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName)) |
| 33 | +} |
| 34 | + |
| 35 | +interface ResourceReconfigureProps { |
| 36 | + /** Every workflow this resource is used in (from the diff's `resourceUsages`). */ |
| 37 | + workflows: ForkResourceUsage['workflows'] |
| 38 | + /** This resource's dependent fields across all its workflows (from `dependentReconfigs`). */ |
| 39 | + dependents: ForkDependentReconfig[] |
| 40 | + /** The chosen target id (credential/KB/table) the selectors query against. */ |
| 41 | + parentTargetValue: string |
| 42 | + /** True when the target was changed in-session: start blank (the old value won't resolve). */ |
| 43 | + parentChanged: boolean |
| 44 | + reconfig: Record<string, string> |
| 45 | + setReconfig: Dispatch<SetStateAction<Record<string, string>>> |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Always-on per-resource reconfigure listing: every workflow the resource is used in, each a |
| 50 | + * chevron row that expands to its blocks + dependent selectors so the user can (re)configure |
| 51 | + * them at any time - not only right after a target swap. A workflow with nothing configurable |
| 52 | + * (a secret/file, or a credential with no dependent selector here) renders greyed and |
| 53 | + * non-expandable with a tooltip, so the usage is still visible. |
| 54 | + */ |
| 55 | +export function ResourceReconfigure({ |
| 56 | + workflows, |
| 57 | + dependents, |
| 58 | + parentTargetValue, |
| 59 | + parentChanged, |
| 60 | + reconfig, |
| 61 | + setReconfig, |
| 62 | +}: ResourceReconfigureProps) { |
| 63 | + // Group each workflow's dependents into blocks once per (workflows, dependents) change, so |
| 64 | + // the grouping + per-workflow filter doesn't re-run on every parent re-render (setTargets / |
| 65 | + // setReconfig fire often during the editing step). |
| 66 | + const workflowBlocks = useMemo( |
| 67 | + () => |
| 68 | + workflows.map((workflow) => ({ |
| 69 | + workflowId: workflow.workflowId, |
| 70 | + workflowName: workflow.workflowName, |
| 71 | + blocks: groupByBlock(dependents.filter((d) => d.targetWorkflowId === workflow.workflowId)), |
| 72 | + })), |
| 73 | + [workflows, dependents] |
| 74 | + ) |
| 75 | + |
| 76 | + if (workflows.length === 0) return null |
| 77 | + return ( |
| 78 | + <div className='mt-4'> |
| 79 | + <SettingsSection label='Workflows'> |
| 80 | + <div className='flex flex-col gap-1.5'> |
| 81 | + {workflowBlocks.map((workflow) => ( |
| 82 | + <ReconfigWorkflowRow |
| 83 | + key={workflow.workflowId} |
| 84 | + workflowName={workflow.workflowName} |
| 85 | + blocks={workflow.blocks} |
| 86 | + parentTargetValue={parentTargetValue} |
| 87 | + parentChanged={parentChanged} |
| 88 | + reconfig={reconfig} |
| 89 | + setReconfig={setReconfig} |
| 90 | + /> |
| 91 | + ))} |
| 92 | + </div> |
| 93 | + </SettingsSection> |
| 94 | + </div> |
| 95 | + ) |
| 96 | +} |
| 97 | + |
| 98 | +interface ReconfigWorkflowRowProps { |
| 99 | + workflowName: string |
| 100 | + blocks: ReconfigBlock[] |
| 101 | + parentTargetValue: string |
| 102 | + parentChanged: boolean |
| 103 | + reconfig: Record<string, string> |
| 104 | + setReconfig: Dispatch<SetStateAction<Record<string, string>>> |
| 105 | +} |
| 106 | + |
| 107 | +/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */ |
| 108 | +function ReconfigWorkflowRow({ |
| 109 | + workflowName, |
| 110 | + blocks, |
| 111 | + parentTargetValue, |
| 112 | + parentChanged, |
| 113 | + reconfig, |
| 114 | + setReconfig, |
| 115 | +}: ReconfigWorkflowRowProps) { |
| 116 | + const [open, setOpen] = useState(false) |
| 117 | + const configurable = blocks.length > 0 |
| 118 | + |
| 119 | + return ( |
| 120 | + <div className={cn('flex flex-col gap-1', configurable && open && 'pb-2')}> |
| 121 | + {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed, |
| 122 | + non-expandable row uses a native title tooltip to explain why. */} |
| 123 | + <button |
| 124 | + type='button' |
| 125 | + disabled={!configurable} |
| 126 | + onClick={() => setOpen((value) => !value)} |
| 127 | + title={configurable ? undefined : 'Used here, but nothing to configure for this resource'} |
| 128 | + className={cn( |
| 129 | + 'flex w-full items-center gap-2 text-left text-sm transition-colors', |
| 130 | + configurable |
| 131 | + ? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]' |
| 132 | + : 'cursor-default text-[var(--text-muted)]' |
| 133 | + )} |
| 134 | + > |
| 135 | + <span className='min-w-0 flex-1 truncate'>{workflowName}</span> |
| 136 | + <ChevronDown |
| 137 | + className={cn( |
| 138 | + 'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform', |
| 139 | + configurable ? open && 'rotate-180' : 'opacity-40' |
| 140 | + )} |
| 141 | + /> |
| 142 | + </button> |
| 143 | + {configurable && open |
| 144 | + ? blocks.map((block) => ( |
| 145 | + <BlockReconfig |
| 146 | + key={block.targetBlockId} |
| 147 | + block={block} |
| 148 | + parentTargetValue={parentTargetValue} |
| 149 | + parentChanged={parentChanged} |
| 150 | + reconfig={reconfig} |
| 151 | + setReconfig={setReconfig} |
| 152 | + /> |
| 153 | + )) |
| 154 | + : null} |
| 155 | + </div> |
| 156 | + ) |
| 157 | +} |
| 158 | + |
| 159 | +interface BlockReconfigProps { |
| 160 | + block: ReconfigBlock |
| 161 | + parentTargetValue: string |
| 162 | + parentChanged: boolean |
| 163 | + reconfig: Record<string, string> |
| 164 | + setReconfig: Dispatch<SetStateAction<Record<string, string>>> |
| 165 | +} |
| 166 | + |
| 167 | +/** One block card: its dependent selectors, chained so a parent feeds its in-block children. */ |
| 168 | +function BlockReconfig({ |
| 169 | + block, |
| 170 | + parentTargetValue, |
| 171 | + parentChanged, |
| 172 | + reconfig, |
| 173 | + setReconfig, |
| 174 | +}: BlockReconfigProps) { |
| 175 | + // A field's effective value: the user's re-pick, else the stored value (stable parent) - but |
| 176 | + // blank after a parent change, since the old value no longer resolves. Shared with the modal. |
| 177 | + const effectiveValue = (field: ForkDependentReconfig) => |
| 178 | + effectiveDependentValue(field, reconfig, parentChanged) |
| 179 | + |
| 180 | + // Chain re-picks: a field that provides a SelectorContext key feeds its effective value to |
| 181 | + // its in-block descendants (a spreadsheet drives the sheet selector). |
| 182 | + const providedValues: Record<string, string> = {} |
| 183 | + const providerByKey = new Map<string, string>() |
| 184 | + for (const field of block.fields) { |
| 185 | + if (field.providesContextKey) { |
| 186 | + providerByKey.set(field.providesContextKey, dependentKey(field)) |
| 187 | + const value = effectiveValue(field) |
| 188 | + if (value) providedValues[field.providesContextKey] = value |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + return ( |
| 193 | + <div className='ml-2 flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] p-3'> |
| 194 | + <span className='font-medium text-[var(--text-secondary)] text-small'>{block.blockName}</span> |
| 195 | + {block.fields.map((field) => { |
| 196 | + // Disabled until the parent target is set AND every in-block parent it depends on has |
| 197 | + // a value, so a child never queries a stale upstream value. |
| 198 | + const ready = field.consumesContextKeys.every( |
| 199 | + (key) => !providerByKey.has(key) || providedValues[key] !== undefined |
| 200 | + ) |
| 201 | + return ( |
| 202 | + <div key={dependentKey(field)} className='flex flex-col gap-1'> |
| 203 | + <span className='text-[var(--text-tertiary)] text-caption'> |
| 204 | + {field.title} |
| 205 | + {field.required ? <span className='text-[var(--text-error)]'> *</span> : null} |
| 206 | + </span> |
| 207 | + <DependentFieldSelector |
| 208 | + selectorKey={field.selectorKey as SelectorKey} |
| 209 | + context={{ |
| 210 | + ...field.context, |
| 211 | + ...providedValues, |
| 212 | + [field.parentContextKey]: parentTargetValue, |
| 213 | + }} |
| 214 | + enabled={parentTargetValue !== '' && ready} |
| 215 | + value={effectiveValue(field)} |
| 216 | + onChange={(value) => |
| 217 | + setReconfig((prev) => { |
| 218 | + const nextState = { ...prev, [dependentKey(field)]: value } |
| 219 | + // A changed parent invalidates its children's stale re-picks. |
| 220 | + const providedKey = field.providesContextKey |
| 221 | + if (providedKey) { |
| 222 | + for (const sibling of block.fields) { |
| 223 | + if (sibling.consumesContextKeys.includes(providedKey)) { |
| 224 | + delete nextState[dependentKey(sibling)] |
| 225 | + } |
| 226 | + } |
| 227 | + } |
| 228 | + return nextState |
| 229 | + }) |
| 230 | + } |
| 231 | + title={field.title} |
| 232 | + /> |
| 233 | + </div> |
| 234 | + ) |
| 235 | + })} |
| 236 | + </div> |
| 237 | + ) |
| 238 | +} |
0 commit comments