From eea9505fbb1919c7f52ff7c195cb223dc3cbf688 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sat, 25 Apr 2026 14:52:59 +0000 Subject: [PATCH 1/5] feat(ui): add SelectField renderer to InspectorPane for enum-constrained strings Detect when a tunable string property has an enum constraint in its JSON Schema and render a in the Inspector/Monitor View. - Add FormSelect styled component matching existing FormInput pattern - Add SelectField renderer following existing StringField/NumberField/BooleanField pattern - Route enum-constrained strings to SelectField in renderField switch - Send UpdateParams on selection change via existing handleInputChange Closes #328 Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/panes/InspectorPane.tsx | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ui/src/panes/InspectorPane.tsx b/ui/src/panes/InspectorPane.tsx index 9442f81d..05430b64 100644 --- a/ui/src/panes/InspectorPane.tsx +++ b/ui/src/panes/InspectorPane.tsx @@ -71,6 +71,28 @@ const FormInput = styled.input` } `; +const FormSelect = styled.select` + width: 100%; + max-width: 100%; + padding: 8px; + background: var(--sk-panel-bg); + border: 1px solid var(--sk-border); + border-radius: 4px; + color: var(--sk-text); + box-sizing: border-box; + font-family: inherit; + cursor: pointer; + + &:focus-visible { + outline: 1px solid var(--sk-primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } +`; + const FormTextarea = styled.textarea` width: 100%; max-width: 100%; @@ -183,6 +205,29 @@ const StringField: React.FC<{ ); }; +// Helper: Render select field for enum-constrained strings +const SelectField: React.FC<{ + inputId: string; + value: unknown; + schema: JsonSchemaProperty; + readOnly: boolean; + onChange: (value: string) => void; +}> = ({ inputId, value, schema, readOnly, onChange }) => ( + onChange(e.target.value)} + disabled={readOnly} + aria-label={schema.description} + > + {(schema.enum ?? []).map((opt) => ( + + ))} + +); + // Helper: Render number field const NumberField: React.FC<{ inputId: string; @@ -286,6 +331,17 @@ const InspectorPane: React.FC = ({ switch (schema.type) { case 'string': + if (schema.enum && schema.enum.length > 0) { + return ( + handleInputChange(key, v, schema)} + /> + ); + } return ( Date: Sat, 25 Apr 2026 14:58:13 +0000 Subject: [PATCH 2/5] fix(ui): resolve SelectField value when currentValue is not in enum Move enum-value resolution into SelectField so the dropdown always shows a valid option. When the resolved param value does not match any enum entry (e.g. empty string fallback), falls back to schema.default or the first enum value. Add InspectorPane unit tests covering: - select dropdown rendering for enum-constrained strings - fallback to first enum value on unmatched currentValue - UpdateParams dispatch on selection change - text input rendering for non-enum strings - disabled state for non-tunable params in monitor view Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/panes/InspectorPane.test.tsx | 190 ++++++++++++++++++++++++++++ ui/src/panes/InspectorPane.tsx | 36 +++--- 2 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 ui/src/panes/InspectorPane.test.tsx diff --git a/ui/src/panes/InspectorPane.test.tsx b/ui/src/panes/InspectorPane.test.tsx new file mode 100644 index 00000000..9dd0f204 --- /dev/null +++ b/ui/src/panes/InspectorPane.test.tsx @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('jotai/react', () => ({ + useAtomValue: () => ({}), +})); + +vi.mock('@/stores/sessionAtoms', () => ({ + nodeParamsAtom: () => 'mock-atom', +})); + +// Import after mocks are set up +const { default: InspectorPane } = await import('./InspectorPane'); + +const baseNodeDefinition = { + kind: 'test-node', + description: 'A test node', + inputs: [], + outputs: [], + param_schema: {}, +}; + +const baseNode = { + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { label: 'TestNode', kind: 'test-node', params: {} }, +}; + +describe('InspectorPane', () => { + let onParamChange: ReturnType; + let onLabelChange: ReturnType; + + beforeEach(() => { + onParamChange = vi.fn(); + onLabelChange = vi.fn(); + }); + + it('renders a select dropdown for enum-constrained string properties', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720', '1920x1080'], + description: 'Viewport resolution', + }, + }, + }, + }; + + render( + , + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toBeInTheDocument(); + expect(select).toHaveValue('1280x720'); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent('640x480'); + expect(options[1]).toHaveTextContent('1280x720'); + expect(options[2]).toHaveTextContent('1920x1080'); + }); + + it('falls back to first enum value when current value does not match any option', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720', '1920x1080'], + description: 'Viewport resolution', + tunable: true, + }, + }, + }, + }; + + render( + , + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toHaveValue('640x480'); + }); + + it('sends UpdateParams on selection change', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + mode: { + type: 'string', + enum: ['fast', 'balanced', 'quality'], + description: 'Processing mode', + tunable: true, + }, + }, + }, + }; + + render( + , + ); + + const select = screen.getByRole('combobox', { name: 'Processing mode' }); + fireEvent.change(select, { target: { value: 'quality' } }); + + expect(onParamChange).toHaveBeenCalledWith('node-1', 'mode', 'quality'); + }); + + it('renders a text input for non-enum string properties', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + title: { + type: 'string', + description: 'Display title', + }, + }, + }, + }; + + render( + , + ); + + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Display title' })).toBeInTheDocument(); + }); + + it('disables enum select for non-tunable params in monitor view', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720'], + description: 'Viewport resolution', + tunable: false, + }, + }, + }, + }; + + render( + , + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toBeDisabled(); + }); +}); diff --git a/ui/src/panes/InspectorPane.tsx b/ui/src/panes/InspectorPane.tsx index 05430b64..98c3d7e9 100644 --- a/ui/src/panes/InspectorPane.tsx +++ b/ui/src/panes/InspectorPane.tsx @@ -212,21 +212,27 @@ const SelectField: React.FC<{ schema: JsonSchemaProperty; readOnly: boolean; onChange: (value: string) => void; -}> = ({ inputId, value, schema, readOnly, onChange }) => ( - onChange(e.target.value)} - disabled={readOnly} - aria-label={schema.description} - > - {(schema.enum ?? []).map((opt) => ( - - ))} - -); +}> = ({ inputId, value, schema, readOnly, onChange }) => { + const options = (schema.enum ?? []).map(String); + const resolved = options.includes(String(value)) + ? String(value) + : String(schema.default ?? options[0] ?? ''); + return ( + onChange(e.target.value)} + disabled={readOnly} + aria-label={schema.description} + > + {options.map((opt) => ( + + ))} + + ); +}; // Helper: Render number field const NumberField: React.FC<{ From 41142216da363d6b38d9734013d15c676a0d1931 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sat, 25 Apr 2026 14:58:29 +0000 Subject: [PATCH 3/5] style: format InspectorPane test file Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/panes/InspectorPane.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/panes/InspectorPane.test.tsx b/ui/src/panes/InspectorPane.test.tsx index 9dd0f204..17179d76 100644 --- a/ui/src/panes/InspectorPane.test.tsx +++ b/ui/src/panes/InspectorPane.test.tsx @@ -60,7 +60,7 @@ describe('InspectorPane', () => { nodeDefinition={definition} onParamChange={onParamChange} onLabelChange={onLabelChange} - />, + /> ); const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); @@ -95,7 +95,7 @@ describe('InspectorPane', () => { nodeDefinition={definition} onParamChange={onParamChange} onLabelChange={onLabelChange} - />, + /> ); const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); @@ -124,7 +124,7 @@ describe('InspectorPane', () => { onParamChange={onParamChange} onLabelChange={onLabelChange} isMonitorView - />, + /> ); const select = screen.getByRole('combobox', { name: 'Processing mode' }); @@ -152,7 +152,7 @@ describe('InspectorPane', () => { nodeDefinition={definition} onParamChange={onParamChange} onLabelChange={onLabelChange} - />, + /> ); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); @@ -181,7 +181,7 @@ describe('InspectorPane', () => { onParamChange={onParamChange} onLabelChange={onLabelChange} isMonitorView - />, + /> ); const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); From 2c3103033e6fad1f33b6895f504f41e86d45c405 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sat, 25 Apr 2026 15:19:54 +0000 Subject: [PATCH 4/5] fix: add missing NodeDefinition fields and type casts in InspectorPane tests Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/panes/InspectorPane.test.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/ui/src/panes/InspectorPane.test.tsx b/ui/src/panes/InspectorPane.test.tsx index 17179d76..5d1010ac 100644 --- a/ui/src/panes/InspectorPane.test.tsx +++ b/ui/src/panes/InspectorPane.test.tsx @@ -5,6 +5,8 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NodeDefinition } from '@/types/types'; + vi.mock('jotai/react', () => ({ useAtomValue: () => ({}), })); @@ -16,12 +18,14 @@ vi.mock('@/stores/sessionAtoms', () => ({ // Import after mocks are set up const { default: InspectorPane } = await import('./InspectorPane'); -const baseNodeDefinition = { +const baseNodeDefinition: NodeDefinition = { kind: 'test-node', description: 'A test node', inputs: [], outputs: [], param_schema: {}, + categories: [], + bidirectional: false, }; const baseNode = { @@ -32,12 +36,12 @@ const baseNode = { }; describe('InspectorPane', () => { - let onParamChange: ReturnType; - let onLabelChange: ReturnType; + let onParamChange: (nodeId: string, paramName: string, value: unknown) => void; + let onLabelChange: (nodeId: string, newLabel: string) => void; beforeEach(() => { - onParamChange = vi.fn(); - onLabelChange = vi.fn(); + onParamChange = vi.fn<(nodeId: string, paramName: string, value: unknown) => void>(); + onLabelChange = vi.fn<(nodeId: string, newLabel: string) => void>(); }); it('renders a select dropdown for enum-constrained string properties', () => { @@ -57,7 +61,7 @@ describe('InspectorPane', () => { render( @@ -92,7 +96,7 @@ describe('InspectorPane', () => { render( @@ -120,7 +124,7 @@ describe('InspectorPane', () => { render( { render( @@ -177,7 +181,7 @@ describe('InspectorPane', () => { render( Date: Sat, 25 Apr 2026 15:24:02 +0000 Subject: [PATCH 5/5] fix(ui): validate schema.default against enum options in SelectField Ensure the fallback value is always a valid enum member. If schema.default is not in the enum array, fall back to the first option instead of using the invalid default. Add test for schema.default not in enum edge case. Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/panes/InspectorPane.test.tsx | 28 ++++++++++++++++++++++++++++ ui/src/panes/InspectorPane.tsx | 6 +++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ui/src/panes/InspectorPane.test.tsx b/ui/src/panes/InspectorPane.test.tsx index 5d1010ac..bfe9439d 100644 --- a/ui/src/panes/InspectorPane.test.tsx +++ b/ui/src/panes/InspectorPane.test.tsx @@ -106,6 +106,34 @@ describe('InspectorPane', () => { expect(select).toHaveValue('640x480'); }); + it('falls back to first enum value when schema.default is not in enum', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720'], + default: 'not-a-valid-option', + description: 'Viewport resolution', + }, + }, + }, + }; + + render( + + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toHaveValue('640x480'); + }); + it('sends UpdateParams on selection change', () => { const definition = { ...baseNodeDefinition, diff --git a/ui/src/panes/InspectorPane.tsx b/ui/src/panes/InspectorPane.tsx index 98c3d7e9..0bef5411 100644 --- a/ui/src/panes/InspectorPane.tsx +++ b/ui/src/panes/InspectorPane.tsx @@ -214,9 +214,9 @@ const SelectField: React.FC<{ onChange: (value: string) => void; }> = ({ inputId, value, schema, readOnly, onChange }) => { const options = (schema.enum ?? []).map(String); - const resolved = options.includes(String(value)) - ? String(value) - : String(schema.default ?? options[0] ?? ''); + const defaultStr = schema.default != null ? String(schema.default) : undefined; + const fallback = (defaultStr && options.includes(defaultStr) ? defaultStr : options[0]) ?? ''; + const resolved = options.includes(String(value)) ? String(value) : fallback; return (