From 5574d39482564ab6ab3d6b1fd155be586f499c83 Mon Sep 17 00:00:00 2001 From: choonkeat Date: Mon, 10 Nov 2025 23:20:02 +0800 Subject: [PATCH 1/8] Add tests exposing cascading visibility bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive tests that expose the bug where hidden field values incorrectly affect other fields' visibility logic. Go Unit Tests (go/validate_test.go): - TestCascadingVisibilityBugFix with 5 scenarios: 1. Cascading visibility - Field C depends on hidden Field B value 2. Deep cascading visibility - A→B→C→D all cascade 3. EqualsField with hidden target field 4. StringContains with hidden field 5. Multiple conditions with one field hidden Playwright E2E Tests (e2e/cascading-visibility-bug.spec.ts): - 4 scenarios × 3 browsers = 12 tests: 1. Cascading visibility - Original bug reproduction 2. Deep cascading - 4-level dependency chain 3. StringContains with hidden field 4. EqualsField with hidden target field All tests currently FAIL as expected, demonstrating the bug where: - Hidden fields retain values in form submissions - These values incorrectly trigger visibility of dependent fields - Example: car_brand='Toyota' (hidden) still shows prefer_japanese field The next commit will implement the fix to make these tests pass. --- e2e/cascading-visibility-bug.spec.ts | 282 +++++++++++++++++++++++++ go/validate_test.go | 305 +++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 e2e/cascading-visibility-bug.spec.ts 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/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) + } + } + }) + } +} From 18ac22081f76b2c047e61cf8d15478fb09fa6ad8 Mon Sep 17 00:00:00 2001 From: choonkeat Date: Mon, 10 Nov 2025 23:20:34 +0800 Subject: [PATCH 2/8] Fix cascading visibility bug - sanitize hidden field values (PR49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented fix for bug where hidden field values incorrectly affected other fields' visibility. Now sanitizes form values by removing hidden field values before checking visibility rules. Server-Side (Go): - Added sanitizeFormValues() function in go/validate.go - Iteratively removes values for hidden fields until stable state - Integrated into ValidFormValues() before validation - Handles cascading dependencies, circular refs, deep nesting Client-Side (Elm): - Added sanitizeFormValues() and helper in src/Main.elm - Mirrors Go implementation with iterative stabilization - Updated OnFormValuesUpdated to sanitize after user input - Prevents hidden field values from affecting browser visibility Implementation Details: - Max 10 iterations to prevent infinite loops (typically 1-2 needed) - O(N×D) complexity where N=fields, D=depth - No breaking signature changes - Handles all comparison types: Equals, StringContains, EndsWith, GreaterThan, EqualsField Test Results: - All 5 Go unit test scenarios now PASS - All 12 Playwright E2E tests now PASS (4 scenarios × 3 browsers) - No regressions in existing tests - Comprehensive coverage of edge cases Example Fix: Before: car_brand='Toyota' (hidden) → prefer_japanese shows (bug) After: car_brand removed during sanitization → prefer_japanese hidden (correct) Files Modified: - go/validate.go (+44 lines) - src/Main.elm (+62 lines) - dist/tiny-form-fields.esm.js (rebuilt) - dist/tiny-form-fields.js (rebuilt) --- dist/tiny-form-fields.esm.js | 289 +++++++++++++++++++++-------------- dist/tiny-form-fields.js | 289 +++++++++++++++++++++-------------- go/validate.go | 44 +++++- src/Main.elm | 62 +++++++- 4 files changed, 456 insertions(+), 228 deletions(-) diff --git a/dist/tiny-form-fields.esm.js b/dist/tiny-form-fields.esm.js index 68d4bf4..69d4b61 100644 --- a/dist/tiny-form-fields.esm.js +++ b/dist/tiny-form-fields.esm.js @@ -7539,6 +7539,179 @@ var $elm$core$Array$push = F2( A2($elm$core$Elm$JsArray$push, a, tail), array); }); +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 $elm$core$Basics$composeL = F3( + function (g, f, x) { + return g( + f(x)); + }); +var $elm$core$List$all = F2( + function (isOkay, list) { + return !A2( + $elm$core$List$any, + A2($elm$core$Basics$composeL, $elm$core$Basics$not, isOkay), + list); + }); +var $elm$core$String$endsWith = _String_endsWith; +var $elm$core$String$toFloat = _String_toFloat; +var $author$project$Main$evaluateCondition = F2( + function (trackedFormValues, condition) { + var fieldName = condition.a; + var comparison = condition.b; + switch (comparison.$) { + case 0: + var givenValue = comparison.a; + return A2( + $elm$core$List$member, + givenValue, + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + case 1: + var givenValue = comparison.a; + return A2( + $elm$core$List$any, + $elm$core$String$contains(givenValue), + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + case 2: + var givenValue = comparison.a; + return A2( + $elm$core$List$any, + $elm$core$String$endsWith(givenValue), + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + case 3: + var givenValue = comparison.a; + return A2( + $elm$core$List$any, + function (formValue) { + var _v2 = $elm$core$String$toFloat(givenValue); + if (!_v2.$) { + var givenFloat = _v2.a; + return A2( + $elm$core$Maybe$withDefault, + false, + A2( + $elm$core$Maybe$map, + function (formFloat) { + return _Utils_cmp(formFloat, givenFloat) > 0; + }, + $elm$core$String$toFloat(formValue))); + } else { + return _Utils_cmp(formValue, givenValue) > 0; + } + }, + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + default: + var otherFieldName = comparison.a; + var otherValues = A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, otherFieldName, trackedFormValues)); + return A2( + $elm$core$List$any, + function (v) { + return A2($elm$core$List$member, v, otherValues); + }, + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + } + }); +var $author$project$Main$isVisibilityRuleSatisfied = F2( + function (rules, trackedFormValues) { + return $elm$core$List$isEmpty(rules) || A2( + $elm$core$List$any, + function (rule) { + if (!rule.$) { + var conditions = rule.a; + return A2( + $elm$core$List$all, + $author$project$Main$evaluateCondition(trackedFormValues), + conditions); + } else { + var conditions = rule.a; + return !A2( + $elm$core$List$all, + $author$project$Main$evaluateCondition(trackedFormValues), + conditions); + } + }, + rules); + }); +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, 10); + }); var $elm$core$Process$sleep = _Process_sleep; var $author$project$Main$stringFromInputField = function (inputField) { switch (inputField.$) { @@ -8921,7 +9094,8 @@ var $author$project$Main$update = F2( [value]); } }(); - var newTrackedFormValues = A3($elm$core$Dict$insert, fieldName, newValues, model.u); + var updatedValues = A3($elm$core$Dict$insert, fieldName, newValues, model.u); + var sanitizedValues = A2($author$project$Main$sanitizeFormValues, model.g, updatedValues); var formValues = A3( $elm$json$Json$Encode$dict, $elm$core$Basics$identity, @@ -8936,11 +9110,11 @@ var $author$project$Main$update = F2( key, A2($elm$json$Json$Encode$list, $elm$json$Json$Encode$string, values)); }, - $elm$core$Dict$toList(newTrackedFormValues)))); + $elm$core$Dict$toList(sanitizedValues)))); return _Utils_Tuple2( _Utils_update( model, - {u: newTrackedFormValues}), + {u: sanitizedValues}), $author$project$Main$outgoing( $author$project$Main$encodePortOutgoingValue( $author$project$Main$PortOutgoingFormValues(formValues)))); @@ -12645,115 +12819,6 @@ var $author$project$Main$isChooseManyUsingMinMax = function (formField) { return false; } }; -var $elm$core$Basics$composeL = F3( - function (g, f, x) { - return g( - f(x)); - }); -var $elm$core$List$all = F2( - function (isOkay, list) { - return !A2( - $elm$core$List$any, - A2($elm$core$Basics$composeL, $elm$core$Basics$not, isOkay), - list); - }); -var $elm$core$String$endsWith = _String_endsWith; -var $elm$core$String$toFloat = _String_toFloat; -var $author$project$Main$evaluateCondition = F2( - function (trackedFormValues, condition) { - var fieldName = condition.a; - var comparison = condition.b; - switch (comparison.$) { - case 0: - var givenValue = comparison.a; - return A2( - $elm$core$List$member, - givenValue, - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - case 1: - var givenValue = comparison.a; - return A2( - $elm$core$List$any, - $elm$core$String$contains(givenValue), - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - case 2: - var givenValue = comparison.a; - return A2( - $elm$core$List$any, - $elm$core$String$endsWith(givenValue), - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - case 3: - var givenValue = comparison.a; - return A2( - $elm$core$List$any, - function (formValue) { - var _v2 = $elm$core$String$toFloat(givenValue); - if (!_v2.$) { - var givenFloat = _v2.a; - return A2( - $elm$core$Maybe$withDefault, - false, - A2( - $elm$core$Maybe$map, - function (formFloat) { - return _Utils_cmp(formFloat, givenFloat) > 0; - }, - $elm$core$String$toFloat(formValue))); - } else { - return _Utils_cmp(formValue, givenValue) > 0; - } - }, - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - default: - var otherFieldName = comparison.a; - var otherValues = A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, otherFieldName, trackedFormValues)); - return A2( - $elm$core$List$any, - function (v) { - return A2($elm$core$List$member, v, otherValues); - }, - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - } - }); -var $author$project$Main$isVisibilityRuleSatisfied = F2( - function (rules, trackedFormValues) { - return $elm$core$List$isEmpty(rules) || A2( - $elm$core$List$any, - function (rule) { - if (!rule.$) { - var conditions = rule.a; - return A2( - $elm$core$List$all, - $author$project$Main$evaluateCondition(trackedFormValues), - conditions); - } else { - var conditions = rule.a; - return !A2( - $elm$core$List$all, - $author$project$Main$evaluateCondition(trackedFormValues), - conditions); - } - }, - rules); - }); var $author$project$Main$viewFormPreview = F2( function (customAttrs, _v0) { var formFields = _v0.g; diff --git a/dist/tiny-form-fields.js b/dist/tiny-form-fields.js index cd5a20b..892bbdf 100644 --- a/dist/tiny-form-fields.js +++ b/dist/tiny-form-fields.js @@ -7531,6 +7531,179 @@ var $elm$core$Array$push = F2( A2($elm$core$Elm$JsArray$push, a, tail), array); }); +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 $elm$core$Basics$composeL = F3( + function (g, f, x) { + return g( + f(x)); + }); +var $elm$core$List$all = F2( + function (isOkay, list) { + return !A2( + $elm$core$List$any, + A2($elm$core$Basics$composeL, $elm$core$Basics$not, isOkay), + list); + }); +var $elm$core$String$endsWith = _String_endsWith; +var $elm$core$String$toFloat = _String_toFloat; +var $author$project$Main$evaluateCondition = F2( + function (trackedFormValues, condition) { + var fieldName = condition.a; + var comparison = condition.b; + switch (comparison.$) { + case 0: + var givenValue = comparison.a; + return A2( + $elm$core$List$member, + givenValue, + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + case 1: + var givenValue = comparison.a; + return A2( + $elm$core$List$any, + $elm$core$String$contains(givenValue), + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + case 2: + var givenValue = comparison.a; + return A2( + $elm$core$List$any, + $elm$core$String$endsWith(givenValue), + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + case 3: + var givenValue = comparison.a; + return A2( + $elm$core$List$any, + function (formValue) { + var _v2 = $elm$core$String$toFloat(givenValue); + if (!_v2.$) { + var givenFloat = _v2.a; + return A2( + $elm$core$Maybe$withDefault, + false, + A2( + $elm$core$Maybe$map, + function (formFloat) { + return _Utils_cmp(formFloat, givenFloat) > 0; + }, + $elm$core$String$toFloat(formValue))); + } else { + return _Utils_cmp(formValue, givenValue) > 0; + } + }, + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + default: + var otherFieldName = comparison.a; + var otherValues = A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, otherFieldName, trackedFormValues)); + return A2( + $elm$core$List$any, + function (v) { + return A2($elm$core$List$member, v, otherValues); + }, + A2( + $elm$core$Maybe$withDefault, + _List_Nil, + A2($elm$core$Dict$get, fieldName, trackedFormValues))); + } + }); +var $author$project$Main$isVisibilityRuleSatisfied = F2( + function (rules, trackedFormValues) { + return $elm$core$List$isEmpty(rules) || A2( + $elm$core$List$any, + function (rule) { + if (!rule.$) { + var conditions = rule.a; + return A2( + $elm$core$List$all, + $author$project$Main$evaluateCondition(trackedFormValues), + conditions); + } else { + var conditions = rule.a; + return !A2( + $elm$core$List$all, + $author$project$Main$evaluateCondition(trackedFormValues), + conditions); + } + }, + rules); + }); +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, 10); + }); var $elm$core$Process$sleep = _Process_sleep; var $author$project$Main$stringFromInputField = function (inputField) { switch (inputField.$) { @@ -8913,7 +9086,8 @@ var $author$project$Main$update = F2( [value]); } }(); - var newTrackedFormValues = A3($elm$core$Dict$insert, fieldName, newValues, model.u); + var updatedValues = A3($elm$core$Dict$insert, fieldName, newValues, model.u); + var sanitizedValues = A2($author$project$Main$sanitizeFormValues, model.g, updatedValues); var formValues = A3( $elm$json$Json$Encode$dict, $elm$core$Basics$identity, @@ -8928,11 +9102,11 @@ var $author$project$Main$update = F2( key, A2($elm$json$Json$Encode$list, $elm$json$Json$Encode$string, values)); }, - $elm$core$Dict$toList(newTrackedFormValues)))); + $elm$core$Dict$toList(sanitizedValues)))); return _Utils_Tuple2( _Utils_update( model, - {u: newTrackedFormValues}), + {u: sanitizedValues}), $author$project$Main$outgoing( $author$project$Main$encodePortOutgoingValue( $author$project$Main$PortOutgoingFormValues(formValues)))); @@ -12637,115 +12811,6 @@ var $author$project$Main$isChooseManyUsingMinMax = function (formField) { return false; } }; -var $elm$core$Basics$composeL = F3( - function (g, f, x) { - return g( - f(x)); - }); -var $elm$core$List$all = F2( - function (isOkay, list) { - return !A2( - $elm$core$List$any, - A2($elm$core$Basics$composeL, $elm$core$Basics$not, isOkay), - list); - }); -var $elm$core$String$endsWith = _String_endsWith; -var $elm$core$String$toFloat = _String_toFloat; -var $author$project$Main$evaluateCondition = F2( - function (trackedFormValues, condition) { - var fieldName = condition.a; - var comparison = condition.b; - switch (comparison.$) { - case 0: - var givenValue = comparison.a; - return A2( - $elm$core$List$member, - givenValue, - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - case 1: - var givenValue = comparison.a; - return A2( - $elm$core$List$any, - $elm$core$String$contains(givenValue), - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - case 2: - var givenValue = comparison.a; - return A2( - $elm$core$List$any, - $elm$core$String$endsWith(givenValue), - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - case 3: - var givenValue = comparison.a; - return A2( - $elm$core$List$any, - function (formValue) { - var _v2 = $elm$core$String$toFloat(givenValue); - if (!_v2.$) { - var givenFloat = _v2.a; - return A2( - $elm$core$Maybe$withDefault, - false, - A2( - $elm$core$Maybe$map, - function (formFloat) { - return _Utils_cmp(formFloat, givenFloat) > 0; - }, - $elm$core$String$toFloat(formValue))); - } else { - return _Utils_cmp(formValue, givenValue) > 0; - } - }, - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - default: - var otherFieldName = comparison.a; - var otherValues = A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, otherFieldName, trackedFormValues)); - return A2( - $elm$core$List$any, - function (v) { - return A2($elm$core$List$member, v, otherValues); - }, - A2( - $elm$core$Maybe$withDefault, - _List_Nil, - A2($elm$core$Dict$get, fieldName, trackedFormValues))); - } - }); -var $author$project$Main$isVisibilityRuleSatisfied = F2( - function (rules, trackedFormValues) { - return $elm$core$List$isEmpty(rules) || A2( - $elm$core$List$any, - function (rule) { - if (!rule.$) { - var conditions = rule.a; - return A2( - $elm$core$List$all, - $author$project$Main$evaluateCondition(trackedFormValues), - conditions); - } else { - var conditions = rule.a; - return !A2( - $elm$core$List$all, - $author$project$Main$evaluateCondition(trackedFormValues), - conditions); - } - }, - rules); - }); var $author$project$Main$viewFormPreview = F2( function (customAttrs, _v0) { var formFields = _v0.g; diff --git a/go/validate.go b/go/validate.go index 29e071a..56f201d 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 := 10 // Prevent infinite loops (should never hit this in practice) + + 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/src/Main.elm b/src/Main.elm index fd7a6e6..b9dcf81 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -876,17 +876,21 @@ update msg model = Nothing -> [ value ] - newTrackedFormValues = + updatedValues = Dict.insert fieldName newValues model.trackedFormValues + -- Sanitize values to remove hidden field values + sanitizedValues = + sanitizeFormValues model.formFields updatedValues + formValues = - Dict.toList newTrackedFormValues + Dict.toList sanitizedValues |> List.map (\( key, values ) -> ( key, Json.Encode.list Json.Encode.string values )) |> Dict.fromList |> Json.Encode.dict identity identity in ( { model - | trackedFormValues = newTrackedFormValues + | trackedFormValues = sanitizedValues } , outgoing (encodePortOutgoingValue (PortOutgoingFormValues formValues)) ) @@ -4520,6 +4524,58 @@ isVisibilityRuleSatisfied rules trackedFormValues = +{- Sanitize form values by removing values for hidden fields. + Iterates until reaching a stable state to handle cascading visibility. + This prevents hidden fields from affecting the visibility of other fields. +-} + + +sanitizeFormValues : Array FormField -> Dict String (List String) -> Dict String (List String) +sanitizeFormValues formFields values = + sanitizeFormValuesHelper formFields values 10 + + +sanitizeFormValuesHelper : Array FormField -> Dict String (List String) -> Int -> Dict String (List String) +sanitizeFormValuesHelper formFields currentValues remainingIterations = + if remainingIterations <= 0 then + -- Max iterations reached, return current state + currentValues + + else + let + sanitized = + Array.foldl + (\field acc -> + let + fieldName = + fieldNameOf field + in + -- Only keep field value if field is visible + if isVisibilityRuleSatisfied field.visibilityRule currentValues then + case Dict.get fieldName currentValues of + Just fieldValues -> + Dict.insert fieldName fieldValues acc + + Nothing -> + acc + + else + -- Field is hidden, remove its value + acc + ) + Dict.empty + formFields + in + -- Check if values changed (stable state reached) + if sanitized == currentValues then + sanitized + + else + -- Continue iterating with sanitized values + sanitizeFormValuesHelper formFields sanitized (remainingIterations - 1) + + + {- Helper to get conditions from a rule -} From 08bd3b942eb80747cd9274484008235a17c41535 Mon Sep 17 00:00:00 2001 From: choonkeat Date: Tue, 11 Nov 2025 07:26:57 +0800 Subject: [PATCH 3/8] use a single pipeline pattern with |> instead of multiple intermediate variables eliminates the risk of using the wrong variable since there's only one clear variable name now --- src/Main.elm | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Main.elm b/src/Main.elm index b9dcf81..fb14b13 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -876,21 +876,19 @@ update msg model = Nothing -> [ value ] - updatedValues = + newTrackedFormValues = Dict.insert fieldName newValues model.trackedFormValues - - -- Sanitize values to remove hidden field values - sanitizedValues = - sanitizeFormValues model.formFields updatedValues + |> sanitizeFormValues model.formFields formValues = - Dict.toList sanitizedValues + newTrackedFormValues + |> Dict.toList |> List.map (\( key, values ) -> ( key, Json.Encode.list Json.Encode.string values )) |> Dict.fromList |> Json.Encode.dict identity identity in ( { model - | trackedFormValues = sanitizedValues + | trackedFormValues = newTrackedFormValues } , outgoing (encodePortOutgoingValue (PortOutgoingFormValues formValues)) ) From fc2cd90f3df0429ce041d6f9ae9f165a790c34a5 Mon Sep 17 00:00:00 2001 From: choonkeat Date: Tue, 11 Nov 2025 07:28:12 +0800 Subject: [PATCH 4/8] improve local dev dx by not disturbing the production build --- .gitignore | 1 + Makefile | 2 +- index.html | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) 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/index.html b/index.html index ae243fc..501b62c 100644 --- a/index.html +++ b/index.html @@ -123,7 +123,7 @@