diff --git a/.eslintrc.js b/.eslintrc.js index 3c144064eb62..b5b4add538f6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,7 +24,7 @@ const restrictedImportPatterns = [ ]; module.exports = { - extends: ['expensify', 'plugin:storybook/recommended', 'plugin:react-hooks/recommended', 'prettier', 'plugin:react-native-a11y/basic'], + extends: ['expensify', 'plugin:storybook/recommended', 'plugin:react-hooks/recommended', 'plugin:react-native-a11y/basic', 'prettier'], plugins: ['react-hooks', 'react-native-a11y'], parser: 'babel-eslint', ignorePatterns: ['!.*', 'src/vendor', '.github/actions/**/index.js', 'desktop/dist/*.js', 'dist/*.js', 'node_modules/.bin/**', 'node_modules/.cache/**', '.git/**'], @@ -46,7 +46,6 @@ module.exports = { touchables: ['PressableWithoutFeedback', 'PressableWithFeedback'], }, ], - curly: 'error', }, }, { @@ -76,6 +75,7 @@ module.exports = { patterns: restrictedImportPatterns, }, ], + curly: 'error', }, }, { @@ -162,6 +162,7 @@ module.exports = { patterns: restrictedImportPatterns, }, ], + curly: 'error', }, }, { diff --git a/.github/scripts/createDocsRoutes.js b/.github/scripts/createDocsRoutes.js index 39cd98383de1..6604a9d207fa 100644 --- a/.github/scripts/createDocsRoutes.js +++ b/.github/scripts/createDocsRoutes.js @@ -16,7 +16,7 @@ const platformNames = { * @returns {String} */ function toTitleCase(str) { - return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()); + return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1)); } /** diff --git a/.github/scripts/findUnusedKeys.sh b/.github/scripts/findUnusedKeys.sh new file mode 100755 index 000000000000..77c3ea25326b --- /dev/null +++ b/.github/scripts/findUnusedKeys.sh @@ -0,0 +1,375 @@ +#!/bin/bash + +# Configurations +declare LIB_PATH +LIB_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd ../../ && pwd)" + +readonly SRC_DIR="${LIB_PATH}/src" +readonly STYLES_DIR="${LIB_PATH}/src/styles" +readonly STYLES_FILE="${LIB_PATH}/src/styles/styles.js" +readonly UTILITIES_STYLES_FILE="${LIB_PATH}/src/styles/utilities" +readonly STYLES_KEYS_FILE="${LIB_PATH}/scripts/style_keys_list_temp.txt" +readonly UTILITY_STYLES_KEYS_FILE="${LIB_PATH}/scripts/utility_keys_list_temp.txt" +readonly REMOVAL_KEYS_FILE="${LIB_PATH}/scripts/removal_keys_list_temp.txt" +readonly AMOUNT_LINES_TO_SHOW=3 + +readonly FILE_EXTENSIONS=('-name' '*.js' '-o' '-name' '*.jsx' '-o' '-name' '*.ts' '-o' '-name' '*.tsx') + +source scripts/shellUtils.sh + +# trap ctrl-c and call ctrl_c() +trap ctrl_c INT + +delete_temp_files() { + find "${LIB_PATH}/scripts" -name "*keys_list_temp*" -type f -exec rm -f {} \; +} + +# shellcheck disable=SC2317 # Don't warn about unreachable commands in this function +ctrl_c() { + delete_temp_files + exit 1 +} + +count_lines() { + local file=$1 + if [[ -e "$file" ]]; then + wc -l < "$file" + else + echo "File not found: $file" + fi +} + +# Read the style file with unused keys +show_unused_style_keywords() { + while IFS=: read -r key file line_number; do + title "File: $file:$line_number" + + # Get lines before and after the error line + local lines_before=$((line_number - AMOUNT_LINES_TO_SHOW)) + local lines_after=$((line_number + AMOUNT_LINES_TO_SHOW)) + + # Read the lines into an array + local lines=() + while IFS= read -r line; do + lines+=("$line") + done < "$file" + + # Loop through the lines + for ((i = lines_before; i <= lines_after; i++)); do + local line="${lines[i]}" + # Print context of the error line + echo "$line" + done + error "Unused key: $key" + echo "--------------------------------" + done < "$STYLES_KEYS_FILE" +} + +# Function to remove a keyword from the temp file +remove_keyword() { + local keyword="$1" + if grep -q "$keyword" "$STYLES_KEYS_FILE"; then + grep -v "$keyword" "$STYLES_KEYS_FILE" > "$REMOVAL_KEYS_FILE" + mv "$REMOVAL_KEYS_FILE" "$STYLES_KEYS_FILE" + + return 0 # Keyword was removed + else + return 1 # Keyword was not found + fi +} + +lookfor_unused_keywords() { + # Loop through all files in the src folder + while read -r file; do + + # Search for keywords starting with "styles" + while IFS= read -r keyword; do + + # Remove any [ ] characters from the keyword + local clean_keyword="${keyword//[\[\]]/}" + # skip styles. keyword that might be used in comments + if [[ "$clean_keyword" == "styles." ]]; then + continue + fi + + if ! remove_keyword "$clean_keyword" ; then + # In case of a leaf of the styles object is being used, it means the parent objects is being used + # we need to mark it as used. + if [[ "$clean_keyword" =~ ^styles\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$ ]]; then + # Keyword has more than two words, remove words after the second word + local keyword_prefix="${clean_keyword%.*}" + remove_keyword "$keyword_prefix" + fi + fi + done < <(grep -E -o '\bstyles\.[a-zA-Z0-9_.]*' "$file" | grep -v '\/\/' | grep -vE '\/\*.*\*\/') + done < <(find "${SRC_DIR}" -type f \( "${FILE_EXTENSIONS[@]}" \)) +} + + +# Function to find and store keys from a file +find_styles_object_and_store_keys() { + local file="$1" + local base_name="${2:-styles}" # Set styles as default + local line_number=0 + local inside_arrow_function=false + + while IFS= read -r line; do + ((line_number++)) + + # Check if we are inside an arrow function and we find a closing curly brace + if [[ "$inside_arrow_function" == true ]]; then + if [[ "$line" =~ ^[[:space:]]*\}\) ]]; then + inside_arrow_function=false + fi + continue + fi + + # Check if we are inside an arrow function + if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\(\{ || "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then + inside_arrow_function=true + continue + fi + + # Skip lines that are not key-related + if [[ ! "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\{|^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{|^[[:space:]]*\} ]]; then + continue + fi + + if [[ "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\{ ]]; then + key="${BASH_REMATCH[2]%%:*{*)}" + echo "styles.${key}|...${key}|${base_name}.${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE" + fi + done < "$file" +} + +find_styles_functions_and_store_keys() { + local file="$1" + local line_number=0 + local inside_object=false + local inside_arrow_function=false + local key="" + + while IFS= read -r line; do + ((line_number++)) + + # Skip lines that are not key-related + if [[ "${line}" == *styles* ]]; then + continue + fi + + # Check if we are inside an arrow function and we find a closing curly brace + if [[ "$inside_arrow_function" == true ]]; then + if [[ "$line" =~ ^[[:space:]]*\}\) ]]; then + inside_arrow_function=false + fi + continue + fi + + # Check if we are inside an arrow function + if [[ "${line}" =~ ^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\( ]]; then + inside_arrow_function=true + key="${line%%:*}" + key="${key// /}" # Trim spaces + echo "styles.${key}|...${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE" + continue + fi + + if [[ "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then + inside_arrow_function=true + key="${BASH_REMATCH[2]}" + key="${key// /}" # Trim spaces + echo "styles.${key}|...${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE" + continue + fi + + done < "$file" +} + +find_theme_style_and_store_keys() { + local file="$1" + local start_line_number="$2" + local base_name="${3:-styles}" # Set styles as default + local parent_keys=() + local root_key="" + local line_number=0 + local inside_arrow_function=false + + while IFS= read -r line; do + ((line_number++)) + + if [ ! "$line_number" -ge "$start_line_number" ]; then + continue + fi + + # Check if we are inside an arrow function and we find a closing curly brace + if [[ "$inside_arrow_function" == true ]]; then + if [[ "$line" =~ ^[[:space:]]*\}\) ]]; then + inside_arrow_function=false + fi + continue + fi + + # Check if we are inside an arrow function + if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\(\{ || "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then + inside_arrow_function=true + continue + fi + + # Skip lines that are not key-related + if [[ ! "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\{|^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{|^[[:space:]]*\} ]]; then + + continue + fi + + if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{|^[[:space:]]*([a-zA-Z0-9_-])+:[[:space:]]*\(.*\)[[:space:]]*'=>'[[:space:]]*\(\{ ]]; then + # Removing all the extra lines after the ":" + local key="${line%%:*}" + key="${key// /}" # Trim spaces + + if [[ ${#parent_keys[@]} -gt 0 ]]; then + local parent_key_trimmed="${parent_keys[${#parent_keys[@]}-1]// /}" # Trim spaces + key="$parent_key_trimmed.$key" + elif [[ -n "$root_key" ]]; then + local parent_key_trimmed="${root_key// /}" # Trim spaces + key="$parent_key_trimmed.$key" + fi + + echo "styles.${key}|...${key}|${base_name}.${key}:${file}:${line_number}" >> "$STYLES_KEYS_FILE" + parent_keys+=("$key") + elif [[ "$line" =~ ^[[:space:]]*\} ]]; then + parent_keys=("${parent_keys[@]:0:${#parent_keys[@]}-1}") + fi + done < "$file" +} + +# Given that all the styles are inside of a function, we need to find the function and then look for the styles +collect_theme_keys_from_styles() { + local file="$1" + local line_number=0 + local inside_styles=false + + while IFS= read -r line; do + ((line_number++)) + + if [[ "$inside_styles" == false ]]; then + if [[ "$line" =~ ^[[:space:]]*(const|let|var)[[:space:]]+([a-zA-Z0-9_-]+)[[:space:]]*=[[:space:]]*\(.*\)[[:space:]]*'=>' ]]; then + key="${BASH_REMATCH[2]}" + key="${key// /}" # Trim spaces + if [[ "$key" == "styles"* ]]; then + inside_styles=true + # Need to start within the style function + ((line_number++)) + find_theme_style_and_store_keys "$STYLES_FILE" "$line_number" + fi + continue + fi + fi + done < "$file" +} + +lookfor_unused_spread_keywords() { + local inside_object=false + local key="" + + while IFS= read -r line; do + # Detect the start of an object + if [[ "$line" =~ ^[[:space:]]*([a-zA-Z0-9_-]+\.)?[a-zA-Z0-9_-]+:[[:space:]]*\{ ]]; then + inside_object=true + fi + + # Detect the end of an object + if [[ "$line" =~ ^[[:space:]]*\},?$ ]]; then + inside_object=false + fi + + # If we're inside an object and the line starts with '...', capture the key + if [[ "$inside_object" == true && "$line" =~ ^[[:space:]]*\.\.\.([a-zA-Z0-9_]+)(\(.+\))?,?$ ]]; then + key="${BASH_REMATCH[1]}" + remove_keyword "...${key}" + fi + done < "$STYLES_FILE" +} + +find_utility_styles_store_prefix() { + # Loop through all files in the src folder + while read -r file; do + # Search for keywords starting with "styles" + while IFS= read -r keyword; do + local variable="${keyword##*/}" + local variable_trimmed="${variable// /}" # Trim spaces + + echo "$variable_trimmed" >> "$UTILITY_STYLES_KEYS_FILE" + done < <(grep -E -o './utilities/[a-zA-Z0-9_-]+' "$file" | grep -v '\/\/' | grep -vE '\/\*.*\*\/') + done < <(find "${STYLES_DIR}" -type f \( "${FILE_EXTENSIONS[@]}" \)) + + # Sort and remove duplicates from the temporary file + sort -u -o "${UTILITY_STYLES_KEYS_FILE}" "${UTILITY_STYLES_KEYS_FILE}" +} + +find_utility_usage_as_styles() { + while read -r file; do + local root_key + local parent_dir + + # Get the folder name, given this utility files are index.js + parent_dir=$(dirname "$file") + root_key=$(basename "${parent_dir}") + + if [[ "${root_key}" == "utilities" ]]; then + continue + fi + + find_theme_style_and_store_keys "${file}" 0 "${root_key}" + done < <(find "${UTILITIES_STYLES_FILE}" -type f \( "${FILE_EXTENSIONS[@]}" \)) +} + +lookfor_unused_utilities() { + # Read each utility keyword from the file + while read -r keyword; do + # Creating a copy so later the replacement can reference it + local original_keyword="${keyword}" + + # Iterate through all files in "src/styles" + while read -r file; do + # Find all words that match "$keyword.[a-zA-Z0-9_-]+" + while IFS= read -r match; do + # Replace the utility prefix with "styles" + local variable="${match/#$original_keyword/styles}" + # Call the remove_keyword function with the variable + remove_keyword "${variable}" + remove_keyword "${match}" + done < <(grep -E -o "$original_keyword\.[a-zA-Z0-9_-]+" "$file" | grep -v '\/\/' | grep -vE '\/\*.*\*\/') + done < <(find "${STYLES_DIR}" -type f \( "${FILE_EXTENSIONS[@]}" \)) + done < "$UTILITY_STYLES_KEYS_FILE" +} + +echo "🔍 Looking for styles." +# Find and store the name of the utility files as keys +find_utility_styles_store_prefix +find_utility_usage_as_styles + +# Find and store keys from styles.js +find_styles_object_and_store_keys "$STYLES_FILE" +find_styles_functions_and_store_keys "$STYLES_FILE" +collect_theme_keys_from_styles "$STYLES_FILE" + +echo "🗄️ Now going through the codebase and looking for unused keys." + +# Look for usages of utilities into src/styles +lookfor_unused_utilities +lookfor_unused_spread_keywords +lookfor_unused_keywords + +final_styles_line_count=$(count_lines "$STYLES_KEYS_FILE") + +if [[ $final_styles_line_count -eq 0 ]]; then + # Exit successfully (status code 0) + delete_temp_files + success "Styles are in a good shape" + exit 0 +else + show_unused_style_keywords + delete_temp_files + error "Unused keys: $final_styles_line_count" + exit 1 +fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5953a4aa89e2..b403a1eb737c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -33,3 +33,7 @@ jobs: echo 'Error: Prettier diff detected! Please run `npm run prettier` and commit the changes.' exit 1 fi + + - name: Run unused style searcher + shell: bash + run: ./.github/scripts/findUnusedKeys.sh diff --git a/.gitignore b/.gitignore index aae9baad529f..d3b4daac04d7 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,7 @@ tsconfig.tsbuildinfo # Workflow test logs /workflow_tests/logs/ + +# Yalc +.yalc +yalc.lock diff --git a/docs/new-expensify/hubs/exports.html b/docs/new-expensify/hubs/exports.html index e69de29bb2d1..16c96cb51d01 100644 --- a/docs/new-expensify/hubs/exports.html +++ b/docs/new-expensify/hubs/exports.html @@ -0,0 +1,6 @@ +--- +layout: default +title: Exports +--- + +{% include hub.html %} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ff0500eb385b..c2e322bf8ba2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -190,7 +190,7 @@ "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.38", + "eslint-config-expensify": "^2.0.39", "eslint-config-prettier": "^8.8.0", "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsdoc": "^46.2.6", @@ -26492,9 +26492,9 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.38.tgz", - "integrity": "sha512-jAlrYSjkDV8YESUUPcaTjUM8Fgru+37FS+Hq6zzcRR0FbA5bLiOPguhJHo77XpYh5N+PEf4wrpgsS04sjdgDPg==", + "version": "2.0.39", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.39.tgz", + "integrity": "sha512-DIxR3k99ZIDPE2NK+WLLRWpoDq06gTXdY8XZg9Etd1UqZ7fXm/Yz3/QkTxu7CH7UaXbCH3P4PTo023ULQGKOSw==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^0.11.0", @@ -67265,9 +67265,9 @@ } }, "eslint-config-expensify": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.38.tgz", - "integrity": "sha512-jAlrYSjkDV8YESUUPcaTjUM8Fgru+37FS+Hq6zzcRR0FbA5bLiOPguhJHo77XpYh5N+PEf4wrpgsS04sjdgDPg==", + "version": "2.0.39", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.39.tgz", + "integrity": "sha512-DIxR3k99ZIDPE2NK+WLLRWpoDq06gTXdY8XZg9Etd1UqZ7fXm/Yz3/QkTxu7CH7UaXbCH3P4PTo023ULQGKOSw==", "dev": true, "requires": { "@lwc/eslint-plugin-lwc": "^0.11.0", diff --git a/package.json b/package.json index 44b936c8c588..c568ff7b0ab4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", "test:e2e": "node tests/e2e/testRunner.js --development", + "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js" }, @@ -232,7 +233,7 @@ "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.38", + "eslint-config-expensify": "^2.0.39", "eslint-config-prettier": "^8.8.0", "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsdoc": "^46.2.6", diff --git a/src/CONST.ts b/src/CONST.ts index dcd5ac1a8db7..eed1b98ae551 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1358,6 +1358,7 @@ const CONST = { DATE: 'date', DESCRIPTION: 'description', MERCHANT: 'merchant', + CATEGORY: 'category', RECEIPT: 'receipt', }, FOOTER: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 05256f2b806c..8f95dff079fc 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -249,6 +249,7 @@ const ONYXKEYS = { REPORT_DRAFT_COMMENT_NUMBER_OF_LINES: 'reportDraftCommentNumberOfLines_', REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', REPORT_USER_IS_TYPING: 'reportUserIsTyping_', + REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', @@ -386,6 +387,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES]: number; [ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean; [ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: boolean; + [ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean; [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2c37116db395..feead4890114 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -139,8 +139,8 @@ export default { SEARCH: 'search', TEACHERS_UNITE: 'teachersunite', I_KNOW_A_TEACHER: 'teachersunite/i-know-a-teacher', - INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal', I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher', + INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal', DETAILS: 'details', getDetailsRoute: (login: string) => `details?login=${encodeURIComponent(login)}`, PROFILE: 'a/:accountID', diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js index baa958106f84..10e8a76f756d 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.js +++ b/src/components/AvatarCropModal/AvatarCropModal.js @@ -366,6 +366,7 @@ function AvatarCropModal(props) { style={[styles.pb0]} includePaddingTop={false} includeSafeAreaPaddingBottom={false} + testID={AvatarCropModal.displayName} > {props.isSmallScreenWidth && } { - if (!iou.category) { + if (!selectedCategory) { return []; } return [ { - name: iou.category, + name: selectedCategory, enabled: true, accountID: null, }, ]; - }, [iou.category]); + }, [selectedCategory]); const initialFocusedIndex = useMemo(() => { if (isCategoriesCountBelowThreshold && selectedOptions.length > 0) { @@ -53,20 +50,6 @@ function CategoryPicker({policyCategories, reportID, iouType, iou, policyRecentl const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, searchValue); const shouldShowTextInput = !isCategoriesCountBelowThreshold; - const navigateBack = () => { - Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID)); - }; - - const updateCategory = (category) => { - if (category.searchText === iou.category) { - IOU.resetMoneyRequestCategory(); - } else { - IOU.setMoneyRequestCategory(category.searchText); - } - - navigateBack(); - }; - return ( ); } @@ -97,7 +80,4 @@ export default withOnyx({ policyRecentlyUsedCategories: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`, }, - iou: { - key: ONYXKEYS.IOU, - }, })(CategoryPicker); diff --git a/src/components/CountryPicker/CountrySelectorModal.js b/src/components/CountryPicker/CountrySelectorModal.js index ac543d9921d2..6c6cd19af0c7 100644 --- a/src/components/CountryPicker/CountrySelectorModal.js +++ b/src/components/CountryPicker/CountrySelectorModal.js @@ -78,6 +78,7 @@ function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySele style={[styles.pb0]} includePaddingTop={false} includeSafeAreaPaddingBottom={false} + testID={CountrySelectorModal.displayName} > {({safeAreaPaddingBottomStyle}) => ( diff --git a/src/components/HeaderPageLayout.js b/src/components/HeaderPageLayout.js index bec1e52b1cad..17c2255593e9 100644 --- a/src/components/HeaderPageLayout.js +++ b/src/components/HeaderPageLayout.js @@ -58,6 +58,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty shouldEnablePickerAvoiding={false} includeSafeAreaPaddingBottom={false} offlineIndicatorStyle={[appBGColor]} + testID={HeaderPageLayout.displayName} > {({safeAreaPaddingBottomStyle}) => ( <> diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js index aab54612e206..7bcd57385d5f 100755 --- a/src/components/HeaderWithBackButton/index.js +++ b/src/components/HeaderWithBackButton/index.js @@ -47,6 +47,7 @@ function HeaderWithBackButton({ }, threeDotsMenuItems = [], children = null, + onModalHide = () => {}, shouldOverlay = false, }) { const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); @@ -138,6 +139,7 @@ function HeaderWithBackButton({ menuItems={threeDotsMenuItems} onIconPress={onThreeDotsButtonPress} anchorPosition={threeDotsAnchorPosition} + onModalHide={onModalHide} shouldOverlay={shouldOverlay} /> )} diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js index d3fcda0ea5fd..0bfffb733052 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.js +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.js @@ -136,8 +136,6 @@ class BaseInvertedFlatList extends Component { // Native platforms do not need to measure items and work fine without this. // Web requires that items be measured or else crazy things happen when scrolling. getItemLayout={this.props.shouldMeasureItems ? this.getItemLayout : undefined} - // We keep this property very low so that chat switching remains fast - maxToRenderPerBatch={1} windowSize={15} // Commenting the line below as it breaks the unread indicator test diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js index 74409e9a0fe0..d46cd5801605 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.js @@ -119,6 +119,9 @@ function InvertedFlatList(props) { shouldMeasureItems contentContainerStyle={StyleSheet.compose(contentContainerStyle, styles.justifyContentEnd)} onScroll={handleScroll} + // We need to keep batch size to one to workaround a bug in react-native-web. + // This can be removed once https://github.com/Expensify/App/pull/24482 is merged. + maxToRenderPerBatch={1} /> ); } diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 2c51d6332946..4eebdd387fab 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -195,6 +195,7 @@ export default React.memo( key: ONYXKEYS.NVP_PREFERRED_LOCALE, }, }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ parentReportActions: { key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`, @@ -209,6 +210,7 @@ export default React.memo( // However, performance overhead of this is minimized by using memos inside the component. receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION}, }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ transaction: { key: ({fullReport, parentReportActions}) => diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index 7a2248ffafb9..d9f51e111a43 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -54,7 +54,9 @@ const MapView = forwardRef(({accessToken, style, ma }, [accessToken]); const setMapIdle = (e: MapState) => { - if (e.gestures.isGestureActive) return; + if (e.gestures.isGestureActive) { + return; + } setIsIdle(true); if (onMapReady) { onMapReady(); diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index bb4eeb7a18ac..268351699567 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -24,11 +24,7 @@ import variables from '../styles/variables'; import * as Session from '../libs/actions/Session'; import Hoverable from './Hoverable'; import useWindowDimensions from '../hooks/useWindowDimensions'; -import RenderHTML from './RenderHTML'; -import getPlatform from '../libs/getPlatform'; - -const platform = getPlatform(); -const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; +import MenuItemRenderHTMLTitle from './MenuItemRenderHTMLTitle'; const propTypes = menuItemPropTypes; @@ -251,16 +247,10 @@ const MenuItem = React.forwardRef((props, ref) => { )} - {Boolean(props.title) && - (Boolean(props.shouldRenderAsHTML) || (Boolean(props.shouldParseTitle) && Boolean(html.length))) && - (isNative ? ( - - ) : ( - - - - ))} - {!props.shouldRenderAsHTML && !html.length && Boolean(props.title) && ( + {Boolean(props.title) && (Boolean(props.shouldRenderAsHTML) || (Boolean(props.shouldParseTitle) && Boolean(html.length))) && ( + + )} + {!props.shouldRenderAsHTML && !props.shouldParseTitle && Boolean(props.title) && ( + + + ); +} + +MenuItemRenderHTMLTitle.propTypes = propTypes; +MenuItemRenderHTMLTitle.defaultProps = defaultProps; +MenuItemRenderHTMLTitle.displayName = 'MenuItemRenderHTMLTitle'; + +export default MenuItemRenderHTMLTitle; diff --git a/src/components/MenuItemRenderHTMLTitle/index.native.js b/src/components/MenuItemRenderHTMLTitle/index.native.js new file mode 100644 index 000000000000..b3dff8d77eff --- /dev/null +++ b/src/components/MenuItemRenderHTMLTitle/index.native.js @@ -0,0 +1,17 @@ +import React from 'react'; +import RenderHTML from '../RenderHTML'; +import menuItemRenderHTMLTitlePropTypes from './propTypes'; + +const propTypes = menuItemRenderHTMLTitlePropTypes; + +const defaultProps = {}; + +function MenuItemRenderHTMLTitle(props) { + return ; +} + +MenuItemRenderHTMLTitle.propTypes = propTypes; +MenuItemRenderHTMLTitle.defaultProps = defaultProps; +MenuItemRenderHTMLTitle.displayName = 'MenuItemRenderHTMLTitle'; + +export default MenuItemRenderHTMLTitle; diff --git a/src/components/MenuItemRenderHTMLTitle/propTypes.js b/src/components/MenuItemRenderHTMLTitle/propTypes.js new file mode 100644 index 000000000000..68e279eb28c3 --- /dev/null +++ b/src/components/MenuItemRenderHTMLTitle/propTypes.js @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types'; + +const propTypes = { + /** Processed title to display for the MenuItem */ + title: PropTypes.string.isRequired, +}; + +export default propTypes; diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 4703ca099c7c..13471407914f 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -194,14 +194,23 @@ function MoneyRequestConfirmationList(props) { const {unit, rate, currency} = props.mileageRate; const distance = lodashGet(transaction, 'routes.route0.distance', 0); const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0; - const shouldCategoryBeEditable = !_.isEmpty(props.policyCategories) && Permissions.canUseCategories(props.betas); + + // A flag for verifying that the current report is a sub-report of a workspace chat + const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(ReportUtils.getReport(props.reportID))), [props.reportID]); + + // A flag for showing the categories field + const shouldShowCategories = isPolicyExpenseChat && Permissions.canUseCategories(props.betas) && OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)); // Fetches the first tag list of the policy const tagListKey = _.first(_.keys(props.policyTags)); const tagList = lodashGet(props.policyTags, [tagListKey, 'tags'], []); const tagListName = lodashGet(props.policyTags, [tagListKey, 'name'], ''); const canUseTags = Permissions.canUseTags(props.betas); - const shouldShowTags = canUseTags && _.any(tagList, (tag) => tag.enabled); + // A flag for showing the tags field + const shouldShowTags = isPolicyExpenseChat && canUseTags && _.any(tagList, (tag) => tag.enabled); + + // A flag for showing the billable field + const shouldShowBillable = canUseTags && !lodashGet(props.policy, 'disabledFields.defaultBillable', true); const hasRoute = TransactionUtils.hasRoute(transaction); const isDistanceRequestWithoutRoute = props.isDistanceRequest && !hasRoute; @@ -518,7 +527,7 @@ function MoneyRequestConfirmationList(props) { disabled={didConfirm || props.isReadOnly || !isTypeRequest} /> )} - {shouldCategoryBeEditable && ( + {shouldShowCategories && ( )} - {canUseTags && !lodashGet(props.policy, 'disabledFields.defaultBillable', true) && ( + {shouldShowBillable && ( {translate('common.billable')} {}, withoutOverlay: false, + onModalHide: () => {}, }; function PopoverMenu(props) { @@ -78,6 +82,7 @@ function PopoverMenu(props) { isVisible={props.isVisible} onModalHide={() => { setFocusedIndex(-1); + props.onModalHide(); if (selectedItemIndex.current !== null) { props.menuItems[selectedItemIndex.current].onSelected(); selectedItemIndex.current = null; diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index 61a602be0bda..21aac35f4005 100644 --- a/src/components/ReimbursementAccountLoadingIndicator.js +++ b/src/components/ReimbursementAccountLoadingIndicator.js @@ -4,14 +4,12 @@ import PropTypes from 'prop-types'; import Lottie from 'lottie-react-native'; import * as LottieAnimations from './LottieAnimations'; import styles from '../styles/styles'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import useLocalize from '../hooks/useLocalize'; import Text from './Text'; import HeaderWithBackButton from './HeaderWithBackButton'; import ScreenWrapper from './ScreenWrapper'; import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; -import compose from '../libs/compose'; -import {withNetwork} from './OnyxProvider'; const propTypes = { /** Whether the user is submitting verifications data */ @@ -19,17 +17,18 @@ const propTypes = { /** Method to trigger when pressing back button of the header */ onBackButtonPress: PropTypes.func.isRequired, - ...withLocalizePropTypes, }; function ReimbursementAccountLoadingIndicator(props) { + const {translate} = useLocalize(); return ( @@ -42,7 +41,7 @@ function ReimbursementAccountLoadingIndicator(props) { style={styles.loadingVBAAnimation} /> - {props.translate('reimbursementAccountLoadingAnimation.explanationLine')} + {translate('reimbursementAccountLoadingAnimation.explanationLine')} ) : ( @@ -56,4 +55,4 @@ function ReimbursementAccountLoadingIndicator(props) { ReimbursementAccountLoadingIndicator.propTypes = propTypes; ReimbursementAccountLoadingIndicator.displayName = 'ReimbursementAccountLoadingIndicator'; -export default compose(withLocalize, withNetwork())(ReimbursementAccountLoadingIndicator); +export default ReimbursementAccountLoadingIndicator; diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index d8b15653d8af..178cab75a0c2 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -1,7 +1,8 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; +import lodashValues from 'lodash/values'; import PropTypes from 'prop-types'; import reportPropTypes from '../../pages/reportPropTypes'; import ONYXKEYS from '../../ONYXKEYS'; @@ -9,9 +10,11 @@ import ROUTES from '../../ROUTES'; import Navigation from '../../libs/Navigation/Navigation'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails'; import compose from '../../libs/compose'; +import Permissions from '../../libs/Permissions'; import MenuItemWithTopDescription from '../MenuItemWithTopDescription'; import styles from '../../styles/styles'; import * as ReportUtils from '../../libs/ReportUtils'; +import * as OptionsListUtils from '../../libs/OptionsListUtils'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import * as StyleUtils from '../../styles/StyleUtils'; import CONST from '../../CONST'; @@ -27,26 +30,36 @@ import Image from '../Image'; import ReportActionItemImage from './ReportActionItemImage'; import * as TransactionUtils from '../../libs/TransactionUtils'; import OfflineWithFeedback from '../OfflineWithFeedback'; +import categoryPropTypes from '../categoryPropTypes'; import SpacerView from '../SpacerView'; const propTypes = { /** The report currently being looked at */ report: reportPropTypes.isRequired, + /** Whether we should display the horizontal rule below the component */ + shouldShowHorizontalRule: PropTypes.bool.isRequired, + + /* Onyx Props */ + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + /** The expense report or iou report (only will have a value if this is a transaction thread) */ parentReport: iouReportPropTypes, + /** Collection of categories attached to a policy */ + policyCategories: PropTypes.objectOf(categoryPropTypes), + /** The transaction associated with the transactionThread */ transaction: transactionPropTypes, - /** Whether we should display the horizontal rule below the component */ - shouldShowHorizontalRule: PropTypes.bool.isRequired, - ...withCurrentUserPersonalDetailsPropTypes, }; const defaultProps = { + betas: [], parentReport: {}, + policyCategories: {}, transaction: { amount: 0, currency: CONST.CURRENCY.USD, @@ -54,7 +67,7 @@ const defaultProps = { }, }; -function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, transaction}) { +function MoneyRequestView({betas, report, parentReport, policyCategories, shouldShowHorizontalRule, transaction}) { const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); @@ -66,6 +79,7 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans currency: transactionCurrency, comment: transactionDescription, merchant: transactionMerchant, + category: transactionCategory, } = ReportUtils.getTransactionDetails(transaction); const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; @@ -73,6 +87,10 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction); + // A flag for verifying that the current report is a sub-report of a workspace chat + const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]); + // A flag for showing categories + const shouldShowCategory = isPolicyExpenseChat && Permissions.canUseCategories(betas) && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); let description = `${translate('iou.amount')} • ${translate('iou.cash')}`; if (isSettled) { @@ -170,6 +188,18 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans subtitleTextStyle={styles.textLabelError} /> + {shouldShowCategory && ( + + Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} + /> + + )} `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, }, policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`, + }, session: { key: ONYXKEYS.SESSION, }, diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js index f760e5d5aeb4..1cad1e96b26d 100644 --- a/src/components/ScreenWrapper/index.js +++ b/src/components/ScreenWrapper/index.js @@ -109,6 +109,7 @@ class ScreenWrapper extends React.Component { style={styles.flex1} // eslint-disable-next-line react/jsx-props-no-spreading {...(this.props.environment === CONST.ENVIRONMENT.DEV ? this.panResponder.panHandlers : {})} + testID={this.props.testID} > {}, shouldOverlay: false, }; -function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay}) { +function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, onModalHide, shouldOverlay}) { const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); const buttonRef = useRef(null); const {translate} = useLocalize(); @@ -73,6 +77,9 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me }; const hidePopoverMenu = () => { + InteractionManager.runAfterInteractions(() => { + onModalHide(); + }); setPopupMenuVisible(false); }; @@ -105,6 +112,7 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me { Log.info('[BootSplash] hiding splash screen', false); return BootSplash.hide(); } diff --git a/src/libs/BootSplash/index.js b/src/libs/BootSplash/index.ts similarity index 62% rename from src/libs/BootSplash/index.js rename to src/libs/BootSplash/index.ts index c169f380a8eb..24842fe631f4 100644 --- a/src/libs/BootSplash/index.js +++ b/src/libs/BootSplash/index.ts @@ -1,20 +1,21 @@ import Log from '../Log'; +import {VisibilityStatus} from './types'; -function resolveAfter(delay) { - return new Promise((resolve) => setTimeout(resolve, delay)); +function resolveAfter(delay: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delay)); } -function hide() { +function hide(): Promise { Log.info('[BootSplash] hiding splash screen', false); return document.fonts.ready.then(() => { const splash = document.getElementById('splash'); if (splash) { - splash.style.opacity = 0; + splash.style.opacity = '0'; } return resolveAfter(250).then(() => { - if (!splash || !splash.parentNode) { + if (!splash?.parentNode) { return; } splash.parentNode.removeChild(splash); @@ -22,7 +23,7 @@ function hide() { }); } -function getVisibilityStatus() { +function getVisibilityStatus(): Promise { return Promise.resolve(document.getElementById('splash') ? 'visible' : 'hidden'); } diff --git a/src/libs/BootSplash/types.ts b/src/libs/BootSplash/types.ts new file mode 100644 index 000000000000..2329d5315817 --- /dev/null +++ b/src/libs/BootSplash/types.ts @@ -0,0 +1,9 @@ +type VisibilityStatus = 'visible' | 'hidden'; + +type BootSplashModule = { + navigationBarHeight: number; + hide: () => Promise; + getVisibilityStatus: () => Promise; +}; + +export type {BootSplashModule, VisibilityStatus}; diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.js b/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.js deleted file mode 100644 index 4306b0cff3f6..000000000000 --- a/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.js +++ /dev/null @@ -1,5 +0,0 @@ -function canUseTouchScreen() { - return true; -} - -export default canUseTouchScreen; diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.ts b/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.ts new file mode 100644 index 000000000000..60980801e73c --- /dev/null +++ b/src/libs/DeviceCapabilities/canUseTouchScreen/index.native.ts @@ -0,0 +1,5 @@ +import CanUseTouchScreen from './types'; + +const canUseTouchScreen: CanUseTouchScreen = () => true; + +export default canUseTouchScreen; diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/index.js b/src/libs/DeviceCapabilities/canUseTouchScreen/index.ts similarity index 51% rename from src/libs/DeviceCapabilities/canUseTouchScreen/index.js rename to src/libs/DeviceCapabilities/canUseTouchScreen/index.ts index 17dcc9dffd73..9e21f5a42b5d 100644 --- a/src/libs/DeviceCapabilities/canUseTouchScreen/index.js +++ b/src/libs/DeviceCapabilities/canUseTouchScreen/index.ts @@ -1,29 +1,38 @@ +import {Merge} from 'type-fest'; +import CanUseTouchScreen from './types'; + +type ExtendedNavigator = Merge; + /** * Allows us to identify whether the platform has a touchscreen. * * https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent - * - * @returns {Boolean} */ -function canUseTouchScreen() { +const canUseTouchScreen: CanUseTouchScreen = () => { let hasTouchScreen = false; + + // TypeScript removed support for msMaxTouchPoints, this doesn't mean however that + // this property doesn't exist - hence the use of ExtendedNavigator to ensure + // that the functionality doesn't change + // https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029 if ('maxTouchPoints' in navigator) { hasTouchScreen = navigator.maxTouchPoints > 0; } else if ('msMaxTouchPoints' in navigator) { - hasTouchScreen = navigator.msMaxTouchPoints > 0; + hasTouchScreen = (navigator as ExtendedNavigator).msMaxTouchPoints > 0; } else { - const mQ = window.matchMedia && matchMedia('(pointer:coarse)'); + // Same case as for Navigator - TypeScript thinks that matchMedia is obligatory property of window although it may not be + const mQ = window.matchMedia?.('(pointer:coarse)'); if (mQ && mQ.media === '(pointer:coarse)') { hasTouchScreen = !!mQ.matches; } else if ('orientation' in window) { hasTouchScreen = true; // deprecated, but good fallback } else { // Only as a last resort, fall back to user agent sniffing - const UA = navigator.userAgent; + const UA = (navigator as ExtendedNavigator).userAgent; hasTouchScreen = /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) || /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA); } } return hasTouchScreen; -} +}; export default canUseTouchScreen; diff --git a/src/libs/DeviceCapabilities/canUseTouchScreen/types.ts b/src/libs/DeviceCapabilities/canUseTouchScreen/types.ts new file mode 100644 index 000000000000..6b71ecffeb05 --- /dev/null +++ b/src/libs/DeviceCapabilities/canUseTouchScreen/types.ts @@ -0,0 +1,3 @@ +type CanUseTouchScreen = () => boolean; + +export default CanUseTouchScreen; diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/index.js b/src/libs/DeviceCapabilities/hasHoverSupport/index.js deleted file mode 100644 index 84a3fbbc5ed1..000000000000 --- a/src/libs/DeviceCapabilities/hasHoverSupport/index.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Allows us to identify whether the platform is hoverable. - * - * @returns {Boolean} - */ -function hasHoverSupport() { - return window.matchMedia('(hover: hover) and (pointer: fine)').matches; -} - -export default hasHoverSupport; diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/index.native.js b/src/libs/DeviceCapabilities/hasHoverSupport/index.native.ts similarity index 52% rename from src/libs/DeviceCapabilities/hasHoverSupport/index.native.js rename to src/libs/DeviceCapabilities/hasHoverSupport/index.native.ts index d77fcc17448a..097b3b0cbba1 100644 --- a/src/libs/DeviceCapabilities/hasHoverSupport/index.native.js +++ b/src/libs/DeviceCapabilities/hasHoverSupport/index.native.ts @@ -1,9 +1,8 @@ +import HasHoverSupport from './types'; + /** * Allows us to identify whether the platform is hoverable. - * - * @returns {Boolean} */ - -const hasHoverSupport = () => false; +const hasHoverSupport: HasHoverSupport = () => false; export default hasHoverSupport; diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/index.ts b/src/libs/DeviceCapabilities/hasHoverSupport/index.ts new file mode 100644 index 000000000000..1ff0f461db69 --- /dev/null +++ b/src/libs/DeviceCapabilities/hasHoverSupport/index.ts @@ -0,0 +1,8 @@ +import HasHoverSupport from './types'; + +/** + * Allows us to identify whether the platform is hoverable. + */ +const hasHoverSupport: HasHoverSupport = () => window.matchMedia?.('(hover: hover) and (pointer: fine)').matches; + +export default hasHoverSupport; diff --git a/src/libs/DeviceCapabilities/hasHoverSupport/types.ts b/src/libs/DeviceCapabilities/hasHoverSupport/types.ts new file mode 100644 index 000000000000..b8fe944cf88e --- /dev/null +++ b/src/libs/DeviceCapabilities/hasHoverSupport/types.ts @@ -0,0 +1,3 @@ +type HasHoverSupport = () => boolean; + +export default HasHoverSupport; diff --git a/src/libs/DeviceCapabilities/index.js b/src/libs/DeviceCapabilities/index.ts similarity index 100% rename from src/libs/DeviceCapabilities/index.js rename to src/libs/DeviceCapabilities/index.ts diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts index 99c728f0a210..33d7311973cb 100644 --- a/src/libs/Growl.ts +++ b/src/libs/Growl.ts @@ -12,7 +12,9 @@ const isReadyPromise = new Promise((resolve) => { }); function setIsReady() { - if (!resolveIsReadyPromise) return; + if (!resolveIsReadyPromise) { + return; + } resolveIsReadyPromise(); } @@ -21,7 +23,9 @@ function setIsReady() { */ function show(bodyText: string, type: string, duration: number = CONST.GROWL.DURATION) { isReadyPromise.then(() => { - if (!growlRef?.current?.show) return; + if (!growlRef?.current?.show) { + return; + } growlRef.current.show(bodyText, type, duration); }); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 5c110264e034..f3939eabe6f7 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -345,7 +345,7 @@ const NewTeachersUniteNavigator = createModalStackNavigator([ }, { getComponent: () => { - const IntroSchoolPrincipalPage = require('../../../pages/TeachersUnite/IntroSchoolPrincipalPage').default; + const IntroSchoolPrincipalPage = require('../../../pages/TeachersUnite/ImTeacherPage').default; return IntroSchoolPrincipalPage; }, name: 'Intro_School_Principal', diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 3c6e879bd423..3bdf77745432 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -593,6 +593,30 @@ function isCurrentUser(userDetails) { return _.some(_.keys(loginList), (login) => login.toLowerCase() === userDetailsLogin.toLowerCase()); } +/** + * Calculates count of all enabled options + * + * @param {Object[]} options - an initial strings array + * @param {Boolean} options[].enabled - a flag to enable/disable option in a list + * @param {String} options[].name - a name of an option + * @returns {Number} + */ +function getEnabledCategoriesCount(options) { + return _.filter(options, (option) => option.enabled).length; +} + +/** + * Verifies that there is at least one enabled option + * + * @param {Object[]} options - an initial strings array + * @param {Boolean} options[].enabled - a flag to enable/disable option in a list + * @param {String} options[].name - a name of an option + * @returns {Boolean} + */ +function hasEnabledOptions(options) { + return _.some(options, (option) => option.enabled); +} + /** * Build the options for the category tree hierarchy via indents * @@ -606,6 +630,10 @@ function getCategoryOptionTree(options, isOneLine = false) { const optionCollection = {}; _.each(options, (option) => { + if (!option.enabled) { + return; + } + if (isOneLine) { if (_.has(optionCollection, option.name)) { return; @@ -656,10 +684,26 @@ function getCategoryOptionTree(options, isOneLine = false) { */ function getCategoryListSections(categories, recentlyUsedCategories, selectedOptions, searchInputValue, maxRecentReportsToShow) { const categorySections = []; - const categoriesValues = _.values(categories); + const categoriesValues = _.chain(categories) + .values() + .filter((category) => category.enabled) + .value(); + const numberOfCategories = _.size(categoriesValues); let indexOffset = 0; + if (numberOfCategories === 0 && selectedOptions.length > 0) { + categorySections.push({ + // "Selected" section + title: '', + shouldShow: false, + indexOffset, + data: getCategoryOptionTree(selectedOptions, true), + }); + + return categorySections; + } + if (!_.isEmpty(searchInputValue)) { const searchCategories = _.filter(categoriesValues, (category) => category.name.toLowerCase().includes(searchInputValue.toLowerCase())); @@ -1474,6 +1518,8 @@ export { isSearchStringMatch, shouldOptionShowTooltip, getLastMessageTextForReport, + getEnabledCategoriesCount, + hasEnabledOptions, getCategoryOptionTree, formatMemberForList, }; diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js index 164f284a4ef5..d2de5b1c0d7e 100644 --- a/src/libs/PolicyUtils.js +++ b/src/libs/PolicyUtils.js @@ -6,11 +6,12 @@ import ONYXKEYS from '../ONYXKEYS'; /** * Filter out the active policies, which will exclude policies with pending deletion + * These are policies that we can use to create reports with in NewDot. * @param {Object} policies * @returns {Array} */ function getActivePolicies(policies) { - return _.filter(policies, (policy) => policy && policy.isPolicyExpenseChatEnabled && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return _.filter(policies, (policy) => policy && (policy.isPolicyExpenseChatEnabled || policy.areChatRoomsEnabled) && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } /** @@ -166,6 +167,14 @@ function getIneligibleInvitees(policyMembers, personalDetails) { return memberEmailsToExclude; } +/** + * @param {Object} policy + * @returns {Boolean} + */ +function isPendingDeletePolicy(policy) { + return policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + export { getActivePolicies, hasPolicyMemberError, @@ -179,4 +188,5 @@ export { isPolicyAdmin, getMemberAccountIDsForWorkspace, getIneligibleInvitees, + isPendingDeletePolicy, }; diff --git a/src/libs/Pusher/EventType.js b/src/libs/Pusher/EventType.js index 639e10020fc7..85ccc5e17242 100644 --- a/src/libs/Pusher/EventType.js +++ b/src/libs/Pusher/EventType.js @@ -5,6 +5,7 @@ export default { REPORT_COMMENT: 'reportComment', ONYX_API_UPDATE: 'onyxApiUpdate', + USER_IS_LEAVING_ROOM: 'client-userIsLeavingRoom', USER_IS_TYPING: 'client-userIsTyping', MULTIPLE_EVENTS: 'multipleEvents', MULTIPLE_EVENT_TYPE: { diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index edf646d0266b..eee9d6549f6c 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -947,23 +947,31 @@ function getIconsForParticipants(participants, personalDetails) { for (let i = 0; i < participantsList.length; i++) { const accountID = participantsList[i]; const avatarSource = UserUtils.getAvatar(lodashGet(personalDetails, [accountID, 'avatar'], ''), accountID); - participantDetails.push([ - accountID, - lodashGet(personalDetails, [accountID, 'displayName']) || lodashGet(personalDetails, [accountID, 'login'], ''), - lodashGet(personalDetails, [accountID, 'firstName'], ''), - avatarSource, - ]); + const displayNameLogin = lodashGet(personalDetails, [accountID, 'displayName']) || lodashGet(personalDetails, [accountID, 'login'], ''); + participantDetails.push([accountID, displayNameLogin, avatarSource]); } - // Sort all logins by first name (which is the second element in the array) - const sortedParticipantDetails = participantDetails.sort((a, b) => a[2] - b[2]); + const sortedParticipantDetails = _.chain(participantDetails) + .sort((first, second) => { + // First sort by displayName/login + const displayNameLoginOrder = first[1].localeCompare(second[1]); + if (displayNameLoginOrder !== 0) { + return displayNameLoginOrder; + } - // Now that things are sorted, gather only the avatars (third element in the array) and return those + // Then fallback on accountID as the final sorting criteria. + // This will ensure that the order of avatars with same login/displayName + // stay consistent across all users and devices + return first[0] > second[0]; + }) + .value(); + + // Now that things are sorted, gather only the avatars (second element in the array) and return those const avatars = []; for (let i = 0; i < sortedParticipantDetails.length; i++) { const userIcon = { id: sortedParticipantDetails[i][0], - source: sortedParticipantDetails[i][3], + source: sortedParticipantDetails[i][2], type: CONST.ICON_TYPE_AVATAR, name: sortedParticipantDetails[i][1], }; @@ -1641,6 +1649,29 @@ function getParentReport(report) { return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, {}); } +/** + * Returns the root parentReport if the given report is nested. + * Uses recursion to iterate any depth of nested reports. + * + * @param {Object} report + * @returns {Object} + */ +function getRootParentReport(report) { + if (!report) { + return {}; + } + + // Returns the current report as the root report, because it does not have a parentReportID + if (!report.parentReportID) { + return report; + } + + const parentReport = getReport(report.parentReportID); + + // Runs recursion to iterate a parent report + return getRootParentReport(parentReport); +} + /** * Get the title for a report. * @@ -3676,6 +3707,7 @@ export { isAllowedToComment, getBankAccountRoute, getParentReport, + getRootParentReport, getTaskParentReportActionIDInAssigneeReport, getReportPreviewMessage, getModifiedExpenseMessage, diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index cea530f6f47a..5dcfbc467c20 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -147,6 +147,10 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep shouldStopSmartscan = true; } + if (_.has(transactionChanges, 'category')) { + updatedTransaction.category = transactionChanges.category; + } + if (shouldStopSmartscan && _.has(transaction, 'receipt') && !_.isEmpty(transaction.receipt) && lodashGet(transaction, 'receipt.state') !== CONST.IOU.RECEIPT_STATE.OPEN) { updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN; } @@ -157,6 +161,7 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep ...(_.has(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(_.has(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }; return updatedTransaction; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 08b032e383b8..86cd791f7fce 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1148,6 +1148,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC } // STEP 4: Compose the optimistic data + const currentTime = DateUtils.getDBTime(); const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1171,6 +1172,14 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.chatReportID}`, value: updatedChatReport, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: currentTime, + lastVisibleActionCreated: currentTime, + }, + }, ]; const successData = [ @@ -1191,6 +1200,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC created: null, currency: null, merchant: null, + category: null, }, }, }, @@ -1226,6 +1236,14 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.chatReportID}`, value: chatReport, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: transactionThread.lastReadTime, + lastVisibleActionCreated: transactionThread.lastVisibleActionCreated, + }, + }, ]; // STEP 6: Call the API endpoint diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index aa0d4b432da4..9fa1e5fe0567 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -103,24 +103,24 @@ function getReportChannelName(reportID) { } /** - * There are 2 possibilities that we can receive via pusher for a user's typing status: + * There are 2 possibilities that we can receive via pusher for a user's typing/leaving status: * 1. The "new" way from New Expensify is passed as {[login]: Boolean} (e.g. {yuwen@expensify.com: true}), where the value - * is whether the user with that login is typing on the report or not. + * is whether the user with that login is typing/leaving on the report or not. * 2. The "old" way from e.com which is passed as {userLogin: login} (e.g. {userLogin: bstites@expensify.com}) * * This method makes sure that no matter which we get, we return the "new" format * - * @param {Object} typingStatus + * @param {Object} status * @returns {Object} */ -function getNormalizedTypingStatus(typingStatus) { - let normalizedTypingStatus = typingStatus; +function getNormalizedStatus(status) { + let normalizedStatus = status; - if (_.first(_.keys(typingStatus)) === 'userLogin') { - normalizedTypingStatus = {[typingStatus.userLogin]: true}; + if (_.first(_.keys(status)) === 'userLogin') { + normalizedStatus = {[status.userLogin]: true}; } - return normalizedTypingStatus; + return normalizedStatus; } /** @@ -141,7 +141,7 @@ function subscribeToReportTypingEvents(reportID) { // If the pusher message comes from OldDot, we expect the typing status to be keyed by user // login OR by 'Concierge'. If the pusher message comes from NewDot, it is keyed by accountID // since personal details are keyed by accountID. - const normalizedTypingStatus = getNormalizedTypingStatus(typingStatus); + const normalizedTypingStatus = getNormalizedStatus(typingStatus); const accountIDOrLogin = _.first(_.keys(normalizedTypingStatus)); if (!accountIDOrLogin) { @@ -170,6 +170,41 @@ function subscribeToReportTypingEvents(reportID) { }); } +/** + * Initialize our pusher subscriptions to listen for someone leaving a room. + * + * @param {String} reportID + */ +function subscribeToReportLeavingEvents(reportID) { + if (!reportID) { + return; + } + + // Make sure we have a clean Leaving indicator before subscribing to leaving events + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportID}`, false); + + const pusherChannelName = getReportChannelName(reportID); + Pusher.subscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, (leavingStatus) => { + // If the pusher message comes from OldDot, we expect the leaving status to be keyed by user + // login OR by 'Concierge'. If the pusher message comes from NewDot, it is keyed by accountID + // since personal details are keyed by accountID. + const normalizedLeavingStatus = getNormalizedStatus(leavingStatus); + const accountIDOrLogin = _.first(_.keys(normalizedLeavingStatus)); + + if (!accountIDOrLogin) { + return; + } + + if (Number(accountIDOrLogin) !== currentUserAccountID) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportID}`, true); + }).catch((error) => { + Log.hmmm('[Report] Failed to initially subscribe to Pusher channel', false, {errorType: error.type, pusherChannelName}); + }); +} + /** * Remove our pusher subscriptions to listen for someone typing in a report. * @@ -185,6 +220,21 @@ function unsubscribeFromReportChannel(reportID) { Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.USER_IS_TYPING); } +/** + * Remove our pusher subscriptions to listen for someone leaving a report. + * + * @param {String} reportID + */ +function unsubscribeFromLeavingRoomReportChannel(reportID) { + if (!reportID) { + return; + } + + const pusherChannelName = getReportChannelName(reportID); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportID}`, false); + Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM); +} + // New action subscriber array for report pages let newActionSubscribers = []; @@ -865,6 +915,17 @@ function broadcastUserIsTyping(reportID) { typingStatus[currentUserAccountID] = true; Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_TYPING, typingStatus); } +/** + * Broadcasts to the report's private pusher channel whether a user is leaving a report + * + * @param {String} reportID + */ +function broadcastUserIsLeavingRoom(reportID) { + const privateReportChannelName = getReportChannelName(reportID); + const leavingStatus = {}; + leavingStatus[currentUserAccountID] = true; + Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus); +} /** * When a report changes in Onyx, this fetches the report from the API if the report doesn't have a name @@ -1781,6 +1842,12 @@ function getCurrentUserAccountID() { function leaveRoom(reportID) { const report = lodashGet(allReports, [reportID], {}); const reportKeys = _.keys(report); + + // Pusher's leavingStatus should be sent earlier. + // Place the broadcast before calling the LeaveRoom API to prevent a race condition + // between Onyx report being null and Pusher's leavingStatus becoming true. + broadcastUserIsLeavingRoom(reportID); + API.write( 'LeaveRoom', { @@ -2071,10 +2138,13 @@ export { updateWriteCapabilityAndNavigate, updateNotificationPreferenceAndNavigate, subscribeToReportTypingEvents, + subscribeToReportLeavingEvents, unsubscribeFromReportChannel, + unsubscribeFromLeavingRoomReportChannel, saveReportComment, saveReportCommentNumberOfLines, broadcastUserIsTyping, + broadcastUserIsLeavingRoom, togglePinnedState, editReportComment, handleUserDeletedLinksInHtml, diff --git a/src/libs/isInputAutoFilled.ts b/src/libs/isInputAutoFilled.ts index 0abe634001e4..e1b9942b0e78 100644 --- a/src/libs/isInputAutoFilled.ts +++ b/src/libs/isInputAutoFilled.ts @@ -4,7 +4,9 @@ import isSelectorSupported from './isSelectorSupported'; * Check the input is auto filled or not */ export default function isInputAutoFilled(input: Element): boolean { - if (!input?.matches) return false; + if (!input?.matches) { + return false; + } if (isSelectorSupported(':autofill')) { return input.matches(':-webkit-autofill') || input.matches(':autofill'); } diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js index e401cbd9db69..588e1a5642aa 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.js @@ -2,7 +2,6 @@ import _ from 'underscore'; import Log from './Log'; import AddEncryptedAuthToken from './migrations/AddEncryptedAuthToken'; import RenamePriorityModeKey from './migrations/RenamePriorityModeKey'; -import MoveToIndexedDB from './migrations/MoveToIndexedDB'; import RenameExpensifyNewsStatus from './migrations/RenameExpensifyNewsStatus'; import AddLastVisibleActionCreated from './migrations/AddLastVisibleActionCreated'; import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; @@ -14,15 +13,7 @@ export default function () { return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [ - MoveToIndexedDB, - RenamePriorityModeKey, - AddEncryptedAuthToken, - RenameExpensifyNewsStatus, - AddLastVisibleActionCreated, - PersonalDetailsByAccountID, - RenameReceiptFilename, - ]; + const migrationPromises = [RenamePriorityModeKey, AddEncryptedAuthToken, RenameExpensifyNewsStatus, AddLastVisibleActionCreated, PersonalDetailsByAccountID, RenameReceiptFilename]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/MoveToIndexedDB.js b/src/libs/migrations/MoveToIndexedDB.js deleted file mode 100644 index 1f62985ed7bf..000000000000 --- a/src/libs/migrations/MoveToIndexedDB.js +++ /dev/null @@ -1,75 +0,0 @@ -import _ from 'underscore'; -import Onyx from 'react-native-onyx'; - -import Log from '../Log'; -import ONYXKEYS from '../../ONYXKEYS'; -import getPlatform from '../getPlatform'; -import CONST from '../../CONST'; - -/** - * Test whether the current platform is eligible for migration - * This migration is only applicable for desktop/web - * We're also skipping logged-out users as there would be nothing to migrate - * - * @returns {Boolean} - */ -function shouldMigrate() { - const isTargetPlatform = _.contains([CONST.PLATFORM.WEB, CONST.PLATFORM.DESKTOP], getPlatform()); - if (!isTargetPlatform) { - Log.info('[Migrate Onyx] Skipped migration MoveToIndexedDB (Not applicable to current platform)'); - return false; - } - - const session = window.localStorage.getItem(ONYXKEYS.SESSION); - if (!session || !session.includes('authToken')) { - Log.info('[Migrate Onyx] Skipped migration MoveToIndexedDB (Not applicable to logged out users)'); - return false; - } - - return true; -} - -/** - * Find Onyx data and move it from local storage to IndexedDB - * - * @returns {Promise} - */ -function applyMigration() { - const onyxKeys = new Set(_.values(ONYXKEYS)); - const onyxCollections = _.values(ONYXKEYS.COLLECTION); - - // Prepare a key-value dictionary of keys eligible for migration - // Targeting existing Onyx keys in local storage or any key prefixed by a collection name - const dataToMigrate = _.chain(window.localStorage) - .keys() - .filter((key) => onyxKeys.has(key) || _.some(onyxCollections, (collectionKey) => key.startsWith(collectionKey))) - .map((key) => [key, JSON.parse(window.localStorage.getItem(key))]) - .object() - .value(); - - // Move the data in Onyx and only then delete it from local storage - return Onyx.multiSet(dataToMigrate).then(() => _.each(dataToMigrate, (value, key) => window.localStorage.removeItem(key))); -} - -/** - * Migrate Web/Desktop storage to IndexedDB - * - * @returns {Promise} - */ -export default function () { - if (!shouldMigrate()) { - return Promise.resolve(); - } - - return new Promise((resolve, reject) => { - applyMigration() - .then(() => { - Log.info('[Migrate Onyx] Ran migration MoveToIndexedDB'); - resolve(); - }) - .catch((e) => { - Log.alert('[Migrate Onyx] MoveToIndexedDB failed', {error: e.message, stack: e.stack}, false); - reject(e); - }); - }); -} diff --git a/src/libs/requireParameters.ts b/src/libs/requireParameters.ts index 098a6d114430..ebeb55e254e0 100644 --- a/src/libs/requireParameters.ts +++ b/src/libs/requireParameters.ts @@ -14,7 +14,9 @@ export default function requireParameters(parameterNames: string[], parameters: const propertiesToRedact = ['authToken', 'password', 'partnerUserSecret', 'twoFactorAuthCode']; const parametersCopy = {...parameters}; Object.keys(parametersCopy).forEach((key) => { - if (!propertiesToRedact.includes(key.toString())) return; + if (!propertiesToRedact.includes(key.toString())) { + return; + } parametersCopy[key] = ''; }); diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index 98bc09a7a217..7c04970c3980 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -103,6 +103,7 @@ class AddPersonalBankAccountPage extends React.Component { includeSafeAreaPaddingBottom={shouldShowSuccess} shouldEnablePickerAvoiding={false} shouldShowOfflineIndicator={false} + testID={AddPersonalBankAccountPage.displayName} > + { + onSubmit({ + category: category.searchText, + }); + }; + + return ( + + + + + + ); +} + +EditRequestCategoryPage.propTypes = propTypes; +EditRequestCategoryPage.displayName = 'EditRequestCategoryPage'; + +export default EditRequestCategoryPage; diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js index a29936ba9ba4..9edce7350400 100644 --- a/src/pages/EditRequestCreatedPage.js +++ b/src/pages/EditRequestCreatedPage.js @@ -23,6 +23,7 @@ function EditRequestCreatedPage({defaultCreated, onSubmit}) {
descriptionInputRef.current && descriptionInputRef.current.focus()} + testID={EditRequestDescriptionPage.displayName} > merchantInputRef.current && merchantInputRef.current.focus()} + testID={EditRequestMerchantPage.displayName} > { + let updatedCategory = transactionChanges.category; + // In case the same category has been selected, do reset of the category. + if (transactionCategory === updatedCategory) { + updatedCategory = ''; + } + editMoneyRequest({category: updatedCategory}); + }} + /> + ); + } + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { return ( `${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`, }, }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ parentReport: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`, diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js index 47aa23a93432..2ef33a72423b 100644 --- a/src/pages/EditRequestReceiptPage.js +++ b/src/pages/EditRequestReceiptPage.js @@ -31,6 +31,7 @@ function EditRequestReceiptPage({route, transactionID}) { + {() => { if (this.props.userWallet.errorCode === CONST.WALLET.ERROR.KYC) { return ( diff --git a/src/pages/ErrorPage/NotFoundPage.js b/src/pages/ErrorPage/NotFoundPage.js index f0121cd8f3ef..445e81296566 100644 --- a/src/pages/ErrorPage/NotFoundPage.js +++ b/src/pages/ErrorPage/NotFoundPage.js @@ -5,7 +5,7 @@ import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoun // eslint-disable-next-line rulesdir/no-negated-variables function NotFoundPage() { return ( - + ); diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js index 1a9a2d8c8767..53da810007ea 100644 --- a/src/pages/FlagCommentPage.js +++ b/src/pages/FlagCommentPage.js @@ -154,7 +154,10 @@ function FlagCommentPage(props) { )); return ( - + {({safeAreaPaddingBottomStyle}) => ( diff --git a/src/pages/GetAssistancePage.js b/src/pages/GetAssistancePage.js index 34f996936654..97b498d758b7 100644 --- a/src/pages/GetAssistancePage.js +++ b/src/pages/GetAssistancePage.js @@ -78,7 +78,7 @@ function GetAssistancePage(props) { } return ( - + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index cb54aa8e5a7b..c3b66910face 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -173,6 +173,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate}) includeSafeAreaPaddingBottom={false} includePaddingTop={false} shouldEnableMaxHeight + testID={NewChatPage.displayName} > {({safeAreaPaddingBottomStyle, insets}) => ( focusAndUpdateMultilineInputRange(privateNotesInput.current)} + testID={PrivateNotesEditPage.displayName} > + Navigation.dismissModal()} /> {report.isLoadingPrivateNotes && _.isEmpty(lodashGet(report, 'privateNotes', {})) ? ( - + ) : ( _.map(privateNotes, (item, index) => getMenuItem(item, index)) )} diff --git a/src/pages/PrivateNotes/PrivateNotesViewPage.js b/src/pages/PrivateNotes/PrivateNotesViewPage.js index 48f053f10f90..4c6d960d5d9a 100644 --- a/src/pages/PrivateNotes/PrivateNotesViewPage.js +++ b/src/pages/PrivateNotes/PrivateNotesViewPage.js @@ -56,7 +56,10 @@ function PrivateNotesViewPage({route, personalDetailsList, session, report}) { const privateNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], ''); return ( - + { function ProfilePage(props) { const accountID = Number(lodashGet(props.route.params, 'accountID', 0)); - // eslint-disable-next-line rulesdir/prefer-early-return - useEffect(() => { - if (ValidationUtils.isValidAccountRoute(accountID)) { - PersonalDetails.openPublicProfilePage(accountID); - } - }, [accountID]); - const details = lodashGet(props.personalDetails, accountID, ValidationUtils.isValidAccountRoute(accountID) ? {} : {isloading: false}); const displayName = details.displayName ? details.displayName : props.translate('common.hidden'); @@ -143,8 +136,15 @@ function ProfilePage(props) { const chatReportWithCurrentUser = !isCurrentUser && !Session.isAnonymousUser() ? ReportUtils.getChatByParticipants([accountID]) : 0; + // eslint-disable-next-line rulesdir/prefer-early-return + useEffect(() => { + if (ValidationUtils.isValidAccountRoute(accountID) && !hasMinimumDetails) { + PersonalDetails.openPublicProfilePage(accountID); + } + }, [accountID, hasMinimumDetails]); + return ( - + Navigation.goBack(navigateBackTo)} diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index 1ef43843571c..761be71d864a 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -145,7 +145,10 @@ function ACHContractStep(props) { }; return ( - + + + + + + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} @@ -385,7 +385,7 @@ class ReimbursementAccountPage extends React.Component { if (errorText) { return ( - + + + diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index 6ccb7a0c2e87..c10401fb90db 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -99,7 +99,10 @@ function ReportParticipantsPage(props) { })); return ( - + {({safeAreaPaddingBottomStyle}) => ( {({didScreenTransitionEnd}) => ( diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js index 2ee29380ff80..141f4e841853 100755 --- a/src/pages/SearchPage.js +++ b/src/pages/SearchPage.js @@ -169,7 +169,10 @@ class SearchPage extends Component { ); return ( - + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <> diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index a36149a5f4fa..f19c79db5459 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -54,7 +54,7 @@ class ShareCodePage extends React.Component { const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; return ( - + Navigation.goBack(isReport ? ROUTES.getReportDetailsRoute(this.props.report.reportID) : ROUTES.SETTINGS)} diff --git a/src/pages/TeachersUnite/ImTeacherPage.js b/src/pages/TeachersUnite/ImTeacherPage.js index dbeba700d208..a938abf81b79 100644 --- a/src/pages/TeachersUnite/ImTeacherPage.js +++ b/src/pages/TeachersUnite/ImTeacherPage.js @@ -1,54 +1,36 @@ import React from 'react'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import HeaderWithBackButton from '../../components/HeaderWithBackButton'; -import ROUTES from '../../ROUTES'; -import Navigation from '../../libs/Navigation/Navigation'; -import FixedFooter from '../../components/FixedFooter'; -import styles from '../../styles/styles'; -import Button from '../../components/Button'; -import * as Illustrations from '../../components/Icon/Illustrations'; -import variables from '../../styles/variables'; -import useLocalize from '../../hooks/useLocalize'; -import BlockingView from '../../components/BlockingViews/BlockingView'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as LoginUtils from '../../libs/LoginUtils'; +import ImTeacherUpdateEmailPage from './ImTeacherUpdateEmailPage'; +import IntroSchoolPrincipalPage from './IntroSchoolPrincipalPage'; -const propTypes = {}; +const propTypes = { + /** Current user session */ + session: PropTypes.shape({ + /** Current user primary login */ + email: PropTypes.string.isRequired, + }), +}; -const defaultProps = {}; +const defaultProps = { + session: { + email: null, + }, +}; -function ImTeacherPage() { - const {translate} = useLocalize(); - - return ( - - Navigation.goBack(ROUTES.TEACHERS_UNITE)} - /> - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} - iconWidth={variables.signInLogoWidthLargeScreen} - iconHeight={variables.lhnLogoWidth} - /> - -