Skip to content

Commit f5d8440

Browse files
authored
Merge pull request #1536 from dgageot/validation-error
Show validation errors in elicitation dialog when Enter is pressed
2 parents 1ea5ff7 + 43d125f commit f5d8440

File tree

3 files changed

+136
-40
lines changed

3 files changed

+136
-40
lines changed

pkg/tui/dialog/elicitation.go

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type ElicitationDialog struct {
4545
enumIndexes map[int]int // selected index for enum fields
4646
currentField int
4747
keyMap elicitationKeyMap
48+
fieldErrors map[int]string // validation error messages per field
4849
}
4950

5051
type elicitationKeyMap struct {
@@ -60,6 +61,7 @@ func NewElicitationDialog(message string, schema any, _ map[string]any) Dialog {
6061
inputs: make([]textinput.Model, len(fields)),
6162
boolValues: make(map[int]bool),
6263
enumIndexes: make(map[int]int),
64+
fieldErrors: make(map[int]string),
6365
keyMap: elicitationKeyMap{
6466
Up: key.NewBinding(key.WithKeys("up", "shift+tab")),
6567
Down: key.NewBinding(key.WithKeys("down", "tab")),
@@ -126,6 +128,9 @@ func (d *ElicitationDialog) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, t
126128

127129
// toggleCurrentSelection toggles boolean or cycles enum for the current field.
128130
func (d *ElicitationDialog) toggleCurrentSelection() {
131+
// Clear error when user interacts with the field
132+
delete(d.fieldErrors, d.currentField)
133+
129134
switch d.currentFieldType() {
130135
case "boolean":
131136
d.boolValues[d.currentField] = !d.boolValues[d.currentField]
@@ -147,16 +152,26 @@ func (d *ElicitationDialog) submit() (layout.Model, tea.Cmd) {
147152
cmd := d.close(tools.ElicitationActionAccept, nil)
148153
return d, cmd
149154
}
150-
if content, ok := d.collectValues(); ok {
151-
cmd := d.close(tools.ElicitationActionAccept, content)
152-
return d, cmd
155+
156+
// Clear previous errors and validate
157+
d.fieldErrors = make(map[int]string)
158+
content, firstErrorIdx := d.collectAndValidate()
159+
160+
if firstErrorIdx >= 0 {
161+
// Focus the first field with an error
162+
d.focusField(firstErrorIdx)
163+
return d, nil
153164
}
154-
return d, nil
165+
166+
cmd := d.close(tools.ElicitationActionAccept, content)
167+
return d, cmd
155168
}
156169

157170
func (d *ElicitationDialog) updateCurrentInput(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) {
158171
// Only text-based fields (not boolean/enum) use the text input
159172
if d.isTextInputField() {
173+
// Clear error for current field when user types
174+
delete(d.fieldErrors, d.currentField)
160175
var cmd tea.Cmd
161176
d.inputs[d.currentField], cmd = d.inputs[d.currentField].Update(msg)
162177
return d, cmd
@@ -168,11 +183,19 @@ func (d *ElicitationDialog) moveFocus(delta int) {
168183
if len(d.fields) == 0 {
169184
return
170185
}
171-
if len(d.inputs) > 0 {
186+
newField := (d.currentField + delta + len(d.fields)) % len(d.fields)
187+
d.focusField(newField)
188+
}
189+
190+
// focusField moves focus to the specified field index.
191+
func (d *ElicitationDialog) focusField(idx int) {
192+
if idx < 0 || idx >= len(d.fields) {
193+
return
194+
}
195+
if len(d.inputs) > 0 && d.currentField < len(d.inputs) {
172196
d.inputs[d.currentField].Blur()
173197
}
174-
// Wrap around when cycling through fields
175-
d.currentField = (d.currentField + delta + len(d.fields)) % len(d.fields)
198+
d.currentField = idx
176199
// Only focus text input for fields that use it
177200
if d.isTextInputField() {
178201
d.inputs[d.currentField].Focus()
@@ -192,8 +215,11 @@ func (d *ElicitationDialog) close(action tools.ElicitationAction, content map[st
192215
return CloseWithElicitationResponse(action, content)
193216
}
194217

195-
func (d *ElicitationDialog) collectValues() (map[string]any, bool) {
218+
// collectAndValidate validates all fields and returns the collected values.
219+
// Returns the content map and the index of the first field with an error (-1 if valid).
220+
func (d *ElicitationDialog) collectAndValidate() (map[string]any, int) {
196221
content := make(map[string]any)
222+
firstErrorIdx := -1
197223

198224
for i, field := range d.fields {
199225
switch field.Type {
@@ -203,7 +229,10 @@ func (d *ElicitationDialog) collectValues() (map[string]any, bool) {
203229
idx := d.enumIndexes[i]
204230
if idx < 0 || idx >= len(field.EnumValues) {
205231
if field.Required {
206-
return nil, false
232+
d.fieldErrors[i] = "Selection required"
233+
if firstErrorIdx < 0 {
234+
firstErrorIdx = i
235+
}
207236
}
208237
continue
209238
}
@@ -212,40 +241,65 @@ func (d *ElicitationDialog) collectValues() (map[string]any, bool) {
212241
val := strings.TrimSpace(d.inputs[i].Value())
213242
if val == "" {
214243
if field.Required {
215-
return nil, false
244+
d.fieldErrors[i] = "This field is required"
245+
if firstErrorIdx < 0 {
246+
firstErrorIdx = i
247+
}
216248
}
217249
continue
218250
}
219-
parsed, ok := d.parseFieldValue(val, field)
220-
if !ok {
221-
return nil, false
251+
parsed, errMsg := d.parseAndValidateField(val, field)
252+
if errMsg != "" {
253+
d.fieldErrors[i] = errMsg
254+
if firstErrorIdx < 0 {
255+
firstErrorIdx = i
256+
}
257+
continue
222258
}
223259
content[field.Name] = parsed
224260
}
225261
}
226-
return content, true
262+
return content, firstErrorIdx
227263
}
228264

229-
// parseFieldValue parses and validates a field value based on its type.
230-
func (d *ElicitationDialog) parseFieldValue(val string, field ElicitationField) (any, bool) {
265+
// parseAndValidateField parses and validates a field value, returning the parsed value and an error message.
266+
func (d *ElicitationDialog) parseAndValidateField(val string, field ElicitationField) (any, string) {
231267
if val == "" {
232-
return nil, false
268+
return nil, ""
233269
}
234270

235271
switch field.Type {
236272
case "number":
237273
f, err := strconv.ParseFloat(val, 64)
238-
return f, err == nil && validateNumberField(f, field)
274+
if err != nil {
275+
return nil, "Must be a valid number"
276+
}
277+
if errMsg := validateNumberFieldWithMessage(f, field); errMsg != "" {
278+
return nil, errMsg
279+
}
280+
return f, ""
239281

240282
case "integer":
241283
n, err := strconv.ParseInt(val, 10, 64)
242-
return n, err == nil && validateNumberField(float64(n), field)
284+
if err != nil {
285+
return nil, "Must be a whole number"
286+
}
287+
if errMsg := validateNumberFieldWithMessage(float64(n), field); errMsg != "" {
288+
return nil, errMsg
289+
}
290+
return n, ""
243291

244292
case "enum":
245-
return val, slices.Contains(field.EnumValues, val)
293+
if !slices.Contains(field.EnumValues, val) {
294+
return nil, "Invalid selection"
295+
}
296+
return val, ""
246297

247298
default: // string
248-
return val, validateStringField(val, field)
299+
if errMsg := validateStringFieldWithMessage(val, field); errMsg != "" {
300+
return nil, errMsg
301+
}
302+
return val, ""
249303
}
250304
}
251305

@@ -301,7 +355,14 @@ func (d *ElicitationDialog) renderField(content *Content, i int, field Elicitati
301355
if field.Required {
302356
label += "*"
303357
}
304-
content.AddContent(styles.DialogContentStyle.Bold(true).Render(label))
358+
359+
// Check if this field has an error
360+
hasError := d.fieldErrors[i] != ""
361+
labelStyle := styles.DialogContentStyle.Bold(true)
362+
if hasError {
363+
labelStyle = labelStyle.Foreground(styles.Error)
364+
}
365+
content.AddContent(labelStyle.Render(label))
305366

306367
// Render field input based on type
307368
isFocused := i == d.currentField
@@ -314,6 +375,12 @@ func (d *ElicitationDialog) renderField(content *Content, i int, field Elicitati
314375
d.inputs[i].SetWidth(contentWidth)
315376
content.AddContent(d.inputs[i].View())
316377
}
378+
379+
// Show error message if present
380+
if hasError {
381+
errorStyle := styles.DialogContentStyle.Foreground(styles.Error).Italic(true)
382+
content.AddContent(errorStyle.Render(" ⚠ " + d.fieldErrors[i]))
383+
}
317384
}
318385

319386
func (d *ElicitationDialog) renderBooleanField(content *Content, i int, isFocused bool) {

pkg/tui/dialog/elicitation_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ func TestNewElicitationDialog(t *testing.T) {
323323
}
324324
}
325325

326-
func TestElicitationDialog_collectValues(t *testing.T) {
326+
func TestElicitationDialog_collectAndValidate(t *testing.T) {
327327
t.Parallel()
328328

329329
tests := []struct {
@@ -536,7 +536,8 @@ func TestElicitationDialog_collectValues(t *testing.T) {
536536
tt.setupInputs(dialog)
537537
}
538538

539-
content, valid := dialog.collectValues()
539+
content, firstErrorIdx := dialog.collectAndValidate()
540+
valid := firstErrorIdx < 0
540541
assert.Equal(t, tt.expectedValid, valid)
541542

542543
if valid && tt.expectedKeys != nil {

pkg/tui/dialog/validation.go

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dialog
22

33
import (
4+
"fmt"
45
"net/mail"
56
"net/url"
67
"regexp"
@@ -28,47 +29,74 @@ func getCompiledPattern(pattern string) (*regexp.Regexp, error) {
2829

2930
// validateStringField validates a string value against field constraints.
3031
func validateStringField(val string, field ElicitationField) bool {
32+
return validateStringFieldWithMessage(val, field) == ""
33+
}
34+
35+
// validateStringFieldWithMessage validates a string value and returns a descriptive error message.
36+
func validateStringFieldWithMessage(val string, field ElicitationField) string {
3137
if field.MinLength > 0 && len(val) < field.MinLength {
32-
return false
38+
return fmt.Sprintf("Must be at least %d characters", field.MinLength)
3339
}
3440
if field.Pattern != "" {
3541
compiled, err := getCompiledPattern(field.Pattern)
36-
if err != nil || !compiled.MatchString(val) {
37-
return false
42+
if err != nil {
43+
return "Invalid pattern in schema"
44+
}
45+
if !compiled.MatchString(val) {
46+
return "Invalid format"
3847
}
3948
}
40-
return validateFormat(val, field.Format)
49+
return validateFormatWithMessage(val, field.Format)
4150
}
4251

4352
// validateNumberField validates a numeric value against field constraints.
4453
func validateNumberField(val float64, field ElicitationField) bool {
54+
return validateNumberFieldWithMessage(val, field) == ""
55+
}
56+
57+
// validateNumberFieldWithMessage validates a numeric value and returns a descriptive error message.
58+
func validateNumberFieldWithMessage(val float64, field ElicitationField) string {
4559
if field.HasMinimum && val < field.Minimum {
46-
return false
60+
return fmt.Sprintf("Must be at least %v", field.Minimum)
4761
}
4862
if field.HasMaximum && val > field.Maximum {
49-
return false
63+
return fmt.Sprintf("Must be at most %v", field.Maximum)
5064
}
51-
return true
65+
return ""
5266
}
5367

5468
// validateFormat validates a string value against a JSON Schema format.
5569
func validateFormat(val, format string) bool {
70+
return validateFormatWithMessage(val, format) == ""
71+
}
72+
73+
// validateFormatWithMessage validates a string against a JSON Schema format and returns an error message.
74+
func validateFormatWithMessage(val, format string) string {
5675
switch format {
5776
case "":
58-
return true
77+
return ""
5978
case "email":
60-
_, err := mail.ParseAddress(val)
61-
return err == nil
79+
if _, err := mail.ParseAddress(val); err != nil {
80+
return "Must be a valid email address"
81+
}
82+
return ""
6283
case "uri":
6384
u, err := url.Parse(val)
64-
return err == nil && u.Scheme != "" && u.Host != ""
85+
if err != nil || u.Scheme == "" || u.Host == "" {
86+
return "Must be a valid URL (http:// or https://)"
87+
}
88+
return ""
6589
case "date":
66-
_, err := time.Parse("2006-01-02", val)
67-
return err == nil
90+
if _, err := time.Parse("2006-01-02", val); err != nil {
91+
return "Must be a valid date (YYYY-MM-DD)"
92+
}
93+
return ""
6894
case "date-time":
69-
_, err := time.Parse(time.RFC3339, val)
70-
return err == nil
95+
if _, err := time.Parse(time.RFC3339, val); err != nil {
96+
return "Must be a valid date-time (RFC3339 format)"
97+
}
98+
return ""
7199
default:
72-
return true // Unknown format - be permissive
100+
return "" // Unknown format - be permissive
73101
}
74102
}

0 commit comments

Comments
 (0)