Skip to content

Commit 981e33d

Browse files
fix(ui): fix editing of controls in the UI (#218)
##Summary## Fixed edit-control modal crashes for template-backed controls that may not include template_values or rendered fields. The UI now routes any control with template metadata to the template editor instead of falling through to the raw control editor. ##Scope## User-facing/API changes: Fixes “Something went wrong” when editing sparse/unrendered template controls. Internal changes: Added defensive template normalization and a regression fixture/test. **Out of scope**: Backend API behavior changes. ##Risk and Rollout## Risk level: low Rollback plan: Revert the UI changes in the template edit/control edit files and test fixtures. ##Testing## Added or updated automated tests Ran make check (not run; UI-only change. Ran targeted UI checks instead.) Manually verified behavior Ran: make ui-typecheck make ui-lint make ui-build Note: Playwright integration tests could not run because local Chromium is missing. ##Checklist## Linked issue/spec (if applicable) Updated docs/examples for user-facing changes Included any required follow-up tasks: install Playwright browsers and run the focused integration test.
1 parent 9530368 commit 981e33d

5 files changed

Lines changed: 80 additions & 5 deletions

File tree

ui/src/core/components/template-param-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function TemplateParamForm({
139139
errors,
140140
}: TemplateParamFormProps) {
141141
const paramEntries = useMemo(
142-
() => Object.entries(template.parameters),
142+
() => Object.entries(template.parameters ?? {}),
143143
[template.parameters]
144144
);
145145

ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import { applyApiErrorsToForms } from './utils';
5555

5656
function isTemplateBacked(control: Control): boolean {
5757
const def = control.control as Record<string, unknown> | undefined;
58-
return def?.template != null && def?.template_values != null;
58+
return def?.template != null;
5959
}
6060

6161
const EVALUATOR_CONFIG_HEIGHT = 450;

ui/src/core/page-components/agent-detail/modals/edit-control/template-edit-content.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ type TemplateEditContentProps = {
4545

4646
type EditorMode = 'params' | 'json';
4747

48+
function isRecord(value: unknown): value is Record<string, unknown> {
49+
return typeof value === 'object' && value !== null && !Array.isArray(value);
50+
}
51+
4852
/**
4953
* Editor for template-backed controls. Shows the parameter form by default,
5054
* with a toggle to edit the full template JSON.
@@ -58,7 +62,18 @@ export function TemplateEditContent({
5862
// Access template fields via cast — these exist at runtime but aren't in the
5963
// generated API types yet. Will be cleaned up after type regeneration.
6064
const definitionRaw = control.control as Record<string, unknown>;
61-
const template = definitionRaw.template as TemplateControlInput['template'];
65+
const template = useMemo(() => {
66+
const rawControl = control.control as Record<string, unknown>;
67+
const templateRaw = isRecord(rawControl.template)
68+
? rawControl.template
69+
: {};
70+
return {
71+
...templateRaw,
72+
parameters: isRecord(templateRaw.parameters)
73+
? templateRaw.parameters
74+
: {},
75+
} as TemplateControlInput['template'];
76+
}, [control.control]);
6277
const storedValues = definitionRaw.template_values as
6378
| Record<string, TemplateValue>
6479
| undefined;
@@ -116,12 +131,12 @@ export function TemplateEditContent({
116131
// Dynamically extract parameter names from the current JSON text so
117132
// completions update as the user edits the parameters block.
118133
const templateParameterNames = useMemo(() => {
119-
if (editorMode !== 'json') return Object.keys(template.parameters);
134+
if (editorMode !== 'json') return Object.keys(template.parameters ?? {});
120135
try {
121136
const parsed = JSON.parse(jsonText) as TemplateControlInput;
122137
return Object.keys(parsed?.template?.parameters ?? {});
123138
} catch {
124-
return Object.keys(template.parameters);
139+
return Object.keys(template.parameters ?? {});
125140
}
126141
}, [editorMode, jsonText, template.parameters]);
127142

ui/tests/control-templates.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,29 @@ test.describe('Control Templates', () => {
134134
).toBeVisible();
135135
});
136136

137+
test('opens unrendered template controls without rendered fields', async ({
138+
mockedPage,
139+
}) => {
140+
await mockRoutes.agent(mockedPage, {
141+
controls: { data: mockData.controlsWithUnrenderedTemplate },
142+
});
143+
await mockRoutes.controlRenderTemplate(mockedPage);
144+
145+
await mockedPage.goto(
146+
getAgentRoute(agentId, {
147+
tab: 'controls',
148+
query: { modal: 'edit', controlId: '11' },
149+
})
150+
);
151+
152+
const dialog = mockedPage.getByRole('dialog');
153+
await expect(dialog.getByText('Template Parameters')).toBeVisible();
154+
await expect(
155+
dialog.getByText('This template has no configurable parameters.')
156+
).toBeVisible();
157+
await expect(dialog.getByText('Something went wrong')).not.toBeVisible();
158+
});
159+
137160
test('can toggle to Full JSON mode and see template JSON', async ({
138161
mockedPage,
139162
}) => {

ui/tests/fixtures.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,32 @@ const templateBackedControl: Control = {
188188
} as Control['control'],
189189
};
190190

191+
const unrenderedTemplateControl = {
192+
id: 11,
193+
name: 'Unrendered Template Guard',
194+
control: {
195+
enabled: false,
196+
template: {
197+
description: 'Template without supplied values',
198+
definition_template: {
199+
description: 'Deny when input matches pattern',
200+
execution: 'server',
201+
scope: {
202+
stages: ['pre'],
203+
},
204+
condition: {
205+
selector: { path: 'input' },
206+
evaluator: {
207+
name: 'regex',
208+
config: { pattern: '\\bsecret\\b' },
209+
},
210+
},
211+
action: { decision: 'deny' },
212+
},
213+
},
214+
},
215+
} as unknown as Control;
216+
191217
const controlsList: Control[] = [
192218
{
193219
id: 1,
@@ -258,6 +284,11 @@ const controlsWithTemplateList: Control[] = [
258284
templateBackedControl,
259285
];
260286

287+
const controlsWithUnrenderedTemplateList: Control[] = [
288+
...controlsList,
289+
unrenderedTemplateControl,
290+
];
291+
261292
const controlsResponse: AgentControlsResponse = {
262293
controls: controlsList,
263294
};
@@ -266,6 +297,10 @@ const controlsWithTemplateResponse: AgentControlsResponse = {
266297
controls: controlsWithTemplateList,
267298
};
268299

300+
const controlsWithUnrenderedTemplateResponse: AgentControlsResponse = {
301+
controls: controlsWithUnrenderedTemplateList,
302+
};
303+
269304
// Control summaries for GET /api/v1/controls (list all controls)
270305
const controlSummariesList: (ControlSummary & {
271306
used_by_agent?: { agent_name: string } | null;
@@ -621,7 +656,9 @@ export const mockData = {
621656
agentWithSteps: agentWithStepsResponse,
622657
controls: controlsResponse,
623658
controlsWithTemplate: controlsWithTemplateResponse,
659+
controlsWithUnrenderedTemplate: controlsWithUnrenderedTemplateResponse,
624660
templateControl: templateBackedControl,
661+
unrenderedTemplateControl,
625662
listControls: listControlsResponse,
626663
templateControlSummary: templateControlSummary,
627664
evaluators: evaluatorsResponse,

0 commit comments

Comments
 (0)