Skip to content

Commit b2e2702

Browse files
authored
fix: prevent empty poll options and questions in composition (#1541)
1 parent 86cd646 commit b2e2702

File tree

3 files changed

+79
-33
lines changed

3 files changed

+79
-33
lines changed

src/messageComposer/middleware/pollComposer/state.ts

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const VALID_MAX_VOTES_VALUE_REGEX = /^([2-9]|10)$/;
1111

1212
export const MAX_POLL_OPTIONS = 100 as const;
1313

14+
const textFieldIsEmpty = (text: string) => !text.trim();
15+
1416
export type PollStateValidationOutput = Partial<
1517
Omit<Record<keyof PollComposerState['data'], string>, 'options'> & {
1618
options?: Record<string, string>;
@@ -74,9 +76,20 @@ export const defaultPollFieldBlurEventValidators: Partial<
7476
return { max_votes_allowed: undefined };
7577
},
7678
name: ({ value }) => {
77-
if (!value) return { name: 'Name is required' };
79+
if (textFieldIsEmpty(value)) return { name: 'Question is required' };
7880
return { name: undefined };
7981
},
82+
options: (params) => {
83+
const defaultResult = pollStateChangeValidators.options?.(params);
84+
const errors = defaultResult?.options ?? {};
85+
params.value.forEach((option: { id: string; text: string }, index: number) => {
86+
const isTheLastOption = index === params.value.length - 1;
87+
if (textFieldIsEmpty(option.text) && !isTheLastOption) {
88+
errors[option.id] = 'Option is empty';
89+
}
90+
});
91+
return Object.keys(errors).length > 0 ? { options: errors } : { options: undefined };
92+
},
8093
};
8194

8295
export type PollCompositionStateProcessorOutput = Partial<PollComposerState['data']>;
@@ -168,15 +181,19 @@ export const createPollComposerStateMiddleware = ({
168181
processors: customProcessors,
169182
validators: customValidators,
170183
}: PollComposerStateMiddlewareFactoryOptions = {}): PollComposerStateMiddleware => {
171-
const universalHandler = (
172-
state: PollComposerStateChangeMiddlewareValue,
184+
const universalHandler = ({
185+
state,
186+
validators,
187+
processors,
188+
}: {
189+
state: PollComposerStateChangeMiddlewareValue;
173190
validators: Partial<
174191
Record<keyof PollComposerState['data'], PollStateChangeValidator>
175-
>,
192+
>;
176193
processors?: Partial<
177194
Record<keyof PollComposerState['data'], PollCompositionStateProcessor>
178-
>,
179-
) => {
195+
>;
196+
}) => {
180197
const { previousState, targetFields } = state;
181198

182199
let newData: Partial<PollComposerState['data']>;
@@ -210,9 +227,9 @@ export const createPollComposerStateMiddleware = ({
210227
const validator = validators[key as keyof PollComposerState['data']];
211228
if (validator) {
212229
const error = validator({
230+
currentError: previousState.errors[key as keyof PollComposerState['data']],
213231
data: previousState.data,
214232
value: newData[key as keyof PollComposerState['data']],
215-
currentError: previousState.errors[key as keyof PollComposerState['data']],
216233
});
217234
acc = { ...acc, ...error };
218235
}
@@ -232,21 +249,19 @@ export const createPollComposerStateMiddleware = ({
232249
}: MiddlewareHandlerParams<PollComposerStateChangeMiddlewareValue>) => {
233250
if (!state.targetFields) return forward();
234251
const { previousState, injectedFieldErrors } = state;
235-
const finalValidators = {
236-
...pollStateChangeValidators,
237-
...defaultPollFieldChangeEventValidators,
238-
...customValidators?.handleFieldChange,
239-
};
240-
const finalProcessors = {
241-
...pollCompositionStateProcessors,
242-
...customProcessors?.handleFieldChange,
243-
};
244-
245-
const { newData, newErrors } = universalHandler(
252+
253+
const { newData, newErrors } = universalHandler({
254+
processors: {
255+
...pollCompositionStateProcessors,
256+
...customProcessors?.handleFieldChange,
257+
},
246258
state,
247-
finalValidators,
248-
finalProcessors,
249-
);
259+
validators: {
260+
...pollStateChangeValidators,
261+
...defaultPollFieldChangeEventValidators,
262+
...customValidators?.handleFieldChange,
263+
},
264+
});
250265

251266
return next({
252267
...state,
@@ -265,16 +280,15 @@ export const createPollComposerStateMiddleware = ({
265280
if (!state.targetFields) return forward();
266281

267282
const { previousState } = state;
268-
const finalValidators = {
269-
...pollStateChangeValidators,
270-
...defaultPollFieldBlurEventValidators,
271-
...customValidators?.handleFieldBlur,
272-
};
273-
const { newData, newErrors } = universalHandler(
283+
const { newData, newErrors } = universalHandler({
284+
processors: customProcessors?.handleFieldBlur,
274285
state,
275-
finalValidators,
276-
customProcessors?.handleFieldBlur,
277-
);
286+
validators: {
287+
...pollStateChangeValidators,
288+
...defaultPollFieldBlurEventValidators,
289+
...customValidators?.handleFieldBlur,
290+
},
291+
});
278292

279293
return next({
280294
...state,

src/messageComposer/pollComposer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ export class PollComposer {
154154
max_votes_allowed: data.max_votes_allowed
155155
? parseInt(data.max_votes_allowed)
156156
: undefined,
157-
options: data.options?.filter((o) => o.text).map((o) => ({ text: o.text })),
157+
options: data.options
158+
?.filter((o) => o.text.trim())
159+
.map((o) => ({ text: o.text })),
158160
},
159161
errors,
160162
},

test/unit/MessageComposer/middleware/pollComposer/state.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,13 +384,43 @@ describe('PollComposerStateMiddleware', () => {
384384
});
385385

386386
describe('options validation', () => {
387-
it('should validate empty options on blur', async () => {
387+
it('should not validate empty options on blur', async () => {
388388
const stateMiddleware = setup();
389389
const result = await stateMiddleware.handlers.handleFieldBlur(
390390
setupHandlerParams({
391391
nextState: { ...getInitialState() },
392392
previousState: { ...getInitialState() },
393-
targetFields: { options: [{ id: 'option-id', text: '' }] },
393+
targetFields: {
394+
options: [
395+
{ id: 'option-id1', text: '' },
396+
{ id: 'option-id2', text: '' },
397+
],
398+
},
399+
}),
400+
);
401+
402+
expect(result.state.nextState.errors.options).toBeDefined();
403+
expect(Object.keys(result.state.nextState.errors.options!)).toHaveLength(2);
404+
expect(result.state.nextState.errors.options!['option-id1']).toBe(
405+
'Option is empty',
406+
);
407+
expect(result.state.nextState.errors.options!['option-id2']).toBe(
408+
'Option already exists',
409+
);
410+
});
411+
412+
it('should validate last empty option on blur', async () => {
413+
const stateMiddleware = setup();
414+
const result = await stateMiddleware.handlers.handleFieldBlur(
415+
setupHandlerParams({
416+
nextState: { ...getInitialState() },
417+
previousState: { ...getInitialState() },
418+
targetFields: {
419+
options: [
420+
{ id: 'option-id1', text: 'A' },
421+
{ id: 'option-id2', text: '' },
422+
],
423+
},
394424
}),
395425
);
396426

0 commit comments

Comments
 (0)