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 liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
const intakeConfig = { ...defaultIntakeConfig(), ...(intakeStage?.configJson ?? {}) } as IntakeConfig
const filterConfig = { ...defaultFilterConfig(), ...(filterStage?.configJson ?? {}) } as FilterConfig
const evalConfig = { ...defaultEvaluationConfig(), ...(evalStage?.configJson ?? {}) } as EvaluationConfig
const liveConfig = { ...defaultLiveConfig(), ...(liveStage?.configJson ?? {}) } as LiveFinalConfig
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid

View File

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

View File

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

View File

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

View File

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