Fix pipeline edit crash: merge defaults with DB configJson
Build and Push Docker Image / build (push) Successful in 10m2s Details

The DB configJson uses different field names than wizard types expect
(e.g., deterministic.rules vs rules, votingEnabled vs juryVotingEnabled).
The ?? operator only guards null/undefined, but configJson is {} (truthy),
so defaults never applied. This caused config.rules.map() to crash with
"Cannot read properties of undefined (reading 'map')".

Fix: spread defaults first then overlay DB values, and add defensive ??
fallbacks in all section components for potentially undefined properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-13 22:55:35 +01:00
parent 7d1c87e938
commit 451b483880
5 changed files with 41 additions and 37 deletions

View File

@ -187,10 +187,10 @@ export default function EditPipelinePage() {
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION') const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL') const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig const intakeConfig = { ...defaultIntakeConfig(), ...(intakeStage?.configJson ?? {}) } as IntakeConfig
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig const filterConfig = { ...defaultFilterConfig(), ...(filterStage?.configJson ?? {}) } as FilterConfig
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig const evalConfig = { ...defaultEvaluationConfig(), ...(evalStage?.configJson ?? {}) } as EvaluationConfig
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig const liveConfig = { ...defaultLiveConfig(), ...(liveStage?.configJson ?? {}) } as LiveFinalConfig
const basicsValid = validateBasics(state).valid const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid const tracksValid = validateTracks(state.tracks).valid

View File

@ -32,7 +32,7 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
type="number" type="number"
min={1} min={1}
max={20} max={20}
value={config.requiredReviews} value={config.requiredReviews ?? 3}
disabled={isActive} disabled={isActive}
onChange={(e) => onChange={(e) =>
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 }) updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
@ -49,7 +49,7 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
type="number" type="number"
min={1} min={1}
max={100} max={100}
value={config.maxLoadPerJuror} value={config.maxLoadPerJuror ?? 20}
disabled={isActive} disabled={isActive}
onChange={(e) => onChange={(e) =>
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 }) updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
@ -66,7 +66,7 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
type="number" type="number"
min={0} min={0}
max={50} max={50}
value={config.minLoadPerJuror} value={config.minLoadPerJuror ?? 5}
disabled={isActive} disabled={isActive}
onChange={(e) => onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 }) updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
@ -78,7 +78,7 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
</div> </div>
</div> </div>
{config.minLoadPerJuror > config.maxLoadPerJuror && ( {(config.minLoadPerJuror ?? 0) > (config.maxLoadPerJuror ?? 20) && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
Min load per juror cannot exceed max load per juror. Min load per juror cannot exceed max load per juror.
</p> </p>
@ -92,7 +92,7 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
</p> </p>
</div> </div>
<Switch <Switch
checked={config.availabilityWeighting} checked={config.availabilityWeighting ?? true}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked }) updateConfig({ availabilityWeighting: checked })
} }
@ -103,7 +103,7 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
<div className="space-y-2"> <div className="space-y-2">
<Label>Overflow Policy</Label> <Label>Overflow Policy</Label>
<Select <Select
value={config.overflowPolicy} value={config.overflowPolicy ?? 'queue'}
onValueChange={(value) => onValueChange={(value) =>
updateConfig({ updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'], overflowPolicy: value as EvaluationConfig['overflowPolicy'],

View File

@ -27,8 +27,10 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
onChange({ ...config, ...updates }) onChange({ ...config, ...updates })
} }
const rules = config.rules ?? []
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => { const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...config.rules] const updated = [...rules]
updated[index] = { ...updated[index], ...updates } updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated }) onChange({ ...config, rules: updated })
} }
@ -37,14 +39,14 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
onChange({ onChange({
...config, ...config,
rules: [ rules: [
...config.rules, ...rules,
{ field: '', operator: 'equals', value: '', weight: 1 }, { field: '', operator: 'equals', value: '', weight: 1 },
], ],
}) })
} }
const removeRule = (index: number) => { const removeRule = (index: number) => {
onChange({ ...config, rules: config.rules.filter((_, i) => i !== index) }) onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
} }
return ( return (
@ -64,7 +66,7 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
</Button> </Button>
</div> </div>
{config.rules.map((rule, index) => ( {rules.map((rule, index) => (
<Card key={index}> <Card key={index}>
<CardContent className="pt-3 pb-3 px-4"> <CardContent className="pt-3 pb-3 px-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -116,7 +118,7 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
</Card> </Card>
))} ))}
{config.rules.length === 0 && ( {rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3"> <p className="text-sm text-muted-foreground text-center py-3">
No gate rules configured. All projects will pass through. No gate rules configured. All projects will pass through.
</p> </p>
@ -145,11 +147,11 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
<Label className="text-xs">High Confidence Threshold</Label> <Label className="text-xs">High Confidence Threshold</Label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Slider <Slider
value={[config.aiConfidenceThresholds.high * 100]} value={[(config.aiConfidenceThresholds?.high ?? 0.85) * 100]}
onValueChange={([v]) => onValueChange={([v]) =>
updateConfig({ updateConfig({
aiConfidenceThresholds: { aiConfidenceThresholds: {
...config.aiConfidenceThresholds, ...(config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }),
high: v / 100, high: v / 100,
}, },
}) })
@ -160,7 +162,7 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
className="flex-1" className="flex-1"
/> />
<span className="text-xs font-mono w-10 text-right"> <span className="text-xs font-mono w-10 text-right">
{Math.round(config.aiConfidenceThresholds.high * 100)}% {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%
</span> </span>
</div> </div>
</div> </div>
@ -168,11 +170,11 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
<Label className="text-xs">Medium Confidence Threshold</Label> <Label className="text-xs">Medium Confidence Threshold</Label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Slider <Slider
value={[config.aiConfidenceThresholds.medium * 100]} value={[(config.aiConfidenceThresholds?.medium ?? 0.6) * 100]}
onValueChange={([v]) => onValueChange={([v]) =>
updateConfig({ updateConfig({
aiConfidenceThresholds: { aiConfidenceThresholds: {
...config.aiConfidenceThresholds, ...(config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }),
medium: v / 100, medium: v / 100,
}, },
}) })
@ -183,7 +185,7 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
className="flex-1" className="flex-1"
/> />
<span className="text-xs font-mono w-10 text-right"> <span className="text-xs font-mono w-10 text-right">
{Math.round(config.aiConfidenceThresholds.medium * 100)}% {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%
</span> </span>
</div> </div>
</div> </div>

View File

@ -26,8 +26,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
onChange({ ...config, ...updates }) onChange({ ...config, ...updates })
} }
const fileRequirements = config.fileRequirements ?? []
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => { const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...config.fileRequirements] const updated = [...fileRequirements]
updated[index] = { ...updated[index], ...updates } updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated }) onChange({ ...config, fileRequirements: updated })
} }
@ -36,7 +38,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
onChange({ onChange({
...config, ...config,
fileRequirements: [ fileRequirements: [
...config.fileRequirements, ...fileRequirements,
{ {
name: '', name: '',
description: '', description: '',
@ -49,7 +51,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
} }
const removeFileReq = (index: number) => { const removeFileReq = (index: number) => {
const updated = config.fileRequirements.filter((_, i) => i !== index) const updated = fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated }) onChange({ ...config, fileRequirements: updated })
} }
@ -71,7 +73,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
</p> </p>
</div> </div>
<Switch <Switch
checked={config.submissionWindowEnabled} checked={config.submissionWindowEnabled ?? true}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked }) updateConfig({ submissionWindowEnabled: checked })
} }
@ -85,7 +87,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
<div className="space-y-2"> <div className="space-y-2">
<Label>Late Submission Policy</Label> <Label>Late Submission Policy</Label>
<Select <Select
value={config.lateSubmissionPolicy} value={config.lateSubmissionPolicy ?? 'flag'}
onValueChange={(value) => onValueChange={(value) =>
updateConfig({ updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'], lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
@ -104,14 +106,14 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
</Select> </Select>
</div> </div>
{config.lateSubmissionPolicy === 'flag' && ( {(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Grace Period (hours)</Label> <Label>Grace Period (hours)</Label>
<Input <Input
type="number" type="number"
min={0} min={0}
max={168} max={168}
value={config.lateGraceHours} value={config.lateGraceHours ?? 24}
onChange={(e) => onChange={(e) =>
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 }) updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
} }
@ -130,13 +132,13 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
</Button> </Button>
</div> </div>
{config.fileRequirements.length === 0 && ( {fileRequirements.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center"> <p className="text-sm text-muted-foreground py-4 text-center">
No file requirements configured. Projects can be submitted without files. No file requirements configured. Projects can be submitted without files.
</p> </p>
)} )}
{config.fileRequirements.map((req, index) => ( {fileRequirements.map((req, index) => (
<Card key={index}> <Card key={index}>
<CardContent className="pt-4 space-y-3"> <CardContent className="pt-4 space-y-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

@ -33,7 +33,7 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
</p> </p>
</div> </div>
<Switch <Switch
checked={config.juryVotingEnabled} checked={config.juryVotingEnabled ?? true}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked }) updateConfig({ juryVotingEnabled: checked })
} }
@ -50,7 +50,7 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
</p> </p>
</div> </div>
<Switch <Switch
checked={config.audienceVotingEnabled} checked={config.audienceVotingEnabled ?? false}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked }) updateConfig({ audienceVotingEnabled: checked })
} }
@ -58,13 +58,13 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
/> />
</div> </div>
{config.audienceVotingEnabled && ( {(config.audienceVotingEnabled ?? false) && (
<div className="pl-4 border-l-2 border-muted space-y-3"> <div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs">Audience Vote Weight</Label> <Label className="text-xs">Audience Vote Weight</Label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Slider <Slider
value={[config.audienceVoteWeight * 100]} value={[(config.audienceVoteWeight ?? 0) * 100]}
onValueChange={([v]) => onValueChange={([v]) =>
updateConfig({ audienceVoteWeight: v / 100 }) updateConfig({ audienceVoteWeight: v / 100 })
} }
@ -74,7 +74,7 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
className="flex-1" className="flex-1"
/> />
<span className="text-xs font-mono w-10 text-right"> <span className="text-xs font-mono w-10 text-right">
{Math.round(config.audienceVoteWeight * 100)}% {Math.round((config.audienceVoteWeight ?? 0) * 100)}%
</span> </span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -88,7 +88,7 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
<div className="space-y-2"> <div className="space-y-2">
<Label>Cohort Setup Mode</Label> <Label>Cohort Setup Mode</Label>
<Select <Select
value={config.cohortSetupMode} value={config.cohortSetupMode ?? 'manual'}
onValueChange={(value) => onValueChange={(value) =>
updateConfig({ updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'], cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
@ -113,7 +113,7 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
<div className="space-y-2"> <div className="space-y-2">
<Label>Result Reveal Policy</Label> <Label>Result Reveal Policy</Label>
<Select <Select
value={config.revealPolicy} value={config.revealPolicy ?? 'ceremony'}
onValueChange={(value) => onValueChange={(value) =>
updateConfig({ updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'], revealPolicy: value as LiveFinalConfig['revealPolicy'],