Skip to content

Commit d578824

Browse files
authored
feat(surveys): add validation types and utilities (#2820)
Add survey validation support for open text questions - Add validation types and utilities to @posthog/core (MinLength, MaxLength) - Show requirements hint below input (e.g., "Enter at least 10 characters") - Disable submit button when validation fails - Support in both browser SDK and React Native SDK
1 parent afdf746 commit d578824

File tree

12 files changed

+570
-9
lines changed

12 files changed

+570
-9
lines changed

.changeset/sixty-forks-wear.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'posthog-react-native': minor
3+
'posthog-js': minor
4+
'@posthog/core': minor
5+
---
6+
7+
Add survey response validation for message length (min and max length). Fixes whitespace-only bypass for required questions. Existing surveys work unchanged but now properly reject blank responses.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { expect, test } from '../utils/posthog-playwright-test-base'
2+
import { start } from '../utils/setup'
3+
4+
const startOptions = {
5+
options: {},
6+
flagsResponseOverrides: {
7+
surveys: true,
8+
},
9+
url: './playground/cypress/index.html',
10+
}
11+
12+
const appearanceWithThanks = {
13+
displayThankYouMessage: true,
14+
thankYouMessageHeader: 'Thanks!',
15+
}
16+
17+
test.describe('survey validation', () => {
18+
test('required field rejects whitespace-only input', async ({ page, context }) => {
19+
const surveysAPICall = page.route('**/surveys/**', async (route) => {
20+
await route.fulfill({
21+
json: {
22+
surveys: [
23+
{
24+
id: 'validation-test-1',
25+
name: 'Required field test',
26+
type: 'popover',
27+
start_date: '2021-01-01T00:00:00Z',
28+
questions: [
29+
{
30+
type: 'open',
31+
question: 'Required feedback',
32+
id: 'q1',
33+
optional: false,
34+
},
35+
],
36+
},
37+
],
38+
},
39+
})
40+
})
41+
42+
await start(startOptions, page, context)
43+
await surveysAPICall
44+
45+
// Wait for survey form to appear
46+
await expect(page.locator('.PostHogSurvey-validation-test-1').locator('.survey-form')).toBeVisible()
47+
48+
// Type only spaces
49+
await page.locator('textarea').fill(' ')
50+
51+
// Submit button should be disabled (validation failed - whitespace only)
52+
await expect(page.locator('button:has-text("Submit")')).toBeDisabled()
53+
54+
// Survey form should still be visible
55+
await expect(page.locator('.PostHogSurvey-validation-test-1').locator('.survey-form')).toBeVisible()
56+
})
57+
58+
test('required field accepts valid input after trim', async ({ page, context }) => {
59+
const surveysAPICall = page.route('**/surveys/**', async (route) => {
60+
await route.fulfill({
61+
json: {
62+
surveys: [
63+
{
64+
id: 'validation-test-2',
65+
name: 'Valid input test',
66+
type: 'popover',
67+
start_date: '2021-01-01T00:00:00Z',
68+
questions: [
69+
{
70+
type: 'open',
71+
question: 'Required feedback',
72+
id: 'q1',
73+
optional: false,
74+
},
75+
],
76+
appearance: appearanceWithThanks,
77+
},
78+
],
79+
},
80+
})
81+
})
82+
83+
await start(startOptions, page, context)
84+
await surveysAPICall
85+
await expect(page.locator('.PostHogSurvey-validation-test-2').locator('.survey-form')).toBeVisible()
86+
87+
// Type valid content with surrounding whitespace
88+
await page.locator('textarea').fill(' valid response ')
89+
await page.locator('button:has-text("Submit")').click()
90+
91+
// Should show thank you message (submitted successfully)
92+
await expect(page.locator('.PostHogSurvey-validation-test-2')).toContainText('Thanks')
93+
})
94+
95+
test('backwards compat - old survey without validation field works', async ({ page, context }) => {
96+
const surveysAPICall = page.route('**/surveys/**', async (route) => {
97+
await route.fulfill({
98+
json: {
99+
surveys: [
100+
{
101+
id: 'validation-test-3',
102+
name: 'Old survey format',
103+
type: 'popover',
104+
start_date: '2021-01-01T00:00:00Z',
105+
questions: [
106+
{
107+
type: 'open',
108+
question: 'Old question format',
109+
id: 'q1',
110+
// No 'validation' field - old survey format
111+
// No 'optional' field - defaults to required
112+
},
113+
],
114+
appearance: appearanceWithThanks,
115+
},
116+
],
117+
},
118+
})
119+
})
120+
121+
await start(startOptions, page, context)
122+
await surveysAPICall
123+
await expect(page.locator('.PostHogSurvey-validation-test-3').locator('.survey-form')).toBeVisible()
124+
125+
await page.locator('textarea').fill('valid response')
126+
await page.locator('button:has-text("Submit")').click()
127+
128+
// Should show thank you message (submitted successfully)
129+
await expect(page.locator('.PostHogSurvey-validation-test-3')).toContainText('Thanks')
130+
})
131+
132+
test('minLength validation prevents short input', async ({ page, context }) => {
133+
const surveysAPICall = page.route('**/surveys/**', async (route) => {
134+
await route.fulfill({
135+
json: {
136+
surveys: [
137+
{
138+
id: 'validation-test-4',
139+
name: 'MinLength test',
140+
type: 'popover',
141+
start_date: '2021-01-01T00:00:00Z',
142+
questions: [
143+
{
144+
type: 'open',
145+
question: 'Enter at least 10 characters',
146+
id: 'q1',
147+
optional: false,
148+
validation: [{ type: 'min_length', value: 10 }],
149+
},
150+
],
151+
},
152+
],
153+
},
154+
})
155+
})
156+
157+
await start(startOptions, page, context)
158+
await surveysAPICall
159+
await expect(page.locator('.PostHogSurvey-validation-test-4').locator('.survey-form')).toBeVisible()
160+
161+
await page.locator('textarea').fill('short')
162+
163+
// Submit button should be disabled (validation failed - too short)
164+
await expect(page.locator('button:has-text("Submit")')).toBeDisabled()
165+
166+
// Survey form should still be visible
167+
await expect(page.locator('.PostHogSurvey-validation-test-4').locator('.survey-form')).toBeVisible()
168+
})
169+
})

packages/browser/src/__tests__/extensions/surveys.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1825,7 +1825,8 @@ describe('preview renders', () => {
18251825
console.log('Found textarea:', !!textarea)
18261826

18271827
await act(async () => {
1828-
fireEvent.change(textarea!, { target: { value: 'Test answer' } })
1828+
// Use fireEvent.input to trigger onInput handler (change event fires on blur)
1829+
fireEvent.input(textarea!, { target: { value: 'Test answer' } })
18291830
})
18301831

18311832
// Find and click the submit button (using button type="button" instead of form-submit class)

packages/browser/src/extensions/surveys/components/QuestionTypes.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@ import {
88
SurveyAppearance,
99
SurveyQuestionType,
1010
} from '../../../posthog-surveys-types'
11-
import { isArray, isNull, isNumber, isString } from '@posthog/core'
11+
import {
12+
isArray,
13+
isNull,
14+
isNumber,
15+
isString,
16+
getValidationError,
17+
getLengthFromRules,
18+
getRequirementsHint,
19+
SurveyValidationType,
20+
} from '@posthog/core'
1221
import {
1322
dissatisfiedEmoji,
1423
neutralEmoji,
@@ -97,7 +106,6 @@ export function OpenTextQuestion({
97106
}
98107
return ''
99108
})
100-
101109
useEffect(() => {
102110
setTimeout(() => {
103111
if (!isPreviewMode) {
@@ -108,6 +116,30 @@ export function OpenTextQuestion({
108116

109117
const htmlFor = `surveyQuestion${displayQuestionIndex}`
110118

119+
// Validation logic
120+
const validationError = useMemo(() => {
121+
return getValidationError(text, question.validation, question.optional)
122+
}, [text, question.validation, question.optional])
123+
124+
// Build requirements hint message
125+
const minLength = getLengthFromRules(question.validation, SurveyValidationType.MinLength)
126+
const maxLength = getLengthFromRules(question.validation, SurveyValidationType.MaxLength)
127+
const requirementsHint = useMemo(() => getRequirementsHint(minLength, maxLength), [minLength, maxLength])
128+
129+
const handleSubmit = () => {
130+
if (validationError) {
131+
return
132+
}
133+
onSubmit(text.trim())
134+
}
135+
136+
const handlePreviewSubmit = () => {
137+
if (validationError) {
138+
return
139+
}
140+
onPreviewSubmit(text.trim())
141+
}
142+
111143
return (
112144
<Fragment>
113145
<div className="question-container">
@@ -117,6 +149,8 @@ export function OpenTextQuestion({
117149
id={htmlFor}
118150
rows={4}
119151
placeholder={appearance?.placeholder}
152+
minLength={minLength}
153+
maxLength={maxLength}
120154
onInput={(e) => {
121155
setText(e.currentTarget.value)
122156
e.stopPropagation()
@@ -126,13 +160,14 @@ export function OpenTextQuestion({
126160
}}
127161
value={text}
128162
/>
163+
{requirementsHint && <div className="validation-hint">{requirementsHint}</div>}
129164
</div>
130165
<BottomSection
131166
text={question.buttonText || 'Submit'}
132-
submitDisabled={!text && !question.optional}
167+
submitDisabled={!!validationError}
133168
appearance={appearance}
134-
onSubmit={() => onSubmit(text)}
135-
onPreviewSubmit={() => onPreviewSubmit(text)}
169+
onSubmit={handleSubmit}
170+
onPreviewSubmit={handlePreviewSubmit}
136171
/>
137172
</Fragment>
138173
)

packages/browser/src/extensions/surveys/survey.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,13 @@
563563
}
564564
}
565565

566+
/* Validation Hint */
567+
.validation-hint {
568+
font-size: 12px;
569+
opacity: 0.7;
570+
margin-top: 4px;
571+
}
572+
566573
/* Cancel Button (Circular X button) */
567574
.form-cancel {
568575
background: white;

packages/browser/src/posthog-surveys-types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type { Properties, PropertyMatchType } from './types'
8-
import type { SurveyAppearance as CoreSurveyAppearance } from '@posthog/core'
8+
import type { SurveyAppearance as CoreSurveyAppearance, SurveyValidationRule } from '@posthog/core'
99

1010
export enum SurveyEventType {
1111
Activation = 'events',
@@ -94,6 +94,7 @@ interface SurveyQuestionBase {
9494
optional?: boolean
9595
buttonText?: string
9696
branching?: NextQuestionBranching | EndBranching | ResponseBasedBranching | SpecificQuestionBranching
97+
validation?: SurveyValidationRule[]
9798
}
9899

99100
export interface BasicSurveyQuestion extends SurveyQuestionBase {

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { uuidv7 } from './vendor/uuidv7'
55
export * from './posthog-core'
66
export * from './posthog-core-stateless'
77
export * from './types'
8+
export { getValidationError, getLengthFromRules, getRequirementsHint } from './surveys/validation'

0 commit comments

Comments
 (0)