Skip to content

Commit

Permalink
Merge pull request #52941 from wildan-m/wildan/fix/50398-fix-max-leng…
Browse files Browse the repository at this point in the history
…th-validation-for-task

Add Task Title Validation on Main Composer Text Change
  • Loading branch information
marcaaron authored Dec 9, 2024
2 parents 54d24a3 + 36e4c3e commit c7f4e6c
Show file tree
Hide file tree
Showing 9 changed files with 89 additions and 32 deletions.
13 changes: 12 additions & 1 deletion src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ type OnboardingMessage = {
type?: string;
};

const EMAIL_WITH_OPTIONAL_DOMAIN =
/(?=((?=[\w'#%+-]+(?:\.[\w'#%+-]+)*@?)[\w.'#%+-]{1,64}(?:@(?:(?=[a-z\d]+(?:-+[a-z\d]+)*\.)(?:[a-z\d-]{1,63}\.)+[a-z]{2,63}))?(?= |_|\b))(?<end>.*))\S{3,254}(?=\k<end>$)/;

const CONST = {
HEIC_SIGNATURES: [
'6674797068656963', // 'ftypheic' - Indicates standard HEIC file
Expand Down Expand Up @@ -1321,7 +1324,7 @@ const CONST = {
TEST_TOOLS_MODAL_THROTTLE_TIME: 800,
TOOLTIP_SENSE: 1000,
TRIE_INITIALIZATION: 'trie_initialization',
COMMENT_LENGTH_DEBOUNCE_TIME: 500,
COMMENT_LENGTH_DEBOUNCE_TIME: 1500,
SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300,
RESIZE_DEBOUNCE_TIME: 100,
UNREAD_UPDATE_DEBOUNCE_TIME: 300,
Expand Down Expand Up @@ -3051,6 +3054,14 @@ const CONST = {
get EXPENSIFY_POLICY_DOMAIN_NAME() {
return new RegExp(`${EXPENSIFY_POLICY_DOMAIN}([a-zA-Z0-9]+)\\${EXPENSIFY_POLICY_DOMAIN_EXTENSION}`);
},

/**
* Matching task rule by group
* Group 1: Start task rule with []
* Group 2: Optional email group between \s+....\s* start rule with @+valid email or short mention
* Group 3: Title is remaining characters
*/
TASK_TITLE_WITH_OPTONAL_SHORT_MENTION: `^\\[\\]\\s+(?:@(?:${EMAIL_WITH_OPTIONAL_DOMAIN}))?\\s*([\\s\\S]*)`,
},

PRONOUNS: {
Expand Down
12 changes: 9 additions & 3 deletions src/components/ExceededCommentLength.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import Text from './Text';

function ExceededCommentLength() {
type ExceededCommentLengthProps = {
maxCommentLength?: number;
isTaskTitle?: boolean;
};

function ExceededCommentLength({maxCommentLength = CONST.MAX_COMMENT_LENGTH, isTaskTitle}: ExceededCommentLengthProps) {
const styles = useThemeStyles();
const {numberFormat, translate} = useLocalize();

const translationKey = isTaskTitle ? 'composer.taskTitleExceededMaxLength' : 'composer.commentExceededMaxLength';

return (
<Text
style={[styles.textMicro, styles.textDanger, styles.chatItemComposeSecondaryRow, styles.mlAuto, styles.pl2]}
numberOfLines={1}
>
{translate('composer.commentExceededMaxLength', {formattedMaxLength: numberFormat(CONST.MAX_COMMENT_LENGTH)})}
{translate(translationKey, {formattedMaxLength: numberFormat(maxCommentLength)})}
</Text>
);
}

ExceededCommentLength.displayName = 'ExceededCommentLength';

export default memo(ExceededCommentLength);
9 changes: 3 additions & 6 deletions src/hooks/useHandleExceedMaxCommentLength.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import debounce from 'lodash/debounce';
import {useCallback, useMemo, useState} from 'react';
import {useCallback, useState} from 'react';
import * as ReportUtils from '@libs/ReportUtils';
import type {ParsingDetails} from '@libs/ReportUtils';
import CONST from '@src/CONST';

const useHandleExceedMaxCommentLength = () => {
const [hasExceededMaxCommentLength, setHasExceededMaxCommentLength] = useState(false);

const handleValueChange = useCallback(
const validateCommentMaxLength = useCallback(
(value: string, parsingDetails?: ParsingDetails) => {
if (ReportUtils.getCommentLength(value, parsingDetails) <= CONST.MAX_COMMENT_LENGTH) {
if (hasExceededMaxCommentLength) {
Expand All @@ -20,9 +19,7 @@ const useHandleExceedMaxCommentLength = () => {
[hasExceededMaxCommentLength],
);

const validateCommentMaxLength = useMemo(() => debounce(handleValueChange, 1500, {leading: true}), [handleValueChange]);

return {hasExceededMaxCommentLength, validateCommentMaxLength};
return {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength};
};

export default useHandleExceedMaxCommentLength;
15 changes: 15 additions & 0 deletions src/hooks/useHandleExceedMaxTaskTitleLength.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {useCallback, useState} from 'react';
import CONST from '@src/CONST';

const useHandleExceedMaxTaskTitleLength = () => {
const [hasExceededMaxTaskTitleLength, setHasExceededMaxTitleLength] = useState(false);

const validateTaskTitleMaxLength = useCallback((title: string) => {
const exceeded = title ? title.length > CONST.TITLE_CHARACTER_LIMIT : false;
setHasExceededMaxTitleLength(exceeded);
}, []);

return {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength};
};

export default useHandleExceedMaxTaskTitleLength;
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ const translations = {
noExtensionFoundForMimeType: 'No extension found for mime type',
problemGettingImageYouPasted: 'There was a problem getting the image you pasted',
commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum comment length is ${formattedMaxLength} characters.`,
taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum task title length is ${formattedMaxLength} characters.`,
},
baseUpdateAppModal: {
updateApp: 'Update app',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ const translations = {
noExtensionFoundForMimeType: 'No se encontró una extension para este tipo de contenido',
problemGettingImageYouPasted: 'Ha ocurrido un problema al obtener la imagen que has pegado',
commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `El comentario debe tener máximo ${formattedMaxLength} caracteres.`,
taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `La longitud máxima del título de una tarea es de ${formattedMaxLength} caracteres.`,
},
baseUpdateAppModal: {
updateApp: 'Actualizar app',
Expand Down
52 changes: 44 additions & 8 deletions src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useNavigation} from '@react-navigation/native';
import lodashDebounce from 'lodash/debounce';
import noop from 'lodash/noop';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native';
Expand All @@ -23,6 +24,7 @@ import EducationalTooltip from '@components/Tooltip/EducationalTooltip';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebounce from '@hooks/useDebounce';
import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength';
import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand Down Expand Up @@ -171,7 +173,9 @@ function ReportActionCompose({
* Updates the composer when the comment length is exceeded
* Shows red borders and prevents the comment from being sent
*/
const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength();
const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength();
const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength();
const [exceededMaxLength, setExceededMaxLength] = useState<number | null>(null);

const suggestionsRef = useRef<SuggestionsRef>(null);
const composerRef = useRef<ComposerRef>();
Expand Down Expand Up @@ -306,6 +310,16 @@ function ReportActionCompose({
onComposerFocus?.();
}, [onComposerFocus]);

useEffect(() => {
if (hasExceededMaxTaskTitleLength) {
setExceededMaxLength(CONST.TITLE_CHARACTER_LIMIT);
} else if (hasExceededMaxCommentLength) {
setExceededMaxLength(CONST.MAX_COMMENT_LENGTH);
} else {
setExceededMaxLength(null);
}
}, [hasExceededMaxTaskTitleLength, hasExceededMaxCommentLength]);

// We are returning a callback here as we want to incoke the method on unmount only
useEffect(
() => () => {
Expand Down Expand Up @@ -333,7 +347,7 @@ function ReportActionCompose({

const hasReportRecipient = !isEmptyObject(reportRecipient);

const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength;
const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || !!exceededMaxLength;

// Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value
// useSharedValue on web doesn't support functions, so we need to wrap it in an object.
Expand Down Expand Up @@ -394,14 +408,31 @@ function ReportActionCompose({
],
);

const validateMaxLength = useCallback(
(value: string) => {
const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTONAL_SHORT_MENTION);
if (taskCommentMatch) {
const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replace(/\n/g, ' ') : '';
setHasExceededMaxCommentLength(false);
validateTaskTitleMaxLength(title);
} else {
setHasExceededMaxTitleLength(false);
validateCommentMaxLength(value, {reportID});
}
},
[setHasExceededMaxCommentLength, setHasExceededMaxTitleLength, validateTaskTitleMaxLength, validateCommentMaxLength, reportID],
);

const debouncedValidate = useMemo(() => lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}), [validateMaxLength]);

const onValueChange = useCallback(
(value: string) => {
if (value.length === 0 && isComposerFullSize) {
Report.setIsComposerFullSize(reportID, false);
}
validateCommentMaxLength(value, {reportID});
debouncedValidate(value);
},
[isComposerFullSize, reportID, validateCommentMaxLength],
[isComposerFullSize, reportID, debouncedValidate],
);

return (
Expand Down Expand Up @@ -436,15 +467,15 @@ function ReportActionCompose({
styles.flexRow,
styles.chatItemComposeBox,
isComposerFullSize && styles.chatItemFullComposeBox,
hasExceededMaxCommentLength && styles.borderColorDanger,
!!exceededMaxLength && styles.borderColorDanger,
]}
>
<AttachmentModal
headerTitle={translate('reportActionCompose.sendAttachment')}
onConfirm={addAttachment}
onModalShow={() => setIsAttachmentPreviewActive(true)}
onModalHide={onAttachmentPreviewClose}
shouldDisableSendButton={hasExceededMaxCommentLength}
shouldDisableSendButton={!!exceededMaxLength}
>
{({displayFileInModal}) => (
<>
Expand All @@ -469,7 +500,7 @@ function ReportActionCompose({
focus();
}}
actionButtonRef={actionButtonRef}
shouldDisableAttachmentItem={hasExceededMaxCommentLength}
shouldDisableAttachmentItem={!!exceededMaxLength}
/>
<ComposerWithSuggestions
ref={(ref) => {
Expand Down Expand Up @@ -554,7 +585,12 @@ function ReportActionCompose({
>
{!shouldUseNarrowLayout && <OfflineIndicator containerStyles={[styles.chatItemComposeSecondaryRow]} />}
<ReportTypingIndicator reportID={reportID} />
{hasExceededMaxCommentLength && <ExceededCommentLength />}
{!!exceededMaxLength && (
<ExceededCommentLength
maxCommentLength={exceededMaxLength}
isTaskTitle={hasExceededMaxTaskTitleLength}
/>
)}
</View>
</OfflineWithFeedback>
{!isSmallScreenWidth && (
Expand Down
5 changes: 3 additions & 2 deletions src/pages/home/report/ReportActionItemMessageEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ function ReportActionItemMessageEdit(
const [selection, setSelection] = useState<TextSelection>({start: draft.length, end: draft.length, positionX: 0, positionY: 0});
const [isFocused, setIsFocused] = useState<boolean>(false);
const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength();
const debouncedValidateCommentMaxLength = useMemo(() => lodashDebounce(validateCommentMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME), [validateCommentMaxLength]);
const [modal, setModal] = useState<OnyxTypes.Modal>({
willAlertModalBecomeVisible: false,
isVisible: false,
Expand Down Expand Up @@ -453,8 +454,8 @@ function ReportActionItemMessageEdit(
);

useEffect(() => {
validateCommentMaxLength(draft, {reportID});
}, [draft, reportID, validateCommentMaxLength]);
debouncedValidateCommentMaxLength(draft, {reportID});
}, [draft, reportID, debouncedValidateCommentMaxLength]);

useEffect(() => {
// required for keeping last state of isFocused variable
Expand Down
13 changes: 1 addition & 12 deletions src/pages/home/report/ReportFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,7 @@ function ReportFooter({

const handleCreateTask = useCallback(
(text: string): boolean => {
/**
* Matching task rule by group
* Group 1: Start task rule with []
* Group 2: Optional email group between \s+....\s* start rule with @+valid email or short mention
* Group 3: Title is remaining characters
*/
// The regex is copied from the expensify-common CONST file, but the domain is optional to accept short mention
const emailWithOptionalDomainRegex =
/(?=((?=[\w'#%+-]+(?:\.[\w'#%+-]+)*@?)[\w.'#%+-]{1,64}(?:@(?:(?=[a-z\d]+(?:-+[a-z\d]+)*\.)(?:[a-z\d-]{1,63}\.)+[a-z]{2,63}))?(?= |_|\b))(?<end>.*))\S{3,254}(?=\k<end>$)/;
const taskRegex = `^\\[\\]\\s+(?:@(?:${emailWithOptionalDomainRegex.source}))?\\s*([\\s\\S]*)`;

const match = text.match(taskRegex);
const match = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTONAL_SHORT_MENTION);
if (!match) {
return false;
}
Expand Down

0 comments on commit c7f4e6c

Please sign in to comment.