Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import { describe, expect, it } from 'vitest'
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
import {
applyDependentRepick,
dependentKey,
effectiveCopyDependentValue,
effectiveDependentValue,
getActionableDependentFields,
isDependentConfigurationActionable,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'

const field = (overrides: Partial<ForkDependentReconfig> = {}): ForkDependentReconfig => ({
Expand Down Expand Up @@ -97,3 +100,245 @@ describe('effectiveCopyDependentValue', () => {
expect(effectiveCopyDependentValue(f, {})).toBe('')
})
})

describe('applyDependentRepick', () => {
it('clears direct and transitive descendants without touching unrelated fields', () => {
const site = field({
subBlockKey: 'siteId',
currentValue: 'site-old',
providesContextKey: 'siteId',
})
const drive = field({
subBlockKey: 'driveId',
currentValue: 'drive-old',
providesContextKey: 'driveId',
consumesContextKeys: ['siteId'],
})
const spreadsheet = field({
subBlockKey: 'spreadsheetId',
currentValue: 'spreadsheet-old',
providesContextKey: 'spreadsheetId',
consumesContextKeys: ['driveId'],
})
const sheet = field({
subBlockKey: 'sheetName',
currentValue: 'Sheet1',
consumesContextKeys: ['spreadsheetId'],
})
const unrelated = field({ subBlockKey: 'label', currentValue: 'keep-me' })
const previous = {
[dependentKey(drive)]: 'drive-repicked',
[dependentKey(spreadsheet)]: 'spreadsheet-repicked',
[dependentKey(sheet)]: 'Sheet2',
[dependentKey(unrelated)]: 'still-keep-me',
}

const next = applyDependentRepick(
previous,
site,
[site, drive, spreadsheet, sheet, unrelated],
'site-new'
)

expect(next).toEqual({
[dependentKey(site)]: 'site-new',
[dependentKey(drive)]: '',
[dependentKey(spreadsheet)]: '',
[dependentKey(sheet)]: '',
[dependentKey(unrelated)]: 'still-keep-me',
})
expect(effectiveDependentValue(drive, next, false)).toBe('')
expect(effectiveCopyDependentValue(sheet, next)).toBe('')
})

it('only changes the selected field when it provides no selector context', () => {
const leaf = field({ subBlockKey: 'issueKey', currentValue: 'ISSUE-1' })
const unrelated = field({ subBlockKey: 'label', currentValue: 'keep-me' })

expect(
applyDependentRepick(
{ [dependentKey(unrelated)]: 'still-keep-me' },
leaf,
[leaf, unrelated],
'ISSUE-2'
)
).toEqual({
[dependentKey(leaf)]: 'ISSUE-2',
[dependentKey(unrelated)]: 'still-keep-me',
})
})
})

describe('isDependentConfigurationActionable', () => {
it('hides stored values when the mapped parent is unchanged', () => {
expect(
isDependentConfigurationActionable(
field({ required: true, currentValue: 'INBOX' }),
{},
{
parentResolved: true,
parentChanged: false,
copying: false,
}
)
).toBe(false)
})

it('shows a required value that is missing under an unchanged mapped parent', () => {
expect(
isDependentConfigurationActionable(
field({ required: true, currentValue: '' }),
{},
{
parentResolved: true,
parentChanged: false,
copying: false,
}
)
).toBe(true)
})

it('hides a missing optional value under an unchanged mapped parent', () => {
expect(
isDependentConfigurationActionable(
field({ required: false, currentValue: '' }),
{},
{
parentResolved: true,
parentChanged: false,
copying: false,
}
)
).toBe(false)
})

it('shows every dependent when the mapped parent changed', () => {
expect(
isDependentConfigurationActionable(
field({ required: false, currentValue: 'INBOX' }),
{},
{
parentResolved: true,
parentChanged: true,
copying: false,
}
)
).toBe(true)
})

it('shows every dependent when the parent will be copied', () => {
expect(
isDependentConfigurationActionable(
field({ required: false, currentValue: 'INBOX' }),
{},
{
parentResolved: true,
parentChanged: false,
copying: true,
}
)
).toBe(true)
})

it('hides dependents until their parent is resolved', () => {
expect(
isDependentConfigurationActionable(
field({ required: true, currentValue: '' }),
{},
{
parentResolved: false,
parentChanged: false,
copying: false,
}
)
).toBe(false)
})
})

describe('getActionableDependentFields', () => {
const unchangedMappedParent = {
parentResolved: true,
parentChanged: false,
copying: false,
}

it('includes the context provider for a required missing child', () => {
const spreadsheet = field({
subBlockKey: 'spreadsheetId',
title: 'Spreadsheet',
currentValue: '',
providesContextKey: 'spreadsheetId',
})
const sheet = field({
subBlockKey: 'sheetName',
title: 'Sheet',
currentValue: '',
required: true,
consumesContextKeys: ['spreadsheetId'],
})

expect(
getActionableDependentFields([spreadsheet, sheet], {}, unchangedMappedParent).map(
(dependent) => dependent.subBlockKey
)
).toEqual(['spreadsheetId', 'sheetName'])
})

it('keeps a saved context provider visible while its child needs configuration', () => {
const spreadsheet = field({
subBlockKey: 'spreadsheetId',
title: 'Spreadsheet',
currentValue: 'spreadsheet-target',
providesContextKey: 'spreadsheetId',
})
const sheet = field({
subBlockKey: 'sheetName',
title: 'Sheet',
currentValue: '',
required: true,
consumesContextKeys: ['spreadsheetId'],
})

expect(
getActionableDependentFields([spreadsheet, sheet], {}, unchangedMappedParent).map(
(dependent) => dependent.subBlockKey
)
).toEqual(['spreadsheetId', 'sheetName'])
})

it('walks transitive providers and leaves unrelated optional fields hidden', () => {
const unrelated = field({
subBlockKey: 'optionalLabel',
title: 'Optional label',
currentValue: '',
})
const site = field({
subBlockKey: 'siteId',
title: 'Site',
currentValue: '',
providesContextKey: 'siteId',
})
const drive = field({
subBlockKey: 'driveId',
title: 'Drive',
currentValue: '',
providesContextKey: 'driveId',
consumesContextKeys: ['siteId'],
})
const spreadsheet = field({
subBlockKey: 'spreadsheetId',
title: 'Spreadsheet',
currentValue: '',
required: true,
consumesContextKeys: ['driveId'],
})

expect(
getActionableDependentFields(
[unrelated, site, drive, spreadsheet],
{},
unchangedMappedParent
).map((dependent) => dependent.subBlockKey)
).toEqual(['siteId', 'driveId', 'spreadsheetId'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ export function dependentKey(dependent: ForkDependentReconfig): string {
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
}

/**
* Store a dependent re-pick and clear every selector transitively scoped by it. Empty-string
* overrides are intentional: an absent override means "fall back to the stored value", while a
* changed provider makes every stored descendant stale for both mapped and copied parents.
*/
export function applyDependentRepick(
reconfig: Record<string, string>,
changedField: ForkDependentReconfig,
blockFields: ForkDependentReconfig[],
value: string
): Record<string, string> {
const changedKey = dependentKey(changedField)
const nextState = { ...reconfig, [changedKey]: value }
if (!changedField.providesContextKey) return nextState

const pendingContextKeys = [changedField.providesContextKey]
const visitedFields = new Set([changedKey])
for (let index = 0; index < pendingContextKeys.length; index += 1) {
const contextKey = pendingContextKeys[index]
if (!contextKey) continue

for (const field of blockFields) {
const fieldKey = dependentKey(field)
if (visitedFields.has(fieldKey) || !field.consumesContextKeys.includes(contextKey)) continue

visitedFields.add(fieldKey)
nextState[fieldKey] = ''
if (field.providesContextKey) pendingContextKeys.push(field.providesContextKey)
}
}

return nextState
}

/**
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
Expand Down Expand Up @@ -37,3 +71,57 @@ export function effectiveCopyDependentValue(
if (repicked !== undefined) return repicked
return field.currentValue || field.sourceValue
}

export interface DependentConfigurationState {
parentResolved: boolean
parentChanged: boolean
copying: boolean
}

/**
* Whether a dependent selector needs to be shown. A changed or copied parent requires review
* because its children resolve in a different scope. An unchanged mapping only needs a selector
* when a required value is missing; its stored values are already valid and sync-ready.
*/
export function isDependentConfigurationActionable(
field: ForkDependentReconfig,
reconfig: Record<string, string>,
state: DependentConfigurationState
): boolean {
if (!state.parentResolved) return false
if (state.parentChanged || state.copying) return true
return field.required && effectiveDependentValue(field, reconfig, false) === ''
}
Comment thread
icecrasher321 marked this conversation as resolved.

/**
* Actionable fields plus the transitive in-block providers that scope them. A provider belongs
* in the configuration UI whenever one of its descendants needs action, even if its saved value
* is present, so the user can see and change the context in which the child is selected.
*/
export function getActionableDependentFields(
fields: ForkDependentReconfig[],
reconfig: Record<string, string>,
state: DependentConfigurationState
): ForkDependentReconfig[] {
const actionable = new Set(
fields.filter((field) => isDependentConfigurationActionable(field, reconfig, state))
)
const providersByContextKey = new Map<string, ForkDependentReconfig>()
for (const field of fields) {
if (field.providesContextKey) providersByContextKey.set(field.providesContextKey, field)
}

const pending = Array.from(actionable)
for (let index = 0; index < pending.length; index += 1) {
const field = pending[index]
if (!field) continue
for (const contextKey of field.consumesContextKeys) {
const provider = providersByContextKey.get(contextKey)
if (!provider || actionable.has(provider)) continue
actionable.add(provider)
pending.push(provider)
}
}

return fields.filter((field) => actionable.has(field))
}
Comment thread
icecrasher321 marked this conversation as resolved.
Loading
Loading