diff --git a/.gitignore b/.gitignore index 2dae7d8..bb09b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ /playwright/.cache/ elm-stuff /httpbin-server +/dist/tiny-form-fields.dev.esm.js diff --git a/Makefile b/Makefile index 1cce398..b9b3554 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ run: node_modules/.bin/elm-esm npx elm-live src/Main.elm \ --start-page index.html \ --path-to-elm node_modules/.bin/elm-esm \ - -- --output=dist/tiny-form-fields.esm.js $(ELM_MAKE_FLAGS) + -- --output=dist/tiny-form-fields.dev.esm.js $(ELM_MAKE_FLAGS) node_modules/.bin/elm-esm: npm ci diff --git a/dist/tiny-form-fields.esm.js b/dist/tiny-form-fields.esm.js index 68d4bf4..c1d0720 100644 --- a/dist/tiny-form-fields.esm.js +++ b/dist/tiny-form-fields.esm.js @@ -12754,6 +12754,74 @@ var $author$project$Main$isVisibilityRuleSatisfied = F2( }, rules); }); +var $elm$core$Array$foldl = F3( + function (func, baseCase, _v0) { + var tree = _v0.c; + var tail = _v0.d; + var helper = F2( + function (node, acc) { + if (!node.$) { + var subTree = node.a; + return A3($elm$core$Elm$JsArray$foldl, helper, acc, subTree); + } else { + var values = node.a; + return A3($elm$core$Elm$JsArray$foldl, func, acc, values); + } + }); + return A3( + $elm$core$Elm$JsArray$foldl, + func, + A3($elm$core$Elm$JsArray$foldl, helper, baseCase, tree), + tail); + }); +var $author$project$Main$sanitizeFormValuesHelper = F3( + function (formFields, currentValues, remainingIterations) { + sanitizeFormValuesHelper: + while (true) { + if (remainingIterations <= 0) { + return currentValues; + } else { + var sanitized = A3( + $elm$core$Array$foldl, + F2( + function (field, acc) { + var fieldName = $author$project$Main$fieldNameOf(field); + if (A2($author$project$Main$isVisibilityRuleSatisfied, field.m, currentValues)) { + var _v0 = A2($elm$core$Dict$get, fieldName, currentValues); + if (!_v0.$) { + var fieldValues = _v0.a; + return A3($elm$core$Dict$insert, fieldName, fieldValues, acc); + } else { + return acc; + } + } else { + return acc; + } + }), + $elm$core$Dict$empty, + formFields); + if (_Utils_eq(sanitized, currentValues)) { + return sanitized; + } else { + var $temp$formFields = formFields, + $temp$currentValues = sanitized, + $temp$remainingIterations = remainingIterations - 1; + formFields = $temp$formFields; + currentValues = $temp$currentValues; + remainingIterations = $temp$remainingIterations; + continue sanitizeFormValuesHelper; + } + } + } + }); +var $author$project$Main$sanitizeFormValues = F2( + function (formFields, values) { + return A3( + $author$project$Main$sanitizeFormValuesHelper, + formFields, + values, + $elm$core$Array$length(formFields)); + }); var $author$project$Main$viewFormPreview = F2( function (customAttrs, _v0) { var formFields = _v0.g; @@ -12822,7 +12890,10 @@ var $author$project$Main$viewFormPreview = F2( A2( $elm$core$Array$filter, function (formField) { - return A2($author$project$Main$isVisibilityRuleSatisfied, formField.m, trackedFormValues) && (!A2($author$project$Main$fieldHasEmptyFilter, formField, trackedFormValues)); + return A2( + $author$project$Main$isVisibilityRuleSatisfied, + formField.m, + A2($author$project$Main$sanitizeFormValues, formFields, trackedFormValues)) && (!A2($author$project$Main$fieldHasEmptyFilter, formField, trackedFormValues)); }, formFields))); }); diff --git a/dist/tiny-form-fields.js b/dist/tiny-form-fields.js index cd5a20b..1d49ef0 100644 --- a/dist/tiny-form-fields.js +++ b/dist/tiny-form-fields.js @@ -12746,6 +12746,74 @@ var $author$project$Main$isVisibilityRuleSatisfied = F2( }, rules); }); +var $elm$core$Array$foldl = F3( + function (func, baseCase, _v0) { + var tree = _v0.c; + var tail = _v0.d; + var helper = F2( + function (node, acc) { + if (!node.$) { + var subTree = node.a; + return A3($elm$core$Elm$JsArray$foldl, helper, acc, subTree); + } else { + var values = node.a; + return A3($elm$core$Elm$JsArray$foldl, func, acc, values); + } + }); + return A3( + $elm$core$Elm$JsArray$foldl, + func, + A3($elm$core$Elm$JsArray$foldl, helper, baseCase, tree), + tail); + }); +var $author$project$Main$sanitizeFormValuesHelper = F3( + function (formFields, currentValues, remainingIterations) { + sanitizeFormValuesHelper: + while (true) { + if (remainingIterations <= 0) { + return currentValues; + } else { + var sanitized = A3( + $elm$core$Array$foldl, + F2( + function (field, acc) { + var fieldName = $author$project$Main$fieldNameOf(field); + if (A2($author$project$Main$isVisibilityRuleSatisfied, field.m, currentValues)) { + var _v0 = A2($elm$core$Dict$get, fieldName, currentValues); + if (!_v0.$) { + var fieldValues = _v0.a; + return A3($elm$core$Dict$insert, fieldName, fieldValues, acc); + } else { + return acc; + } + } else { + return acc; + } + }), + $elm$core$Dict$empty, + formFields); + if (_Utils_eq(sanitized, currentValues)) { + return sanitized; + } else { + var $temp$formFields = formFields, + $temp$currentValues = sanitized, + $temp$remainingIterations = remainingIterations - 1; + formFields = $temp$formFields; + currentValues = $temp$currentValues; + remainingIterations = $temp$remainingIterations; + continue sanitizeFormValuesHelper; + } + } + } + }); +var $author$project$Main$sanitizeFormValues = F2( + function (formFields, values) { + return A3( + $author$project$Main$sanitizeFormValuesHelper, + formFields, + values, + $elm$core$Array$length(formFields)); + }); var $author$project$Main$viewFormPreview = F2( function (customAttrs, _v0) { var formFields = _v0.g; @@ -12814,7 +12882,10 @@ var $author$project$Main$viewFormPreview = F2( A2( $elm$core$Array$filter, function (formField) { - return A2($author$project$Main$isVisibilityRuleSatisfied, formField.m, trackedFormValues) && (!A2($author$project$Main$fieldHasEmptyFilter, formField, trackedFormValues)); + return A2( + $author$project$Main$isVisibilityRuleSatisfied, + formField.m, + A2($author$project$Main$sanitizeFormValues, formFields, trackedFormValues)) && (!A2($author$project$Main$fieldHasEmptyFilter, formField, trackedFormValues)); }, formFields))); }); diff --git a/e2e/cascading-visibility-bug.spec.ts b/e2e/cascading-visibility-bug.spec.ts new file mode 100644 index 0000000..bde8b90 --- /dev/null +++ b/e2e/cascading-visibility-bug.spec.ts @@ -0,0 +1,282 @@ +// tests/cascading-visibility-bug.spec.ts +// Tests for PR49: Hidden field values should not affect other fields' visibility +import { test, expect } from '@playwright/test'; +import { addField } from './test-utils'; + +test('Cascading visibility - Field C depends on hidden Field B value (THE BUG)', async ({ + page, +}) => { + await page.goto(''); + + // Field A: "Do you have a car?" + await addField(page, 'Radio buttons', [ + { label: 'Radio buttons question title', value: 'Do you have a car?' }, + ]); + + // Field B: "Car brand" - shown when Field A = "Yes" + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Car brand', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Do you have a car?', + comparison: [ + { + type: 'Equals', + value: 'Yes', + }, + ], + }, + ], + }, + ]); + + // Field C: "Do you prefer Japanese brands?" - shown when Field B = "Toyota" + await addField(page, 'Radio buttons', [ + { + label: 'Radio buttons question title', + value: 'Do you prefer Japanese brands?', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Car brand', + comparison: [ + { + type: 'Equals', + value: 'Toyota', + }, + ], + }, + ], + }, + ]); + + // Switch to preview mode + const page1Promise = page.waitForEvent('popup'); + await page.getByRole('link', { name: 'View form' }).click(); + const page1 = await page1Promise; + + // Step 1: User selects "Yes" for has car + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + + // Field B (Car brand) should appear + await expect(page1.locator('text="Car brand"')).toBeVisible(); + + // Step 2: User enters "Toyota" in car brand + await page1.getByLabel('Car brand').fill('Toyota'); + + // Field C (prefer Japanese) should appear + await expect(page1.locator('text="Do you prefer Japanese brands?"')).toBeVisible(); + + // Step 3: User changes their mind and selects "No" for has car + await page1.getByRole('radio', { name: 'No' }).first().click(); + + // Field B should be hidden (correct) + await expect(page1.locator('text="Car brand"')).toHaveCount(0); + + // Field C should ALSO be hidden (this was the bug - it would stay visible) + // Because Field B is hidden, its value "Toyota" should not affect Field C's visibility + await expect(page1.locator('text="Do you prefer Japanese brands?"')).toHaveCount(0); +}); + +test('Deep cascading visibility - A→B→C→D all cascade', async ({ page }) => { + await page.goto(''); + + // Field A: Control field + await addField(page, 'Radio buttons', [ + { label: 'Radio buttons question title', value: 'Field A' }, + ]); + + // Field B: Shown when A = "Yes" + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Field B', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Field A', + comparison: [{ type: 'Equals', value: 'Yes' }], + }, + ], + }, + ]); + + // Field C: Shown when B = "EnableC" + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Field C', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Field B', + comparison: [{ type: 'Equals', value: 'EnableC' }], + }, + ], + }, + ]); + + // Field D: Shown when C = "EnableD" + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Field D', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Field C', + comparison: [{ type: 'Equals', value: 'EnableD' }], + }, + ], + }, + ]); + + // Switch to preview mode + const page1Promise = page.waitForEvent('popup'); + await page.getByRole('link', { name: 'View form' }).click(); + const page1 = await page1Promise; + + // Build up the cascade + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Field B"')).toBeVisible(); + + await page1.getByLabel('Field B').fill('EnableC'); + await expect(page1.locator('text="Field C"')).toBeVisible(); + + await page1.getByLabel('Field C').fill('EnableD'); + await expect(page1.locator('text="Field D"')).toBeVisible(); + + // Now break the cascade at the top + await page1.getByRole('radio', { name: 'No' }).first().click(); + + // All downstream fields should disappear + await expect(page1.locator('text="Field B"')).toHaveCount(0); + await expect(page1.locator('text="Field C"')).toHaveCount(0); + await expect(page1.locator('text="Field D"')).toHaveCount(0); +}); + +test('StringContains with hidden field', async ({ page }) => { + await page.goto(''); + + // Enable toggle + await addField(page, 'Radio buttons', [ + { label: 'Radio buttons question title', value: 'Enable description?' }, + ]); + + // Description field - use Single-line instead to avoid the Multi-line button name issue + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Description', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Enable description?', + comparison: [{ type: 'Equals', value: 'Yes' }], + }, + ], + }, + ]); + + // Urgent note - shown when description contains "urgent" + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Urgent Note', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Description', + comparison: [{ type: 'StringContains', value: 'urgent' }], + }, + ], + }, + ]); + + // Switch to preview mode + const page1Promise = page.waitForEvent('popup'); + await page.getByRole('link', { name: 'View form' }).click(); + const page1 = await page1Promise; + + // Enable description and add urgent text + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Description"')).toBeVisible(); + + await page1.getByLabel('Description').fill('This is urgent'); + await expect(page1.locator('text="Urgent Note"')).toBeVisible(); + + // Disable description + await page1.getByRole('radio', { name: 'No' }).first().click(); + + // Both Description and Urgent Note should be hidden + await expect(page1.locator('text="Description"')).toHaveCount(0); + await expect(page1.locator('text="Urgent Note"')).toHaveCount(0); +}); + +test('EqualsField with hidden target field', async ({ page }) => { + await page.goto(''); + + // Show confirmation toggle + await addField(page, 'Radio buttons', [ + { label: 'Radio buttons question title', value: 'Show email confirmation?' }, + ]); + + // Email field (always visible in this test) + await addField(page, 'Email', [{ label: 'Email question title', value: 'Email' }]); + + // Confirm Email - shown when toggle = "Yes" + await addField(page, 'Email', [ + { + label: 'Email question title', + value: 'Confirm Email', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Show email confirmation?', + comparison: [{ type: 'Equals', value: 'Yes' }], + }, + ], + }, + ]); + + // Submit blocker - hidden when Email equals Confirm Email + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Emails must match to submit', + visibilityRule: [ + { + type: 'Hide this question when', + field: 'Email', + comparison: [{ type: 'Equals (field)', value: 'Confirm Email' }], + }, + ], + }, + ]); + + // Switch to preview mode + const page1Promise = page.waitForEvent('popup'); + await page.getByRole('link', { name: 'View form' }).click(); + const page1 = await page1Promise; + + // Enter matching emails with confirmation visible + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await page1.getByLabel('Email').first().fill('test@example.com'); + await page1.getByLabel('Confirm Email').fill('test@example.com'); + + // Blocker should be hidden (emails match) + await expect(page1.locator('text="Emails must match to submit"')).toHaveCount(0); + + // Now hide the confirmation field + await page1.getByRole('radio', { name: 'No' }).first().click(); + + // Confirm Email is hidden + await expect(page1.locator('text="Confirm Email"')).toHaveCount(0); + + // Blocker should now be VISIBLE because Confirm Email is hidden, + // so the EqualsField comparison fails (Email != empty) + await expect(page1.locator('text="Emails must match to submit"')).toBeVisible(); +}); diff --git a/e2e/value-persistence-bug.spec.ts b/e2e/value-persistence-bug.spec.ts new file mode 100644 index 0000000..070fff3 --- /dev/null +++ b/e2e/value-persistence-bug.spec.ts @@ -0,0 +1,155 @@ +// Test to verify that field values persist when hidden and then unhidden +// This test currently FAILS because sanitizeFormValues deletes hidden field values +import { test, expect } from '@playwright/test'; +import { addField } from './test-utils'; + +test('Field value should persist when hidden and then unhidden', async ({ page }) => { + await page.goto(''); + + // Field A: Toggle to show/hide Field B + await addField(page, 'Radio buttons', [ + { label: 'Radio buttons question title', value: 'Show details?' }, + ]); + + // Field B: Text field that is conditionally shown + await addField(page, 'Single-line free text', [ + { + label: 'Single-line free text question title', + value: 'Your details', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Show details?', + comparison: [ + { + type: 'Equals', + value: 'Yes', + }, + ], + }, + ], + }, + ]); + + // Switch to preview mode + const page1Promise = page.waitForEvent('popup'); + await page.getByRole('link', { name: 'View form' }).click(); + const page1 = await page1Promise; + + // Step 1: Show Field B + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Your details"')).toBeVisible(); + + // Step 2: Enter a value in Field B + const testValue = 'Important information'; + await page1.getByLabel('Your details').fill(testValue); + + // Verify the value was entered + await expect(page1.getByLabel('Your details')).toHaveValue(testValue); + + // Step 3: Hide Field B + await page1.getByRole('radio', { name: 'No' }).first().click(); + await expect(page1.locator('text="Your details"')).toHaveCount(0); + + // Step 4: Unhide Field B + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Your details"')).toBeVisible(); + + await expect(page1.getByLabel('Your details')).toHaveValue(testValue); +}); + +test('Dropdown value should persist when hidden and then unhidden', async ({ page }) => { + await page.goto(''); + + // Toggle field + await addField(page, 'Radio buttons', [ + { label: 'Radio buttons question title', value: 'Show color?' }, + ]); + + // Dropdown field + await addField(page, 'Dropdown', [ + { + label: 'Dropdown question title', + value: 'Favorite color', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Show color?', + comparison: [{ type: 'Equals', value: 'Yes' }], + }, + ], + }, + ]); + + // Switch to preview mode + const page1Promise = page.waitForEvent('popup'); + await page.getByRole('link', { name: 'View form' }).click(); + const page1 = await page1Promise; + + // Show the dropdown + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Favorite color"')).toBeVisible(); + + // Select a value + await page1.getByRole('combobox', { name: 'Favorite color' }).selectOption('Red'); + await expect(page1.getByRole('combobox', { name: 'Favorite color' })).toHaveValue('Red'); + + // Hide the dropdown + await page1.getByRole('radio', { name: 'No' }).first().click(); + await expect(page1.locator('text="Favorite color"')).toHaveCount(0); + + // Unhide the dropdown + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Favorite color"')).toBeVisible(); + + await expect(page1.getByRole('combobox', { name: 'Favorite color' })).toHaveValue('Red'); +}); + +test('Radio button selection should persist when hidden and then unhidden', async ({ page }) => { + await page.goto(''); + + // Toggle field + await addField(page, 'Radio buttons', [ + { label: 'Radio buttons question title', value: 'Show opinion?' }, + ]); + + // Radio button field + await addField(page, 'Radio buttons', [ + { + label: 'Radio buttons question title', + value: 'Do you agree?', + visibilityRule: [ + { + type: 'Show this question when', + field: 'Show opinion?', + comparison: [{ type: 'Equals', value: 'Yes' }], + }, + ], + }, + ]); + + // Switch to preview mode + const page1Promise = page.waitForEvent('popup'); + await page.getByRole('link', { name: 'View form' }).click(); + const page1 = await page1Promise; + + // Show the radio buttons + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Do you agree?"')).toBeVisible(); + + // Select "No" (the second set of Yes/No buttons) + const allNoButtons = await page1.getByRole('radio', { name: 'No' }).all(); + await allNoButtons[1].click(); + await expect(allNoButtons[1]).toBeChecked(); + + // Hide the radio buttons + await page1.getByRole('radio', { name: 'No' }).first().click(); + await expect(page1.locator('text="Do you agree?"')).toHaveCount(0); + + // Unhide the radio buttons + await page1.getByRole('radio', { name: 'Yes' }).first().click(); + await expect(page1.locator('text="Do you agree?"')).toBeVisible(); + + const allNoButtonsAfter = await page1.getByRole('radio', { name: 'No' }).all(); + await expect(allNoButtonsAfter[1]).toBeChecked(); +}); diff --git a/go/validate.go b/go/validate.go index 29e071a..fc406eb 100644 --- a/go/validate.go +++ b/go/validate.go @@ -650,6 +650,45 @@ func isFieldVisible(field TinyFormField, values url.Values) bool { return field.VisibilityRule[0].Type == "HideWhen" // Default: show for HideWhen, hide for ShowWhen } +// sanitizeFormValues removes values for hidden fields, iterating until a stable state is reached. +// This prevents hidden fields from affecting the visibility of other fields. +func sanitizeFormValues(fields []TinyFormField, values url.Values) url.Values { + current := values + maxIterations := len(fields) // Maximum possible cascade depth for N fields + + for i := 0; i < maxIterations; i++ { + sanitized := make(url.Values) + changed := false + + for _, field := range fields { + fieldName := field.FieldName() + + // Check if field is visible with current values + if isFieldVisible(field, current) { + // Field is visible, preserve its value + if fieldValues, ok := current[fieldName]; ok { + sanitized[fieldName] = fieldValues + } + } else { + // Field is hidden, remove its value + if _, hadValue := current[fieldName]; hadValue { + changed = true + } + } + } + + // If nothing changed, we've reached a stable state + if !changed { + return sanitized + } + + current = sanitized + } + + // After max iterations, return current state + return current +} + // ValidFormValues validates the form submission values against the form definition. // Returns nil if validation passes, otherwise returns an error. func ValidFormValues(formFields []byte, values url.Values) error { @@ -658,7 +697,10 @@ func ValidFormValues(formFields []byte, values url.Values) error { return fmt.Errorf("error parsing form fields: %w", err) } - return fields.Validate(values) + // Sanitize values to remove hidden field values before validation + sanitized := sanitizeFormValues(fields, values) + + return fields.Validate(sanitized) } // isEmptyValue checks if a slice of strings is empty or contains only empty strings. diff --git a/go/validate_test.go b/go/validate_test.go index c777ae5..a37c73f 100644 --- a/go/validate_test.go +++ b/go/validate_test.go @@ -1548,3 +1548,308 @@ func TestVisibilityRules(t *testing.T) { }) } } + +func TestCascadingVisibilityBugFix(t *testing.T) { + scenarios := []struct { + name string + formFields string + values url.Values + expectedError error + }{ + { + name: "Cascading visibility - Field C depends on hidden Field B's value (THE BUG)", + formFields: `[ + { + "label": "Do you have a car?", + "name": "has_car", + "presence": "Required", + "type": { + "type": "ChooseOne", + "choices": ["Yes", "No"] + } + }, + { + "label": "Car brand", + "name": "car_brand", + "presence": "Optional", + "type": { + "type": "ShortText", + "inputType": "text", + "attributes": {"type": "text"} + }, + "visibilityRule": [ + { + "type": "ShowWhen", + "conditions": [ + { + "type": "Field", + "fieldName": "has_car", + "comparison": { + "type": "Equals", + "value": "Yes" + } + } + ] + } + ] + }, + { + "label": "Do you prefer Japanese brands?", + "name": "prefer_japanese", + "presence": "Required", + "type": { + "type": "ChooseOne", + "choices": ["Yes", "No"] + }, + "visibilityRule": [ + { + "type": "ShowWhen", + "conditions": [ + { + "type": "Field", + "fieldName": "car_brand", + "comparison": { + "type": "Equals", + "value": "Toyota" + } + } + ] + } + ] + } + ]`, + values: url.Values{ + "has_car": []string{"No"}, + "car_brand": []string{"Toyota"}, + "prefer_japanese": []string{""}, + }, + expectedError: nil, // Should pass - prefer_japanese should be hidden + }, + { + name: "Deep cascading visibility - A→B→C→D all cascade", + formFields: `[ + { + "label": "Field A", + "name": "field_a", + "presence": "Required", + "type": {"type": "ChooseOne", "choices": ["Yes", "No"]} + }, + { + "label": "Field B", + "name": "field_b", + "presence": "Optional", + "type": {"type": "ShortText", "inputType": "text", "attributes": {"type": "text"}}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [{ + "type": "Field", + "fieldName": "field_a", + "comparison": {"type": "Equals", "value": "Yes"} + }] + }] + }, + { + "label": "Field C", + "name": "field_c", + "presence": "Optional", + "type": {"type": "ShortText", "inputType": "text", "attributes": {"type": "text"}}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [{ + "type": "Field", + "fieldName": "field_b", + "comparison": {"type": "Equals", "value": "EnableC"} + }] + }] + }, + { + "label": "Field D", + "name": "field_d", + "presence": "Required", + "type": {"type": "ShortText", "inputType": "text", "attributes": {"type": "text"}}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [{ + "type": "Field", + "fieldName": "field_c", + "comparison": {"type": "Equals", "value": "EnableD"} + }] + }] + } + ]`, + values: url.Values{ + "field_a": []string{"No"}, + "field_b": []string{"EnableC"}, + "field_c": []string{"EnableD"}, + "field_d": []string{""}, + }, + expectedError: nil, // All downstream fields should be hidden + }, + { + name: "EqualsField with hidden target field", + formFields: `[ + { + "label": "Show email confirmation?", + "name": "show_confirm", + "presence": "Required", + "type": {"type": "ChooseOne", "choices": ["Yes", "No"]} + }, + { + "label": "Email", + "name": "email", + "presence": "Required", + "type": {"type": "ShortText", "inputType": "Email", "attributes": {"type": "email"}} + }, + { + "label": "Confirm Email", + "name": "confirm_email", + "presence": "Optional", + "type": {"type": "ShortText", "inputType": "Email", "attributes": {"type": "email"}}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [{ + "type": "Field", + "fieldName": "show_confirm", + "comparison": {"type": "Equals", "value": "Yes"} + }] + }] + }, + { + "label": "Submit blocker", + "name": "blocker", + "presence": "Required", + "type": {"type": "ShortText", "inputType": "text", "attributes": {"type": "text", "value": "ok", "pattern": "ok"}}, + "visibilityRule": [{ + "type": "HideWhen", + "conditions": [{ + "type": "Field", + "fieldName": "email", + "comparison": {"type": "EqualsField", "value": "confirm_email"} + }] + }] + } + ]`, + values: url.Values{ + "show_confirm": []string{"No"}, + "email": []string{"test@example.com"}, + "confirm_email": []string{"test@example.com"}, + // blocker field is visible (emails can't be compared since confirm_email is hidden) + // blocker has value="ok" in HTML but pattern="ok", and we're not submitting it + // so it should fail validation + }, + expectedError: ErrRequiredFieldMissing, // Blocker is visible and required but not provided + }, + { + name: "StringContains with hidden field", + formFields: `[ + { + "label": "Enable description?", + "name": "enable_desc", + "presence": "Required", + "type": {"type": "ChooseOne", "choices": ["Yes", "No"]} + }, + { + "label": "Description", + "name": "description", + "presence": "Optional", + "type": {"type": "LongText"}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [{ + "type": "Field", + "fieldName": "enable_desc", + "comparison": {"type": "Equals", "value": "Yes"} + }] + }] + }, + { + "label": "Urgent Note", + "name": "urgent_note", + "presence": "Required", + "type": {"type": "ShortText", "inputType": "text", "attributes": {"type": "text"}}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [{ + "type": "Field", + "fieldName": "description", + "comparison": {"type": "StringContains", "value": "urgent"} + }] + }] + } + ]`, + values: url.Values{ + "enable_desc": []string{"No"}, + "description": []string{"This is urgent"}, + "urgent_note": []string{""}, + }, + expectedError: nil, // urgent_note should be hidden (description is hidden) + }, + { + name: "Multiple conditions with one field hidden", + formFields: `[ + { + "label": "Field A", + "name": "field_a", + "presence": "Required", + "type": {"type": "ChooseOne", "choices": ["Yes", "No"]} + }, + { + "label": "Field B", + "name": "field_b", + "presence": "Optional", + "type": {"type": "ShortText", "inputType": "text", "attributes": {"type": "text"}}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [{ + "type": "Field", + "fieldName": "field_a", + "comparison": {"type": "Equals", "value": "Yes"} + }] + }] + }, + { + "label": "Field C", + "name": "field_c", + "presence": "Required", + "type": {"type": "ShortText", "inputType": "text", "attributes": {"type": "text"}}, + "visibilityRule": [{ + "type": "ShowWhen", + "conditions": [ + { + "type": "Field", + "fieldName": "field_a", + "comparison": {"type": "Equals", "value": "No"} + }, + { + "type": "Field", + "fieldName": "field_b", + "comparison": {"type": "Equals", "value": "SpecialValue"} + } + ] + }] + } + ]`, + values: url.Values{ + "field_a": []string{"No"}, + "field_b": []string{"SpecialValue"}, + "field_c": []string{""}, + }, + expectedError: nil, // field_c should be hidden (field_b is hidden so second condition fails) + }, + } + + for _, tt := range scenarios { + t.Run(tt.name, func(t *testing.T) { + err := ValidFormValues([]byte(tt.formFields), tt.values) + if tt.expectedError == nil { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + } else { + if !errors.Is(err, tt.expectedError) { + t.Errorf("Expected error %v, got: %v", tt.expectedError, err) + } + } + }) + } +} diff --git a/index.html b/index.html index ae243fc..501b62c 100644 --- a/index.html +++ b/index.html @@ -123,7 +123,7 @@