Skip to content
Merged
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
79 changes: 60 additions & 19 deletions src/apps/tracker-web/src/components/gate/GatePolicyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
is unchanged.
========================================================================= */
import React from 'react';
import { ApiError, usePhaseArtifactProfiles, useUpsertGatePolicy } from '../../api';
import { ApiError, useArtifactFieldSchemas, usePhaseArtifactProfiles, useUpsertGatePolicy } from '../../api';
import type {
ApprovalStages,
ApprovalStrategy,
Expand Down Expand Up @@ -37,12 +37,6 @@ const CRITERION_OPERATORS = [
'gte', 'lte', 'between', 'regex', 'min-length', 'count-gte',
] as const;

/** Artifact the criteria evaluate against — opportunity at the entry gate, initiative at phase gates. */
const CRITERION_ARTIFACTS = [
{ value: 'opportunity', label: 'Opportunity' },
{ value: 'initiative', label: 'Initiative' },
] as const;

/** A blank default policy so a matrix renders a row per phase even with no data. */
export function defaultGatePolicy(phase: string): GatePolicyDto {
return {
Expand Down Expand Up @@ -88,6 +82,36 @@ const EditPolicyBody: React.FC<{
(kind: string) => phaseArtifacts.find((a) => a.artifactKind === kind)?.label ?? kind,
[phaseArtifacts],
);
// T-036 §3.5: criteria evaluate an artifact's fields; the artifact source is
// phase-dependent (intake → the opportunity; phase gates → a Core deliverable
// of the phase). The `fieldPath` is picked from the artifact's field catalog
// (base ∪ custom.<name>) — never free text.
const schemas = useArtifactFieldSchemas();

/** The artifact types criteria may evaluate for this phase. */
const artifactOptions = React.useMemo(
() =>
phase === 'intake'
? [{ value: 'opportunity', label: t('Opportunity', 'Oportunidad') }]
: phaseArtifacts.map((a) => ({ value: a.artifactKind, label: a.label })),
[phase, phaseArtifacts, t],
);

/** Field-path options for an artifact type = base fields ∪ custom.<name>. */
const fieldOptionsFor = React.useCallback(
(artifactType: string): { value: string; label: string }[] => {
const schema = schemas.data?.find((s) => s.artifactType === artifactType);
if (!schema) return [];
return [
...schema.baseFields.map((f) => ({ value: f, label: f })),
...schema.customFields.map((cf) => ({
value: `custom.${cf.name}`,
label: `custom.${cf.name} · ${cf.label}`,
})),
];
},
[schemas.data],
);
const [st, setSt] = React.useState<EditState>(() => ({
mode: (policy.mode as GateMode) ?? 'SIMPLE',
requiredEvidence: policy.requiredEvidence.map((e) => ({ ...e })),
Expand All @@ -108,7 +132,7 @@ const EditPolicyBody: React.FC<{
{
id: crypto.randomUUID(),
label: '',
artifactType: phase === 'intake' ? 'opportunity' : 'initiative',
artifactType: phase === 'intake' ? 'opportunity' : (phaseArtifacts[0]?.artifactKind ?? ''),
fieldPath: '',
operator: 'non-empty',
expected: [],
Expand Down Expand Up @@ -444,10 +468,10 @@ const EditPolicyBody: React.FC<{
</InfoHint>
</label>
<p style={{ fontSize: 11.5, color: 'var(--ink-3)', margin: '2px 0 8px' }}>
{t('Field-level checks on the ', 'Chequeos a nivel de campo sobre ')}
{t('Field-level checks on ', 'Chequeos a nivel de campo sobre ')}
{phase === 'intake'
? t('opportunity', 'la opportunity')
: t('initiative', 'la initiative')}
? t('the opportunity', 'la oportunidad')
: t('the selected artifact', 'el artefacto seleccionado')}
{t('. A mandatory ', '. Un criterio obligatorio ')}
<b>{t('blocking', 'blocking')}</b>
{t(' criterion left unmet returns the gate; ', ' sin cumplir devuelve el gate; los ')}
Expand Down Expand Up @@ -516,9 +540,14 @@ const EditPolicyBody: React.FC<{
<div style={{ flex: 1 }}>
<Select
label={t('Artifact', 'Artefacto')}
// Intake is locked to the opportunity; phase gates offer the
// phase's Core deliverable artifacts only (T-036 §3.5).
disabled={phase === 'intake'}
value={c.artifactType}
onChange={(e) => updateCriterion(i, { artifactType: e.target.value })}
options={CRITERION_ARTIFACTS.map((a) => ({ value: a.value, label: a.label }))}
onChange={(e) =>
updateCriterion(i, { artifactType: e.target.value, fieldPath: '' })
}
options={artifactOptions}
/>
</div>
<div style={{ flex: 1 }}>
Expand All @@ -532,12 +561,24 @@ const EditPolicyBody: React.FC<{
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<TextField
label={t('Field path', 'Ruta de campo')}
placeholder={phase === 'intake' ? 'context.problem' : 'businessCase.riskLevel'}
value={c.fieldPath}
onChange={(e) => updateCriterion(i, { fieldPath: e.target.value })}
/>
{fieldOptionsFor(c.artifactType).length === 0 ? (
<p style={{ fontSize: 11.5, color: 'var(--ink-3)', margin: '2px 2px 0' }}>
{t(
'No fields on this artifact — add custom fields in “Artifact fields” first.',
'Este artefacto no tiene campos — agrega campos custom en “Campos de artefacto” primero.',
)}
</p>
) : (
<Select
label={t('Field path', 'Ruta de campo')}
value={c.fieldPath}
onChange={(e) => updateCriterion(i, { fieldPath: e.target.value })}
options={[
{ value: '', label: t('Select a field…', 'Selecciona un campo…') },
...fieldOptionsFor(c.artifactType),
]}
/>
)}
</div>
<div style={{ flex: 1 }}>
<TextField
Expand Down
Loading