stream-chat
Version:
JS SDK for the Stream Chat API
308 lines (275 loc) • 10 kB
text/typescript
import type { Middleware, MiddlewareHandlerParams } from '../../../middleware';
import { generateUUIDv4 } from '../../../utils';
import type {
PollComposerFieldErrors,
PollComposerState,
PollComposerStateChangeMiddlewareValue,
TargetedPollOptionTextUpdate,
} from './types';
export const VALID_MAX_VOTES_VALUE_REGEX = /^([2-9]|10)$/;
export const MAX_POLL_OPTIONS = 100 as const;
const textFieldIsEmpty = (text: string) => !text.trim();
export type PollStateValidationOutput = Partial<
Omit<Record<keyof PollComposerState['data'], string>, 'options'> & {
options?: Record<string, string>;
}
>;
export type PollStateChangeValidator = (params: {
data: PollComposerState['data'];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
currentError?: PollComposerFieldErrors[keyof PollComposerFieldErrors];
}) => PollStateValidationOutput;
export const pollStateChangeValidators: Partial<
Record<keyof PollComposerState['data'], PollStateChangeValidator>
> = {
enforce_unique_vote: () => ({ max_votes_allowed: undefined }),
max_votes_allowed: ({ data, value }) => {
if (data.enforce_unique_vote && value)
return { max_votes_allowed: 'Enforce unique vote is enabled' };
const numericMatch = value.match(/^[0-9]+$/);
if (!numericMatch && value) {
return { max_votes_allowed: 'Only numbers are allowed' };
}
if (value?.length > 1 && !value.match(VALID_MAX_VOTES_VALUE_REGEX))
return { max_votes_allowed: 'Type a number from 2 to 10' };
return { max_votes_allowed: undefined };
},
options: ({ value: options }) => {
const errors: Record<string, string> = {};
const seenOptions = new Set<string>();
options.forEach((option: { id: string; text: string }) => {
if (seenOptions.has(option.text) && option.text.length) {
errors[option.id] = 'Option already exists';
} else {
seenOptions.add(option.text);
}
});
return Object.keys(errors).length > 0 ? { options: errors } : { options: undefined };
},
};
export const defaultPollFieldChangeEventValidators: Partial<
Record<keyof PollComposerState['data'], PollStateChangeValidator>
> = {
name: ({ currentError, value }) =>
value && currentError
? { name: undefined }
: { name: typeof currentError === 'string' ? currentError : undefined },
};
export const defaultPollFieldBlurEventValidators: Partial<
Record<keyof PollComposerState['data'], PollStateChangeValidator>
> = {
max_votes_allowed: ({ value }) => {
if (value && !value.match(VALID_MAX_VOTES_VALUE_REGEX))
return { max_votes_allowed: 'Type a number from 2 to 10' };
return { max_votes_allowed: undefined };
},
name: ({ value }) => {
if (textFieldIsEmpty(value)) return { name: 'Question is required' };
return { name: undefined };
},
options: (params) => {
const defaultResult = pollStateChangeValidators.options?.(params);
const errors = defaultResult?.options ?? {};
params.value.forEach((option: { id: string; text: string }, index: number) => {
const isTheLastOption = index === params.value.length - 1;
if (textFieldIsEmpty(option.text) && !isTheLastOption) {
errors[option.id] = 'Option is empty';
}
});
return Object.keys(errors).length > 0 ? { options: errors } : { options: undefined };
},
};
export type PollCompositionStateProcessorOutput = Partial<PollComposerState['data']>;
export type PollCompositionStateProcessor = (params: {
data: PollComposerState['data'];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
}) => PollCompositionStateProcessorOutput;
export const isTargetedOptionTextUpdate = (
value: unknown,
): value is TargetedPollOptionTextUpdate =>
!Array.isArray(value) &&
typeof (value as TargetedPollOptionTextUpdate)?.index === 'number' &&
typeof (value as TargetedPollOptionTextUpdate)?.text === 'string';
export const pollCompositionStateProcessors: Partial<
Record<keyof PollComposerState['data'], PollCompositionStateProcessor>
> = {
enforce_unique_vote: ({ value }) => ({
enforce_unique_vote: value,
max_votes_allowed: '',
}),
options: ({ value, data }) => {
// If it's a direct array update (like drag-drop reordering)
if (Array.isArray(value)) {
return {
options: value.map((option) => ({
id: option.id,
text: option.text.trim(),
})),
};
}
// For single option updates
const { index, text } = value;
const prevOptions = data.options || [];
const shouldRemoveOption =
prevOptions && prevOptions.slice(index + 1).length > 0 && !text;
const optionListHead = prevOptions.slice(0, index);
const optionListTail = prevOptions.slice(index + 1);
const newOptions = [
...optionListHead,
...(shouldRemoveOption ? [] : [{ ...prevOptions[index], text }]),
...optionListTail,
];
const shouldAddNewOption =
prevOptions.length < MAX_POLL_OPTIONS &&
!newOptions.some((option) => !option.text.trim());
if (shouldAddNewOption) {
newOptions.push({ id: generateUUIDv4(), text: '' });
}
return { options: newOptions };
},
};
export type PollComposerStateMiddlewareFactoryOptions = {
processors?: {
handleFieldChange?: Partial<
Record<keyof PollComposerState['data'], PollCompositionStateProcessor>
>;
handleFieldBlur?: Partial<
Record<keyof PollComposerState['data'], PollCompositionStateProcessor>
>;
};
validators?: {
handleFieldChange?: Partial<
Record<keyof PollComposerState['data'], PollStateChangeValidator>
>;
handleFieldBlur?: Partial<
Record<keyof PollComposerState['data'], PollStateChangeValidator>
>;
};
};
export type PollComposerStateMiddleware = Middleware<
PollComposerStateChangeMiddlewareValue,
'handleFieldChange' | 'handleFieldBlur'
>;
export const createPollComposerStateMiddleware = ({
processors: customProcessors,
validators: customValidators,
}: PollComposerStateMiddlewareFactoryOptions = {}): PollComposerStateMiddleware => {
const universalHandler = ({
state,
validators,
processors,
}: {
state: PollComposerStateChangeMiddlewareValue;
validators: Partial<
Record<keyof PollComposerState['data'], PollStateChangeValidator>
>;
processors?: Partial<
Record<keyof PollComposerState['data'], PollCompositionStateProcessor>
>;
}) => {
const { previousState, targetFields } = state;
let newData: Partial<PollComposerState['data']>;
if (!processors && isTargetedOptionTextUpdate(targetFields.options)) {
const options = [...previousState.data.options];
const targetOption = previousState.data.options[targetFields.options.index];
if (targetOption) {
targetOption.text = targetFields.options.text;
options.splice(targetFields.options.index, 1, targetOption);
}
newData = { ...targetFields, options };
} else if (!processors) {
newData = targetFields as PollComposerState['data'];
} else {
newData = Object.entries(targetFields).reduce(
(acc, [key, value]) => {
const processor = processors[key as keyof PollComposerState['data']];
acc = {
...acc,
...(processor
? processor({ data: previousState.data, value })
: { [key]: value }),
};
return acc;
},
{} as PollComposerState['data'],
);
}
const newErrors = Object.keys(targetFields).reduce((acc, key) => {
const validator = validators[key as keyof PollComposerState['data']];
if (validator) {
const error = validator({
currentError: previousState.errors[key as keyof PollComposerState['data']],
data: previousState.data,
value: newData[key as keyof PollComposerState['data']],
});
acc = { ...acc, ...error };
}
return acc;
}, {} as PollComposerFieldErrors);
return { newData, newErrors };
};
return {
id: 'stream-io/poll-composer-state-processing',
handlers: {
handleFieldChange: ({
state,
next,
forward,
}: MiddlewareHandlerParams<PollComposerStateChangeMiddlewareValue>) => {
if (!state.targetFields) return forward();
const { previousState, injectedFieldErrors } = state;
const { newData, newErrors } = universalHandler({
processors: {
...pollCompositionStateProcessors,
...customProcessors?.handleFieldChange,
},
state,
validators: {
...pollStateChangeValidators,
...defaultPollFieldChangeEventValidators,
...customValidators?.handleFieldChange,
},
});
return next({
...state,
nextState: {
...previousState,
data: { ...previousState.data, ...newData },
errors: { ...previousState.errors, ...newErrors, ...injectedFieldErrors },
},
});
},
handleFieldBlur: ({
state,
next,
forward,
}: MiddlewareHandlerParams<PollComposerStateChangeMiddlewareValue>) => {
if (!state.targetFields) return forward();
const { previousState } = state;
const { newData, newErrors } = universalHandler({
processors: customProcessors?.handleFieldBlur,
state,
validators: {
...pollStateChangeValidators,
...defaultPollFieldBlurEventValidators,
...customValidators?.handleFieldBlur,
},
});
return next({
...state,
nextState: {
...previousState,
data: { ...previousState.data, ...newData },
errors: {
...previousState.errors,
...newErrors,
...state.injectedFieldErrors,
},
},
});
},
},
};
};