Skip to content

Commit 2f1f46e

Browse files
committed
Merge branch 'master' into feat/offline-support-rework
2 parents 9060ddb + b2e2702 commit 2f1f46e

File tree

4 files changed

+168
-43
lines changed

4 files changed

+168
-43
lines changed

src/messageComposer/middleware/pollComposer/state.ts

Lines changed: 53 additions & 37 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']>;
@@ -116,24 +129,26 @@ export const pollCompositionStateProcessors: Partial<
116129
const { index, text } = value;
117130
const prevOptions = data.options || [];
118131

119-
const shouldAddEmptyOption =
120-
prevOptions.length < MAX_POLL_OPTIONS &&
121-
(!prevOptions || (prevOptions.slice(index + 1).length === 0 && !!text));
122-
123132
const shouldRemoveOption =
124133
prevOptions && prevOptions.slice(index + 1).length > 0 && !text;
125134

126135
const optionListHead = prevOptions.slice(0, index);
127-
const optionListTail = shouldAddEmptyOption
128-
? [{ id: generateUUIDv4(), text: '' }]
129-
: prevOptions.slice(index + 1);
136+
const optionListTail = prevOptions.slice(index + 1);
130137

131138
const newOptions = [
132139
...optionListHead,
133140
...(shouldRemoveOption ? [] : [{ ...prevOptions[index], text }]),
134141
...optionListTail,
135142
];
136143

144+
const shouldAddNewOption =
145+
prevOptions.length < MAX_POLL_OPTIONS &&
146+
!newOptions.some((option) => !option.text.trim());
147+
148+
if (shouldAddNewOption) {
149+
newOptions.push({ id: generateUUIDv4(), text: '' });
150+
}
151+
137152
return { options: newOptions };
138153
},
139154
};
@@ -166,15 +181,19 @@ export const createPollComposerStateMiddleware = ({
166181
processors: customProcessors,
167182
validators: customValidators,
168183
}: PollComposerStateMiddlewareFactoryOptions = {}): PollComposerStateMiddleware => {
169-
const universalHandler = (
170-
state: PollComposerStateChangeMiddlewareValue,
184+
const universalHandler = ({
185+
state,
186+
validators,
187+
processors,
188+
}: {
189+
state: PollComposerStateChangeMiddlewareValue;
171190
validators: Partial<
172191
Record<keyof PollComposerState['data'], PollStateChangeValidator>
173-
>,
192+
>;
174193
processors?: Partial<
175194
Record<keyof PollComposerState['data'], PollCompositionStateProcessor>
176-
>,
177-
) => {
195+
>;
196+
}) => {
178197
const { previousState, targetFields } = state;
179198

180199
let newData: Partial<PollComposerState['data']>;
@@ -208,9 +227,9 @@ export const createPollComposerStateMiddleware = ({
208227
const validator = validators[key as keyof PollComposerState['data']];
209228
if (validator) {
210229
const error = validator({
230+
currentError: previousState.errors[key as keyof PollComposerState['data']],
211231
data: previousState.data,
212232
value: newData[key as keyof PollComposerState['data']],
213-
currentError: previousState.errors[key as keyof PollComposerState['data']],
214233
});
215234
acc = { ...acc, ...error };
216235
}
@@ -230,21 +249,19 @@ export const createPollComposerStateMiddleware = ({
230249
}: MiddlewareHandlerParams<PollComposerStateChangeMiddlewareValue>) => {
231250
if (!state.targetFields) return forward();
232251
const { previousState, injectedFieldErrors } = state;
233-
const finalValidators = {
234-
...pollStateChangeValidators,
235-
...defaultPollFieldChangeEventValidators,
236-
...customValidators?.handleFieldChange,
237-
};
238-
const finalProcessors = {
239-
...pollCompositionStateProcessors,
240-
...customProcessors?.handleFieldChange,
241-
};
242-
243-
const { newData, newErrors } = universalHandler(
252+
253+
const { newData, newErrors } = universalHandler({
254+
processors: {
255+
...pollCompositionStateProcessors,
256+
...customProcessors?.handleFieldChange,
257+
},
244258
state,
245-
finalValidators,
246-
finalProcessors,
247-
);
259+
validators: {
260+
...pollStateChangeValidators,
261+
...defaultPollFieldChangeEventValidators,
262+
...customValidators?.handleFieldChange,
263+
},
264+
});
248265

249266
return next({
250267
...state,
@@ -263,16 +280,15 @@ export const createPollComposerStateMiddleware = ({
263280
if (!state.targetFields) return forward();
264281

265282
const { previousState } = state;
266-
const finalValidators = {
267-
...pollStateChangeValidators,
268-
...defaultPollFieldBlurEventValidators,
269-
...customValidators?.handleFieldBlur,
270-
};
271-
const { newData, newErrors } = universalHandler(
283+
const { newData, newErrors } = universalHandler({
284+
processors: customProcessors?.handleFieldBlur,
272285
state,
273-
finalValidators,
274-
customProcessors?.handleFieldBlur,
275-
);
286+
validators: {
287+
...pollStateChangeValidators,
288+
...defaultPollFieldBlurEventValidators,
289+
...customValidators?.handleFieldBlur,
290+
},
291+
});
276292

277293
return next({
278294
...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
},

src/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,7 @@ export type BanUserOptions = UnBanUserOptions & {
937937
ip_ban?: boolean;
938938
reason?: string;
939939
timeout?: number;
940+
delete_messages?: DeleteMessagesOptions;
940941
};
941942

942943
export type ChannelOptions = {
@@ -3222,6 +3223,7 @@ export class ErrorFromResponse<T> extends Error {
32223223
public code: number | null;
32233224
public status: number;
32243225
public response: AxiosResponse<T>;
3226+
public name = 'ErrorFromResponse';
32253227

32263228
constructor(
32273229
message: string,
@@ -3240,6 +3242,29 @@ export class ErrorFromResponse<T> extends Error {
32403242
this.response = response;
32413243
this.status = status;
32423244
}
3245+
3246+
// Vitest helper (serialized errors are too large to read)
3247+
// https://github.com/vitest-dev/vitest/blob/v3.1.3/packages/utils/src/error.ts#L60-L62
3248+
toJSON() {
3249+
const extra = [
3250+
['status', this.status],
3251+
['code', this.code],
3252+
] as const;
3253+
3254+
const joinable = [];
3255+
3256+
for (const [key, value] of extra) {
3257+
if (typeof value !== 'undefined' && value !== null) {
3258+
joinable.push(`${key}: ${value}`);
3259+
}
3260+
}
3261+
3262+
return {
3263+
message: `(${joinable.join(', ')}) - ${this.message}`,
3264+
stack: this.stack,
3265+
name: this.name,
3266+
};
3267+
}
32433268
}
32443269

32453270
export type QueryPollsResponse = {
@@ -3509,11 +3534,14 @@ export type CustomCheckFlag = {
35093534
reason?: string;
35103535
};
35113536

3537+
export type DeleteMessagesOptions = 'soft' | 'hard';
3538+
35123539
export type SubmitActionOptions = {
35133540
ban?: {
35143541
channel_ban_only?: boolean;
35153542
reason?: string;
35163543
timeout?: number;
3544+
delete_messages?: DeleteMessagesOptions;
35173545
};
35183546
delete_message?: {
35193547
hard_delete?: boolean;

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

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,14 @@ describe('PollComposerStateMiddleware', () => {
182182
expect(result.status).toBeUndefined;
183183
});
184184

185-
it('should add a new empty option when the last option is filled', async () => {
185+
it('should add a new empty option when the all the options are filled', async () => {
186186
const stateMiddleware = setup();
187187
const result = await stateMiddleware.handlers.handleFieldChange(
188188
setupHandlerParams({
189189
nextState: { ...getInitialState() },
190-
previousState: { ...getInitialState() },
190+
previousState: {
191+
...getInitialState(),
192+
},
191193
targetFields: {
192194
options: {
193195
index: 0,
@@ -199,7 +201,54 @@ describe('PollComposerStateMiddleware', () => {
199201

200202
expect(result.state.nextState.data.options.length).toBe(2);
201203
expect(result.state.nextState.data.options[0].text).toBe('Option 1');
202-
expect(result.state.nextState.data.options[1].text).toBe('');
204+
expect(result.state.nextState.data.options[1].text).toEqual('');
205+
206+
expect(result.status).toBeUndefined;
207+
});
208+
209+
it('should reorder options and add a new empty option when all the options are filled', async () => {
210+
const stateMiddleware = setup();
211+
212+
const reOrderedOptions = [
213+
{
214+
id: 'option-2',
215+
text: '',
216+
},
217+
{
218+
id: 'option-1',
219+
text: 'Option 1',
220+
},
221+
];
222+
223+
const result = await stateMiddleware.handlers.handleFieldChange(
224+
setupHandlerParams({
225+
nextState: {
226+
...getInitialState(),
227+
data: {
228+
...getInitialState().data,
229+
options: reOrderedOptions,
230+
},
231+
},
232+
previousState: {
233+
...getInitialState(),
234+
data: {
235+
...getInitialState().data,
236+
options: reOrderedOptions,
237+
},
238+
},
239+
targetFields: {
240+
options: {
241+
index: 0,
242+
text: 'Option 2',
243+
},
244+
},
245+
}),
246+
);
247+
248+
expect(result.state.nextState.data.options.length).toBe(3);
249+
expect(result.state.nextState.data.options[0].text).toBe('Option 2');
250+
expect(result.state.nextState.data.options[1].text).toBe('Option 1');
251+
expect(result.state.nextState.data.options[2].text).toEqual('');
203252
expect(result.status).toBeUndefined;
204253
});
205254

@@ -335,13 +384,43 @@ describe('PollComposerStateMiddleware', () => {
335384
});
336385

337386
describe('options validation', () => {
338-
it('should validate empty options on blur', async () => {
387+
it('should not validate empty options on blur', async () => {
388+
const stateMiddleware = setup();
389+
const result = await stateMiddleware.handlers.handleFieldBlur(
390+
setupHandlerParams({
391+
nextState: { ...getInitialState() },
392+
previousState: { ...getInitialState() },
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 () => {
339413
const stateMiddleware = setup();
340414
const result = await stateMiddleware.handlers.handleFieldBlur(
341415
setupHandlerParams({
342416
nextState: { ...getInitialState() },
343417
previousState: { ...getInitialState() },
344-
targetFields: { options: [{ id: 'option-id', text: '' }] },
418+
targetFields: {
419+
options: [
420+
{ id: 'option-id1', text: 'A' },
421+
{ id: 'option-id2', text: '' },
422+
],
423+
},
345424
}),
346425
);
347426

0 commit comments

Comments
 (0)