diff --git a/.eslintrc.js b/.eslintrc.js index 85a4e86797b6..822a7f66b474 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,11 +14,21 @@ const restrictedImportPaths = [ importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'], message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.", }, + { + name: 'awesome-phonenumber', + importNames: ['parsePhoneNumber'], + message: "Please use '@libs/PhoneNumber' instead.", + }, { name: 'react-native-safe-area-context', importNames: ['useSafeAreaInsets', 'SafeAreaConsumer', 'SafeAreaInsetsContext'], message: "Please use 'useSafeAreaInsets' from 'src/hooks/useSafeAreaInset' and/or 'SafeAreaConsumer' from 'src/components/SafeAreaConsumer' instead.", }, + { + name: 'react', + importNames: ['CSSProperties'], + message: "Please use 'ViewStyle', 'TextStyle', 'ImageStyle' from 'react-native' instead.", + }, ]; const restrictedImportPatterns = [ diff --git a/android/app/build.gradle b/android/app/build.gradle index d059f2a7e9f2..597d85de1c09 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -91,8 +91,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001041308 - versionName "1.4.13-8" + versionCode 1001041401 + versionName "1.4.14-1" } flavorDimensions "default" diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md index bc62020ffd54..a583941bf71d 100644 --- a/contributingGuides/TS_STYLE.md +++ b/contributingGuides/TS_STYLE.md @@ -24,6 +24,8 @@ - [1.17 `.tsx`](#tsx) - [1.18 No inline prop types](#no-inline-prop-types) - [1.19 Satisfies operator](#satisfies-operator) + - [1.20 Hooks instead of HOCs](#hooks-instead-of-hocs) + - [1.21 `compose` usage](#compose-usage) - [Exception to Rules](#exception-to-rules) - [Communication Items](#communication-items) - [Migration Guidelines](#migration-guidelines) @@ -124,7 +126,7 @@ type Foo = { -- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation. +- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages and JavaScript's built-in modules (e.g. `window` object) can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation. > Why? Type errors in `d.ts` files are not checked by TypeScript [^1]. @@ -509,6 +511,102 @@ type Foo = { } satisfies Record; ``` + + +- [1.20](#hooks-instead-of-hocs) **Hooks instead of HOCs**: Replace HOCs usage with Hooks whenever possible. + + > Why? Hooks are easier to use (can be used inside the function component), and don't need nesting or `compose` when exporting the component. It also allows us to remove `compose` completely in some components since it has been bringing up some issues with TypeScript. Read the [`compose` usage](#compose-usage) section for further information about the TypeScript issues with `compose`. + + > Note: Because Onyx doesn't provide a hook yet, in a component that accesses Onyx data with `withOnyx` HOC, please make sure that you don't use other HOCs (if applicable) to avoid HOC nesting. + + ```tsx + // BAD + type ComponentOnyxProps = { + session: OnyxEntry; + }; + + type ComponentProps = WindowDimensionsProps & + WithLocalizeProps & + ComponentOnyxProps & { + someProp: string; + }; + + function Component({windowWidth, windowHeight, translate, session, someProp}: ComponentProps) { + // component's code + } + + export default compose( + withWindowDimensions, + withLocalize, + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), + )(Component); + + // GOOD + type ComponentOnyxProps = { + session: OnyxEntry; + }; + + type ComponentProps = ComponentOnyxProps & { + someProp: string; + }; + + function Component({session, someProp}: ComponentProps) { + const {windowWidth, windowHeight} = useWindowDimensions(); + const {translate} = useLocalize(); + // component's code + } + + // There is no hook alternative for withOnyx yet. + export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + })(Component); + ``` + + + +- [1.21](#compose-usage) **`compose` usage**: Avoid the usage of `compose` function to compose HOCs in TypeScript files. Use nesting instead. + + > Why? `compose` function doesn't work well with TypeScript when dealing with several HOCs being used in a component, many times resulting in wrong types and errors. Instead, nesting can be used to allow a seamless use of multiple HOCs and result in a correct return type of the compoment. Also, you can use [hooks instead of HOCs](#hooks-instead-of-hocs) whenever possible to minimize or even remove the need of HOCs in the component. + + ```ts + // BAD + export default compose( + withCurrentUserPersonalDetails, + withReportOrNotFound(), + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), + )(Component); + + // GOOD + export default withCurrentUserPersonalDetails( + withReportOrNotFound()( + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + })(Component), + ), + ); + + // GOOD - alternative to HOC nesting + const ComponentWithOnyx = withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + })(Component); + const ComponentWithReportOrNotFound = withReportOrNotFound()(ComponentWithOnyx); + export default withCurrentUserPersonalDetails(ComponentWithReportOrNotFound); + ``` + ## Exception to Rules Most of the rules are enforced in ESLint or checked by TypeScript. If you think your particular situation warrants an exception, post the context in the `#expensify-open-source` Slack channel with your message prefixed with `TS EXCEPTION:`. The internal engineer assigned to the PR should be the one that approves each exception, however all discussion regarding granting exceptions should happen in the public channel instead of the GitHub PR page so that the TS migration team can access them easily. @@ -521,7 +619,7 @@ This rule will apply until the migration is done. After the migration, discussio > Comment in the `#expensify-open-source` Slack channel if any of the following situations are encountered. Each comment should be prefixed with `TS ATTENTION:`. Internal engineers will access each situation and prescribe solutions to each case. Internal engineers should refer to general solutions to each situation that follows each list item. -- I think types definitions in a third party library is incomplete or incorrect +- I think types definitions in a third party library or JavaScript's built-in module are incomplete or incorrect When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in `/src/types/modules/*.d.ts`, each library as a separate file. @@ -540,7 +638,7 @@ declare module "external-library-name" { > This section contains instructions that are applicable during the migration. -- 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with `TS ATTENTION:`). +- 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team, or when you need to add new files under `src/libs`, `src/hooks`, `src/styles`, and `src/languages` directories. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with `TS ATTENTION:`). - If you're migrating a module that doesn't have a default implementation (i.e. `index.ts`, e.g. `getPlatform`), convert `index.website.js` to `index.ts`. Without `index.ts`, TypeScript cannot get type information where the module is imported. @@ -579,6 +677,25 @@ object?.foo ?? 'bar'; const y: number = 123; // TS error: Unused '@ts-expect-error' directive. ``` +- The TS issue I'm working on is blocked by another TS issue because of type errors. What should I do? + + In order to proceed with the migration faster, we are now allowing the use of `@ts-expect-error` annotation to temporally suppress those errors and help you unblock your issues. The only requirements is that you MUST add the annotation with a comment explaining that it must be removed when the blocking issue is migrated, e.g.: + + ```tsx + return ( + + ); + ``` + + **You will also need to reference the blocking issue in your PR.** You can find all the TS issues [here](https://github.com/orgs/Expensify/projects/46). + ## Learning Resources ### Quickest way to learn TypeScript diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index e320b690c226..d4e12d396ceb 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -31,7 +31,7 @@ platforms: - href: billing-and-subscriptions title: Billing & Subscriptions - icon: /assets/images/money-wings.svg + icon: /assets/images/subscription-annual.svg description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods. - href: expense-and-report-features @@ -71,7 +71,7 @@ platforms: - href: send-payments title: Send Payments - icon: /assets/images/money-wings.svg + icon: /assets/images/send-money.svg description: Uncover step-by-step guidance on sending direct reimbursements to employees, paying an invoice to a vendor, and utilizing third-party payment options. - href: workspace-and-domain-settings @@ -105,7 +105,7 @@ platforms: - href: billing-and-plan-types title: Billing & Plan Types - icon: /assets/images/money-wings.svg + icon: /assets/images/subscription-annual.svg description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods. - href: expensify-card diff --git a/docs/assets/images/money-wings.svg b/docs/assets/images/money-wings.svg deleted file mode 100644 index 87ffdf28ec4b..000000000000 --- a/docs/assets/images/money-wings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/assets/images/send-money.svg b/docs/assets/images/send-money.svg new file mode 100644 index 000000000000..e858f0d5c327 --- /dev/null +++ b/docs/assets/images/send-money.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/subscription-annual.svg b/docs/assets/images/subscription-annual.svg new file mode 100644 index 000000000000..a4b99a43b16e --- /dev/null +++ b/docs/assets/images/subscription-annual.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5aac6a284866..897a300143b7 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.13 + 1.4.14 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.13.8 + 1.4.14.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ab2d9de9573d..01316af0c986 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.13 + 1.4.14 CFBundleSignature ???? CFBundleVersion - 1.4.13.8 + 1.4.14.1 diff --git a/package-lock.json b/package-lock.json index b1b7102bc9fb..2d58e7cc7f4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.13-8", + "version": "1.4.14-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.13-8", + "version": "1.4.14-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -50,7 +50,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -29894,8 +29894,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0", - "integrity": "sha512-s9l/Zy3UjDBrq0WTkgEue1DXLRkkYtuqnANQlVmODHJ9HkJADjrVSv2D0U3ltqd9X7vLCLCmmwl5AUE6466gGg==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849", + "integrity": "sha512-H7UrLgWIr8mCoPc1oxbeYW2RwLzUWI6jdjbV6cRnrlp8cDW3IyZISF+BQSPFDj7bMhNAbczQPtEOE1gld21Cvg==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -29911,7 +29911,7 @@ "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", "string.prototype.replaceall": "^1.0.6", "ua-parser-js": "^1.0.35", - "underscore": "1.13.1" + "underscore": "1.13.6" } }, "node_modules/expensify-common/node_modules/prop-types": { @@ -29983,12 +29983,6 @@ "node": "*" } }, - "node_modules/expensify-common/node_modules/underscore": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", - "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", - "license": "MIT" - }, "node_modules/express": { "version": "4.18.1", "license": "MIT", @@ -74403,9 +74397,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0", - "integrity": "sha512-s9l/Zy3UjDBrq0WTkgEue1DXLRkkYtuqnANQlVmODHJ9HkJADjrVSv2D0U3ltqd9X7vLCLCmmwl5AUE6466gGg==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849", + "integrity": "sha512-H7UrLgWIr8mCoPc1oxbeYW2RwLzUWI6jdjbV6cRnrlp8cDW3IyZISF+BQSPFDj7bMhNAbczQPtEOE1gld21Cvg==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -74420,7 +74414,7 @@ "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", "string.prototype.replaceall": "^1.0.6", "ua-parser-js": "^1.0.35", - "underscore": "1.13.1" + "underscore": "1.13.6" }, "dependencies": { "prop-types": { @@ -74467,11 +74461,6 @@ "version": "1.0.35", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==" - }, - "underscore": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", - "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" } } }, diff --git a/package.json b/package.json index c667423b9e69..d4726491e36e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.13-8", + "version": "1.4.14-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -98,7 +98,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", diff --git a/src/CONST.ts b/src/CONST.ts index 219807587a25..b29456ba170b 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -77,6 +77,12 @@ const CONST = { AVATAR_MAX_WIDTH_PX: 4096, AVATAR_MAX_HEIGHT_PX: 4096, + BREADCRUMB_TYPE: { + ROOT: 'root', + STRONG: 'strong', + NORMAL: 'normal', + }, + DEFAULT_AVATAR_COUNT: 24, OLD_DEFAULT_AVATAR_COUNT: 8, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0cc7934ad007..b4282cd8b842 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -373,7 +373,7 @@ type OnyxValues = { [ONYXKEYS.NETWORK]: OnyxTypes.Network; [ONYXKEYS.CUSTOM_STATUS_DRAFT]: OnyxTypes.CustomStatusDraft; [ONYXKEYS.INPUT_FOCUSED]: boolean; - [ONYXKEYS.PERSONAL_DETAILS_LIST]: Record; + [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList; [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; [ONYXKEYS.CURRENCY_LIST]: Record; diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index 3c764b36f3eb..d9e4ef2c0f6e 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -276,6 +276,11 @@ function AddressSearch({ values.state = stateFallback; } + // Set the state to be the same as the city in case the state is empty. + if (_.isEmpty(values.state)) { + values.state = values.city; + } + // Some edge-case addresses may lack both street_number and route in the API response, resulting in an empty "values.street" // We are setting up a fallback to ensure "values.street" is populated with a relevant value if (!values.street && details.adr_address) { diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index 7dadd86debfe..8604d20130c7 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -30,14 +30,14 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}} const originalMessage = reportClosedAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED ? reportClosedAction.originalMessage : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; - let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [report.ownerAccountID, 'displayName']); + let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[report?.ownerAccountID ?? 0]?.displayName); let oldDisplayName: string | undefined; if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { const newAccountID = originalMessage?.newAccountID; const oldAccountID = originalMessage?.oldAccountID; - displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [newAccountID, 'displayName']); - oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [oldAccountID, 'displayName']); + displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[newAccountID ?? 0]?.displayName); + oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[oldAccountID ?? 0]?.displayName); } const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 5ea21502f2ca..b9bae33d7e23 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -65,7 +65,6 @@ function AvatarWithDisplayName({ const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(report); const isExpenseRequest = ReportUtils.isExpenseRequest(report); - const defaultSubscriptSize = isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : size; const avatarBorderColor = isAnonymous ? theme.highlightBG : theme.componentBG; const actorAccountID = useRef(null); @@ -118,7 +117,7 @@ function AvatarWithDisplayName({ backgroundColor={avatarBorderColor} mainAvatar={icons[0]} secondaryAvatar={icons[1]} - size={defaultSubscriptSize} + size={size} /> ) : ( ; +}; + +function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const [primaryBreadcrumb, secondaryBreadcrumb] = breadcrumbs; + + return ( + + {primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT ? ( + +
+ } + shouldShowEnvironmentBadge + /> + + ) : ( + + {primaryBreadcrumb.text} + + )} + + {!!secondaryBreadcrumb && ( + <> + / + + {secondaryBreadcrumb.text} + + + )} + + ); +} + +Breadcrumbs.displayName = 'Breadcrumbs'; + +export type {BreadcrumbsProps}; +export default Breadcrumbs; diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index 23bc068e8fe0..715603ea362e 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -9,7 +9,7 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; -type CheckboxProps = ChildrenProps & { +type CheckboxProps = Partial & { /** Whether checkbox is checked */ isChecked?: boolean; @@ -91,7 +91,7 @@ function Checkbox( ref={ref} style={[StyleUtils.getCheckboxPressableStyle(containerBorderRadius + 2), style]} // to align outline on focus, border-radius of pressable should be 2px more than Checkbox onKeyDown={handleSpaceKey} - role={CONST.ACCESSIBILITY_ROLE.CHECKBOX} + role={CONST.ROLE.CHECKBOX} aria-checked={isChecked} accessibilityLabel={accessibilityLabel} pressDimmingValue={1} diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js deleted file mode 100644 index 24f61c305dda..000000000000 --- a/src/components/CheckboxWithLabel.js +++ /dev/null @@ -1,146 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useState} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; -import Checkbox from './Checkbox'; -import FormHelpMessage from './FormHelpMessage'; -import PressableWithFeedback from './Pressable/PressableWithFeedback'; -import refPropTypes from './refPropTypes'; -import Text from './Text'; - -/** - * Returns an error if the required props are not provided - * @param {Object} props - * @returns {Error|null} - */ -const requiredPropsCheck = (props) => { - if (!props.label && !props.LabelComponent) { - return new Error('One of "label" or "LabelComponent" must be provided'); - } - - if (props.label && typeof props.label !== 'string') { - return new Error('Prop "label" must be a string'); - } - - if (props.LabelComponent && typeof props.LabelComponent !== 'function') { - return new Error('Prop "LabelComponent" must be a function'); - } -}; - -const propTypes = { - /** Whether the checkbox is checked */ - isChecked: PropTypes.bool, - - /** Called when the checkbox or label is pressed */ - onInputChange: PropTypes.func, - - /** Container styles */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - /** Text that appears next to check box */ - label: requiredPropsCheck, - - /** Component to display for label */ - LabelComponent: requiredPropsCheck, - - /** Error text to display */ - errorText: PropTypes.string, - - /** Value for checkbox. This prop is intended to be set by Form.js only */ - value: PropTypes.bool, - - /** The default value for the checkbox */ - defaultValue: PropTypes.bool, - - /** React ref being forwarded to the Checkbox input */ - forwardedRef: refPropTypes, - - /** The ID used to uniquely identify the input in a Form */ - /* eslint-disable-next-line react/no-unused-prop-types */ - inputID: PropTypes.string, - - /** Saves a draft of the input value when used in a form */ - /* eslint-disable-next-line react/no-unused-prop-types */ - shouldSaveDraft: PropTypes.bool, - - /** An accessibility label for the checkbox */ - accessibilityLabel: PropTypes.string, -}; - -const defaultProps = { - inputID: undefined, - style: [], - label: undefined, - LabelComponent: undefined, - errorText: '', - shouldSaveDraft: false, - isChecked: false, - value: undefined, - defaultValue: false, - forwardedRef: () => {}, - accessibilityLabel: undefined, - onInputChange: () => {}, -}; - -function CheckboxWithLabel(props) { - const styles = useThemeStyles(); - // We need to pick the first value that is strictly a boolean - // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065 - const [isChecked, setIsChecked] = useState(() => _.find([props.value, props.defaultValue, props.isChecked], (value) => _.isBoolean(value))); - - const toggleCheckbox = () => { - const newState = !isChecked; - props.onInputChange(newState); - setIsChecked(newState); - }; - - const LabelComponent = props.LabelComponent; - - return ( - - - - - {props.label && {props.label}} - {LabelComponent && } - - - - - ); -} - -CheckboxWithLabel.propTypes = propTypes; -CheckboxWithLabel.defaultProps = defaultProps; -CheckboxWithLabel.displayName = 'CheckboxWithLabel'; - -const CheckboxWithLabelWithRef = React.forwardRef((props, ref) => ( - -)); - -CheckboxWithLabelWithRef.displayName = 'CheckboxWithLabelWithRef'; - -export default CheckboxWithLabelWithRef; diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx new file mode 100644 index 000000000000..9660c9e1a2e5 --- /dev/null +++ b/src/components/CheckboxWithLabel.tsx @@ -0,0 +1,107 @@ +import React, {ComponentType, ForwardedRef, useState} from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import Checkbox from './Checkbox'; +import FormHelpMessage from './FormHelpMessage'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import Text from './Text'; + +type RequiredLabelProps = + | { + /** Text that appears next to check box */ + label: string; + + /** Component to display for label + * If label is provided, LabelComponent is not required + */ + LabelComponent?: ComponentType; + } + | { + /** Component to display for label */ + LabelComponent: ComponentType; + + /** Text that appears next to check box + * If LabelComponent is provided, label is not required + */ + label?: string; + }; + +type CheckboxWithLabelProps = RequiredLabelProps & { + /** Whether the checkbox is checked */ + isChecked?: boolean; + + /** Called when the checkbox or label is pressed */ + onInputChange?: (value?: boolean) => void; + + /** Container styles */ + style?: StyleProp; + + /** Error text to display */ + errorText?: string; + + /** Value for checkbox. This prop is intended to be set by Form.js only */ + value?: boolean; + + /** The default value for the checkbox */ + defaultValue?: boolean; + + /** The ID used to uniquely identify the input in a Form */ + /* eslint-disable-next-line react/no-unused-prop-types */ + inputID?: string; + + /** Saves a draft of the input value when used in a form */ + // eslint-disable-next-line react/no-unused-prop-types + shouldSaveDraft?: boolean; + + /** An accessibility label for the checkbox */ + accessibilityLabel?: string; +}; + +function CheckboxWithLabel( + {errorText = '', isChecked: isCheckedProp = false, defaultValue = false, onInputChange = () => {}, LabelComponent, label, accessibilityLabel, style, value}: CheckboxWithLabelProps, + ref: ForwardedRef, +) { + const styles = useThemeStyles(); + // We need to pick the first value that is strictly a boolean + // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065 + const [isChecked, setIsChecked] = useState(() => [value, defaultValue, isCheckedProp].find((item) => typeof item === 'boolean')); + + const toggleCheckbox = () => { + onInputChange(!isChecked); + setIsChecked(!isChecked); + }; + + return ( + + + + + {label && {label}} + {LabelComponent && } + + + + + ); +} + +CheckboxWithLabel.displayName = 'CheckboxWithLabel'; + +export default React.forwardRef(CheckboxWithLabel); diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js deleted file mode 100644 index af64831df117..000000000000 --- a/src/components/Composer/index.android.js +++ /dev/null @@ -1,147 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import {StyleSheet} from 'react-native'; -import _ from 'underscore'; -import RNTextInput from '@components/RNTextInput'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ComposerUtils from '@libs/ComposerUtils'; - -const propTypes = { - /** Maximum number of lines in the text input */ - maxLines: PropTypes.number, - - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear: PropTypes.bool, - - /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, - - /** When the input has cleared whoever owns this input should know about it */ - onClear: PropTypes.func, - - /** Set focus to this component the first time it renders. - * Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ - autoFocus: PropTypes.bool, - - /** Prevent edits and interactions like focus for this input. */ - isDisabled: PropTypes.bool, - - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - - /** Whether the full composer can be opened */ - isFullComposerAvailable: PropTypes.bool, - - /** Allow the full composer to be opened */ - setIsFullComposerAvailable: PropTypes.func, - - /** Whether the composer is full size */ - isComposerFullSize: PropTypes.bool, - - /** General styles to apply to the text input */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, -}; - -const defaultProps = { - shouldClear: false, - onClear: () => {}, - autoFocus: false, - isDisabled: false, - forwardedRef: null, - selection: { - start: 0, - end: 0, - }, - maxLines: undefined, - isFullComposerAvailable: false, - setIsFullComposerAvailable: () => {}, - isComposerFullSize: false, - style: null, -}; - -function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isComposerFullSize, setIsFullComposerAvailable, ...props}) { - const textInput = useRef(null); - const theme = useTheme(); - const styles = useThemeStyles(); - - /** - * Set the TextInput Ref - * @param {Element} el - */ - const setTextInputRef = useCallback((el) => { - textInput.current = el; - if (!_.isFunction(forwardedRef) || textInput.current === null) { - return; - } - - // This callback prop is used by the parent component using the constructor to - // get a ref to the inner textInput element e.g. if we do - // this.textInput = el} /> this will not - // return a ref to the component, but rather the HTML element by default - forwardedRef(textInput.current); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current.clear(); - onClear(); - }, [shouldClear, onClear]); - - /** - * Set maximum number of lines - * @return {Number} - */ - const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) { - return 1000000; - } - return maxLines; - }, [isComposerFullSize, maxLines]); - - const composerStyles = useMemo(() => { - StyleSheet.flatten(props.style); - }, [props.style]); - - return ( - ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} - rejectResponderTermination={false} - // Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line, - // when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670) - // @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines) - // TODO: remove this comment once upstream PR is merged and available in a future release - maxNumberOfLines={maxNumberOfLines} - textAlignVertical="center" - style={[composerStyles]} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...props} - readOnly={isDisabled} - /> - ); -} - -Composer.propTypes = propTypes; -Composer.defaultProps = defaultProps; - -const ComposerWithRef = React.forwardRef((props, ref) => ( - -)); - -ComposerWithRef.displayName = 'ComposerWithRef'; - -export default ComposerWithRef; diff --git a/src/components/Composer/index.android.tsx b/src/components/Composer/index.android.tsx new file mode 100644 index 000000000000..46c2a5f06ded --- /dev/null +++ b/src/components/Composer/index.android.tsx @@ -0,0 +1,96 @@ +import React, {ForwardedRef, useCallback, useEffect, useMemo, useRef} from 'react'; +import {StyleSheet, TextInput} from 'react-native'; +import RNTextInput from '@components/RNTextInput'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ComposerUtils from '@libs/ComposerUtils'; +import {ComposerProps} from './types'; + +function Composer( + { + shouldClear = false, + onClear = () => {}, + isDisabled = false, + maxLines, + isComposerFullSize = false, + setIsFullComposerAvailable = () => {}, + style, + autoFocus = false, + selection = { + start: 0, + end: 0, + }, + isFullComposerAvailable = false, + ...props + }: ComposerProps, + ref: ForwardedRef, +) { + const textInput = useRef(null); + + const styles = useThemeStyles(); + const theme = useTheme(); + + /** + * Set the TextInput Ref + */ + const setTextInputRef = useCallback((el: TextInput) => { + textInput.current = el; + if (typeof ref !== 'function' || textInput.current === null) { + return; + } + + // This callback prop is used by the parent component using the constructor to + // get a ref to the inner textInput element e.g. if we do + // this.textInput = el} /> this will not + // return a ref to the component, but rather the HTML element by default + ref(textInput.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!shouldClear) { + return; + } + textInput.current?.clear(); + onClear(); + }, [shouldClear, onClear]); + + /** + * Set maximum number of lines + */ + const maxNumberOfLines = useMemo(() => { + if (isComposerFullSize) { + return 1000000; + } + return maxLines; + }, [isComposerFullSize, maxLines]); + + const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]); + + return ( + ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} + rejectResponderTermination={false} + // Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line, + // when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670) + // @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines) + // TODO: remove this comment once upstream PR is merged and available in a future release + maxNumberOfLines={maxNumberOfLines} + textAlignVertical="center" + style={[composerStyles]} + autoFocus={autoFocus} + selection={selection} + isFullComposerAvailable={isFullComposerAvailable} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...props} + readOnly={isDisabled} + /> + ); +} + +Composer.displayName = 'Composer'; + +export default React.forwardRef(Composer); diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js deleted file mode 100644 index c9947999b273..000000000000 --- a/src/components/Composer/index.ios.js +++ /dev/null @@ -1,147 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import {StyleSheet} from 'react-native'; -import _ from 'underscore'; -import RNTextInput from '@components/RNTextInput'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ComposerUtils from '@libs/ComposerUtils'; - -const propTypes = { - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear: PropTypes.bool, - - /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, - - /** When the input has cleared whoever owns this input should know about it */ - onClear: PropTypes.func, - - /** Set focus to this component the first time it renders. - * Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ - autoFocus: PropTypes.bool, - - /** Prevent edits and interactions like focus for this input. */ - isDisabled: PropTypes.bool, - - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - - /** Whether the full composer can be opened */ - isFullComposerAvailable: PropTypes.bool, - - /** Maximum number of lines in the text input */ - maxLines: PropTypes.number, - - /** Allow the full composer to be opened */ - setIsFullComposerAvailable: PropTypes.func, - - /** Whether the composer is full size */ - isComposerFullSize: PropTypes.bool, - - /** General styles to apply to the text input */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, -}; - -const defaultProps = { - shouldClear: false, - onClear: () => {}, - autoFocus: false, - isDisabled: false, - forwardedRef: null, - selection: { - start: 0, - end: 0, - }, - maxLines: undefined, - isFullComposerAvailable: false, - setIsFullComposerAvailable: () => {}, - isComposerFullSize: false, - style: null, -}; - -function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isComposerFullSize, setIsFullComposerAvailable, ...props}) { - const textInput = useRef(null); - const theme = useTheme(); - const styles = useThemeStyles(); - - /** - * Set the TextInput Ref - * @param {Element} el - */ - const setTextInputRef = useCallback((el) => { - textInput.current = el; - if (!_.isFunction(forwardedRef) || textInput.current === null) { - return; - } - - // This callback prop is used by the parent component using the constructor to - // get a ref to the inner textInput element e.g. if we do - // this.textInput = el} /> this will not - // return a ref to the component, but rather the HTML element by default - forwardedRef(textInput.current); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current.clear(); - onClear(); - }, [shouldClear, onClear]); - - /** - * Set maximum number of lines - * @return {Number} - */ - const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) { - return; - } - return maxLines; - }, [isComposerFullSize, maxLines]); - - const composerStyles = useMemo(() => { - StyleSheet.flatten(props.style); - }, [props.style]); - - // On native layers we like to have the Text Input not focused so the - // user can read new chats without the keyboard in the way of the view. - // On Android the selection prop is required on the TextInput but this prop has issues on IOS - const propsToPass = _.omit(props, 'selection'); - return ( - ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} - rejectResponderTermination={false} - smartInsertDelete={false} - maxNumberOfLines={maxNumberOfLines} - style={[composerStyles, styles.verticalAlignMiddle]} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...propsToPass} - readOnly={isDisabled} - /> - ); -} - -Composer.propTypes = propTypes; -Composer.defaultProps = defaultProps; - -const ComposerWithRef = React.forwardRef((props, ref) => ( - -)); - -ComposerWithRef.displayName = 'ComposerWithRef'; - -export default ComposerWithRef; diff --git a/src/components/Composer/index.ios.tsx b/src/components/Composer/index.ios.tsx new file mode 100644 index 000000000000..240dfabded0b --- /dev/null +++ b/src/components/Composer/index.ios.tsx @@ -0,0 +1,91 @@ +import React, {ForwardedRef, useCallback, useEffect, useMemo, useRef} from 'react'; +import {StyleSheet, TextInput} from 'react-native'; +import RNTextInput from '@components/RNTextInput'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ComposerUtils from '@libs/ComposerUtils'; +import {ComposerProps} from './types'; + +function Composer( + { + shouldClear = false, + onClear = () => {}, + isDisabled = false, + maxLines, + isComposerFullSize = false, + setIsFullComposerAvailable = () => {}, + autoFocus = false, + isFullComposerAvailable = false, + style, + // On native layers we like to have the Text Input not focused so the + // user can read new chats without the keyboard in the way of the view. + // On Android the selection prop is required on the TextInput but this prop has issues on IOS + selection, + ...props + }: ComposerProps, + ref: ForwardedRef, +) { + const textInput = useRef(null); + + const styles = useThemeStyles(); + const theme = useTheme(); + + /** + * Set the TextInput Ref + */ + const setTextInputRef = useCallback((el: TextInput) => { + textInput.current = el; + if (typeof ref !== 'function' || textInput.current === null) { + return; + } + + // This callback prop is used by the parent component using the constructor to + // get a ref to the inner textInput element e.g. if we do + // this.textInput = el} /> this will not + // return a ref to the component, but rather the HTML element by default + ref(textInput.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!shouldClear) { + return; + } + textInput.current?.clear(); + onClear(); + }, [shouldClear, onClear]); + + /** + * Set maximum number of lines + */ + const maxNumberOfLines = useMemo(() => { + if (isComposerFullSize) { + return; + } + return maxLines; + }, [isComposerFullSize, maxLines]); + + const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]); + + return ( + ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} + rejectResponderTermination={false} + smartInsertDelete={false} + style={[composerStyles, styles.verticalAlignMiddle]} + maxNumberOfLines={maxNumberOfLines} + autoFocus={autoFocus} + isFullComposerAvailable={isFullComposerAvailable} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...props} + readOnly={isDisabled} + /> + ); +} + +Composer.displayName = 'Composer'; + +export default React.forwardRef(Composer); diff --git a/src/components/Composer/index.js b/src/components/Composer/index.tsx similarity index 61% rename from src/components/Composer/index.js rename to src/components/Composer/index.tsx index 3af22b63ed69..4ff5c6dbd75f 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.tsx @@ -1,198 +1,107 @@ +import {useNavigation} from '@react-navigation/native'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {BaseSyntheticEvent, ForwardedRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; -import {StyleSheet, View} from 'react-native'; -import _ from 'underscore'; +import {DimensionValue, NativeSyntheticEvent, Text as RNText, StyleSheet, TextInput, TextInputKeyPressEventData, TextInputProps, TextInputSelectionChangeEventData, View} from 'react-native'; +import {AnimatedProps} from 'react-native-reanimated'; import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withNavigation from '@components/withNavigation'; import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; -import compose from '@libs/compose'; import * as ComposerUtils from '@libs/ComposerUtils'; import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import CONST from '@src/CONST'; - -const propTypes = { - /** Maximum number of lines in the text input */ - maxLines: PropTypes.number, - - /** The default value of the comment box */ - defaultValue: PropTypes.string, - - /** The value of the comment box */ - value: PropTypes.string, - - /** Number of lines for the comment */ - numberOfLines: PropTypes.number, - - /** Callback method to update number of lines for the comment */ - onNumberOfLinesChange: PropTypes.func, - - /** Callback method to handle pasting a file */ - onPasteFile: PropTypes.func, - - /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, - - /** General styles to apply to the text input */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, - - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear: PropTypes.bool, - - /** When the input has cleared whoever owns this input should know about it */ - onClear: PropTypes.func, - - /** Whether or not this TextInput is disabled. */ - isDisabled: PropTypes.bool, - - /** Set focus to this component the first time it renders. - Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ - autoFocus: PropTypes.bool, - - /** Update selection position on change */ - onSelectionChange: PropTypes.func, - - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - - /** Whether the full composer can be opened */ - isFullComposerAvailable: PropTypes.bool, - - /** Allow the full composer to be opened */ - setIsFullComposerAvailable: PropTypes.func, - - /** Should we calculate the caret position */ - shouldCalculateCaretPosition: PropTypes.bool, - - /** Function to check whether composer is covered up or not */ - checkComposerVisibility: PropTypes.func, - - /** Whether this is the report action compose */ - isReportActionCompose: PropTypes.bool, - - /** Whether the sull composer is open */ - isComposerFullSize: PropTypes.bool, - - /** Should make the input only scroll inside the element avoid scroll out to parent */ - shouldContainScroll: PropTypes.bool, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - defaultValue: undefined, - value: undefined, - numberOfLines: 0, - onNumberOfLinesChange: () => {}, - maxLines: -1, - onPasteFile: () => {}, - shouldClear: false, - onClear: () => {}, - style: null, - isDisabled: false, - autoFocus: false, - forwardedRef: null, - onSelectionChange: () => {}, - selection: { - start: 0, - end: 0, - }, - isFullComposerAvailable: false, - setIsFullComposerAvailable: () => {}, - shouldCalculateCaretPosition: false, - checkComposerVisibility: () => false, - isReportActionCompose: false, - isComposerFullSize: false, - shouldContainScroll: false, -}; +import {ComposerProps} from './types'; /** * Retrieves the characters from the specified cursor position up to the next space or new line. * - * @param {string} str - The input string. - * @param {number} cursorPos - The position of the cursor within the input string. - * @returns {string} - The substring from the cursor position up to the next space or new line. + * @param inputString - The input string. + * @param cursorPosition - The position of the cursor within the input string. + * @returns - The substring from the cursor position up to the next space or new line. * If no space or new line is found, returns the substring from the cursor position to the end of the input string. */ -const getNextChars = (str, cursorPos) => { +const getNextChars = (inputString: string, cursorPosition: number): string => { // Get the substring starting from the cursor position - const substr = str.substring(cursorPos); + const subString = inputString.substring(cursorPosition); // Find the index of the next space or new line character - const spaceIndex = substr.search(/[ \n]/); + const spaceIndex = subString.search(/[ \n]/); if (spaceIndex === -1) { - return substr; + return subString; } // If there is a space or new line, return the substring up to the space or new line - return substr.substring(0, spaceIndex); + return subString.substring(0, spaceIndex); }; // Enable Markdown parsing. // On web we like to have the Text Input field always focused so the user can easily type a new chat -function Composer({ - value, - defaultValue, - maxLines, - onKeyPress, - style, - shouldClear, - autoFocus, - translate, - isFullComposerAvailable, - shouldCalculateCaretPosition, - numberOfLines: numberOfLinesProp, - isDisabled, - forwardedRef, - navigation, - onClear, - onPasteFile, - onSelectionChange, - onNumberOfLinesChange, - setIsFullComposerAvailable, - checkComposerVisibility, - selection: selectionProp, - isReportActionCompose, - isComposerFullSize, - shouldContainScroll, - ...props -}) { +function Composer( + { + value, + defaultValue, + maxLines = -1, + onKeyPress = () => {}, + style, + shouldClear = false, + autoFocus = false, + isFullComposerAvailable = false, + shouldCalculateCaretPosition = false, + numberOfLines: numberOfLinesProp = 0, + isDisabled = false, + onClear = () => {}, + onPasteFile = () => {}, + onSelectionChange = () => {}, + onNumberOfLinesChange = () => {}, + setIsFullComposerAvailable = () => {}, + checkComposerVisibility = () => false, + selection: selectionProp = { + start: 0, + end: 0, + }, + isReportActionCompose = false, + isComposerFullSize = false, + shouldContainScroll = false, + ...props + }: ComposerProps, + ref: ForwardedRef>>, +) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {windowWidth} = useWindowDimensions(); - const textRef = useRef(null); - const textInput = useRef(null); + const navigation = useNavigation(); + const textRef = useRef(null); + const textInput = useRef<(HTMLTextAreaElement & TextInput) | null>(null); const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp); - const [selection, setSelection] = useState({ + const [selection, setSelection] = useState< + | { + start: number; + end?: number; + } + | undefined + >({ start: selectionProp.start, end: selectionProp.end, }); const [caretContent, setCaretContent] = useState(''); const [valueBeforeCaret, setValueBeforeCaret] = useState(''); const [textInputWidth, setTextInputWidth] = useState(''); - const isScrollBarVisible = useIsScrollBarVisible(textInput, value); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); useEffect(() => { if (!shouldClear) { return; } - textInput.current.clear(); + textInput.current?.clear(); setNumberOfLines(1); onClear(); }, [shouldClear, onClear]); @@ -208,55 +117,55 @@ function Composer({ /** * Adds the cursor position to the selection change event. - * - * @param {Event} event */ - const addCursorPositionToSelectionChange = (event) => { + const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => { + const webEvent = event as BaseSyntheticEvent; + if (shouldCalculateCaretPosition) { // we do flushSync to make sure that the valueBeforeCaret is updated before we calculate the caret position to receive a proper position otherwise we will calculate position for the previous state flushSync(() => { - setValueBeforeCaret(event.target.value.slice(0, event.nativeEvent.selection.start)); - setCaretContent(getNextChars(value, event.nativeEvent.selection.start)); + setValueBeforeCaret(webEvent.target.value.slice(0, webEvent.nativeEvent.selection.start)); + setCaretContent(getNextChars(value ?? '', webEvent.nativeEvent.selection.start)); }); const selectionValue = { - start: event.nativeEvent.selection.start, - end: event.nativeEvent.selection.end, - positionX: textRef.current.offsetLeft - CONST.SPACE_CHARACTER_WIDTH, - positionY: textRef.current.offsetTop, + start: webEvent.nativeEvent.selection.start, + end: webEvent.nativeEvent.selection.end, + positionX: (textRef.current?.offsetLeft ?? 0) - CONST.SPACE_CHARACTER_WIDTH, + positionY: textRef.current?.offsetTop, }; + onSelectionChange({ + ...webEvent, nativeEvent: { + ...webEvent.nativeEvent, selection: selectionValue, }, }); setSelection(selectionValue); } else { - onSelectionChange(event); - setSelection(event.nativeEvent.selection); + onSelectionChange(webEvent); + setSelection(webEvent.nativeEvent.selection); } }; /** * Set pasted text to clipboard - * @param {String} text */ - const paste = useCallback((text) => { + const paste = useCallback((text?: string) => { try { document.execCommand('insertText', false, text); // Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view. - textInput.current.blur(); - textInput.current.focus(); + textInput.current?.blur(); + textInput.current?.focus(); // eslint-disable-next-line no-empty } catch (e) {} }, []); /** * Manually place the pasted HTML into Composer - * - * @param {String} html - pasted HTML */ const handlePastedHTML = useCallback( - (html) => { + (html: string) => { const parser = new ExpensiMark(); paste(parser.htmlToMarkdown(html)); }, @@ -265,12 +174,10 @@ function Composer({ /** * Paste the plaintext content into Composer. - * - * @param {ClipboardEvent} event */ const handlePastePlainText = useCallback( - (event) => { - const plainText = event.clipboardData.getData('text/plain'); + (event: ClipboardEvent) => { + const plainText = event.clipboardData?.getData('text/plain'); paste(plainText); }, [paste], @@ -279,44 +186,43 @@ function Composer({ /** * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file, * Otherwise, convert pasted HTML to Markdown and set it on the composer. - * - * @param {ClipboardEvent} event */ const handlePaste = useCallback( - (event) => { + (event: ClipboardEvent) => { const isVisible = checkComposerVisibility(); - const isFocused = textInput.current.isFocused(); + const isFocused = textInput.current?.isFocused(); if (!(isVisible || isFocused)) { return; } if (textInput.current !== event.target) { + const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; + // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste - const isTargetInput = event.target.nodeName === 'INPUT' || event.target.nodeName === 'TEXTAREA' || event.target.contentEditable === 'true'; + const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; if (isTargetInput) { return; } - textInput.current.focus(); + textInput.current?.focus(); } event.preventDefault(); - const {files, types} = event.clipboardData; const TEXT_HTML = 'text/html'; // If paste contains files, then trigger file management - if (files.length > 0) { + if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) { // Prevent the default so we do not post the file name into the text box - onPasteFile(event.clipboardData.files[0]); + onPasteFile(event.clipboardData?.files[0]); return; } // If paste contains HTML - if (types.includes(TEXT_HTML)) { - const pastedHTML = event.clipboardData.getData(TEXT_HTML); + if (event.clipboardData?.types.includes(TEXT_HTML)) { + const pastedHTML = event.clipboardData?.getData(TEXT_HTML); const domparser = new DOMParser(); const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; @@ -342,11 +248,11 @@ function Composer({ * divide by line height to get the total number of rows for the textarea. */ const updateNumberOfLines = useCallback(() => { - if (textInput.current === null) { + if (!textInput.current) { return; } // we reset the height to 0 to get the correct scrollHeight - textInput.current.style.height = 0; + textInput.current.style.height = '0'; const computedStyle = window.getComputedStyle(textInput.current); const lineHeight = parseInt(computedStyle.lineHeight, 10) || 20; const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) + parseInt(computedStyle.paddingTop, 10); @@ -372,8 +278,8 @@ function Composer({ const unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste)); const unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste)); - if (_.isFunction(forwardedRef)) { - forwardedRef(textInput.current); + if (typeof ref === 'function') { + ref(textInput.current); } if (textInput.current) { @@ -392,9 +298,9 @@ function Composer({ }, []); const handleKeyPress = useCallback( - (e) => { + (e: NativeSyntheticEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed - if (!onKeyPress || isEnterWhileComposition(e)) { + if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { return; } onKeyPress(e); @@ -410,10 +316,7 @@ function Composer({ opacity: 0, }} > - + {`${valueBeforeCaret} `} (textInput.current = el)} + ref={(el: TextInput & HTMLTextAreaElement) => (textInput.current = el)} selection={selection} style={inputStyleMemo} value={value} - forwardedRef={forwardedRef} defaultValue={defaultValue} autoFocus={autoFocus} /* eslint-disable-next-line react/jsx-props-no-spreading */ @@ -474,9 +376,8 @@ function Composer({ textInput.current.focus(); }); - if (props.onFocus) { - props.onFocus(e); - } + + props.onFocus?.(e); }} /> {shouldCalculateCaretPosition && renderElementForCaretPosition} @@ -484,18 +385,6 @@ function Composer({ ); } -Composer.propTypes = propTypes; -Composer.defaultProps = defaultProps; Composer.displayName = 'Composer'; -const ComposerWithRef = React.forwardRef((props, ref) => ( - -)); - -ComposerWithRef.displayName = 'ComposerWithRef'; - -export default compose(withLocalize, withNavigation)(ComposerWithRef); +export default React.forwardRef(Composer); diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts new file mode 100644 index 000000000000..cc0654b68019 --- /dev/null +++ b/src/components/Composer/types.ts @@ -0,0 +1,76 @@ +import {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; + +type TextSelection = { + start: number; + end?: number; +}; + +type ComposerProps = { + /** Maximum number of lines in the text input */ + maxLines?: number; + + /** The default value of the comment box */ + defaultValue?: string; + + /** The value of the comment box */ + value?: string; + + /** Number of lines for the comment */ + numberOfLines?: number; + + /** Callback method to update number of lines for the comment */ + onNumberOfLinesChange?: (numberOfLines: number) => void; + + /** Callback method to handle pasting a file */ + onPasteFile?: (file?: File) => void; + + /** General styles to apply to the text input */ + // eslint-disable-next-line react/forbid-prop-types + style?: StyleProp; + + /** If the input should clear, it actually gets intercepted instead of .clear() */ + shouldClear?: boolean; + + /** When the input has cleared whoever owns this input should know about it */ + onClear?: () => void; + + /** Whether or not this TextInput is disabled. */ + isDisabled?: boolean; + + /** Set focus to this component the first time it renders. + Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ + autoFocus?: boolean; + + /** Update selection position on change */ + onSelectionChange?: (event: NativeSyntheticEvent) => void; + + /** Selection Object */ + selection?: TextSelection; + + /** Whether the full composer can be opened */ + isFullComposerAvailable?: boolean; + + /** Allow the full composer to be opened */ + setIsFullComposerAvailable?: (value: boolean) => void; + + /** Should we calculate the caret position */ + shouldCalculateCaretPosition?: boolean; + + /** Function to check whether composer is covered up or not */ + checkComposerVisibility?: () => boolean; + + /** Whether this is the report action compose */ + isReportActionCompose?: boolean; + + /** Whether the sull composer is open */ + isComposerFullSize?: boolean; + + onKeyPress?: (event: NativeSyntheticEvent) => void; + + onFocus?: (event: NativeSyntheticEvent) => void; + + /** Should make the input only scroll inside the element avoid scroll out to parent */ + shouldContainScroll?: boolean; +}; + +export type {TextSelection, ComposerProps}; diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index 8af550c9dc66..a2ca930690ac 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,18 +1,21 @@ import {setYear} from 'date-fns'; import _ from 'lodash'; import PropTypes from 'prop-types'; -import React, {useEffect, useState} from 'react'; +import React, {forwardRef, useState} from 'react'; import {View} from 'react-native'; -import InputWrapper from '@components/Form/InputWrapper'; import * as Expensicons from '@components/Icon/Expensicons'; +import refPropTypes from '@components/refPropTypes'; import TextInput from '@components/TextInput'; import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import CalendarPicker from './CalendarPicker'; const propTypes = { + /** React ref being forwarded to the DatePicker input */ + forwardedRef: refPropTypes, + /** * The datepicker supports any value that `new Date()` can parse. * `onInputChange` would always be called with a Date (or null) @@ -33,7 +36,12 @@ const propTypes = { /** A maximum date of calendar to select */ maxDate: PropTypes.objectOf(Date), - ...withLocalizePropTypes, + /** A function that is passed by FormWrapper */ + onInputChange: PropTypes.func.isRequired, + + /** A function that is passed by FormWrapper */ + onTouched: PropTypes.func.isRequired, + ...baseTextInputPropTypes, }; @@ -44,40 +52,33 @@ const datePickerDefaultProps = { value: undefined, }; -function DatePicker({containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, translate, value}) { +function DatePicker({forwardedRef, containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, value}) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const [selectedDate, setSelectedDate] = useState(value || defaultValue || undefined); - useEffect(() => { - if (selectedDate === value || _.isUndefined(value)) { - return; - } - setSelectedDate(value); - }, [selectedDate, value]); - - useEffect(() => { + const onSelected = (newValue) => { if (_.isFunction(onTouched)) { onTouched(); } if (_.isFunction(onInputChange)) { - onInputChange(selectedDate); + onInputChange(newValue); } - // To keep behavior from class component state update callback, we want to run effect only when the selected date is changed. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDate]); + setSelectedDate(newValue); + }; return ( - @@ -103,4 +104,14 @@ DatePicker.propTypes = propTypes; DatePicker.defaultProps = datePickerDefaultProps; DatePicker.displayName = 'DatePicker'; -export default withLocalize(DatePicker); +const DatePickerWithRef = forwardRef((props, ref) => ( + +)); + +DatePickerWithRef.displayName = 'DatePickerWithRef'; + +export default DatePickerWithRef; diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts index 94e4fc7c39c6..5137d6f54108 100644 --- a/src/components/DisplayNames/types.ts +++ b/src/components/DisplayNames/types.ts @@ -29,7 +29,7 @@ type DisplayNamesProps = { tooltipEnabled?: boolean; /** Arbitrary styles of the displayName text */ - textStyles: StyleProp; + textStyles?: StyleProp; /** * Overrides the text that's read by the screen reader when the user interacts with the element. By default, the @@ -42,3 +42,5 @@ type DisplayNamesProps = { }; export default DisplayNamesProps; + +export type {DisplayNameWithTooltip}; diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 869fe1edbfe5..5888bf30b71a 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -5,8 +5,10 @@ import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import withNavigationFocus from '@components/withNavigationFocus'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; import getButtonState from '@libs/getButtonState'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; @@ -43,6 +45,9 @@ function EmojiPickerButton(props) { style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={props.isDisabled} onPress={() => { + if (!props.isFocused) { + return; + } if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.emojiPickerID); } else { @@ -66,4 +71,4 @@ function EmojiPickerButton(props) { EmojiPickerButton.propTypes = propTypes; EmojiPickerButton.defaultProps = defaultProps; EmojiPickerButton.displayName = 'EmojiPickerButton'; -export default withLocalize(EmojiPickerButton); +export default compose(withLocalize, withNavigationFocus)(EmojiPickerButton); diff --git a/src/components/EnvironmentBadge.tsx b/src/components/EnvironmentBadge.tsx index 6babbf119445..3a8445f62880 100644 --- a/src/components/EnvironmentBadge.tsx +++ b/src/components/EnvironmentBadge.tsx @@ -29,7 +29,7 @@ function EnvironmentBadge() { success={environment === CONST.ENVIRONMENT.STAGING || environment === CONST.ENVIRONMENT.ADHOC} error={environment !== CONST.ENVIRONMENT.STAGING && environment !== CONST.ENVIRONMENT.ADHOC} text={text} - badgeStyles={[styles.alignSelfEnd, styles.headerEnvBadge]} + badgeStyles={[styles.alignSelfStart, styles.headerEnvBadge]} textStyles={[styles.headerEnvBadgeText]} environment={environment} /> diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.tsx similarity index 68% rename from src/components/ExceededCommentLength.js rename to src/components/ExceededCommentLength.tsx index 3fd6688944f7..6cd11cc44a5c 100644 --- a/src/components/ExceededCommentLength.js +++ b/src/components/ExceededCommentLength.tsx @@ -1,23 +1,13 @@ -import PropTypes from 'prop-types'; import React from 'react'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import Text from './Text'; -const propTypes = { - shouldShowError: PropTypes.bool.isRequired, -}; - -const defaultProps = {}; - -function ExceededCommentLength(props) { +function ExceededCommentLength() { const styles = useThemeStyles(); const {numberFormat, translate} = useLocalize(); - if (!props.shouldShowError) { - return null; - } return ( {}, - enabledWhenOffline: false, - disablePressOnEnter: false, - isSubmitActionDangerous: false, - useSmallerSubmitButtonSize: false, - footerContent: null, - buttonStyles: [], - errorMessageStyle: [], -}; - -function FormAlertWithSubmitButton(props) { - const styles = useThemeStyles(); - const buttonStyles = [_.isEmpty(props.footerContent) ? {} : styles.mb3, ...props.buttonStyles]; - - return ( - - {(isOffline) => ( - - {isOffline && !props.enabledWhenOffline ? ( -