diff --git a/.eslintignore b/.eslintignore index cd4efd8e..1f6dfa0a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,4 @@ *.d.ts +dist +node_modules +*.config.js diff --git a/.eslintrc.js b/.eslintrc.js index 02a609dc..b3a9bd5a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,11 +1,85 @@ module.exports = { extends: ['expensify', 'prettier'], - rules: { - // Allow JSX to be written in any file ignoring the extension type - 'react/jsx-filename-extension': 'off' - }, - plugins: ['jest'], - env: { - "jest/globals": true - } + parser: '@typescript-eslint/parser', + overrides: [ + { + files: ['*.js', '*.jsx'], + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, + rules: { + // Allow JSX to be written in any file ignoring the extension type + 'react/jsx-filename-extension': 'off', + 'rulesdir/no-api-in-views': 'off', + 'rulesdir/no-multiple-api-calls': 'off', + 'rulesdir/prefer-import-module-contents': 'off', + 'no-constructor-return': 'off', + 'max-classes-per-file': 'off', + 'arrow-body-style': 'off', + 'es/no-nullish-coalescing-operators': 'off', + 'rulesdir/prefer-underscore-method': 'off', + 'es/no-optional-chaining': 'off', + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }, + ], + }, + }, + { + files: ['*.ts', '*.tsx'], + extends: [ + 'expensify', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/stylistic', + 'plugin:import/typescript', + 'plugin:you-dont-need-lodash-underscore/all', + 'prettier', + 'plugin:prettier/recommended', + ], + plugins: ['react', 'import', '@typescript-eslint'], + parserOptions: { + project: './tsconfig.json', + }, + rules: { + 'prefer-regex-literals': 'off', + 'rulesdir/prefer-underscore-method': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/require-default-props': 'off', + 'valid-jsdoc': 'off', + 'es/no-optional-chaining': 'off', + 'es/no-nullish-coalescing-operators': 'off', + 'react/jsx-filename-extension': ['error', {extensions: ['.tsx', '.jsx']}], + 'import/no-unresolved': 'error', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_'}], + '@typescript-eslint/consistent-type-imports': ['error', {prefer: 'type-imports'}], + '@typescript-eslint/consistent-type-exports': ['error', {fixMixedExportsWithInlineTypeSpecifier: false}], + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], + '@typescript-eslint/consistent-type-definitions': 'off', + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }, + ], + }, + }, + ], }; diff --git a/.github/OSBotify-private-key.asc.gpg b/.github/OSBotify-private-key.asc.gpg new file mode 100644 index 00000000..c19d5c97 Binary files /dev/null and b/.github/OSBotify-private-key.asc.gpg differ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 71bab56f..a24f8b13 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,3 +27,12 @@ jobs: - run: npm run lint env: CI: true + + - name: Verify there's no Prettier diff + run: | + npm run prettier -- --loglevel silent + if ! git diff --name-only --exit-code; then + # shellcheck disable=SC2016 + echo 'Error: Prettier diff detected! Please run `npm run prettier` and commit the changes.' + exit 1 + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..baa8fded --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,77 @@ +name: Publish package to npmjs + +# This workflow runs when code is pushed to `main` (i.e: when a pull request is merged) +on: + push: + branches: [main] + +# Ensure that only once instance of this workflow executes at a time. +# If multiple PRs are merged in quick succession, there will only ever be one publish workflow running and one pending. +concurrency: ${{ github.workflow }} + +jobs: + version: + runs-on: ubuntu-latest + + # OSBotify will update the version on `main`, so this check is important to prevent an infinite loop + if: ${{ github.actor != 'OSBotify' }} + + steps: + - uses: actions/checkout@v4 + with: + ref: main + # The OS_BOTIFY_COMMIT_TOKEN is a personal access token tied to osbotify, which allows him to push to protected branches + token: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} + + - name: Decrypt & Import OSBotify GPG key + run: | + cd .github + gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output OSBotify-private-key.asc OSBotify-private-key.asc.gpg + gpg --import OSBotify-private-key.asc + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Set up git for OSBotify + run: | + git config --global user.signingkey 367811D53E34168C + git config --global commit.gpgsign true + git config --global user.name OSBotify + git config --global user.email infra+osbotify@expensify.com + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + registry-url: 'https://registry.npmjs.org' + + - name: Install npm packages + run: npm ci + + - name: Update npm version + run: npm version patch + + - name: Set new version in GitHub ENV + run: echo "NEW_VERSION=$(jq '.version' package.json)" >> $GITHUB_ENV + + - name: Push branch and publish tags + run: git push origin main && git push --tags + + - name: Build package + run: npm run build + + - name: Publish to npm + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Get merged pull request + id: getMergedPullRequest + run: | + read -r number < <(gh pr list --search ${{ github.sha }} --state merged --json 'number' | jq -r '.[0] | [.number] | join(" ")') + echo "number=$number" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Comment on merged pull request + run: gh pr comment ${{ steps.getMergedPullRequest.outputs.number }} --body "🚀Published to npm in v${{ env.NEW_VERSION }}" + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 00000000..09d4a38f --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,24 @@ +name: TypeScript Checks + +on: + pull_request: + types: [opened, synchronize] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + cache: npm + cache-dependency-path: package-lock.json + + - run: npm ci + + - name: Type check with TypeScript + run: npm run typecheck + env: + CI: true diff --git a/.gitignore b/.gitignore index 608a5535..78c6fc53 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,10 @@ npm-debug.log package.json-e .DS_Store *.swp +dist + +# Decrypted private key we do not want to commit +.github/OSBotify-private-key.asc + +# Published package +*.tgz diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..62d44807 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.13.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..d54e0bdf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +package.json +package-lock.json +*.html diff --git a/README.md b/README.md index 11baaf0b..55801113 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # `expensify-common` -This is a collection of JS libraries and components which are used across various Expensify projects. These libraries are provided as-is, and the repos which use them will need to do their own bundling, minifying, and uglifying. +This is a collection of JS/TS libraries and components which are used across various Expensify projects. These libraries are provided as-is, and the repos which use them will need to do their own bundling, minifying, and uglifying. # Installation -1. Clone this repo to a directory of your choosing -2. Run `npm install` to install all the dependencies +`expensify-common` is published to [`npm`](https://www.npmjs.com/package/expensify-common) + +```shell +npm install expensify-common +``` # Development * Write all code as ES6. -* Always lint your code with `npm run grunt watch` -* Make sure you're using http://editorconfig.org/ +* Always lint your code with `npm run lint` ## Testing your code while you are developing The best way to test your code while you are developing changes is via `npm link`. @@ -28,7 +30,7 @@ Alternatively, you can edit files directly in a project's `node_modules` then ap 1. They will review and accept your changes, merge them, then deploy a new version # Deploying a Change (Expensify Only) -Once the PR has been merged, update the `package.json` commit hash in any repos with a dependency on the code being changed in expensify-common, don't forget to run a `npm install` so `package-lock.json` is also updated. Be sure to check the repos below to confirm whether or not they are affected by your changes! +Once the PR has been merged, install the new version of the package with `npm install expensify-common@x.x.x` command. Be sure to check the repos below to confirm whether or not they are affected by your changes! - Expensify/Web-Expensify - Expensify/Web-Secure - Expensify/Mobile-Expensify diff --git a/__tests__/ExpensiMark-HTML-test.js b/__tests__/ExpensiMark-HTML-test.js index d31b5dee..ecafb223 100644 --- a/__tests__/ExpensiMark-HTML-test.js +++ b/__tests__/ExpensiMark-HTML-test.js @@ -23,8 +23,8 @@ test('Test multi-line bold markdown replacement', () => { test('Test bold within code blocks is skipped', () => { - const testString = 'bold\n```*not a bold*```\nThis is *bold*'; - const replacedString = 'bold
*not a bold*
This is bold'; + const testString = 'bold\n```\n*not a bold*\n```\nThis is *bold*'; + const replacedString = 'bold
*not a bold*
This is bold'; expect(parser.replace(testString)).toBe(replacedString); }); @@ -57,6 +57,16 @@ test('Test italic markdown replacement', () => { expect(parser.replace(italicTestStartString)).toBe(italicTestReplacedString); }); +test('Test italic markdown replacement', () => { + const italicTestStartString = 'Note that _this is correctly italicized_\n' + + 'Note that `_this is correctly not italicized_` and _following inline contents are italicized_' + + const italicTestReplacedString = 'Note that this is correctly italicized
' + + 'Note that _this is correctly not italicized_ and following inline contents are italicized' + + expect(parser.replace(italicTestStartString)).toBe(italicTestReplacedString); +}); + // Multi-line text wrapped in _ is successfully replaced with test('Test multi-line italic markdown replacement', () => { const testString = '_Here is a multi-line\ncomment that should\nbe italic_ \n_\n_test\n_'; @@ -202,14 +212,14 @@ test('Test markdown replacement for emails wrapped in bold/strikethrough/italic // Check emails within other markdown test('Test emails within other markdown', () => { const testString = '> test@example.com\n' - + '```test@example.com```\n' + + '```\ntest@example.com\n```\n' + '`test@example.com`\n' + '_test@example.com_ ' + '_test@example.com__ ' + '__test@example.com__ ' + '__test@example.com_'; const result = '
test@example.com
' - + '
test@example.com
' + + '
test@example.com
' + 'test@example.com
' + 'test@example.com ' + 'test@example.com_ ' @@ -410,32 +420,14 @@ test('Test period replacements', () => { }); test('Test code fencing', () => { - let codeFenceExampleMarkdown = '```\nconst javaScript = \'javaScript\'\n```'; - expect(parser.replace(codeFenceExampleMarkdown)).toBe('
const javaScript = 'javaScript'
'); - - codeFenceExampleMarkdown = '```const javaScript = \'javaScript\'\n```'; + const codeFenceExampleMarkdown = '```\nconst javaScript = \'javaScript\'\n```'; expect(parser.replace(codeFenceExampleMarkdown)).toBe('
const javaScript = 'javaScript'
'); - - codeFenceExampleMarkdown = '```\nconst javaScript = \'javaScript\'```'; - expect(parser.replace(codeFenceExampleMarkdown)).toBe('
const javaScript = 'javaScript'
'); - - codeFenceExampleMarkdown = '```const javaScript = \'javaScript\'```'; - expect(parser.replace(codeFenceExampleMarkdown)).toBe('
const javaScript = 'javaScript'
'); }); test('Test code fencing with spaces and new lines', () => { let codeFenceExample = '```\nconst javaScript = \'javaScript\'\n const php = \'php\'\n```'; expect(parser.replace(codeFenceExample)).toBe('
const javaScript = 'javaScript'
const php = 'php'
'); - codeFenceExample = '```const javaScript = \'javaScript\'\n const php = \'php\'\n```'; - expect(parser.replace(codeFenceExample)).toBe('
const javaScript = 'javaScript'
const php = 'php'
'); - - codeFenceExample = '```\nconst javaScript = \'javaScript\'\n const php = \'php\'```'; - expect(parser.replace(codeFenceExample)).toBe('
const javaScript = 'javaScript'
const php = 'php'
'); - - codeFenceExample = '```const javaScript = \'javaScript\'\n const php = \'php\'```'; - expect(parser.replace(codeFenceExample)).toBe('
const javaScript = 'javaScript'
const php = 'php'
'); - codeFenceExample = '```\n\n# test\n\n```'; expect(parser.replace(codeFenceExample)).toBe('

# test

'); @@ -457,6 +449,21 @@ test('Test inline code blocks', () => { expect(parser.replace(inlineCodeStartString)).toBe('My favorite language is JavaScript. How about you?'); }); +test('Test inline code blocks with double backticks', () => { + const inlineCodeStartString = 'My favorite language is ``JavaScript``. How about you?'; + expect(parser.replace(inlineCodeStartString)).toBe('My favorite language is `JavaScript`. How about you?'); +}); + +test('Test inline code blocks with triple backticks', () => { + const inlineCodeStartString = 'My favorite language is ```JavaScript```. How about you?'; + expect(parser.replace(inlineCodeStartString)).toBe('My favorite language is ``JavaScript``. How about you?'); +}); + +test('Test multiple inline codes in one line', () => { + const inlineCodeStartString = 'My favorite language is `JavaScript`. How about you? I also like `Python`.'; + expect(parser.replace(inlineCodeStartString)).toBe('My favorite language is JavaScript. How about you? I also like Python.'); +}); + test('Test inline code with one backtick as content', () => { const inlineCodeStartString = '```'; expect(parser.replace(inlineCodeStartString)).toBe('```'); @@ -488,58 +495,46 @@ test('Test inline code blocks with two backticks', () => { }); test('Test code fencing with ExpensiMark syntax inside', () => { - let codeFenceExample = '```\nThis is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)\n```'; - expect(parser.replace(codeFenceExample)).toBe('
This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)
'); - - codeFenceExample = '```This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)\n```'; + const codeFenceExample = '```\nThis is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)\n```'; expect(parser.replace(codeFenceExample)).toBe('
This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)
'); - - codeFenceExample = '```\nThis is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)```'; - expect(parser.replace(codeFenceExample)).toBe('
This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)
'); - - codeFenceExample = '```This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)```'; - expect(parser.replace(codeFenceExample)).toBe('
This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)
'); }); test('Test code fencing with ExpensiMark syntax outside', () => { - let codeFenceExample = '# Test1 ```code``` Test2'; - expect(parser.replace(codeFenceExample)).toBe('

Test1

code
Test2'); + let codeFenceExample = '# Test1 ```\ncode\n``` Test2'; + expect(parser.replace(codeFenceExample)).toBe('

Test1

code
Test2'); - codeFenceExample = '*Test1 ```code``` Test2*'; - expect(parser.replace(codeFenceExample)).toBe('*Test1
code
Test2*'); - expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('*Test1
code
Test2*'); + codeFenceExample = '*Test1 ```\ncode\n``` Test2*'; + expect(parser.replace(codeFenceExample)).toBe('*Test1
code
Test2*'); + expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('*Test1
code\n
Test2*'); - codeFenceExample = '_Test1 ```code``` Test2_'; - expect(parser.replace(codeFenceExample)).toBe('_Test1
code
Test2_'); - expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('_Test1
code
Test2_'); + codeFenceExample = '_Test1 ```\ncode\n``` Test2_'; + expect(parser.replace(codeFenceExample)).toBe('_Test1
code
Test2_'); + expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('_Test1
code\n
Test2_'); - codeFenceExample = '~Test1 ```code``` Test2~'; - expect(parser.replace(codeFenceExample)).toBe('~Test1
code
Test2~'); - expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('~Test1
code
Test2~'); + codeFenceExample = '~Test1 ```\ncode\n``` Test2~'; + expect(parser.replace(codeFenceExample)).toBe('~Test1
code
Test2~'); + expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('~Test1
code\n
Test2~'); - codeFenceExample = '[```code```](google.com)'; - expect(parser.replace(codeFenceExample)).toBe('[
code
](google.com)'); - expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('[
code
](google.com)'); + codeFenceExample = '[```\ncode\n```](google.com)'; + expect(parser.replace(codeFenceExample)).toBe('[
code
](google.com)'); + expect(parser.replace(codeFenceExample, {shouldKeepRawInput: true})).toBe('[
code\n
](google.com)'); }); test('Test code fencing with additional backticks inside', () => { - let nestedBackticks = '````test````'; - expect(parser.replace(nestedBackticks)).toBe('
`test`
'); + let nestedBackticks = '```\n`test`\n```'; + expect(parser.replace(nestedBackticks)).toBe('
`test`
'); - nestedBackticks = '````\ntest\n````'; - expect(parser.replace(nestedBackticks)).toBe('
`
test
`
'); + nestedBackticks = '```\n`\ntest\n`\n```'; + expect(parser.replace(nestedBackticks)).toBe('
`
test
`
'); - nestedBackticks = '````````'; - expect(parser.replace(nestedBackticks)).toBe('
``
'); + nestedBackticks = '```\n``\n```'; + expect(parser.replace(nestedBackticks)).toBe('
``
'); - nestedBackticks = '````\n````'; - expect(parser.replace(nestedBackticks)).toBe('
`
`
'); + nestedBackticks = '```\n`\n`\n```'; + expect(parser.replace(nestedBackticks)).toBe('
`
`
'); - nestedBackticks = '```````````'; - expect(parser.replace(nestedBackticks)).toBe('
`````
'); - - nestedBackticks = '````This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)````'; - expect(parser.replace(nestedBackticks)).toBe('
`This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)`
'); + nestedBackticks = '```\n`This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)`\n```'; + expect(parser.replace(nestedBackticks)).toBe('
`This is how you can write ~strikethrough~, *bold*, _italics_, and [links](https://www.expensify.com)`
'); }); test('Test combination replacements', () => { @@ -1016,41 +1011,41 @@ test('Test autolink replacement to avoid parsing nested links', () => { }); test('Test quotes markdown replacement with text matching inside and outside codefence without spaces', () => { - const testString = 'The next line should be quoted\n> Hello,I’mtext\n```\nThe next line should not be quoted\n>Hello,I’mtext\nsince its inside a codefence```'; + const testString = 'The next line should be quoted\n> Hello,I’mtext\n```\nThe next line should not be quoted\n>Hello,I’mtext\nsince its inside a codefence\n```'; - const resultString = 'The next line should be quoted
Hello,I’mtext
The next line should not be quoted
>Hello,I’mtext
since its inside a codefence
'; + const resultString = 'The next line should be quoted
Hello,I’mtext
The next line should not be quoted
>Hello,I’mtext
since its inside a codefence
'; expect(parser.replace(testString)).toBe(resultString); }); test('Test quotes markdown replacement with text matching inside and outside codefence at the same line', () => { - const testString = 'The next line should be quoted\n> Hello,I’mtext\nThe next line should not be quoted\n```>Hello,I’mtext```\nsince its inside a codefence'; + const testString = 'The next line should be quoted\n> Hello,I’mtext\nThe next line should not be quoted\n```\n>Hello,I’mtext\n```\nsince its inside a codefence'; - const resultString = 'The next line should be quoted
Hello,I’mtext
The next line should not be quoted
>Hello,I’mtext
since its inside a codefence'; + const resultString = 'The next line should be quoted
Hello,I’mtext
The next line should not be quoted
>Hello,I’mtext
since its inside a codefence'; expect(parser.replace(testString)).toBe(resultString); }); test('Test quotes markdown replacement with text matching inside and outside codefence at the end of the text', () => { - const testString = 'The next line should be quoted\n> Hello,I’mtext\nThe next line should not be quoted\n```>Hello,I’mtext```'; + const testString = 'The next line should be quoted\n> Hello,I’mtext\nThe next line should not be quoted\n```\n>Hello,I’mtext\n```'; - const resultString = 'The next line should be quoted
Hello,I’mtext
The next line should not be quoted
>Hello,I’mtext
'; + const resultString = 'The next line should be quoted
Hello,I’mtext
The next line should not be quoted
>Hello,I’mtext
'; expect(parser.replace(testString)).toBe(resultString); }); test('Test quotes markdown replacement with text matching inside and outside codefence with quotes at the end of the text', () => { - const testString = 'The next line should be quoted\n```> Hello,I’mtext```\nThe next line should not be quoted\n> Hello,I’mtext'; + const testString = 'The next line should be quoted\n```\n> Hello,I’mtext\n```\nThe next line should not be quoted\n> Hello,I’mtext'; - const resultString = 'The next line should be quoted
> Hello,I’mtext
The next line should not be quoted
Hello,I’mtext
'; + const resultString = 'The next line should be quoted
> Hello,I’mtext
The next line should not be quoted
Hello,I’mtext
'; expect(parser.replace(testString)).toBe(resultString); }); test('Test quotes markdown replacement and removing
from
 and 

', () => { - const testString = 'The next line should be quoted\n```>Hello,I’mtext```\nThe next line should not be quoted'; + const testString = 'The next line should be quoted\n```\n>Hello,I’mtext\n```\nThe next line should not be quoted'; - const resultString = 'The next line should be quoted
>Hello,I’mtext
The next line should not be quoted'; + const resultString = 'The next line should be quoted
>Hello,I’mtext
The next line should not be quoted'; expect(parser.replace(testString)).toBe(resultString); }); @@ -1284,8 +1279,14 @@ test('Test for user mention with invalid username', () => { }); test('Test for user mention with codefence style', () => { - const testString = '```@username@expensify.com```'; - const resultString = '
@username@expensify.com
'; + const testString = '```\n@username@expensify.com\n```'; + const resultString = '
@username@expensify.com
'; + expect(parser.replace(testString)).toBe(resultString); +}); + +test('Test for room mention with codefence style', () => { + const testString = '```\n#room\n```'; + const resultString = '
#room
'; expect(parser.replace(testString)).toBe(resultString); }); @@ -1296,8 +1297,8 @@ test('Test for user mention with inlineCodeBlock style', () => { }); test('Test for user mention with text with codefence style', () => { - const testString = '```hi @username@expensify.com```'; - const resultString = '
hi @username@expensify.com
'; + const testString = '```\nhi @username@expensify.com\n```'; + const resultString = '
hi @username@expensify.com
'; expect(parser.replace(testString)).toBe(resultString); }); @@ -1326,8 +1327,8 @@ test('Test for user mention with user email includes underscores', () => { }); test('Test for @here mention with codefence style', () => { - const testString = '```@here```'; - const resultString = '
@here
'; + const testString = '```\n@here\n```'; + const resultString = '
@here
'; expect(parser.replace(testString)).toBe(resultString); }); @@ -1589,11 +1590,11 @@ test('Test here mention with @here@here', () => { }); test('Test link with code fence inside the alias text part', () => { - const testString = '[```code```](google.com) ' - + '[test ```code``` test](google.com)'; + const testString = '[```\ncode\n```](google.com) ' + + '[test ```\ncode\n``` test](google.com)'; - const resultString = '[
code
](google.com) ' - + '[test
code
test](google.com)'; + const resultString = '[
code
](google.com) ' + + '[test
code
test](google.com)'; expect(parser.replace(testString)).toBe(resultString); }); @@ -1635,20 +1636,20 @@ test('Linebreak between end of text and start of code block should be remained', resultString: '|
code
', }, { - testString: 'text1```code```text2', - resultString: 'text1
code
text2', + testString: 'text1```\ncode\n```text2', + resultString: 'text1
code
text2', }, { - testString: 'text1 ``` code ``` text2', - resultString: 'text1
 code 
text2', + testString: 'text1 ```\n code \n``` text2', + resultString: 'text1
 code 
text2', }, { - testString: 'text1\n```code```\ntext2', - resultString: 'text1
code
text2', + testString: 'text1\n```\ncode\n```\ntext2', + resultString: 'text1
code
text2', }, { - testString: 'text1\n``` code ```\ntext2', - resultString: 'text1
 code 
text2', + testString: 'text1\n```\n code \n```\ntext2', + resultString: 'text1
 code 
text2', }, { testString: 'text1\n```\n\ncode\n```\ntext2', @@ -1674,24 +1675,24 @@ test('Linebreak between end of text and start of code block should be remained', test('Test autoEmail with markdown of
, , ,  and  tag', () => {
     const testString = '`code`test@gmail.com '
-        + '```code block```test@gmail.com '
+        + '```\ncode block\n```test@gmail.com '
         + '[Google](https://google.com)test@gmail.com '
         + '_test@gmail.com_ '
         + '_test\n\ntest@gmail.com_ '
         + '`test@expensify.com` '
-        + '```test@expensify.com``` '
+        + '```\ntest@expensify.com\n``` '
         + '@test@expensify.com '
         + '_@username@expensify.com_ '
         + '[https://staging.new.expensify.com/details/test@expensify.com](https://staging.new.expensify.com/details/test@expensify.com) '
         + '[test italic style wrap email _test@gmail.com_ inside a link](https://google.com) ';
 
     const resultString = 'codetest@gmail.com '
-        + '
code block
test@gmail.com ' + + '
code block
test@gmail.com ' + 'Googletest@gmail.com ' + 'test@gmail.com ' + 'test

test@gmail.com
' + 'test@expensify.com ' - + '
test@expensify.com
' + + '
test@expensify.com
' + '@test@expensify.com ' + '@username@expensify.com ' + 'https://staging.new.expensify.com/details/test@expensify.com ' @@ -1798,14 +1799,14 @@ describe('when should keep raw input flag is enabled', () => { test('quote with other markdowns', () => { const quoteTestStartString = '> This is a *quote* that started on a new line.\nHere is a >quote that did not\n```\nhere is a codefenced quote\n>it should not be quoted\n```'; - const quoteTestReplacedString = '
This is a quote that started on a new line.
\nHere is a >quote that did not\n
here is a codefenced quote\n>it should not be quoted\n
'; + const quoteTestReplacedString = '
This is a quote that started on a new line.
\nHere is a >quote that did not\n
here is a codefenced quote\n>it should not be quoted\n
'; expect(parser.replace(quoteTestStartString, {shouldKeepRawInput: true})).toBe(quoteTestReplacedString); }); test('codeBlock with newlines', () => { const quoteTestStartString = '```\nhello world\n```'; - const quoteTestReplacedString = '
hello world\n
'; + const quoteTestReplacedString = '
hello world\n
'; expect(parser.replace(quoteTestStartString, {shouldKeepRawInput: true})).toBe(quoteTestReplacedString); }); @@ -1843,29 +1844,35 @@ describe('when should keep raw input flag is enabled', () => { expect(parser.replace(testString, {shouldKeepRawInput: true})).toBe(resultString); }); - test('user mention from phone number', () => { + test('user mention from invalid phone number', () => { const testString = '@+1234567890'; - const resultString = '@+1234567890'; + const resultString = '@+1234567890'; + expect(parser.replace(testString, {shouldKeepRawInput: true})).toBe(resultString); + }); + + test('user mention from valid phone number', () => { + const testString = '@+15005550006'; + const resultString = '@+15005550006'; expect(parser.replace(testString, {shouldKeepRawInput: true})).toBe(resultString); }); }); }); test('Test code fence within inline code', () => { - let testString = 'Hello world `(```test```)` Hello world'; - expect(parser.replace(testString)).toBe('Hello world `(
test
)` Hello world'); + let testString = 'Hello world `(```\ntest\n```)` Hello world'; + expect(parser.replace(testString)).toBe('Hello world `(
test
)` Hello world'); - testString = 'Hello world `(```test\ntest```)` Hello world'; - expect(parser.replace(testString)).toBe('Hello world `(
test
test
)` Hello world'); + testString = 'Hello world `(```\ntest\ntest\n```)` Hello world'; + expect(parser.replace(testString)).toBe('Hello world `(
test
test
)` Hello world'); - testString = 'Hello world ```(`test`)``` Hello world'; - expect(parser.replace(testString)).toBe('Hello world
(`test`)
Hello world'); + testString = 'Hello world ```\n(`test`)\n``` Hello world'; + expect(parser.replace(testString)).toBe('Hello world
(`test`)
Hello world'); - testString = 'Hello world `test`space```block``` Hello world'; - expect(parser.replace(testString)).toBe('Hello world testspace
block
Hello world'); + testString = 'Hello world `test`space```\nblock\n``` Hello world'; + expect(parser.replace(testString)).toBe('Hello world testspace
block
Hello world'); - testString = 'Hello world ```block```space`test` Hello world'; - expect(parser.replace(testString)).toBe('Hello world
block
spacetest Hello world'); + testString = 'Hello world ```\nblock\n```space`test` Hello world'; + expect(parser.replace(testString)).toBe('Hello world
block
spacetest Hello world'); }); test('Test italic/bold/strikethrough markdown to keep consistency', () => { @@ -1951,6 +1958,45 @@ describe('multi-level blockquote', () => { }); }); +describe('Video markdown conversion to html tag', () => { + test('Single video with source', () => { + const testString = '![test](https://example.com/video.mp4)'; + const resultString = ''; + expect(parser.replace(testString)).toBe(resultString); + }); + + test('Text containing videos', () => { + const testString = 'A video of a banana: ![banana](https://example.com/banana.mp4) a video without name: !(https://example.com/developer.mp4)'; + const resultString = 'A video of a banana: a video without name: '; + expect(parser.replace(testString)).toBe(resultString); + }); + + test('Video with raw data attributes', () => { + const testString = '![test](https://example.com/video.mp4)'; + const resultString = ''; + expect(parser.replace(testString, {shouldKeepRawInput: true})).toBe(resultString); + }) + + test('Single video with extra cached attribues', () => { + const testString = '![test](https://example.com/video.mp4)'; + const resultString = ''; + expect(parser.replace(testString, { + extras: { + videoAttributeCache: { + 'https://example.com/video.mp4': 'data-expensify-height="100" data-expensify-width="100"' + } + } + })).toBe(resultString); + }) + + test('Text containing image and video', () => { + const testString = 'An image of a banana: ![banana](https://example.com/banana.png) and a video of a banana: ![banana](https://example.com/banana.mp4)'; + const resultString = 'An image of a banana: banana and a video of a banana: '; + expect(parser.replace(testString)).toBe(resultString); + }); + +}) + describe('Image markdown conversion to html tag', () => { test('Single image with alt text', () => { const testString = '![test](https://example.com/image.png)'; diff --git a/__tests__/ExpensiMark-HTMLToText-test.js b/__tests__/ExpensiMark-HTMLToText-test.js index 39f5bebc..6757803b 100644 --- a/__tests__/ExpensiMark-HTMLToText-test.js +++ b/__tests__/ExpensiMark-HTMLToText-test.js @@ -151,7 +151,7 @@ test('Mention user html to text', () => { expect(parser.htmlToText(testString)).toBe('@Hidden'); const extras = { - accountIdToName: { + accountIDToName: { '1234': 'user@domain.com', }, }; @@ -180,7 +180,7 @@ test('Mention report html to text', () => { expect(parser.htmlToText(testString)).toBe('#Hidden'); const extras = { - reportIdToName: { + reportIDToName: { '1234': '#room-name', }, }; diff --git a/__tests__/ExpensiMark-Markdown-test.js b/__tests__/ExpensiMark-Markdown-test.js index 0f8e8831..bb1baf1f 100644 --- a/__tests__/ExpensiMark-Markdown-test.js +++ b/__tests__/ExpensiMark-Markdown-test.js @@ -497,6 +497,18 @@ test('map div with quotes', () => { expect(parser.htmlToMarkdown(testString)).toBe(resultString); }); +test('double quotes in same line', () => { + const testString = '
line 1
'; + const resultString = '>> line 1'; + expect(parser.htmlToMarkdown(testString)).toBe(resultString); +}); + +test('triple quotes in same line', () => { + const testString = '
line 1
'; + const resultString = '>>> line 1'; + expect(parser.htmlToMarkdown(testString)).toBe(resultString); +}); + test('map table to newline', () => { const testString = 'line 1line 2'; const resultString = 'line 1\nline 2'; @@ -756,12 +768,16 @@ test('Mention user html to markdown', () => { testString = '@user@DOMAIN.com'; expect(parser.htmlToMarkdown(testString)).toBe('@user@DOMAIN.com'); + // When there is a phone number mention the sms domain `@expensify.sms`should be removed from returned string + testString = '@+311231231@expensify.sms'; + expect(parser.htmlToMarkdown(testString)).toBe('@+311231231'); + // When there is `accountID` and no `extras`, `@Hidden` should be returned testString = ''; expect(parser.htmlToMarkdown(testString)).toBe('@Hidden'); const extras = { - accountIdToName: { + accountIDToName: { '1234': 'user@domain.com', }, }; @@ -790,7 +806,7 @@ test('Mention report html to markdown', () => { expect(parser.htmlToText(testString)).toBe('#Hidden'); const extras = { - reportIdToName: { + reportIDToName: { '1234': '#room-name', }, }; @@ -832,3 +848,28 @@ describe('Image tag conversion to markdown', () => { expect(parser.htmlToMarkdown(testString)).toBe(resultString); }); }); + +describe('Video tag conversion to markdown', () => { + test('Video with name', () => { + const testString = ''; + const resultString = '![video](https://example.com/video.mp4)'; + expect(parser.htmlToMarkdown(testString)).toBe(resultString); + }) + + test('Video without name', () => { + const testString = ''; + const resultString = '!(https://example.com/video.mp4)'; + expect(parser.htmlToMarkdown(testString)).toBe(resultString); + }) + + test('While convert video, cache some extra attributes from the video tag', () => { + const cacheVideoAttributes = jest.fn(); + const testString = ''; + const resultString = '![video](https://example.com/video.mp4)'; + const extras = { + cacheVideoAttributes, + }; + expect(parser.htmlToMarkdown(testString, extras)).toBe(resultString); + expect(cacheVideoAttributes).toHaveBeenCalledWith("https://example.com/video.mp4", ' data-expensify-width="100" data-expensify-height="500" data-expensify-thumbnail-url="https://image.com/img.jpg"') + }) +}) diff --git a/__tests__/ExpensiMark-test.js b/__tests__/ExpensiMark-test.js index cad53935..fa7aa34b 100644 --- a/__tests__/ExpensiMark-test.js +++ b/__tests__/ExpensiMark-test.js @@ -1,6 +1,6 @@ /* eslint-disable max-len */ import ExpensiMark from '../lib/ExpensiMark'; -import _ from 'underscore'; +import * as Utils from '../lib/utils'; const parser = new ExpensiMark(); @@ -17,24 +17,28 @@ test('Test text is unescaped', () => { }); test('Test with regex Maximum regex stack depth reached error', () => { - const testString = '

heading

asjjssjdjdjdjdjdjjeiwiwiwowkdjdjdieikdjfidekjcjdkekejdcjdkeekcjcdidjjcdkekdiccjdkejdjcjxisdjjdkedncicdjejejcckdsijcjdsodjcicdkejdicdjejajasjjssjdjdjdjdjdjjeiwiwiwowkdjdjdieikdjfisjksksjsjssskssjskskssksksksksskdkddkddkdksskskdkdkdksskskskdkdkdkdkekeekdkddenejeodxkdndekkdjddkeemdjxkdenendkdjddekjcjdkekejdcjdkeekcjcdidjjcdkekdiccjdkejdjcjxisdjjdkedncicdjejejcckdsijcjdsodjcicdkejdicdjejajasjjssjdjdjdjdjdjjeiwiwiwowkdjdjdieikdjfidekjcjdkekejdcjdkeekcjcdidjjcdkekdiccjdkejdjcjxisdjjdkedncicdjejejcckdsijcjdsodjcicdkejdi.cdjd'; - const parser = new ExpensiMark(); + const testString = + '

heading

asjjssjdjdjdjdjdjjeiwiwiwowkdjdjdieikdjfidekjcjdkekejdcjdkeekcjcdidjjcdkekdiccjdkejdjcjxisdjjdkedncicdjejejcckdsijcjdsodjcicdkejdicdjejajasjjssjdjdjdjdjdjjeiwiwiwowkdjdjdieikdjfisjksksjsjssskssjskskssksksksksskdkddkddkdksskskdkdkdksskskskdkdkdkdkekeekdkddenejeodxkdndekkdjddkeemdjxkdenendkdjddekjcjdkekejdcjdkeekcjcdidjjcdkekdiccjdkejdjcjxisdjjdkedncicdjejejcckdsijcjdsodjcicdkejdicdjejajasjjssjdjdjdjdjdjjeiwiwiwowkdjdjdieikdjfidekjcjdkekejdcjdkeekcjcdidjjcdkekdiccjdkejdjcjxisdjjdkedncicdjejejcckdsijcjdsodjcicdkejdi.cdjd'; + const expensiMarkParser = new ExpensiMark(); // Mock method modifyTextForUrlLinks to let it throw an error to test try/catch of method ExpensiMark.replace - const modifyTextForUrlLinksMock = jest.fn((a, b, c) => {throw new Error('Maximum regex stack depth reached')}); - parser.modifyTextForUrlLinks = modifyTextForUrlLinksMock; - expect(parser.replace(testString)).toBe(_.escape(testString)); + const modifyTextForUrlLinksMock = jest.fn(() => { + throw new Error('Maximum regex stack depth reached'); + }); + expensiMarkParser.modifyTextForUrlLinks = modifyTextForUrlLinksMock; + expect(expensiMarkParser.replace(testString)).toBe(Utils.escape(testString)); expect(modifyTextForUrlLinksMock).toHaveBeenCalledTimes(1); // Mock method extractLinksInMarkdownComment to let it return undefined to test try/catch of method ExpensiMark.extractLinksInMarkdownComment - const extractLinksInMarkdownCommentMock = jest.fn((a) => undefined); - parser.extractLinksInMarkdownComment = extractLinksInMarkdownCommentMock; - expect(parser.extractLinksInMarkdownComment(testString)).toBe(undefined); - expect(parser.getRemovedMarkdownLinks(testString, 'google.com')).toStrictEqual([]); + const extractLinksInMarkdownCommentMock = jest.fn(() => undefined); + expensiMarkParser.extractLinksInMarkdownComment = extractLinksInMarkdownCommentMock; + expect(expensiMarkParser.extractLinksInMarkdownComment(testString)).toBe(undefined); + expect(expensiMarkParser.getRemovedMarkdownLinks(testString, 'google.com')).toStrictEqual([]); expect(extractLinksInMarkdownCommentMock).toHaveBeenCalledTimes(3); }); test('Test extract link with ending parentheses', () => { - const comment = '[Staging Detail](https://staging.new.expensify.com/details) [Staging Detail](https://staging.new.expensify.com/details)) [Staging Detail](https://staging.new.expensify.com/details)))'; + const comment = + '[Staging Detail](https://staging.new.expensify.com/details) [Staging Detail](https://staging.new.expensify.com/details)) [Staging Detail](https://staging.new.expensify.com/details)))'; const links = ['https://staging.new.expensify.com/details', 'https://staging.new.expensify.com/details', 'https://staging.new.expensify.com/details']; expect(parser.extractLinksInMarkdownComment(comment)).toStrictEqual(links); }); diff --git a/babel.config.js b/babel.config.js index d7c97f55..f792e8e1 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,12 +1,3 @@ module.exports = { - presets: [ - [ - '@babel/preset-env', - { - targets: { - node: 'current', - }, - }, - ], - ], + presets: [['@babel/preset-env', {targets: {node: 'current'}}], '@babel/preset-typescript'], }; diff --git a/lib/API.jsx b/lib/API.jsx index 6a6d0482..672eeb82 100644 --- a/lib/API.jsx +++ b/lib/API.jsx @@ -3,11 +3,12 @@ * WIP, This is in the process of migration from web-e. Please add methods to this as is needed.| * ---------------------------------------------------------------------------------------------- */ -import _ from 'underscore'; // Use this deferred lib so we don't have a dependency on jQuery (so we can use this module in mobile) import {Deferred} from 'simply-deferred'; +import {has} from 'lodash'; import ExpensifyAPIDeferred from './APIDeferred'; +import * as Utils from './utils'; /** * @param {Network} network @@ -39,7 +40,7 @@ export default function API(network, args) { * Returns a promise that is rejected if a change is detected * Otherwise, it is resolved successfully * - * @returns {Object} $.Deferred + * @returns {Object} Deferred */ function isRunningLatestVersionOfCode() { const promise = new Deferred(); @@ -47,7 +48,7 @@ export default function API(network, args) { network .get('/revision.txt') .done((codeRevision) => { - if (codeRevision.trim() === window.CODE_REVISION) { + if (Utils.isWindowAvailable() && codeRevision.trim() === window.CODE_REVISION) { console.debug('Code revision is up to date'); promise.resolve(); } else { @@ -74,7 +75,7 @@ export default function API(network, args) { * @param {String} apiDeferred */ function attachJSONCodeCallbacks(apiDeferred) { - _(defaultHandlers).each((callbacks, code) => { + Object.entries(defaultHandlers).forEach(([code, callbacks]) => { // The key, `code`, is returned as a string, so we must cast it to an Integer const jsonCode = parseInt(code, 10); callbacks.forEach((callback) => { @@ -104,7 +105,7 @@ export default function API(network, args) { let newParameters = {...parameters, command}; // If there was an enhanceParameters() method supplied in our args, then we will call that here - if (args && _.isFunction(args.enhanceParameters)) { + if (args && Utils.isFunction(args.enhanceParameters)) { newParameters = args.enhanceParameters(newParameters); } @@ -150,18 +151,21 @@ export default function API(network, args) { * @param {String} commandName The name of the API command */ function requireParameters(parameterNames, parameters, commandName) { + // eslint-disable-next-line rulesdir/prefer-early-return parameterNames.forEach((parameterName) => { - if (!_(parameters).has(parameterName) || parameters[parameterName] === null || parameters[parameterName] === undefined) { - const parametersCopy = _.clone(parameters); - if (_(parametersCopy).has('authToken')) { - parametersCopy.authToken = ''; - } - if (_(parametersCopy).has('password')) { - parametersCopy.password = ''; - } - const keys = _(parametersCopy).keys().join(', ') || 'none'; - throw new Error(`Parameter ${parameterName} is required for "${commandName}". Supplied parameters: ${keys}`); + if (has(parameters, parameterName) && parameters[parameterName] !== null && parameters[parameterName] !== undefined) { + return; + } + + const parametersCopy = {...parameters}; + if (has(parametersCopy, 'authToken')) { + parametersCopy.authToken = ''; + } + if (has(parametersCopy, 'password')) { + parametersCopy.password = ''; } + const keys = Object.keys(parametersCopy).join(', ') || 'none'; + throw new Error(`Parameter ${parameterName} is required for "${commandName}". Supplied parameters: ${keys}`); }); } @@ -171,7 +175,7 @@ export default function API(network, args) { * @param {Function} callback */ registerDefaultHandler(jsonCodes, callback) { - if (!_(callback).isFunction()) { + if (!Utils.isFunction(callback)) { return; } @@ -228,7 +232,7 @@ export default function API(network, args) { return (parameters, keepalive = false) => { // Optional validate function for required logic before making the call. e.g. validating params in the front-end etc. - if (_.isFunction(data.validate)) { + if (Utils.isFunction(data.validate)) { data.validate(parameters); } @@ -263,7 +267,7 @@ export default function API(network, args) { requireParameters(['email'], parameters, commandName); // Tell the API not to set cookies for this request - const newParameters = _.extend({api_setCookie: false}, parameters); + const newParameters = {...parameters, api_setCookie: false}; return performPOSTRequest(commandName, newParameters); }, @@ -424,7 +428,7 @@ export default function API(network, args) { const commandName = 'ResetPassword'; requireParameters(['email'], parameters, commandName); - const newParameters = _.extend({api_setCookie: false}, parameters); + const newParameters = {...parameters, api_setCookie: false}; return performPOSTRequest(commandName, newParameters); }, @@ -823,7 +827,7 @@ export default function API(network, args) { * * @returns {APIDeferred} */ - createAdminIssuedVirtualCard: function (parameters) { + createAdminIssuedVirtualCard(parameters) { const commandName = 'Card_CreateAdminIssuedVirtualCard'; requireParameters(['cardTitle', 'assigneeEmail', 'cardLimit', 'cardLimitType', 'domainName'], parameters, commandName); return performPOSTRequest(commandName, parameters); @@ -842,7 +846,7 @@ export default function API(network, args) { * * @returns {APIDeferred} */ - editAdminIssuedVirtualCard: function (parameters) { + editAdminIssuedVirtualCard(parameters) { const commandName = 'Card_EditAdminIssuedVirtualCard'; requireParameters(['domainName', 'cardID', 'cardTitle', 'assigneeEmail', 'cardLimit', 'cardLimitType'], parameters, commandName); return performPOSTRequest(commandName, parameters); diff --git a/lib/APIDeferred.jsx b/lib/APIDeferred.jsx index c64b17b8..b95b966c 100644 --- a/lib/APIDeferred.jsx +++ b/lib/APIDeferred.jsx @@ -3,9 +3,9 @@ * WIP, This is in the process of migration from web-e. Please add methods to this as is needed.| * ---------------------------------------------------------------------------------------------- */ - -import _ from 'underscore'; -import {invoke, bulkInvoke} from './Func'; +import {once} from 'lodash'; +import * as Utils from './utils'; +import * as Func from './Func'; /** * @param {jquery.Deferred} promise @@ -50,15 +50,15 @@ export default function APIDeferred(promise, extractedProperty) { function handleError(jsonCode, response) { // Look for handlers for this error code const handlers = errorHandlers[jsonCode]; - if (!_(handlers).isEmpty()) { - bulkInvoke(handlers, [jsonCode, response]); + if (handlers.length > 0) { + Func.bulkInvoke(handlers, [jsonCode, response]); } else { // No explicit handlers, call the unhandled callbacks - bulkInvoke(unhandledCallbacks, [jsonCode, response]); + Func.bulkInvoke(unhandledCallbacks, [jsonCode, response]); } // Always run the "fail" callbacks in case of error - bulkInvoke(failCallbacks, [jsonCode, response]); + Func.bulkInvoke(failCallbacks, [jsonCode, response]); } /** @@ -73,8 +73,8 @@ export default function APIDeferred(promise, extractedProperty) { // Figure out if we need to extract a property from the response, and if it is there. const jsonCode = extractJSONCode(response); - const propertyRequested = !_.isNull(extractedPropertyName); - const requestedPropertyPresent = propertyRequested && response && !_.isUndefined(response[extractedPropertyName]); + const propertyRequested = !Number.isNull(extractedPropertyName); + const requestedPropertyPresent = propertyRequested && response && response[extractedPropertyName] !== undefined; const propertyRequestedButMissing = propertyRequested && !requestedPropertyPresent; // Save the response for any callbacks that might run in the future @@ -86,8 +86,8 @@ export default function APIDeferred(promise, extractedProperty) { returnedData = propertyRequested && requestedPropertyPresent ? response[extractedPropertyName] : response; // And then run the success callbacks - bulkInvoke(doneCallbacks, [returnedData]); - } else if (!_(jsonCode).isNull() && jsonCode !== 200) { + Func.bulkInvoke(doneCallbacks, [returnedData]); + } else if (jsonCode !== null && jsonCode !== 200) { // Exception thrown, handle it handleError(jsonCode, response); } else { @@ -102,7 +102,7 @@ export default function APIDeferred(promise, extractedProperty) { } // Always run the "always" callbacks - bulkInvoke(alwaysCallbacks, [response]); + Func.bulkInvoke(alwaysCallbacks, [response]); } /** @@ -133,8 +133,8 @@ export default function APIDeferred(promise, extractedProperty) { * @returns {APIDeferred} itself, for chaining */ done(callback) { - if (_(callback).isFunction()) { - doneCallbacks.push(_(callback).once()); + if (Utils.isFunction(callback)) { + doneCallbacks.push(once(callback)); ensureFutureCallbacksFire(); } return this; @@ -148,8 +148,8 @@ export default function APIDeferred(promise, extractedProperty) { * @returns {APIDeferred} itself, for chaining */ always(callback) { - if (_(callback).isFunction()) { - alwaysCallbacks.push(_(callback).once()); + if (Utils.isFunction(callback)) { + alwaysCallbacks.push(once(callback)); ensureFutureCallbacksFire(); } return this; @@ -165,7 +165,7 @@ export default function APIDeferred(promise, extractedProperty) { * @returns {APIDeferred} itself, for chaining */ handle(jsonCodes, callback) { - if (_(callback).isFunction()) { + if (Utils.isFunction(callback)) { jsonCodes.forEach((code) => { if (code === 200) { return; @@ -174,7 +174,7 @@ export default function APIDeferred(promise, extractedProperty) { if (!errorHandlers[code]) { errorHandlers[code] = []; } - errorHandlers[code].push(_(callback).once()); + errorHandlers[code].push(once(callback)); }); ensureFutureCallbacksFire(); @@ -191,8 +191,8 @@ export default function APIDeferred(promise, extractedProperty) { * @returns {APIDeferred} itself, for chaining */ unhandled(callback) { - if (_(callback).isFunction()) { - unhandledCallbacks.push(_(callback).once()); + if (Utils.isFunction(callback)) { + unhandledCallbacks.push(once(callback)); ensureFutureCallbacksFire(); } return this; @@ -207,8 +207,8 @@ export default function APIDeferred(promise, extractedProperty) { * @returns {APIDeferred} itself, for chaining */ fail(callback) { - if (_(callback).isFunction()) { - failCallbacks.push(_(callback).once()); + if (Utils.isFunction(callback)) { + failCallbacks.push(once(callback)); ensureFutureCallbacksFire(); } return this; @@ -225,11 +225,11 @@ export default function APIDeferred(promise, extractedProperty) { return promise.then((response) => { const responseCode = extractJSONCode(response); - if (responseCode !== 200 || !_(callback).isFunction()) { + if (responseCode !== 200 || !Utils.isFunction(callback)) { return; } - invoke(callback, [response]); + Func.invoke(callback, [response]); return this; }); diff --git a/lib/BrowserDetect.jsx b/lib/BrowserDetect.jsx index 2f5bfb60..cb0731f8 100644 --- a/lib/BrowserDetect.jsx +++ b/lib/BrowserDetect.jsx @@ -1,3 +1,5 @@ +import * as Utils from './utils'; + const BROWSERS = { EDGE: 'Edge', CHROME: 'Chrome', @@ -13,6 +15,10 @@ const MOBILE_PLATFORMS = { }; function searchString() { + if (!Utils.isWindowAvailable() || !Utils.isNavigatorAvailable()) { + return ''; + } + const data = [ { string: navigator.userAgent, @@ -72,6 +78,10 @@ function searchString() { } function getMobileDevice() { + if (!Utils.isNavigatorAvailable() || !navigator.userAgent) { + return ''; + } + const data = [ { devices: ['iPhone', 'iPad', 'iPod'], diff --git a/lib/CONST.d.ts b/lib/CONST.d.ts deleted file mode 100644 index 77974b46..00000000 --- a/lib/CONST.d.ts +++ /dev/null @@ -1,852 +0,0 @@ -/** - * URL of our CloudFront Instance - */ -export declare const g_cloudFront: 'https://d2k5nsl2zxldvw.cloudfront.net'; -/** - * URL of our image CDN - */ -export declare const g_cloudFrontImg: 'https://d2k5nsl2zxldvw.cloudfront.net/images/'; -export declare const CONST: { - readonly CORPAY_DIRECT_REIMBURSEMENT_CURRENCIES: readonly ['USD', 'GBP', 'EUR', 'AUD', 'CAD']; - /** - * Default max ACH limit. It can be overwritten by a private NVP - */ - readonly ACH_DEFAULT_MAX_AMOUNT_LIMIT: 2000000; - /** - * IRS remimbursement rate for mileage - * WARNING ! UPDATE THE PHP CONSTANT VERSION WHEN UPDATING THIS ONE - */ - readonly MILEAGE_IRS_RATE: 0.545 | 0.58; - readonly COUNTRY: { - readonly US: 'US'; - readonly AU: 'AU'; - readonly UK: 'UK'; - readonly NZ: 'NZ'; - }; - readonly CURRENCIES: { - readonly US: 'USD'; - readonly AU: 'AUD'; - readonly UK: 'GBP'; - readonly NZ: 'NZD'; - }; - readonly STATES: { - readonly AK: { - readonly stateISO: 'AK'; - readonly stateName: 'Alaska'; - }; - readonly AL: { - readonly stateISO: 'AL'; - readonly stateName: 'Alabama'; - }; - readonly AR: { - readonly stateISO: 'AR'; - readonly stateName: 'Arkansas'; - }; - readonly AZ: { - readonly stateISO: 'AZ'; - readonly stateName: 'Arizona'; - }; - readonly CA: { - readonly stateISO: 'CA'; - readonly stateName: 'California'; - }; - readonly CO: { - readonly stateISO: 'CO'; - readonly stateName: 'Colorado'; - }; - readonly CT: { - readonly stateISO: 'CT'; - readonly stateName: 'Connecticut'; - }; - readonly DE: { - readonly stateISO: 'DE'; - readonly stateName: 'Delaware'; - }; - readonly FL: { - readonly stateISO: 'FL'; - readonly stateName: 'Florida'; - }; - readonly GA: { - readonly stateISO: 'GA'; - readonly stateName: 'Georgia'; - }; - readonly HI: { - readonly stateISO: 'HI'; - readonly stateName: 'Hawaii'; - }; - readonly IA: { - readonly stateISO: 'IA'; - readonly stateName: 'Iowa'; - }; - readonly ID: { - readonly stateISO: 'ID'; - readonly stateName: 'Idaho'; - }; - readonly IL: { - readonly stateISO: 'IL'; - readonly stateName: 'Illinois'; - }; - readonly IN: { - readonly stateISO: 'IN'; - readonly stateName: 'Indiana'; - }; - readonly KS: { - readonly stateISO: 'KS'; - readonly stateName: 'Kansas'; - }; - readonly KY: { - readonly stateISO: 'KY'; - readonly stateName: 'Kentucky'; - }; - readonly LA: { - readonly stateISO: 'LA'; - readonly stateName: 'Louisiana'; - }; - readonly MA: { - readonly stateISO: 'MA'; - readonly stateName: 'Massachusetts'; - }; - readonly MD: { - readonly stateISO: 'MD'; - readonly stateName: 'Maryland'; - }; - readonly ME: { - readonly stateISO: 'ME'; - readonly stateName: 'Maine'; - }; - readonly MI: { - readonly stateISO: 'MI'; - readonly stateName: 'Michigan'; - }; - readonly MN: { - readonly stateISO: 'MN'; - readonly stateName: 'Minnesota'; - }; - readonly MO: { - readonly stateISO: 'MO'; - readonly stateName: 'Missouri'; - }; - readonly MS: { - readonly stateISO: 'MS'; - readonly stateName: 'Mississippi'; - }; - readonly MT: { - readonly stateISO: 'MT'; - readonly stateName: 'Montana'; - }; - readonly NC: { - readonly stateISO: 'NC'; - readonly stateName: 'North Carolina'; - }; - readonly ND: { - readonly stateISO: 'ND'; - readonly stateName: 'North Dakota'; - }; - readonly NE: { - readonly stateISO: 'NE'; - readonly stateName: 'Nebraska'; - }; - readonly NH: { - readonly stateISO: 'NH'; - readonly stateName: 'New Hampshire'; - }; - readonly NJ: { - readonly stateISO: 'NJ'; - readonly stateName: 'New Jersey'; - }; - readonly NM: { - readonly stateISO: 'NM'; - readonly stateName: 'New Mexico'; - }; - readonly NV: { - readonly stateISO: 'NV'; - readonly stateName: 'Nevada'; - }; - readonly NY: { - readonly stateISO: 'NY'; - readonly stateName: 'New York'; - }; - readonly OH: { - readonly stateISO: 'OH'; - readonly stateName: 'Ohio'; - }; - readonly OK: { - readonly stateISO: 'OK'; - readonly stateName: 'Oklahoma'; - }; - readonly OR: { - readonly stateISO: 'OR'; - readonly stateName: 'Oregon'; - }; - readonly PA: { - readonly stateISO: 'PA'; - readonly stateName: 'Pennsylvania'; - }; - readonly PR: { - readonly stateISO: 'PR'; - readonly stateName: 'Puerto Rico'; - }; - readonly RI: { - readonly stateISO: 'RI'; - readonly stateName: 'Rhode Island'; - }; - readonly SC: { - readonly stateISO: 'SC'; - readonly stateName: 'South Carolina'; - }; - readonly SD: { - readonly stateISO: 'SD'; - readonly stateName: 'South Dakota'; - }; - readonly TN: { - readonly stateISO: 'TN'; - readonly stateName: 'Tennessee'; - }; - readonly TX: { - readonly stateISO: 'TX'; - readonly stateName: 'Texas'; - }; - readonly UT: { - readonly stateISO: 'UT'; - readonly stateName: 'Utah'; - }; - readonly VA: { - readonly stateISO: 'VA'; - readonly stateName: 'Virginia'; - }; - readonly VT: { - readonly stateISO: 'VT'; - readonly stateName: 'Vermont'; - }; - readonly WA: { - readonly stateISO: 'WA'; - readonly stateName: 'Washington'; - }; - readonly WI: { - readonly stateISO: 'WI'; - readonly stateName: 'Wisconsin'; - }; - readonly WV: { - readonly stateISO: 'WV'; - readonly stateName: 'West Virginia'; - }; - readonly WY: { - readonly stateISO: 'WY'; - readonly stateName: 'Wyoming'; - }; - readonly DC: { - readonly stateISO: 'DC'; - readonly stateName: 'District Of Columbia'; - }; - }; - /** - * Store all the regular expression we are using for matching stuff - */ - readonly REG_EXP: { - /** - * Regular expression to check that a domain is valid - */ - readonly DOMAIN: RegExp; - /** - * Regex matching an text containing an email - */ - readonly EMAIL_PART: "([\\w\\-\\+\\'#]+(?:\\.[\\w\\-\\'\\+]+)*@(?:[\\w\\-]+\\.)+[a-z]{2,})"; - /** - * Regex matching a text containing general phone number - */ - readonly GENERAL_PHONE_PART: RegExp; - /** - * Regex matching a text containing an E.164 format phone number - */ - readonly PHONE_PART: '\\+[1-9]\\d{1,14}'; - /** - * Regular expression to check that a basic name is valid - */ - readonly FREE_NAME: RegExp; - /** - * Regular expression to check that a card is masked - */ - readonly MASKED_CARD: RegExp; - /** - * Regular expression to check that an email is valid - */ - readonly EMAIL: RegExp; - /** - * Regular expression to extract an email from a text - */ - readonly EXTRACT_EMAIL: RegExp; - /** - * Regular expression to search for valid email addresses in a string - */ - readonly EMAIL_SEARCH: RegExp; - /** - * Regular expression to detect if something is a hyperlink - * - * Adapted from: https://gist.github.com/dperini/729294 - */ - readonly HYPERLINK: RegExp; - /** - * Regex to match valid emails during markdown transformations - */ - readonly MARKDOWN_EMAIL: "([a-zA-Z0-9.!#$%&'+/=?^`{|}-][a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]*@[a-zA-Z0-9-]+?(\\.[a-zA-Z]+)+)"; - /** - * Regex matching an text containing an Emoji - */ - readonly EMOJIS: RegExp; - /** - * Regex matching an text containing an Emoji that can be a single emoji or made up by some different emojis - * - * @type RegExp - */ - readonly EMOJI_RULE: RegExp; - }; - readonly REPORT: { - /** - * Limit when we decided to turn off print to pdf and use only the native feature - */ - readonly LIMIT_PRINT_PDF: 250; - readonly ACH_LIMIT: 2000000; - readonly ACH_DEFAULT_DAYS: 4; - /** - * This is the string that a user can enter in a formula to refer to the report title field - */ - readonly TITLE_FORMULA: '{report:title}'; - /** - * The max time a comment can be made after another to be considered the same comment, in seconds - */ - readonly MAX_AGE_SAME_COMMENT: 300; - readonly SMARTREPORT_AGENT_EMAIL: 'smartreports@expensify.com'; - }; - /** - * Root URLs - */ - readonly URL: { - readonly FORUM_ROOT: 'https://community.expensify.com/'; - readonly RECEIPTS: { - readonly DEVELOPMENT: 'https://www.expensify.com.dev/receipts/'; - readonly STAGING: 'https://staging.expensify.com/receipts/'; - readonly PRODUCTION: 'https://www.expensify.com/receipts/'; - }; - readonly CLOUDFRONT: 'https://d2k5nsl2zxldvw.cloudfront.net'; - readonly CLOUDFRONT_IMG: 'https://d2k5nsl2zxldvw.cloudfront.net/images/'; - readonly CLOUDFRONT_FILES: 'https://d2k5nsl2zxldvw.cloudfront.net/files/'; - readonly EXPENSIFY_SYNC_MANAGER: 'quickbooksdesktop/Expensify_QuickBooksDesktop_Setup_2300802.exe'; - readonly USEDOT_ROOT: 'https://use.expensify.com/'; - readonly ITUNES_SUBSCRIPTION: 'https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/manageSubscriptions'; - }; - readonly DATE: { - readonly FORMAT_STRING: 'yyyy-MM-dd'; - readonly FORMAT_STRING_PRETTY: 'MMM d, yyyy'; - /** - * Expensify date format string for moment js - * usage: moment().format( CONST.DATE.MOMENT_FORMAT_STRING ) - */ - readonly MOMENT_FORMAT_STRING: 'YYYY-MM-DD'; - /** - * This is a typical format of the date plus the time - */ - readonly MOMENT_DATE_TIME: 'YYYY-MM-DD HH:mm'; - /** - * Pretty format used for report history items - * - * @example Jun 19, 2019 12:38 PM - */ - readonly MOMENT_DATE_TIME_PRETTY: 'MMM DD YYYY h:mma'; - /** - * Date-time format, including timezone information, eg "2015-10-14T19:44:35+07:00" - */ - readonly MOMENT_DATE_TIME_TIMEZONE: 'YYYY-MM-DDTHH:mm:ssZ'; - /** - * Moment formatting option for a date of this format "Jul 2, 2014" - */ - readonly MOMENT_US_DATE: 'MMM D, YYYY'; - /** - * Moment formatting option for a date of this format "July 2, 2014" - * ie, full month name - */ - readonly MOMENT_US_DATE_LONG: 'MMMM D, YYYY'; - /** - * Moment formatting option for full month name and year as in "July 2015" - */ - readonly MOMENT_US_MONTH_YEAR_LONG: 'MMMM YYYY'; - /** - * Difference between the local time and UTC time in ms - */ - readonly TIMEZONE_OFFSET_MS: number; - readonly SHORT_MONTH_SHORT_DAY: 'MMM d'; - readonly LONG_YEAR_MONTH_DAY_24_TIME: 'yyyy-MM-dd HH:mm:ss'; - readonly SHORT_MONTH_DAY_LOCAL_TIME: 'MMM D [at] LT'; - readonly SHORT_MONTH_DAY_YEAR_LOCAL_TIME: 'MMM D, YYYY [at] LT'; - }; - /** - * Message used by the Func.die() exception - */ - readonly FUNC_DIE_MESSAGE: 'Aborting JavaScript execution'; - /** - * Default for how long the email delivery failure NVP should be valid (in seconds) - * Currently 14 days (14 * 24 * 60 * 60) - * - * WARNING ! UPDATE THE PHP CONSTANT VERSION WHEN UPDATING THIS ONE - */ - readonly EMAIL_DELIVERY_FAILURE_VALIDITY: 1209600; - /** - * Bill Processing-related constants - */ - readonly BILL_PROCESSING_PARTNER_NAME: 'expensify.cash'; - readonly BILL_PROCESSING_EMAIL_DOMAIN: 'expensify.cash'; - /** - * Bank Import Logic Constants - */ - readonly BANK_IMPORT: { - readonly BANK_STATUS_BROKEN: 2; - }; - /** - * Bank Account Logic Constants - */ - readonly BANK_ACCOUNT: { - readonly VERIFICATION_MAX_ATTEMPTS: 7; - }; - /** - * Emails that the user shouldn't be interacting with from the front-end interface - * Trying to add these emails as a delegate, onto a policy, or as an approver is considered invalid - * Any changes here should be reflected in the PHP constant in web-expensify, - * which is located in _constant.php and also named EXPENSIFY_EMAILS. - * And should also be reflected in the constant in expensify/app, - * which is located in src/CONST.js and also named EXPENSIFY_EMAILS. - */ - readonly EXPENSIFY_EMAILS: readonly [ - 'concierge@expensify.com', - 'help@expensify.com', - 'receipts@expensify.com', - 'chronos@expensify.com', - 'qa@expensify.com', - 'contributors@expensify.com', - 'firstresponders@expensify.com', - 'qa+travisreceipts@expensify.com', - 'bills@expensify.com', - 'studentambassadors@expensify.com', - 'accounting@expensify.com', - 'payroll@expensify.com', - 'svfg@expensify.com', - 'integrationtestingcreds@expensify.com', - 'admin@expensify.com', - 'notifications@expensify.com', - ]; - /** - * Emails that the user shouldn't submit reports to nor share reports with - * Any changes here should be reflected in the PHP constant, - * which is located in _constant.php and also named INVALID_APPROVER_AND_SHAREE_EMAILS - */ - readonly INVALID_APPROVER_AND_SHAREE_EMAILS: readonly [ - 'concierge@expensify.com', - 'help@expensify.com', - 'receipts@expensify.com', - 'chronos@expensify.com', - 'qa@expensify.com', - 'contributors@expensify.com', - 'firstresponders@expensify.com', - 'qa+travisreceipts@expensify.com', - 'bills@expensify.com', - 'admin@expensify.com', - 'notifications@expensify.com', - ]; - /** - * Smart scan-related constants - */ - readonly SMART_SCAN: { - readonly COST: 20; - readonly FREE_NUMBER: 25; - }; - readonly SMS: { - readonly DOMAIN: 'expensify.sms'; - readonly E164_REGEX: RegExp; - }; - readonly PASSWORD_COMPLEXITY_REGEX_STRING: '^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z]).{8,}$'; - readonly INTEGRATIONS: { - /** - * Constants that specify how to map (import) Integrations data to Expensify - * Parallel to IntegrationEntityMappingTypeEnum in the IS - */ - readonly DATA_MAPPING: { - readonly NONE: 'NONE'; - readonly TAG: 'TAG'; - readonly REPORT_FIELD: 'REPORT_FIELD'; - readonly DEFAULT: 'DEFAULT'; - }; - readonly EXPORT_DATE: { - readonly LAST_EXPENSE: 'LAST_EXPENSE'; - readonly REPORT_EXPORTED: 'REPORT_EXPORTED'; - readonly REPORT_SUBMITTED: 'REPORT_SUBMITTED'; - }; - readonly XERO_HQ_CONNECTION_NAME: 'xerohq'; - readonly EXPENSIFY_SYNC_MANAGER_VERSION: '23.0.802.0'; - }; - readonly INTEGRATION_TYPES: { - readonly ACCOUNTING: 'accounting'; - readonly HR: 'hr'; - }; - readonly DIRECT_INTEGRATIONS: { - readonly zenefits: { - readonly value: 'zenefits'; - readonly text: 'Zenefits'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/zenefit.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/zenefit_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/zenefit_alert.svg'; - readonly types: readonly ['hr']; - readonly isCorporateOnly: false; - }; - readonly gusto: { - readonly value: 'gusto'; - readonly text: 'Gusto'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/gusto.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/gusto_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/gusto_alert.svg'; - readonly types: readonly ['hr']; - readonly isCorporateOnly: false; - }; - readonly quickbooksOnline: { - readonly value: 'quickbooksOnline'; - readonly text: 'QuickBooks Online'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/quickbooks.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/quickbooks_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/quickbooks_alert.svg'; - readonly types: readonly ['hr', 'accounting']; - readonly isCorporateOnly: false; - }; - readonly xero: { - readonly value: 'xero'; - readonly text: 'Xero'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/xero.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/xero_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/xero_alert.svg'; - readonly types: readonly ['accounting']; - readonly isCorporateOnly: false; - }; - readonly netsuite: { - readonly value: 'netsuite'; - readonly text: 'NetSuite'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/netsuite.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/netsuite_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/netsuite_alert.svg'; - readonly types: readonly ['hr', 'accounting']; - readonly isCorporateOnly: true; - }; - readonly quickbooksDesktop: { - readonly value: 'qbd'; - readonly text: 'QuickBooks Desktop'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/quickbooks.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/quickbooks_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/quickbooks_alert.svg'; - readonly types: readonly ['accounting']; - readonly isCorporateOnly: false; - }; - readonly intacct: { - readonly value: 'intacct'; - readonly text: 'Sage Intacct'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/sage.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/sage_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/sage_alert.svg'; - readonly types: readonly ['hr', 'accounting']; - readonly isCorporateOnly: true; - }; - readonly financialforce: { - readonly value: 'financialforce'; - readonly text: 'Certinia'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/certinia.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/certinia_gray.svg'; - readonly alert_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/certinia_alert.svg'; - readonly types: readonly ['accounting']; - readonly isCorporateOnly: true; - }; - }; - readonly INDIRECT_INTEGRATIONS: { - readonly microsoft_dynamics: { - readonly value: 'microsoft_dynamics'; - readonly text: 'Microsoft Dynamics'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/microsoft_dynamics.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/microsoft_dynamics_gray.svg'; - readonly types: readonly ['accounting']; - readonly isCorporateOnly: true; - }; - readonly oracle: { - readonly value: 'oracle'; - readonly text: 'Oracle'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/oracle.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/oracle_gray.svg'; - readonly types: readonly ['hr', 'accounting']; - readonly isCorporateOnly: true; - }; - readonly sage: { - readonly value: 'sage'; - readonly text: 'Sage'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/sage.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/sage_gray.svg'; - readonly types: readonly ['accounting']; - readonly isCorporateOnly: true; - }; - readonly sap: { - readonly value: 'sap'; - readonly text: 'SAP'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/sap.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/sap_gray.svg'; - readonly types: readonly ['accounting']; - readonly isCorporateOnly: true; - }; - readonly myob: { - readonly value: 'myob'; - readonly text: 'MYOB'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/myob.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/myob_gray.svg'; - readonly types: readonly ['accounting']; - readonly isCorporateOnly: true; - }; - readonly workday: { - readonly value: 'workday'; - readonly text: 'Workday'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/workday.svg'; - readonly gray_image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/workday_gray.svg'; - readonly types: readonly ['hr']; - readonly isCorporateOnly: true; - }; - readonly adp: { - readonly value: 'adp'; - readonly text: 'ADP'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/export-icons/adp.svg'; - readonly types: readonly ['hr']; - readonly isCorporateOnly: true; - }; - readonly generic_indirect_connection: { - readonly value: 'generic_indirect_connection'; - readonly text: 'Other'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - readonly types: readonly ['hr', 'accounting']; - }; - }; - readonly DEFAULT_IS_TEMPLATES: { - readonly default: { - readonly value: 'default_template'; - readonly text: 'Basic Export'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - }; - readonly tag: { - readonly value: 'tag_template'; - readonly text: 'Tag Export'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - }; - readonly category: { - readonly value: 'category_template'; - readonly text: 'Category Export'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - }; - readonly detailed: { - readonly value: 'detailed_export'; - readonly text: 'All Data - Expense Level Export'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - }; - readonly report: { - readonly value: 'report_level_export'; - readonly text: 'All Data - Report Level Export'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - }; - readonly tax: { - readonly value: 'multiple_tax_export'; - readonly text: 'Canadian Multiple Tax Export'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - }; - readonly perdiem: { - readonly value: 'per_diem_export'; - readonly text: 'Per Diem Export'; - readonly image: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/accounting-other--blue.svg'; - }; - }; - readonly NVP: { - readonly DISMISSED_VIOLATIONS: 'dismissedViolations'; - }; - readonly FILESIZE: { - readonly BYTES_IN_MEGABYTE: 1000000; - readonly MAX: 10000000; - }; - readonly PARTNER_NAMES: { - readonly IPHONE: 'iphone'; - readonly ANDROID: 'android'; - readonly CHAT: 'chat-expensify-com'; - }; - readonly LOGIN_TYPES: { - readonly WEB: 'login'; - readonly MOBILE: 'device'; - }; - readonly EXPENSIFY_CARD: { - readonly FEED_NAME: 'Expensify Card'; - readonly FRAUD_STATES: { - readonly NONE: 0; - readonly DOMAIN_CARDS_REIMBURSEMENTS_INVESTIGATION: 1; - readonly DOMAIN_CARDS_RAPID_INCREASE_INVESTIGATION: 2; - readonly DOMAIN_CARDS_RAPID_INCREASE_CLEARED: 3; - readonly DOMAIN_CARDS_RAPID_INCREASE_CONFIRMED: 4; - readonly INDIVIDUAL_CARD_RAPID_INCREASE_INVESTIGATION: 5; - readonly INDIVIDUAL_CARD_RAPID_INCREASE_CLEARED: 6; - readonly INDIVIDUAL_CARD_RAPID_INCREASE_CONFIRMED: 7; - readonly SUSPICIOUS_PAN_ENTRY: 8; - readonly SUSPICIOUS_PAN_ENTRY_CLEARED: 9; - readonly SUSPICIOUS_PAN_ENTRY_CONFIRMED: 10; - }; - }; - readonly TRAVEL_BOOKING: { - readonly OPTIONS: { - readonly shortFlightFare: { - readonly economy: 'Economy'; - readonly premiumEconomy: 'Premium Economy'; - readonly business: 'Business'; - readonly first: 'First'; - }; - readonly longFlightFare: { - readonly economy: 'Economy'; - readonly premiumEconomy: 'Premium Economy'; - readonly business: 'Business'; - readonly first: 'First'; - }; - readonly hotelStar: { - readonly oneStar: '1'; - readonly twoStars: '2'; - readonly threeStars: '3'; - readonly fourStars: '4'; - readonly fiveStars: '5'; - }; - }; - readonly DEFAULT_OPTIONS: { - readonly shortFlightFare: 'economy'; - readonly longFlightFare: 'economy'; - readonly hotelStar: 'fourStars'; - }; - }; - readonly EXPENSIFY_DOMAINS: readonly ['expensify.com', 'expensifail.com', 'expensicorp.com']; - readonly SUBSCRIPTION_CHANGE_REASONS: { - readonly TOO_LIMITED: { - readonly id: 'tooLimited'; - readonly label: 'Functionality needs improvement'; - readonly prompt: 'What software are you migrating to and what led to this decision?'; - }; - readonly TOO_EXPENSIVE: { - readonly id: 'tooExpensive'; - readonly label: 'Too expensive'; - readonly prompt: 'What software are you migrating to and what led to this decision?'; - }; - readonly INADEQUATE_SUPPORT: { - readonly id: 'inadequateSupport'; - readonly label: 'Inadequate customer support'; - readonly prompt: 'What software are you migrating to and what led to this decision?'; - }; - readonly BUSINESS_CLOSING: { - readonly id: 'businessClosing'; - readonly label: 'Company closing, downsizing, or acquired'; - readonly prompt: 'What software are you migrating to and what led to this decision?'; - }; - }; -}; -/** - * UI Constants - */ -export declare const UI: { - readonly ICON: { - readonly DELETE: 'trashcan'; - readonly CAR: 'car'; - readonly CASH: 'cash'; - readonly MANAGED_CARD: 'corporate-card'; - readonly CARD: 'credit-card'; - readonly CLOCK: 'time'; - readonly PER_DIEM: 'per-diem'; - readonly PENDING_CARD: 'card-transaction-pending'; - readonly CSV_UPLOAD: 'csv-upload'; - readonly PENDING_CREDIT_CARD: 'credit-card-pending'; - }; - readonly spinnerDIV: '
'; - readonly spinnerSmallDIV: '
'; - readonly spinnerLargeDIV: '
'; - readonly spinnerClass: 'view_spinner'; - readonly SPINNER: 'spinner'; - readonly imageURLPrefix: 'https://d2k5nsl2zxldvw.cloudfront.net/images/'; - readonly ACTIVE: 'active'; - readonly ERROR: 'error'; - readonly HIDDEN: 'hidden'; - readonly INVISIBLE: 'invisible'; - readonly DEPRECIATED: 'depreciated'; - readonly DISABLED: 'disabled'; - readonly REQUIRED: 'required'; - readonly SELECT_DEFAULT: '###'; - readonly SELECTED: 'selected'; - readonly QR_CODE: 'js_qrCode'; - readonly DIALOG_Z_INDEX: 4000; -}; -export declare const PUBLIC_DOMAINS: readonly [ - 'accountant.com', - 'afis.ch', - 'aol.com', - 'artlover.com', - 'asia.com', - 'att.net', - 'bellsouth.net', - 'bills.expensify.com', - 'btinternet.com', - 'cheerful.com', - 'chromeexpensify.com', - 'comcast.net', - 'consultant.com', - 'contractor.com', - 'cox.net', - 'cpa.com', - 'cryptohistoryprice.com', - 'dr.com', - 'email.com', - 'engineer.com', - 'europe.com', - 'evernote.user', - 'execs.com', - 'expensify.cash', - 'expensify.sms', - 'gmail.com', - 'gmail.con', - 'googlemail.com', - 'hey.com', - 'hotmail.co.uk', - 'hotmail.com', - 'hotmail.fr', - 'hotmail.it', - 'icloud.com', - 'iname.com', - 'jeeviess.com', - 'live.com', - 'mac.com', - 'mail.com', - 'mail.ru', - 'mailfence.com', - 'me.com', - 'msn.com', - 'musician.org', - 'myself.com', - 'outlook.com', - 'pm.me', - 'post.com', - 'privaterelay.appleid.com', - 'proton.me', - 'protonmail.ch', - 'protonmail.com', - 'qq.com', - 'rigl.ch', - 'sasktel.net', - 'sbcglobal.net', - 'spacehotline.com', - 'tafmail.com', - 'techie.com', - 'usa.com', - 'verizon.net', - 'vomoto.com', - 'wolfandcranebar.tech', - 'workmail.com', - 'writeme.com', - 'yahoo.ca', - 'yahoo.co.in', - 'yahoo.co.uk', - 'yahoo.com', - 'yahoo.com.br', - 'ymail.com', -]; diff --git a/lib/CONST.jsx b/lib/CONST.ts similarity index 97% rename from lib/CONST.jsx rename to lib/CONST.ts index b68261b4..0d80b0b9 100644 --- a/lib/CONST.jsx +++ b/lib/CONST.ts @@ -8,14 +8,14 @@ const MOMENT_FORMAT_STRING = 'YYYY-MM-DD'; /** * URL of our CloudFront Instance */ -export const g_cloudFront = 'https://d2k5nsl2zxldvw.cloudfront.net'; +const g_cloudFront = 'https://d2k5nsl2zxldvw.cloudfront.net'; /** * URL of our image CDN */ -export const g_cloudFrontImg = `${g_cloudFront}/images/`; +const g_cloudFrontImg = `${g_cloudFront}/images/`; -export const CONST = { +const CONST = { CORPAY_DIRECT_REIMBURSEMENT_CURRENCIES: ['USD', 'GBP', 'EUR', 'AUD', 'CAD'], /** @@ -361,6 +361,7 @@ export const CONST = { * * @type RegExp */ + // eslint-disable-next-line no-misleading-character-class EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, /** @@ -560,6 +561,7 @@ export const CONST = { 'qa+travisreceipts@expensify.com', 'bills@expensify.com', 'admin@expensify.com', + 'notifications@expensify.com', ], /** @@ -865,30 +867,31 @@ export const CONST = { TOO_LIMITED: { id: 'tooLimited', label: 'Functionality needs improvement', - prompt: 'What software are you migrating to and what led to this decision?', + prompt: 'What software are you moving to and why?', }, TOO_EXPENSIVE: { id: 'tooExpensive', label: 'Too expensive', - prompt: 'What software are you migrating to and what led to this decision?', + prompt: 'What software are you moving to and why?', }, INADEQUATE_SUPPORT: { id: 'inadequateSupport', label: 'Inadequate customer support', - prompt: 'What software are you migrating to and what led to this decision?', + prompt: 'What software are you moving to and why?', }, BUSINESS_CLOSING: { id: 'businessClosing', label: 'Company closing, downsizing, or acquired', - prompt: 'What software are you migrating to and what led to this decision?', + prompt: 'What software are you moving to and why?', }, }, -}; + VIDEO_EXTENSIONS: ['mp4', 'mov', 'avi', 'wmv', 'flv', 'mkv', 'webm', '3gp', 'm4v', 'mpg', 'mpeg', 'ogv'], +} as const; /** * UI Constants */ -export const UI = { +const UI = { ICON: { DELETE: 'trashcan', CAR: 'car', @@ -922,10 +925,10 @@ export const UI = { // Base z-index for dialogs $zindex-dialog in _vars.scss should take it's value from here! DIALOG_Z_INDEX: 4000, -}; +} as const; // List of most frequently used public domains -export const PUBLIC_DOMAINS = [ +const PUBLIC_DOMAINS = [ 'accountant.com', 'afis.ch', 'aol.com', @@ -997,4 +1000,6 @@ export const PUBLIC_DOMAINS = [ 'yahoo.com', 'yahoo.com.br', 'ymail.com', -]; +] as const; + +export {g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS}; diff --git a/lib/Cookie.jsx b/lib/Cookie.jsx index 8dad1345..d2ae6f3c 100644 --- a/lib/Cookie.jsx +++ b/lib/Cookie.jsx @@ -58,7 +58,7 @@ function set(name, value, expiredays) { // Get expiry date, set const exdate = new Date(); exdate.setDate(exdate.getDate() + expiredays); - document.cookie = `${name}=${encodeURIComponent(value)}` + `${expiredays === null ? '' : `;expires=${exdate.toUTCString()}`}`; + document.cookie = `${name}=${encodeURIComponent(value)}${expiredays === null ? '' : `;expires=${exdate.toUTCString()}`}`; } /** diff --git a/lib/CredentialsWrapper.jsx b/lib/CredentialsWrapper.jsx index b6283c07..90fe7759 100644 --- a/lib/CredentialsWrapper.jsx +++ b/lib/CredentialsWrapper.jsx @@ -1,6 +1,6 @@ import localForage from 'localforage'; -export const LOGIN_PARTNER_DETAILS = { +const LOGIN_PARTNER_DETAILS = { CREDENTIALS_KEY: 'DEVICE_SESSION_CREDENTIALS', EXPENSIFY_PARTNER_PREFIX: 'expensify.', PARTNER_NAME: 'chat-expensify-com', @@ -33,6 +33,7 @@ const CredentialWrapper = { */ setCredentials(credentials) { if (!credentials.partnerUserID || !credentials.partnerUserSecret) { + // eslint-disable-next-line prefer-promise-reject-errors return Promise.reject('Invalid credential pair'); } @@ -48,3 +49,4 @@ const CredentialWrapper = { }; export default CredentialWrapper; +export {LOGIN_PARTNER_DETAILS}; diff --git a/lib/Device.d.ts b/lib/Device.d.ts deleted file mode 100644 index 6cb8abd3..00000000 --- a/lib/Device.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare function getOSAndName(): { - os: string | undefined; - osVersion: string | undefined; - deviceName: string | undefined; - deviceVersion: string | undefined; -}; -export {getOSAndName}; diff --git a/lib/Device.jsx b/lib/Device.ts similarity index 65% rename from lib/Device.jsx rename to lib/Device.ts index 43419dba..1100cb45 100644 --- a/lib/Device.jsx +++ b/lib/Device.ts @@ -1,6 +1,13 @@ import {UAParser} from 'ua-parser-js'; -function getOSAndName() { +type DeviceInfo = { + os: string | undefined; + osVersion: string | undefined; + deviceName: string | undefined; + deviceVersion: string | undefined; +}; + +function getOSAndName(): DeviceInfo { const parser = new UAParser(); const result = parser.getResult(); return { diff --git a/lib/ExpenseRule.jsx b/lib/ExpenseRule.jsx index 5d614932..f2e89dd8 100644 --- a/lib/ExpenseRule.jsx +++ b/lib/ExpenseRule.jsx @@ -1,5 +1,3 @@ -import _ from 'underscore'; - export default class ExpenseRule { /** * Creates a new instance of this class. @@ -7,7 +5,7 @@ export default class ExpenseRule { * @param {Array} ruleArray */ constructor(ruleArray) { - _.each(ruleArray, (value, key) => { + ruleArray.forEach((value, key) => { this[key] = value; }); } @@ -21,11 +19,7 @@ export default class ExpenseRule { * @return {Object} */ getApplyWhenByField(field) { - return ( - _.find(this.applyWhen, (conditions) => { - return conditions.field === field; - }) || {} - ); + return this.applyWhen.find((conditions) => conditions.field === field) || {}; } /** @@ -45,7 +39,7 @@ export default class ExpenseRule { */ isMatch(expense) { let isMatch = true; - _.each(this.applyWhen, (conditions) => { + this.applyWhen.forEach((conditions) => { switch (conditions.field) { case 'category': if (!this.checkCondition(conditions.condition, conditions.value, expense.getCategory())) { diff --git a/lib/ExpensiMark.d.ts b/lib/ExpensiMark.d.ts deleted file mode 100644 index f431df7e..00000000 --- a/lib/ExpensiMark.d.ts +++ /dev/null @@ -1,142 +0,0 @@ -declare type Replacement = (...args: string[], extras?: ExtrasObject) => string; -declare type Name = - | 'codeFence' - | 'inlineCodeBlock' - | 'email' - | 'link' - | 'hereMentions' - | 'userMentions' - | 'reportMentions' - | 'autoEmail' - | 'autolink' - | 'quote' - | 'italic' - | 'bold' - | 'strikethrough' - | 'heading1' - | 'newline' - | 'replacepre' - | 'listItem' - | 'exclude' - | 'anchor' - | 'breakline' - | 'blockquoteWrapHeadingOpen' - | 'blockquoteWrapHeadingClose' - | 'blockElementOpen' - | 'blockElementClose' - | 'stripTag'; -declare type Rule = { - name: Name; - process?: (textToProcess: string, replacement: Replacement) => string; - regex?: RegExp; - replacement: Replacement | string; - pre?: (input: string) => string; - post?: (input: string) => string; -}; - -declare type ExtrasObject = { - reportIdToName?: Record; - accountIDToName?: Record; -}; -export default class ExpensiMark { - rules: Rule[]; - htmlToMarkdownRules: Rule[]; - htmlToTextRules: Rule[]; - constructor(); - /** - * Replaces markdown with html elements - * - * @param text - Text to parse as markdown - * @param options - Options to customize the markdown parser - * @param options.filterRules=[] - An array of name of rules as defined in this class. - * If not provided, all available rules will be applied. If provided, only the rules in the array will be applied. - * @param options.disabledRules=[] - An array of name of rules as defined in this class. - * If not provided, all available rules will be applied. If provided, the rules in the array will be skipped. - * @param options.shouldEscapeText=true - Whether or not the text should be escaped - * @param options.shouldKeepRawInput=false - Whether or not the raw input should be kept and returned - */ - replace( - text: string, - { - filterRules, - shouldEscapeText, - shouldKeepRawInput, - }?: { - filterRules?: Name[]; - disabledRules?: Name[]; - shouldEscapeText?: boolean; - shouldKeepRawInput?: boolean; - }, - ): string; - /** - * Checks matched URLs for validity and replace valid links with html elements - * - * @param regex - * @param textToCheck - * @param replacement - */ - modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: Replacement): string; - /** - * Checks matched Emails for validity and replace valid links with html elements - * - * @param regex - * @param textToCheck - * @param replacement - */ - modifyTextForEmailLinks(regex: RegExp, textToCheck: string, replacement: Replacement): string; - /** - * replace block element with '\n' if : - * 1. We have text within the element. - * 2. The text does not end with a new line. - * 3. The text does not have quote mark '>' . - * 4. It's not the last element in the string. - * - * @param htmlString - */ - replaceBlockElementWithNewLine(htmlString: string): string; - /** - * Replaces HTML with markdown - * - * @param htmlString - * @param extras - */ - htmlToMarkdown(htmlString: string, extras?: ExtrasObject): string; - /** - * Convert HTML to text - * - * @param htmlString - * @param extras - */ - htmlToText(htmlString: string, extras?: ExtrasObject): string; - /** - * Modify text for Quotes replacing chevrons with html elements - * - * @param regex - * @param textToCheck - * @param replacement - */ - modifyTextForQuote(regex: RegExp, textToCheck: string, replacement: Replacement): string; - /** - * Format the content of blockquote if the text matches the regex or else just return the original text - * - * @param regex - * @param textToCheck - * @param replacement - */ - formatTextForQuote(regex: RegExp, textToCheck: string, replacement: Replacement): string; - /** - * Check if the input text includes only the open or the close tag of an element. - * - * @param textToCheck - Text to check - */ - containsNonPairTag(textToCheck: string): boolean; - extractLinksInMarkdownComment(comment: string): string[] | undefined; - /** - * Compares two markdown comments and returns a list of the links removed in a new comment. - * - * @param oldComment - * @param newComment - */ - getRemovedMarkdownLinks(oldComment: string, newComment: string): string[]; -} -export {}; diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.ts similarity index 66% rename from lib/ExpensiMark.js rename to lib/ExpensiMark.ts index 21f7081b..222b0f7d 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.ts @@ -1,28 +1,124 @@ -import * as _ from 'underscore'; import Str from './str'; import * as Constants from './CONST'; import * as UrlPatterns from './Url'; -import Log from './Log'; +import Logger from './Logger'; +import * as Utils from './utils'; + +type Extras = { + reportIDToName?: Record; + accountIDToName?: Record; + cacheVideoAttributes?: (vidSource: string, attrs: string) => void; + videoAttributeCache?: Record; +}; +const EXTRAS_DEFAULT = {}; + +type ReplacementFn = (extras: Extras, ...matches: string[]) => string; +type Replacement = ReplacementFn | string; +type ProcessFn = (textToProcess: string, replacement: Replacement, shouldKeepRawInput: boolean) => string; + +type CommonRule = { + name: string; + replacement: Replacement; + rawInputReplacement?: Replacement; + pre?: (input: string) => string; + post?: (input: string) => string; +}; + +type RuleWithRegex = CommonRule & { + regex: RegExp; +}; + +type RuleWithProcess = CommonRule & { + process: ProcessFn; +}; + +type Rule = RuleWithRegex | RuleWithProcess; + +type ReplaceOptions = { + extras?: Extras; + filterRules?: string[]; + disabledRules?: string[]; + shouldEscapeText?: boolean; + shouldKeepRawInput?: boolean; +}; const MARKDOWN_LINK_REGEX = new RegExp(`\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi'); const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi'); +const MARKDOWN_VIDEO_REGEX = new RegExp( + `\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join('|')}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, + 'gi', +); + const SLACK_SPAN_NEW_LINE_TAG = ''; export default class ExpensiMark { + static Log = new Logger({ + serverLoggingCallback: () => undefined, + // eslint-disable-next-line no-console + clientLoggingCallback: (message) => console.warn(message), + isDebug: true, + }); + + /** + * Set the logger to use for logging inside of the ExpensiMark class + * @param logger - The logger object to use + */ + static setLogger(logger: Logger) { + ExpensiMark.Log = logger; + } + + /** Rules to apply to the text */ + rules: Rule[]; + + /** + * The list of regex replacements to do on a HTML comment for converting it to markdown. + * Order of rules is important + */ + htmlToMarkdownRules: RuleWithRegex[]; + + /** + * The list of rules to covert the HTML to text. + * Order of rules is important + */ + htmlToTextRules: RuleWithRegex[]; + + /** + * The list of rules that we have to exclude in shouldKeepWhitespaceRules list. + */ + whitespaceRulesToDisable = ['newline', 'replacepre', 'replacebr', 'replaceh1br']; + + /** + * The list of rules that have to be applied when shouldKeepWhitespace flag is true. + */ + filterRules: (rule: Rule) => boolean; + + /** + * Filters rules to determine which should keep whitespace. + */ + shouldKeepWhitespaceRules: Rule[]; + + /** + * maxQuoteDepth is the maximum depth of nested quotes that we want to support. + */ + maxQuoteDepth: number; + + /** + * currentQuoteDepth is the current depth of nested quotes that we are processing. + */ + currentQuoteDepth: number; + constructor() { /** * The list of regex replacements to do on a comment. Check the link regex is first so links are processed * before other delimiters - * - * @type {Object[]} */ this.rules = [ // Apply the emoji first avoid applying any other formatting rules inside of it { name: 'emoji', regex: Constants.CONST.REG_EXP.EMOJI_RULE, - replacement: (match) => `${match}`, + replacement: (_extras, match) => `${match}`, }, /** @@ -33,7 +129,7 @@ export default class ExpensiMark { name: 'codeFence', // ` is a backtick symbol we are matching on three of them before then after a new line character - regex: /(```(?:\r\n|\n)?)((?:\s*?(?!(?:\r\n|\n)?```(?!`))[\S])+\s*?)((?=(?:\r\n|\n)?)```)/g, + regex: /(```(?:\r\n|\n))((?:\s*?(?!(?:\r\n|\n)?```(?!`))[\S])+\s*?(?:\r\n|\n))(```)/g, // We're using a function here to perform an additional replace on the content // inside the backticks because Android is not able to use
 tags and does
@@ -41,14 +137,13 @@ export default class ExpensiMark {
                 // with the new lines here since they need to be converted into 
. And we don't // want to do this anywhere else since that would break HTML. //   will create styling issues so use - replacement: (match, __, textWithinFences) => { + replacement: (_extras, _match, _g1, textWithinFences) => { const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, ' '); return `
${group}
`; }, - rawInputReplacement: (match, __, textWithinFences) => { - const withinFences = match.replace(/(?:```)([\s\S]*?)(?:```)/g, '$1'); - const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, ' '); - return `
${group}
`; + rawInputReplacement: (_extras, _match, _g1, textWithinFences) => { + const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, ' ').replace(/|<\/emoji>/g, ''); + return `
${group}
`; }, }, @@ -62,16 +157,8 @@ export default class ExpensiMark { // Use the url escaped version of a backtick (`) symbol. Mobile platforms do not support lookbehinds, // so capture the first and third group and place them in the replacement. // but we should not replace backtick symbols if they include
 tags between them.
-                regex: /(\B|_|)`(?:(?!(?:(?!`).)*?
))(.*?\S.*?)`(\B|_|)(?!`|[^<]*<\/pre>)/g,
-                replacement: (match, g1, g2, g3) => {
-                    const regex = /^[`]+$/i;
-
-                    // if content of the inline code block is only backtick symbols, we should not replace them with  tag
-                    if (regex.test(g2)) {
-                        return match;
-                    }
-                    return `${g1}${g2}${g3}`;
-                },
+                regex: /(\B|_|)`(.*?(?![`])\S.*?)`(\B|_|)(?!`|[^<]*<\/pre>)/gm,
+                replacement: '$1$2$3',
             },
 
             /**
@@ -83,9 +170,9 @@ export default class ExpensiMark {
                 name: 'email',
                 process: (textToProcess, replacement, shouldKeepRawInput) => {
                     const regex = new RegExp(`(?!\\[\\s*\\])\\[([^[\\]]*)]\\((mailto:)?${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}\\)`, 'gim');
-                    return this.modifyTextForEmailLinks(regex, textToProcess, replacement, shouldKeepRawInput);
+                    return this.modifyTextForEmailLinks(regex, textToProcess, replacement as ReplacementFn, shouldKeepRawInput);
                 },
-                replacement: (match, g1, g2) => {
+                replacement: (_extras, match, g1, g2) => {
                     if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
                         return match;
                     }
@@ -94,7 +181,7 @@ export default class ExpensiMark {
                     const formattedLabel = label === href ? g2 : label;
                     return `${formattedLabel}`;
                 },
-                rawInputReplacement: (match, g1, g2, g3) => {
+                rawInputReplacement: (_extras, match, g1, g2, g3) => {
                     if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
                         return match;
                     }
@@ -109,13 +196,37 @@ export default class ExpensiMark {
                 name: 'heading1',
                 process: (textToProcess, replacement, shouldKeepRawInput = false) => {
                     const regexp = shouldKeepRawInput ? /^# ( *(?! )(?:(?!
|\n|\r\n).)+)/gm : /^# +(?! )((?:(?!
|\n|\r\n).)+)/gm;
-                    return textToProcess.replace(regexp, replacement);
+                    return this.replaceTextWithExtras(textToProcess, regexp, EXTRAS_DEFAULT, replacement);
                 },
                 replacement: '

$1

', }, /** - * Converts markdown style images to img tags e.g. ![Expensify](https://www.expensify.com/attachment.png) + * Converts markdown style video to video tags e.g. ![Expensify](https://www.expensify.com/attachment.mp4) + * We need to convert before image rules since they will not try to create a image tag from an existing video URL + * Extras arg could contain the attribute cache for the video tag which is cached during the html-to-markdown conversion + */ + { + name: 'video', + regex: MARKDOWN_VIDEO_REGEX, + /** + * @param extras - The extras object + * @param videoName - The first capture group - video name + * @param videoSource - The second capture group - video URL + * @return Returns the HTML video tag + */ + replacement: (extras, _match, videoName, videoSource) => { + const extraAttrs = extras && extras.videoAttributeCache && extras.videoAttributeCache[videoSource]; + return ``; + }, + rawInputReplacement: (extras, _match, videoName, videoSource) => { + const extraAttrs = extras && extras.videoAttributeCache && extras.videoAttributeCache[videoSource]; + return ``; + }, + }, + + /** + * Converts markdown style images to image tags e.g. ![Expensify](https://www.expensify.com/attachment.png) * We need to convert before linking rules since they will not try to create a link from an existing img * tag. * Additional sanitization is done to the alt attribute to prevent parsing it further to html by later @@ -124,8 +235,8 @@ export default class ExpensiMark { { name: 'image', regex: MARKDOWN_IMAGE_REGEX, - replacement: (match, g1, g2) => `${this.escapeAttributeContent(g1)}`, - rawInputReplacement: (match, g1, g2) => + replacement: (_extras, _match, g1, g2) => `${this.escapeAttributeContent(g1)}`, + rawInputReplacement: (_extras, _match, g1, g2) => `${this.escapeAttributeContent(g1)}`, }, @@ -136,14 +247,14 @@ export default class ExpensiMark { */ { name: 'link', - process: (textToProcess, replacement) => this.modifyTextForUrlLinks(MARKDOWN_LINK_REGEX, textToProcess, replacement), - replacement: (match, g1, g2) => { + process: (textToProcess, replacement) => this.modifyTextForUrlLinks(MARKDOWN_LINK_REGEX, textToProcess, replacement as ReplacementFn), + replacement: (_extras, match, g1, g2) => { if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) { return match; } return `${g1.trim()}`; }, - rawInputReplacement: (match, g1, g2) => { + rawInputReplacement: (_extras, match, g1, g2) => { if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) { return match; } @@ -161,7 +272,7 @@ export default class ExpensiMark { { name: 'hereMentions', regex: /([a-zA-Z0-9.!$%&+/=?^`{|}_-]?)(@here)([.!$%&+/=?^`{|}_-]?)(?=\b)(?!([\w'#%+-]*@(?:[a-z\d-]+\.)+[a-z]{2,}(?:\s|$|@here))|((?:(?!|[^<]*(<\/pre>|<\/code>))/gm, - replacement: (match, g1, g2, g3) => { + replacement: (_extras, match, g1, g2, g3) => { if (!Str.isValidMention(match)) { return match; } @@ -178,7 +289,7 @@ export default class ExpensiMark { { name: 'reportMentions', - regex: /(?|<\/code>))/gimu, replacement: '$1', }, @@ -197,15 +308,21 @@ export default class ExpensiMark { `(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${Constants.CONST.REG_EXP.EMAIL_PART}|@${Constants.CONST.REG_EXP.PHONE_PART})(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, 'gim', ), - replacement: (match, g1, g2) => { - if (!Str.isValidMention(match)) { + replacement: (_extras, match, g1, g2) => { + const phoneNumberRegex = new RegExp(`^${Constants.CONST.REG_EXP.PHONE_PART}$`); + const mention = g2.slice(1); + const mentionWithoutSMSDomain = Str.removeSMSDomain(mention); + if (!Str.isValidMention(match) || (phoneNumberRegex.test(mentionWithoutSMSDomain) && !Str.isValidPhoneNumber(mentionWithoutSMSDomain))) { return match; } const phoneRegex = new RegExp(`^@${Constants.CONST.REG_EXP.PHONE_PART}$`); return `${g1}${g2}${phoneRegex.test(g2) ? `@${Constants.CONST.SMS.DOMAIN}` : ''}`; }, - rawInputReplacement: (match, g1, g2) => { - if (!Str.isValidMention(match)) { + rawInputReplacement: (_extras, match, g1, g2) => { + const phoneNumberRegex = new RegExp(`^${Constants.CONST.REG_EXP.PHONE_PART}$`); + const mention = g2.slice(1); + const mentionWithoutSMSDomain = Str.removeSMSDomain(mention); + if (!Str.isValidMention(match) || (phoneNumberRegex.test(mentionWithoutSMSDomain) && !Str.isValidPhoneNumber(mentionWithoutSMSDomain))) { return match; } return `${g1}${g2}`; @@ -227,14 +344,14 @@ export default class ExpensiMark { process: (textToProcess, replacement) => { const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>|.+\\/>))`, 'gi'); - return this.modifyTextForUrlLinks(regex, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex, textToProcess, replacement as ReplacementFn); }, - replacement: (match, g1, g2) => { + replacement: (_extras, _match, g1, g2) => { const href = Str.sanitizeURL(g2); return `${g1}${g2}${g1}`; }, - rawInputReplacement: (_match, g1, g2) => { + rawInputReplacement: (_extras, _match, g1, g2) => { const href = Str.sanitizeURL(g2); return `${g1}${g2}${g1}`; }, @@ -248,22 +365,40 @@ export default class ExpensiMark { // inline code blocks. A single prepending space should be stripped if it exists process: (textToProcess, replacement, shouldKeepRawInput = false) => { const regex = /^(?:>)+ +(?! )(?![^<]*(?:<\/pre>|<\/code>))([^\v\n\r]+)/gm; - const replaceFunction = (g1) => replacement(g1, shouldKeepRawInput); if (shouldKeepRawInput) { - return textToProcess.replace(regex, replaceFunction); + const rawInputRegex = /^(?:>)+ +(?! )(?![^<]*(?:<\/pre>|<\/code>))([^\v\n\r]*)/gm; + return this.replaceTextWithExtras(textToProcess, rawInputRegex, EXTRAS_DEFAULT, replacement); } - return this.modifyTextForQuote(regex, textToProcess, replacement); + return this.modifyTextForQuote(regex, textToProcess, replacement as ReplacementFn); }, - replacement: (g1, shouldKeepRawInput = false) => { + replacement: (_extras, g1) => { + // We want to enable 2 options of nested heading inside the blockquote: "># heading" and "> # heading". + // To do this we need to parse body of the quote without first space + const handleMatch = (match: string) => match; + const textToReplace = g1.replace(/^>( )?/gm, handleMatch); + const filterRules = ['heading1']; + + // if we don't reach the max quote depth we allow the recursive call to process possible quote + if (this.currentQuoteDepth < this.maxQuoteDepth - 1) { + filterRules.push('quote'); + this.currentQuoteDepth++; + } + + const replacedText = this.replace(textToReplace, { + filterRules, + shouldEscapeText: false, + shouldKeepRawInput: false, + }); + this.currentQuoteDepth = 0; + return `
${replacedText}
`; + }, + rawInputReplacement: (_extras, g1) => { // We want to enable 2 options of nested heading inside the blockquote: "># heading" and "> # heading". // To do this we need to parse body of the quote without first space let isStartingWithSpace = false; - const handleMatch = (match, g2) => { - if (shouldKeepRawInput) { - isStartingWithSpace = !!g2; - return ''; - } - return match; + const handleMatch = (_match: string, g2: string) => { + isStartingWithSpace = !!g2; + return ''; }; const textToReplace = g1.replace(/^>( )?/gm, handleMatch); const filterRules = ['heading1']; @@ -277,30 +412,31 @@ export default class ExpensiMark { const replacedText = this.replace(textToReplace, { filterRules, shouldEscapeText: false, - shouldKeepRawInput, + shouldKeepRawInput: true, }); this.currentQuoteDepth = 0; return `
${isStartingWithSpace ? ' ' : ''}${replacedText}
`; }, }, + /** + * Use \b in this case because it will match on words, letters, + * and _: https://www.rexegg.com/regex-boundaries.html#wordboundary + * Use [\s\S]* instead of .* to match newline + */ { - /** - * Use \b in this case because it will match on words, letters, - * and _: https://www.rexegg.com/regex-boundaries.html#wordboundary - * The !_blank is to prevent the `target="_blank">` section of the - * link replacement from being captured Additionally, something like - * `\b\_([^<>]*?)\_\b` doesn't work because it won't replace - * `_https://www.test.com_` - * Use [\s\S]* instead of .* to match newline - */ name: 'italic', - regex: /(?]*)(\b_+|\b)(?!_blank")_((?![\s_])[\s\S]*?[^\s_](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/mention-user>|_blank))/g, + regex: /(<(pre|code|a|mention-user)[^>]*>(.*?)<\/\2>)|((\b_+|\b)_((?![\s_])[\s\S]*?[^\s_](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/mention-user>)))/g, + replacement: (_extras, match, html, tag, content, text, extraLeadingUnderscores, textWithinUnderscores) => { + // Skip any
, , ,  tag contents
+                    if (html) {
+                        return html;
+                    }
 
-                // We want to add extraLeadingUnderscores back before the  tag unless textWithinUnderscores starts with valid email
-                replacement: (match, extraLeadingUnderscores, textWithinUnderscores) => {
+                    // If any tags are included inside underscores, ignore it. ie. _abc 
pre tag
abc_ if (textWithinUnderscores.includes('
') || this.containsNonPairTag(textWithinUnderscores)) { return match; } + if (String(textWithinUnderscores).match(`^${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}`)) { return `${extraLeadingUnderscores}${textWithinUnderscores}`; } @@ -326,12 +462,12 @@ export default class ExpensiMark { // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: 'bold', regex: /(?]*)\B\*(?![^<]*(?:<\/pre>|<\/code>|<\/a>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>))/g, - replacement: (match, g1) => (g1.includes('
') || this.containsNonPairTag(g1) ? match : `${g1}`), + replacement: (_extras, match, g1) => (g1.includes('
') || this.containsNonPairTag(g1) ? match : `${g1}`), }, { name: 'strikethrough', regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>))/g, - replacement: (match, g1) => (g1.includes('
') || this.containsNonPairTag(g1) ? match : `${g1}`), + replacement: (_extras, match, g1) => (g1.includes('
') || this.containsNonPairTag(g1) ? match : `${g1}`), }, { name: 'newline', @@ -355,7 +491,6 @@ export default class ExpensiMark { /** * The list of regex replacements to do on a HTML comment for converting it to markdown. * Order of rules is important - * @type {Object[]} */ this.htmlToMarkdownRules = [ // Used to Exclude tags @@ -422,16 +557,32 @@ export default class ExpensiMark { { name: 'quote', regex: /<(blockquote|q)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: (match, g1, g2) => { + replacement: (_extras, _match, _g1, g2) => { // We remove the line break before heading inside quote to avoid adding extra line - let resultString = g2 + let resultString: string[] | string = g2 .replace(/\n?(

# )/g, '$1') .replace(/(

|<\/h1>)+/g, '\n') .trim() .split('\n'); - const prependGreaterSign = (m) => `> ${m}`; - resultString = _.map(resultString, prependGreaterSign).join('\n'); + // Wrap each string in the array with
and
+ resultString = resultString.map((line) => { + return `
${line}
`; + }); + + resultString = resultString + .map((text) => { + let modifiedText = text; + let depth; + do { + depth = (modifiedText.match(/
/gi) || []).length; + modifiedText = modifiedText.replace(/
/gi, ''); + modifiedText = modifiedText.replace(/<\/blockquote>/gi, ''); + } while (/
/i.test(modifiedText)); + return `${'>'.repeat(depth)} ${modifiedText}`; + }) + .join('\n'); + // We want to keep
tag here and let method replaceBlockElementWithNewLine to handle the line break later return `
${resultString}
`; }, @@ -444,12 +595,12 @@ export default class ExpensiMark { { name: 'codeFence', regex: /<(pre)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)(\n?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: (match, g1, g2) => `\`\`\`\n${g2}\n\`\`\``, + replacement: (_extras, _match, _g1, g2) => `\`\`\`\n${g2}\n\`\`\``, }, { name: 'anchor', regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: (match, g1, g2, g3, g4) => { + replacement: (_extras, _match, _g1, _g2, g3, g4) => { const email = g3.startsWith('mailto:') ? g3.slice(7) : ''; if (email === g4) { return email; @@ -457,10 +608,11 @@ export default class ExpensiMark { return `[${g4}](${email || g3})`; }, }, + { name: 'image', regex: /<]*src\s*=\s*(['"])(.*?)\1(?:[^><]*alt\s*=\s*(['"])(.*?)\3)?[^><]*>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi, - replacement: (match, g1, g2, g3, g4) => { + replacement: (_extras, _match, _g1, g2, _g3, g4) => { if (g4) { return `![${g4}](${g2})`; } @@ -468,13 +620,38 @@ export default class ExpensiMark { return `!(${g2})`; }, }, + + { + name: 'video', + regex: /<]*data-expensify-source\s*=\s*(['"])(\S*?)\1(.*?)>([^><]*)<\/video>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi, + /** + * @param extras - The extras object + * @param match The full match + * @param _g1 The first capture group + * @param videoSource - the second capture group - video source (video URL) + * @param videoAttrs - the third capture group - video attributes (data-expensify-width, data-expensify-height, etc...) + * @param videoName - the fourth capture group will be the video file name (the text between opening and closing video tags) + * @returns The markdown video tag + */ + replacement: (extras, _match, _g1, videoSource, videoAttrs, videoName) => { + if (videoAttrs && extras && extras.cacheVideoAttributes && typeof extras.cacheVideoAttributes === 'function') { + extras.cacheVideoAttributes(videoSource, videoAttrs); + } + if (videoName) { + return `![${videoName}](${videoSource})`; + } + + return `!(${videoSource})`; + }, + }, + { name: 'reportMentions', regex: //gi, - replacement: (match, g1, offset, string, extras) => { - const reportToNameMap = extras.reportIdToName; + replacement: (extras, _match, g1, _offset, _string) => { + const reportToNameMap = extras.reportIDToName; if (!reportToNameMap || !reportToNameMap[g1]) { - Log.alert('[ExpensiMark] Missing report name', {reportID: g1}); + ExpensiMark.Log.alert('[ExpensiMark] Missing report name', {reportID: g1}); return '#Hidden'; } @@ -483,15 +660,18 @@ export default class ExpensiMark { }, { name: 'userMention', - regex: //gi, - replacement: (match, g1, offset, string, extras) => { - const accountToNameMap = extras.accountIdToName; - if (!accountToNameMap || !accountToNameMap[g1]) { - Log.alert('[ExpensiMark] Missing account name', {accountID: g1}); - return '@Hidden'; - } + regex: /(?:)|(?:(.*?)<\/mention-user>)/gi, + replacement: (extras, _match, g1, g2, _offset, _string) => { + if (g1) { + const accountToNameMap = extras.accountIDToName; + if (!accountToNameMap || !accountToNameMap[g1]) { + ExpensiMark.Log.alert('[ExpensiMark] Missing account name', {accountID: g1}); + return '@Hidden'; + } - return `@${extras.accountIdToName[g1]}`; + return `@${extras.accountIDToName?.[g1]}`; + } + return Str.removeSMSDomain(g2); }, }, ]; @@ -499,7 +679,6 @@ export default class ExpensiMark { /** * The list of rules to covert the HTML to text. * Order of rules is important - * @type {Object[]} */ this.htmlToTextRules = [ { @@ -540,10 +719,10 @@ export default class ExpensiMark { { name: 'reportMentions', regex: //gi, - replacement: (match, g1, offset, string, extras) => { - const reportToNameMap = extras.reportIdToName; + replacement: (extras, _match, g1, _offset, _string) => { + const reportToNameMap = extras.reportIDToName; if (!reportToNameMap || !reportToNameMap[g1]) { - Log.alert('[ExpensiMark] Missing report name', {reportID: g1}); + ExpensiMark.Log.alert('[ExpensiMark] Missing report name', {reportID: g1}); return '#Hidden'; } @@ -553,14 +732,13 @@ export default class ExpensiMark { { name: 'userMention', regex: //gi, - replacement: (match, g1, offset, string, extras) => { - const accountToNameMap = extras.accountIdToName; + replacement: (extras, _match, g1, _offset, _string) => { + const accountToNameMap = extras.accountIDToName; if (!accountToNameMap || !accountToNameMap[g1]) { - Log.alert('[ExpensiMark] Missing account name', {accountID: g1}); + ExpensiMark.Log.alert('[ExpensiMark] Missing account name', {accountID: g1}); return '@Hidden'; } - - return `@${extras.accountIdToName[g1]}`; + return `@${extras.accountIDToName?.[g1]}`; }, }, { @@ -572,48 +750,51 @@ export default class ExpensiMark { /** * The list of rules that we have to exclude in shouldKeepWhitespaceRules list. - * @type {Object[]} */ this.whitespaceRulesToDisable = ['newline', 'replacepre', 'replacebr', 'replaceh1br']; /** * The list of rules that have to be applied when shouldKeepWhitespace flag is true. - * @param {Object} rule - The rule to check. - * @returns {boolean} Returns true if the rule should be applied, otherwise false. + * @param rule - The rule to check. + * @returns true if the rule should be applied, otherwise false. */ - this.filterRules = (rule) => !_.includes(this.whitespaceRulesToDisable, rule.name); + this.filterRules = (rule: Rule) => !this.whitespaceRulesToDisable.includes(rule.name); /** * Filters rules to determine which should keep whitespace. - * @returns {Object[]} The filtered rules. + * @returns The filtered rules. */ - this.shouldKeepWhitespaceRules = _.filter(this.rules, this.filterRules); + this.shouldKeepWhitespaceRules = this.rules.filter(this.filterRules); /** * maxQuoteDepth is the maximum depth of nested quotes that we want to support. - * @type {Number} */ this.maxQuoteDepth = 3; /** * currentQuoteDepth is the current depth of nested quotes that we are processing. - * @type {Number} */ this.currentQuoteDepth = 0; } - getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput) { + /** + * Retrieves the HTML ruleset based on the provided filter rules, disabled rules, and shouldKeepRawInput flag. + * @param filterRules - An array of rule names to filter the ruleset. + * @param disabledRules - An array of rule names to disable in the ruleset. + * @param shouldKeepRawInput - A boolean flag indicating whether to keep raw input. + */ + getHtmlRuleset(filterRules: string[], disabledRules: string[], shouldKeepRawInput: boolean) { let rules = this.rules; - const hasRuleName = (rule) => _.contains(filterRules, rule.name); - const hasDisabledRuleName = (rule) => !_.contains(disabledRules, rule.name); + const hasRuleName = (rule: Rule) => filterRules.includes(rule.name); + const hasDisabledRuleName = (rule: Rule) => !disabledRules.includes(rule.name); if (shouldKeepRawInput) { rules = this.shouldKeepWhitespaceRules; } - if (!_.isEmpty(filterRules)) { - rules = _.filter(this.rules, hasRuleName); + if (filterRules.length > 0) { + rules = this.rules.filter(hasRuleName); } - if (!_.isEmpty(disabledRules)) { - rules = _.filter(rules, hasDisabledRuleName); + if (disabledRules.length > 0) { + rules = rules.filter(hasDisabledRuleName); } return rules; } @@ -621,31 +802,29 @@ export default class ExpensiMark { /** * Replaces markdown with html elements * - * @param {String} text - Text to parse as markdown - * @param {Object} [options] - Options to customize the markdown parser - * @param {String[]} [options.filterRules=[]] - An array of name of rules as defined in this class. + * @param text - Text to parse as markdown + * @param [options] - Options to customize the markdown parser + * @param [options.filterRules=[]] - An array of name of rules as defined in this class. * If not provided, all available rules will be applied. - * @param {Boolean} [options.shouldEscapeText=true] - Whether or not the text should be escaped - * @param {String[]} [options.disabledRules=[]] - An array of name of rules as defined in this class. + * @param [options.shouldEscapeText=true] - Whether or not the text should be escaped + * @param [options.disabledRules=[]] - An array of name of rules as defined in this class. * If not provided, all available rules will be applied. If provided, the rules in the array will be skipped. - * - * @returns {String} */ - replace(text, {filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = []} = {}) { + replace(text: string, {filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = [], extras = EXTRAS_DEFAULT}: ReplaceOptions = {}): string { // This ensures that any html the user puts into the comment field shows as raw html - let replacedText = shouldEscapeText ? _.escape(text) : text; + let replacedText = shouldEscapeText ? Utils.escape(text) : text; const rules = this.getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput); - const processRule = (rule) => { + const processRule = (rule: Rule) => { // Pre-process text before applying regex if (rule.pre) { replacedText = rule.pre(replacedText); } - const replacementFunction = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; - if (rule.process) { - replacedText = rule.process(replacedText, replacementFunction, shouldKeepRawInput); + const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; + if ('process' in rule) { + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); } else { - replacedText = replacedText.replace(rule.regex, replacementFunction); + replacedText = this.replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } // Post-process text after applying regex @@ -656,11 +835,10 @@ export default class ExpensiMark { try { rules.forEach(processRule); } catch (e) { - // eslint-disable-next-line no-console - console.warn('Error replacing text with html in ExpensiMark.replace', {error: e}); + ExpensiMark.Log.alert('Error replacing text with html in ExpensiMark.replace', {error: e}); // We want to return text without applying rules if exception occurs during replacing - return shouldEscapeText ? _.escape(text) : text; + return shouldEscapeText ? Utils.escape(text) : text; } return replacedText; @@ -668,14 +846,8 @@ export default class ExpensiMark { /** * Checks matched URLs for validity and replace valid links with html elements - * - * @param {RegExp} regex - * @param {String} textToCheck - * @param {Function} replacement - * - * @returns {String} */ - modifyTextForUrlLinks(regex, textToCheck, replacement) { + modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn): string { let match = regex.exec(textToCheck); let replacedText = ''; let startIndex = 0; @@ -761,7 +933,7 @@ export default class ExpensiMark { filterRules: ['bold', 'strikethrough', 'italic'], shouldEscapeText: false, }); - replacedText = replacedText.concat(replacement(match[0], linkText, url)); + replacedText = replacedText.concat(replacement(EXTRAS_DEFAULT, match[0], linkText, url)); } startIndex = match.index + match[0].length; @@ -777,15 +949,8 @@ export default class ExpensiMark { /** * Checks matched Emails for validity and replace valid links with html elements - * - * @param {RegExp} regex - * @param {String} textToCheck - * @param {Function} replacement - * @param {Boolean} shouldKeepRawInput - * - * @returns {String} */ - modifyTextForEmailLinks(regex, textToCheck, replacement, shouldKeepRawInput) { + modifyTextForEmailLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn, shouldKeepRawInput: boolean): string { let match = regex.exec(textToCheck); let replacedText = ''; let startIndex = 0; @@ -800,8 +965,8 @@ export default class ExpensiMark { shouldEscapeText: false, }); - // rawInputReplacment needs to be called with additional parameters from match - const replacedMatch = shouldKeepRawInput ? replacement(match[0], linkText, match[2], match[3]) : replacement(match[0], linkText, match[3]); + // rawInputReplacement needs to be called with additional parameters from match + const replacedMatch = shouldKeepRawInput ? replacement(EXTRAS_DEFAULT, match[0], linkText, match[2], match[3]) : replacement(EXTRAS_DEFAULT, match[0], linkText, match[3]); replacedText = replacedText.concat(replacedMatch); startIndex = match.index + match[0].length; @@ -820,17 +985,14 @@ export default class ExpensiMark { * 2. The text does not end with a new line. * 3. The text does not have quote mark '>' . * 4. It's not the last element in the string. - * - * @param {String} htmlString - * @returns {String} */ - replaceBlockElementWithNewLine(htmlString) { + replaceBlockElementWithNewLine(htmlString: string): string { // eslint-disable-next-line max-len let splitText = htmlString.split( /|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>|
    |<\/blockquote>/, ); - const stripHTML = (text) => Str.stripHTML(text); - splitText = _.map(splitText, stripHTML); + const stripHTML = (text: string) => Str.stripHTML(text); + splitText = splitText.map(stripHTML); let joinedText = ''; // Delete whitespace at the end @@ -841,7 +1003,7 @@ export default class ExpensiMark { splitText.pop(); } - const processText = (text, index) => { + const processText = (text: string, index: number) => { if (text.trim().length === 0 && !text.match(/\n/)) { return; } @@ -861,13 +1023,8 @@ export default class ExpensiMark { /** * Replaces HTML with markdown - * - * @param {String} htmlString - * @param {Object} extras - * - * @returns {String} */ - htmlToMarkdown(htmlString, extras = {}) { + htmlToMarkdown(htmlString: string, extras: Extras = EXTRAS_DEFAULT): string { let generatedMarkdown = htmlString; const body = /<(body)(?:"[^"]*"|'[^']*'|[^'"><])*>(?:\n|\r\n)?([\s\S]*?)(?:\n|\r\n)?<\/\1>(?![^<]*(<\/pre>|<\/code>))/im; const parseBodyTag = generatedMarkdown.match(body); @@ -877,15 +1034,13 @@ export default class ExpensiMark { generatedMarkdown = parseBodyTag[2]; } - const processRule = (rule) => { + const processRule = (rule: RuleWithRegex) => { // Pre-processes input HTML before applying regex if (rule.pre) { generatedMarkdown = rule.pre(generatedMarkdown); } - // if replacement is a function, we want to pass optional extras to it - const replacementFunction = typeof rule.replacement === 'function' ? (...args) => rule.replacement(...args, extras) : rule.replacement; - generatedMarkdown = generatedMarkdown.replace(rule.regex, replacementFunction); + generatedMarkdown = this.replaceTextWithExtras(generatedMarkdown, rule.regex, extras, rule.replacement); }; this.htmlToMarkdownRules.forEach(processRule); @@ -894,18 +1049,11 @@ export default class ExpensiMark { /** * Convert HTML to text - * - * @param {String} htmlString - * @param {Object} extras - * - * @returns {String} */ - htmlToText(htmlString, extras = {}) { + htmlToText(htmlString: string, extras: Extras = EXTRAS_DEFAULT): string { let replacedText = htmlString; - const processRule = (rule) => { - // if replacement is a function, we want to pass optional extras to it - const replacementFunction = typeof rule.replacement === 'function' ? (...args) => rule.replacement(...args, extras) : rule.replacement; - replacedText = replacedText.replace(rule.regex, replacementFunction); + const processRule = (rule: RuleWithRegex) => { + replacedText = this.replaceTextWithExtras(replacedText, rule.regex, extras, rule.replacement); }; this.htmlToTextRules.forEach(processRule); @@ -918,15 +1066,8 @@ export default class ExpensiMark { /** * Modify text for Quotes replacing chevrons with html elements - * - * @param {RegExp} regex - * @param {String} textToCheck - * @param {Function} replacement - * - * @returns {String} */ - - modifyTextForQuote(regex, textToCheck, replacement) { + modifyTextForQuote(regex: RegExp, textToCheck: string, replacement: ReplacementFn): string { let replacedText = ''; let textToFormat = ''; const match = textToCheck.match(regex); @@ -981,40 +1122,30 @@ export default class ExpensiMark { /** * Format the content of blockquote if the text matches the regex or else just return the original text - * - * @param {RegExp} regex - * @param {String} textToCheck - * @param {Function} replacement - * - * @returns {String} */ - formatTextForQuote(regex, textToCheck, replacement) { + formatTextForQuote(regex: RegExp, textToCheck: string, replacement: ReplacementFn): string { if (textToCheck.match(regex)) { // Remove '>' and trim the spaces between nested quotes - const formatRow = (row) => { + const formatRow = (row: string) => { const quoteContent = row[4] === ' ' ? row.substr(5) : row.substr(4); if (quoteContent.trimStart().startsWith('>')) { return quoteContent.trimStart(); } return quoteContent; }; - let textToFormat = _.map(textToCheck.split('\n'), formatRow).join('\n'); + let textToFormat = textToCheck.split('\n').map(formatRow).join('\n'); // Remove leading and trailing line breaks textToFormat = textToFormat.replace(/^\n+|\n+$/g, ''); - return replacement(textToFormat); + return replacement(EXTRAS_DEFAULT, textToFormat); } return textToCheck; } /** * Check if the input text includes only the open or the close tag of an element. - * - * @param {String} textToCheck - Text to check - * - * @returns {Boolean} */ - containsNonPairTag(textToCheck) { + containsNonPairTag(textToCheck: string): boolean { // Create a regular expression to match HTML tags const tagRegExp = /<([a-z][a-z0-9-]*)\b[^>]*>|<\/([a-z][a-z0-9-]*)\s*>/gi; @@ -1047,10 +1178,9 @@ export default class ExpensiMark { } /** - * @param {String} comment - * @returns {Array} or undefined if exception occurs when executing regex matching + * @returns array or undefined if exception occurs when executing regex matching */ - extractLinksInMarkdownComment(comment) { + extractLinksInMarkdownComment(comment: string): string[] | undefined { try { const htmlString = this.replace(comment, {filterRules: ['link']}); // We use same anchor tag template as link and autolink rules to extract link @@ -1058,35 +1188,30 @@ export default class ExpensiMark { const matches = [...htmlString.matchAll(regex)]; // Element 1 from match is the regex group if it exists which contains the link URLs - const sanitizeMatch = (match) => Str.sanitizeURL(match[1]); - const links = _.map(matches, sanitizeMatch); + const sanitizeMatch = (match: RegExpExecArray) => Str.sanitizeURL(match[1]); + const links = matches.map(sanitizeMatch); return links; } catch (e) { - // eslint-disable-next-line no-console - console.warn('Error parsing url in ExpensiMark.extractLinksInMarkdownComment', {error: e}); + ExpensiMark.Log.alert('Error parsing url in ExpensiMark.extractLinksInMarkdownComment', {error: e}); return undefined; } } /** * Compares two markdown comments and returns a list of the links removed in a new comment. - * - * @param {String} oldComment - * @param {String} newComment - * @returns {Array} */ - getRemovedMarkdownLinks(oldComment, newComment) { + getRemovedMarkdownLinks(oldComment: string, newComment: string): string[] { const linksInOld = this.extractLinksInMarkdownComment(oldComment); const linksInNew = this.extractLinksInMarkdownComment(newComment); - return linksInOld === undefined || linksInNew === undefined ? [] : _.difference(linksInOld, linksInNew); + return linksInOld === undefined || linksInNew === undefined ? [] : linksInOld.filter((link) => !linksInNew.includes(link)); } /** * Escapes the content of an HTML attribute value - * @param {String} content - string content that possible contains HTML - * @returns {String} - original MD content escaped for use in HTML attribute value + * @param content - string content that possible contains HTML + * @returns original MD content escaped for use in HTML attribute value */ - escapeAttributeContent(content) { + escapeAttributeContent(content: string): string { let originalContent = this.htmlToMarkdown(content); if (content === originalContent) { return content; @@ -1095,6 +1220,22 @@ export default class ExpensiMark { // When the attribute contains HTML and is converted back to MD we need to re-escape it to avoid // illegal attribute value characters like `," or ' which might break the HTML originalContent = Str.replaceAll(originalContent, '\n', ''); - return _.escape(originalContent); + return Utils.escape(originalContent); + } + + /** + * Replaces text with a replacement based on a regex + * @param text - The text to replace + * @param regexp - The regex to match + * @param extras - The extras object + * @param replacement - The replacement string or function + * @returns The replaced text + */ + replaceTextWithExtras(text: string, regexp: RegExp, extras: Extras, replacement: Replacement): string { + if (typeof replacement === 'function') { + // if the replacement is a function, we pass the extras object to it + return text.replace(regexp, (...args) => replacement(extras, ...args)); + } + return text.replace(regexp, replacement); } } diff --git a/lib/Func.jsx b/lib/Func.jsx index 3a636b47..7d1ccefb 100644 --- a/lib/Func.jsx +++ b/lib/Func.jsx @@ -1,5 +1,4 @@ -import $ from 'jquery'; -import _ from 'underscore'; +import * as Utils from './utils'; /** * Invokes the given callback with the given arguments @@ -11,7 +10,7 @@ import _ from 'underscore'; * @returns {Mixed} */ function invoke(callback, args, scope) { - if (!_(callback).isFunction()) { + if (!Utils.isFunction(callback)) { return null; } @@ -26,18 +25,18 @@ function invoke(callback, args, scope) { * @param {Array} [args] * @param {Object} [scope] * - * @returns {$.Deferred} + * @returns {Promise} */ function invokeAsync(callback, args, scope) { - if (!_(callback).isFunction()) { - return new $.Deferred().resolve(); + if (!Utils.isFunction(callback)) { + return Promise.resolve(); } let promiseFromCallback = callback.apply(scope, args || []); // If there was not a promise returned from the prefetch callback, then create a dummy promise and resolve it if (!promiseFromCallback) { - promiseFromCallback = new $.Deferred().resolve(); + promiseFromCallback = Promise.resolve(); } return promiseFromCallback; @@ -68,7 +67,11 @@ function die() { * @returns {Array} */ function mapByName(list, methodName) { - return _.map(list, (item) => item[methodName].call(item)); + let arr = list; + if (!Array.isArray(arr)) { + arr = Object.values(arr); + } + return arr.map((item) => item[methodName].call(item)); } export {invoke, invokeAsync, bulkInvoke, die, mapByName}; diff --git a/lib/Log.jsx b/lib/Log.jsx index ba0ca1b1..3c90d904 100644 --- a/lib/Log.jsx +++ b/lib/Log.jsx @@ -1,7 +1,8 @@ -import _ from 'underscore'; +/* eslint-disable no-console */ import API from './API'; import Network from './Network'; import Logger from './Logger'; +import * as Utils from './utils'; /** * Network interface for logger. @@ -22,11 +23,11 @@ function serverLoggingCallback(logger, params) { * @param {String} message */ function clientLoggingCallback(message) { - if (typeof window.g_printableReport !== 'undefined' && window.g_printableReport === true) { + if (Utils.isWindowAvailable() && typeof window.g_printableReport !== 'undefined' && window.g_printableReport === true) { return; } - if (window.console && _.isFunction(console.log)) { + if (window.console && Utils.isFunction(console.log)) { console.log(message); } } @@ -34,5 +35,5 @@ function clientLoggingCallback(message) { export default new Logger({ serverLoggingCallback, clientLoggingCallback, - isDebug: window.DEBUG, + isDebug: Utils.isWindowAvailable() ? window.DEBUG : false, }); diff --git a/lib/Logger.d.ts b/lib/Logger.d.ts deleted file mode 100644 index f2b6995e..00000000 --- a/lib/Logger.d.ts +++ /dev/null @@ -1,63 +0,0 @@ -declare type Parameters = string | Record | Array>; -declare type ServerLoggingCallbackOptions = {api_setCookie: boolean; logPacket: string}; -declare type ServerLoggingCallback = (logger: Logger, options: ServerLoggingCallbackOptions) => Promise<{requestID: string}> | undefined; -declare type ClientLoggingCallBack = (message: string) => void; -declare type LogLine = {message: string; parameters: Parameters; onlyFlushWithOthers: boolean; timestamp: Date}; -export default class Logger { - logLines: LogLine[]; - serverLoggingCallback: ServerLoggingCallback; - clientLoggingCallback: ClientLoggingCallBack; - isDebug: boolean; - constructor({serverLoggingCallback, isDebug, clientLoggingCallback}: {serverLoggingCallback: ServerLoggingCallback; isDebug: boolean; clientLoggingCallback: ClientLoggingCallBack}); - /** - * Ask the server to write the log message - */ - logToServer(): void; - /** - * Add a message to the list - * @param message - * @param parameters The parameters associated with the message - * @param forceFlushToServer Should we force flushing all logs to server? - * @param onlyFlushWithOthers A request will never be sent to the server if all loglines have this set to true - */ - add(message: string, parameters: Parameters, forceFlushToServer: boolean, onlyFlushWithOthers?: boolean): void; - /** - * Caches an informational message locally, to be sent to the server if - * needed later. - * - * @param message The message to log. - * @param sendNow if true, the message will be sent right away. - * @param parameters The parameters to send along with the message - * @param onlyFlushWithOthers A request will never be sent to the server if all loglines have this set to true - */ - info(message: string, sendNow?: boolean, parameters?: Parameters, onlyFlushWithOthers?: boolean): void; - /** - * Logs an alert. - * - * @param message The message to alert. - * @param parameters The parameters to send along with the message - * @param includeStackTrace Must be disabled for testing - */ - alert(message: string, parameters?: Parameters, includeStackTrace?: boolean): void; - /** - * Logs a warn. - * - * @param message The message to warn. - * @param parameters The parameters to send along with the message - */ - warn(message: string, parameters?: Parameters): void; - /** - * Logs a hmmm. - * - * @param message The message to hmmm. - * @param parameters The parameters to send along with the message - */ - hmmm(message: string, parameters?: Parameters): void; - /** - * Logs a message in the browser console. - * - * @param message The message to log. - */ - client(message: string): void; -} -export {}; diff --git a/lib/Logger.jsx b/lib/Logger.jsx deleted file mode 100644 index d1e8180b..00000000 --- a/lib/Logger.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import _ from 'underscore'; - -const MAX_LOG_LINES_BEFORE_FLUSH = 50; -export default class Logger { - constructor({serverLoggingCallback, isDebug, clientLoggingCallback}) { - // An array of log lines that limits itself to a certain number of entries (deleting the oldest) - this.logLines = []; - this.serverLoggingCallback = serverLoggingCallback; - this.clientLoggingCallback = clientLoggingCallback; - this.isDebug = isDebug; - - // Public Methods - return { - info: this.info.bind(this), - alert: this.alert.bind(this), - warn: this.warn.bind(this), - hmmm: this.hmmm.bind(this), - client: this.client.bind(this), - }; - } - - /** - * Ask the server to write the log message - */ - logToServer() { - // We do not want to call the server with an empty list or if all the lines has onlyFlushWithOthers=true - if (!this.logLines.length || _.all(this.logLines, (l) => l.onlyFlushWithOthers)) { - return; - } - - // We don't care about log setting web cookies so let's define it as false - const linesToLog = _.map(this.logLines, (l) => { - delete l.onlyFlushWithOthers; - return l; - }); - this.logLines = []; - const promise = this.serverLoggingCallback(this, {api_setCookie: false, logPacket: JSON.stringify(linesToLog)}); - if (!promise) { - return; - } - promise.then((response) => { - if (response.requestID) { - this.info('Previous log requestID', false, {requestID: response.requestID}, true); - } - }); - } - - /** - * Add a message to the list - * @param {String} message - * @param {Object|String} parameters The parameters associated with the message - * @param {Boolean} forceFlushToServer Should we force flushing all logs to server? - * @param {Boolean} onlyFlushWithOthers A request will never be sent to the server if all loglines have this set to true - */ - add(message, parameters, forceFlushToServer, onlyFlushWithOthers = false) { - const length = this.logLines.push({ - message, - parameters, - onlyFlushWithOthers, - timestamp: new Date(), - }); - - if (this.isDebug) { - this.client(`${message} - ${JSON.stringify(parameters)}`); - } - - // If we're over the limit, flush the logs - if (length > MAX_LOG_LINES_BEFORE_FLUSH || forceFlushToServer) { - this.logToServer(); - } - } - - /** - * Caches an informational message locally, to be sent to the server if - * needed later. - * - * @param {String} message The message to log. - * @param {Boolean} sendNow if true, the message will be sent right away. - * @param {Object|String} parameters The parameters to send along with the message - * @param {Boolean} onlyFlushWithOthers A request will never be sent to the server if all loglines have this set to true - */ - info(message, sendNow = false, parameters = '', onlyFlushWithOthers = false) { - const msg = `[info] ${message}`; - this.add(msg, parameters, sendNow, onlyFlushWithOthers); - } - - /** - * Logs an alert. - * - * @param {String} message The message to alert. - * @param {Object|String} parameters The parameters to send along with the message - * @param {Boolean} includeStackTrace Must be disabled for testing - */ - alert(message, parameters = {}, includeStackTrace = true) { - const msg = `[alrt] ${message}`; - const params = parameters; - - if (includeStackTrace) { - params.stack = JSON.stringify(new Error().stack); - } - - this.add(msg, params, true); - } - - /** - * Logs a warn. - * - * @param {String} message The message to warn. - * @param {Object|String} parameters The parameters to send along with the message - */ - warn(message, parameters = '') { - const msg = `[warn] ${message}`; - this.add(msg, parameters, true); - } - - /** - * Logs a hmmm. - * - * @param {String} message The message to hmmm. - * @param {Object|String} parameters The parameters to send along with the message - */ - hmmm(message, parameters = '') { - const msg = `[hmmm] ${message}`; - this.add(msg, parameters, false); - } - - /** - * Logs a message in the browser console. - * - * @param {String} message The message to log. - */ - client(message) { - if (!this.clientLoggingCallback) { - return; - } - - this.clientLoggingCallback(message); - } -} diff --git a/lib/Logger.ts b/lib/Logger.ts new file mode 100644 index 00000000..61756cf3 --- /dev/null +++ b/lib/Logger.ts @@ -0,0 +1,153 @@ +type Parameters = string | Record | Array> | Error; +type ServerLoggingCallbackOptions = {api_setCookie: boolean; logPacket: string}; +type ServerLoggingCallback = (logger: Logger, options: ServerLoggingCallbackOptions) => Promise<{requestID: string}> | undefined; +type ClientLoggingCallBack = (message: string) => void; +type LogLine = {message: string; parameters: Parameters; onlyFlushWithOthers?: boolean; timestamp: Date}; +type LoggerOptions = {serverLoggingCallback: ServerLoggingCallback; isDebug: boolean; clientLoggingCallback: ClientLoggingCallBack}; + +const MAX_LOG_LINES_BEFORE_FLUSH = 50; + +export default class Logger { + logLines: LogLine[]; + + serverLoggingCallback: ServerLoggingCallback; + + clientLoggingCallback: ClientLoggingCallBack; + + isDebug: boolean; + + constructor({serverLoggingCallback, isDebug, clientLoggingCallback}: LoggerOptions) { + // An array of log lines that limits itself to a certain number of entries (deleting the oldest) + this.logLines = []; + this.serverLoggingCallback = serverLoggingCallback; + this.clientLoggingCallback = clientLoggingCallback; + this.isDebug = isDebug; + + // Public Methods + this.info = this.info.bind(this); + this.alert = this.alert.bind(this); + this.warn = this.warn.bind(this); + this.hmmm = this.hmmm.bind(this); + this.client = this.client.bind(this); + } + + /** + * Ask the server to write the log message + */ + logToServer(): void { + // We do not want to call the server with an empty list or if all the lines has onlyFlushWithOthers=true + if (!this.logLines.length || this.logLines?.every((l) => l.onlyFlushWithOthers)) { + return; + } + + // We don't care about log setting web cookies so let's define it as false + const linesToLog = this.logLines?.map((l) => { + // eslint-disable-next-line no-param-reassign + delete l.onlyFlushWithOthers; + return l; + }); + this.logLines = []; + const promise = this.serverLoggingCallback(this, {api_setCookie: false, logPacket: JSON.stringify(linesToLog)}); + if (!promise) { + return; + } + // eslint-disable-next-line rulesdir/prefer-early-return + promise.then((response) => { + if (!response.requestID) { + return; + } + this.info('Previous log requestID', false, {requestID: response.requestID}, true); + }); + } + + /** + * Add a message to the list + * @param parameters The parameters associated with the message + * @param forceFlushToServer Should we force flushing all logs to server? + * @param onlyFlushWithOthers A request will never be sent to the server if all loglines have this set to true + */ + add(message: string, parameters: Parameters, forceFlushToServer: boolean, onlyFlushWithOthers = false) { + const length = this.logLines.push({ + message, + parameters, + onlyFlushWithOthers, + timestamp: new Date(), + }); + + if (this.isDebug) { + this.client(`${message} - ${JSON.stringify(parameters)}`); + } + + // If we're over the limit, flush the logs + if (length > MAX_LOG_LINES_BEFORE_FLUSH || forceFlushToServer) { + this.logToServer(); + } + } + + /** + * Caches an informational message locally, to be sent to the server if + * needed later. + * + * @param message The message to log. + * @param sendNow if true, the message will be sent right away. + * @param parameters The parameters to send along with the message + * @param onlyFlushWithOthers A request will never be sent to the server if all loglines have this set to true + */ + info(message: string, sendNow = false, parameters: Parameters = '', onlyFlushWithOthers = false) { + const msg = `[info] ${message}`; + this.add(msg, parameters, sendNow, onlyFlushWithOthers); + } + + /** + * Logs an alert. + * + * @param message The message to alert. + * @param parameters The parameters to send along with the message + * @param includeStackTrace Must be disabled for testing + */ + alert(message: string, parameters: Parameters = {}, includeStackTrace = true) { + const msg = `[alrt] ${message}`; + const params = parameters; + + if (includeStackTrace && typeof params === 'object' && !Array.isArray(params)) { + params.stack = JSON.stringify(new Error().stack); + } + + this.add(msg, params, true); + } + + /** + * Logs a warn. + * + * @param {String} message The message to warn. + * @param {Object|String} parameters The parameters to send along with the message + */ + warn(message: string, parameters: Parameters = '') { + const msg = `[warn] ${message}`; + this.add(msg, parameters, true); + } + + /** + * Logs a hmmm. + * + * @param message The message to hmmm. + * @param parameters The parameters to send along with the message + */ + hmmm(message: string, parameters: Parameters = '') { + const msg = `[hmmm] ${message}`; + this.add(msg, parameters, false); + } + + /** + * Logs a message in the browser console. + * + * @param message The message to log. + */ + client(message: string) { + if (!this.clientLoggingCallback) { + return; + } + + this.clientLoggingCallback(message); + } +} diff --git a/lib/Network.jsx b/lib/Network.jsx index ee388da0..7d109a2f 100644 --- a/lib/Network.jsx +++ b/lib/Network.jsx @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import * as Utils from './utils'; /** * Adds our API command to the URL so the API call is more easily identified in the @@ -40,9 +40,11 @@ export default function Network(endpoint) { } // Attach a listener to the event indicating that we're leaving a page - window.onbeforeunload = () => { - isNavigatingAway = true; - }; + if (Utils.isWindowAvailable()) { + window.onbeforeunload = () => { + isNavigatingAway = true; + }; + } return { /** @@ -64,6 +66,7 @@ export default function Network(endpoint) { if (isNewURLFormat) { // Remove command from parameters and use it in the URL const command = parameters.command; + // eslint-disable-next-line no-param-reassign delete parameters.command; newURL = `${endpoint}${command}`; } @@ -83,7 +86,7 @@ export default function Network(endpoint) { // Check to see if parameters contains a File or Blob object // If it does, we should use formData instead of parameters and update // the ajax settings accordingly - _(parameters).each((value, key) => { + Object.entries(parameters).forEach(([key, value]) => { if (!value) { return; } @@ -129,14 +132,14 @@ export default function Network(endpoint) { // Add our data as form data const formData = new FormData(); - _(parameters).each((value, key) => { - if (_.isUndefined(value)) { + Object.entries(parameters).forEach(([key, value]) => { + if (value === undefined) { return; } - if (_.isArray(value)) { - _.each(value, (valueItem, i) => { - if (_.isObject(valueItem)) { - _.each(valueItem, (valueItemObjectValue, valueItemObjectKey) => { + if (Array.isArray(value)) { + value.forEach((valueItem, i) => { + if (Utils.isObject(valueItem)) { + Object.entries(valueItem).forEach(([valueItemObjectKey, valueItemObjectValue]) => { formData.append(`${key}[${i}][${valueItemObjectKey}]`, valueItemObjectValue); }); } else { diff --git a/lib/Num.jsx b/lib/Num.jsx index 82b864d9..80d83c1c 100644 --- a/lib/Num.jsx +++ b/lib/Num.jsx @@ -1,4 +1,3 @@ -import _ from 'underscore'; import Str from './str'; export default { @@ -123,7 +122,7 @@ export default { * @returns {Boolean} true if the number is finite and not NaN. */ isFiniteNumber(number) { - return _.isNumber(number) && _.isFinite(number) && !_.isNaN(number); + return typeof number === 'number' && Number.isFinite(number) && !Number.isNaN(number); }, /** diff --git a/lib/PubSub.jsx b/lib/PubSub.jsx index 31014c4a..aa0df607 100644 --- a/lib/PubSub.jsx +++ b/lib/PubSub.jsx @@ -1,6 +1,7 @@ -import _ from 'underscore'; import has from 'lodash/has'; +import {once} from 'lodash'; import Log from './Log'; +import * as Utils from './utils'; /** * PubSub @@ -45,14 +46,14 @@ const PubSubModule = { return; } - const eventIDs = _.keys(eventMap[eventName]); + const eventIDs = eventMap[eventName].keys(); if (eventName === this.ERROR) { // Doing the split slice 2 because the 1st element of the stacktrace will always be from here (PubSub.publish) // When debugging, we just need to know who called PubSub.publish (so, all next elements in the stack) Log.hmmm('Error published', 0, {tplt: param.tplt, stackTrace: new Error().stack.split(' at ').slice(2)}); } - _.each(eventIDs, (eventID) => { + eventIDs.forEach((eventID) => { const subscriber = eventMap[eventName][eventID]; if (subscriber) { subscriber.callback.call(subscriber.scope, param); @@ -71,8 +72,8 @@ const PubSubModule = { * @returns {String} */ once(eventName, callback, optionalScope) { - const scope = _.isObject(optionalScope) ? optionalScope : window; - const functionToCallOnce = _.once((...args) => callback.apply(scope, args)); + const scope = Utils.isObject(optionalScope) && optionalScope !== null ? optionalScope : window; + const functionToCallOnce = once((...args) => callback.apply(scope, args)); return this.subscribe(eventName, functionToCallOnce); }, @@ -93,8 +94,8 @@ const PubSubModule = { throw new Error('Attempted to subscribe to undefined event'); } - const callback = _.isFunction(optionalCallback) ? optionalCallback : () => {}; - const scope = _.isObject(optionalScope) ? optionalScope : window; + const callback = Utils.isFunction(optionalCallback) ? optionalCallback : () => {}; + const scope = Utils.isObject(optionalScope) && optionalScope !== null ? optionalScope : window; const eventID = generateID(eventName); if (eventMap[eventName] === undefined) { @@ -115,8 +116,8 @@ const PubSubModule = { * @param {String} bindID The id of the element to delete */ unsubscribe(bindID) { - const IDs = _.isArray(bindID) ? bindID : [bindID]; - _.each(IDs, (id) => { + const IDs = Array.isArray(bindID) ? bindID : [bindID]; + IDs.forEach((id) => { const eventName = extractEventName(id); if (has(eventMap, `${eventName}.${id}`)) { delete eventMap[eventName][id]; @@ -125,4 +126,4 @@ const PubSubModule = { }, }; -export default window !== undefined && window.PubSub ? window.PubSub : PubSubModule; +export default Utils.isWindowAvailable() && window.PubSub ? window.PubSub : PubSubModule; diff --git a/lib/ReportHistoryStore.jsx b/lib/ReportHistoryStore.jsx index 6cabca88..b92f694b 100644 --- a/lib/ReportHistoryStore.jsx +++ b/lib/ReportHistoryStore.jsx @@ -1,4 +1,3 @@ -import _ from 'underscore'; import {Deferred} from 'simply-deferred'; export default class ReportHistoryStore { @@ -31,11 +30,12 @@ export default class ReportHistoryStore { * * @returns {Object[]} */ - this.filterHiddenActions = (historyItems) => _.filter(historyItems, (historyItem) => historyItem.shouldShow); + this.filterHiddenActions = (historyItems) => historyItems.filter((historyItem) => historyItem.shouldShow); /** * Public Methods */ + return { /** * Returns the history for a given report. @@ -124,7 +124,7 @@ export default class ReportHistoryStore { this.getFromCache(reportID) .done((cachedHistory) => { // Do we have the reportAction immediately before this one? - if (_.some(cachedHistory, ({reportActionID}) => reportActionID === reportAction.reportActionID)) { + if (cachedHistory.some(({reportActionID}) => reportActionID === reportAction.reportActionID)) { // If we have the previous one then we can assume we have an up to date history minus the most recent // and must merge it into the cache this.mergeHistoryByTimestamp(reportID, [reportAction]); @@ -149,7 +149,7 @@ export default class ReportHistoryStore { * @param {String[]} events */ bindCacheClearingEvents: (events) => { - _.each(events, (event) => this.PubSub.subscribe(event, () => (this.cache = {}))); + events.each((event) => this.PubSub.subscribe(event, () => (this.cache = {}))); }, // We need this to be publically available for cases where we get the report history @@ -169,19 +169,15 @@ export default class ReportHistoryStore { return; } - const newCache = _.reduce( - newHistory.reverse(), - (prev, curr) => { - if (!_.findWhere(prev, {sequenceNumber: curr.sequenceNumber})) { - prev.unshift(curr); - } - return prev; - }, - this.cache[reportID] || [], - ); + const newCache = newHistory.reverse().reduce((prev, curr) => { + if (!prev.some((item) => item.sequenceNumber === curr.sequenceNumber)) { + prev.unshift(curr); + } + return prev; + }, this.cache[reportID] || []); // Sort items in case they have become out of sync - this.cache[reportID] = _.sortBy(newCache, 'sequenceNumber').reverse(); + this.cache[reportID] = newCache.sort((a, b) => b.sequenceNumber - a.sequenceNumber); } /** @@ -195,19 +191,15 @@ export default class ReportHistoryStore { return; } - const newCache = _.reduce( - newHistory.reverse(), - (prev, curr) => { - if (!_.findWhere(prev, {reportActionTimestamp: curr.reportActionTimestamp})) { - prev.unshift(curr); - } - return prev; - }, - this.cache[reportID] || [], - ); + const newCache = newHistory.reverse().reduce((prev, curr) => { + if (!prev.some((item) => item.reportActionTimestamp === curr.reportActionTimestamp)) { + prev.unshift(curr); + } + return prev; + }, this.cache[reportID] || []); // Sort items in case they have become out of sync - this.cache[reportID] = _.sortBy(newCache, 'reportActionTimestamp').reverse(); + this.cache[reportID] = newCache.sort((a, b) => b.reportActionTimestamp - a.reportActionTimestamp); } /** @@ -228,7 +220,7 @@ export default class ReportHistoryStore { // We'll poll the API for the un-cached history const cachedHistory = this.cache[reportID] || []; - const firstHistoryItem = _.first(cachedHistory) || {}; + const firstHistoryItem = cachedHistory[0] || {}; // Grab the most recent sequenceNumber we have and poll the API for fresh data this.API.Report_GetHistory({ @@ -263,9 +255,6 @@ export default class ReportHistoryStore { delete this.cache[reportID]; } - // We'll poll the API for the un-cached history - const cachedHistory = this.cache[reportID] || []; - this.API.Report_GetHistory({ reportID, }) @@ -293,7 +282,7 @@ export default class ReportHistoryStore { const cachedHistory = this.cache[reportID] || []; // If comment is not in cache then fetch it - if (_.isEmpty(cachedHistory)) { + if (cachedHistory.length === 0) { return this.getFlatHistory(reportID); } diff --git a/lib/Templates.jsx b/lib/Templates.jsx index 5f2c3878..17be9b2f 100644 --- a/lib/Templates.jsx +++ b/lib/Templates.jsx @@ -1,5 +1,6 @@ -import _ from 'underscore'; import $ from 'jquery'; +import {template as createTemplate} from 'lodash'; +import * as Utils from './utils'; /** * JS Templating system, powered by underscore template @@ -36,7 +37,7 @@ export default (function () { */ get(data = {}) { if (!this.compiled) { - this.compiled = _.template(this.templateValue); + this.compiled = createTemplate(this.templateValue); this.templateValue = ''; } return this.compiled(data); @@ -67,9 +68,10 @@ export default (function () { get(data = {}) { // Add the "template" object to the parameter to allow nested templates const dataToCompile = {...data}; + // eslint-disable-next-line no-undef dataToCompile.nestedTemplate = Templates.get; if (!this.compiled) { - this.compiled = _.template($(`#${this.id}`).html()); + this.compiled = createTemplate($(`#${this.id}`).html()); } return this.compiled(dataToCompile); } @@ -83,7 +85,7 @@ export default (function () { */ function getTemplate(templatePath) { let template = templateStore; - _.each(templatePath, (pathname) => { + templatePath.forEach((pathname) => { template = template[pathname]; }); return template; @@ -102,7 +104,7 @@ export default (function () { for (let argumentNumber = 0; argumentNumber < wantedNamespace.length; argumentNumber++) { currentArgument = wantedNamespace[argumentNumber]; - if (_.isUndefined(namespace[currentArgument])) { + if (namespace[currentArgument] === undefined) { namespace[currentArgument] = {}; } namespace = namespace[currentArgument]; @@ -119,22 +121,18 @@ export default (function () { * @return {String} */ get(templatePath, data = {}) { - try { - const template = getTemplate(templatePath); - if (_.isUndefined(template)) { - throw Error(`Template '${templatePath}' is not defined`); - } - - // Check for the absense of get which means someone is likely using - // the templating engine wrong and trying to access a template namespace - if (!{}.propertyIsEnumerable.call(template, 'get')) { - throw Error(`'${templatePath}' is not a valid template path`); - } + const template = getTemplate(templatePath); + if (template === undefined) { + throw Error(`Template '${templatePath}' is not defined`); + } - return template.get(data); - } catch (err) { - throw err; + // Check for the absense of get which means someone is likely using + // the templating engine wrong and trying to access a template namespace + if (!{}.propertyIsEnumerable.call(template, 'get')) { + throw Error(`'${templatePath}' is not a valid template path`); } + + return template.get(data); }, /** @@ -143,7 +141,7 @@ export default (function () { * @return {Boolean} */ has(templatePath) { - return !_.isUndefined(getTemplate(templatePath)); + return getTemplate(templatePath) !== undefined; }, /** @@ -151,6 +149,7 @@ export default (function () { */ init() { // Read the DOM to find all the templates, and make them available to the code + // eslint-disable-next-line rulesdir/prefer-underscore-method $('.js_template').each((__, $el) => { const namespaceElements = $el.id.split('_'); const id = namespaceElements.pop(); @@ -170,13 +169,13 @@ export default (function () { */ register(wantedNamespace, templateData) { const namespace = getTemplateNamespace(wantedNamespace); - _.each(_.keys(templateData), (key) => { + Object.keys(templateData).forEach((key) => { const template = templateData[key]; - if (_.isObject(template)) { + if (Utils.isObject(template)) { // If the template is an object, add templates for all keys namespace[key] = {}; - _.each(_.keys(template), (templateKey) => { + Object.keys(template).forEach((templateKey) => { namespace[key][templateKey] = new InlineTemplate(template[templateKey]); }); } else { diff --git a/lib/components/StepProgressBar.js b/lib/components/StepProgressBar.js index d682ef4a..f6564a88 100644 --- a/lib/components/StepProgressBar.js +++ b/lib/components/StepProgressBar.js @@ -1,5 +1,4 @@ import React from 'react'; -import _ from 'underscore'; import PropTypes from 'prop-types'; import cn from 'classnames'; import * as UIConstants from '../CONST'; @@ -24,7 +23,7 @@ const propTypes = { */ function StepProgressBar({steps, currentStep}) { const isCurrentStep = (step) => step.id === currentStep; - const currentStepIndex = Math.max(0, _.findIndex(steps, isCurrentStep)); + const currentStepIndex = Math.max(0, steps.findIndex(isCurrentStep)); const renderStep = (step, i) => { let status = currentStepIndex === i ? UIConstants.UI.ACTIVE : ''; @@ -52,7 +51,7 @@ function StepProgressBar({steps, currentStep}) { id="js_steps_progress" className="progress-wrapper" > -
    {_.map(steps, renderStep)}
    +
    {steps.map(renderStep)}
    ); } diff --git a/lib/components/form/element/combobox.js b/lib/components/form/element/combobox.js index 6843cd51..53a6e10b 100644 --- a/lib/components/form/element/combobox.js +++ b/lib/components/form/element/combobox.js @@ -2,12 +2,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import cn from 'classnames'; -import _ from 'underscore'; -import get from 'lodash/get'; -import has from 'lodash/has'; -import uniqBy from 'lodash/uniqBy'; +import {defer, has, isEqual, template, uniqBy} from 'lodash'; import Str from '../../../str'; import DropDown from './dropdown'; +import * as Utils from '../../../utils'; const propTypes = { // These are the elements to show in the dropdown @@ -54,6 +52,7 @@ const propTypes = { // that are already selected with the check mark. alreadySelectedOptions: PropTypes.arrayOf( PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), text: PropTypes.string, }), @@ -186,39 +185,39 @@ class Combobox extends React.Component { } // Our dropdown will be open, so we need to listen for our click away events // and put focus on the input - _.defer(this.resetClickAwayHandler); + defer(this.resetClickAwayHandler); $(this.value).focus().select(); } // eslint-disable-next-line react/no-unsafe UNSAFE_componentWillReceiveProps(nextProps) { - if (_.isUndefined(nextProps.value) || _.isEmpty(nextProps.value) || _.isEqual(nextProps.value, this.state.currentValue)) { + if (!nextProps.value || isEqual(nextProps.value, this.state.currentValue)) { return; } this.setValue(nextProps.value); - if (!_.isUndefined(nextProps.options)) { + if (nextProps.options !== undefined) { // If the options have an id property, we use that to compare them and determine if they changed, if not // we'll use the whole options array. if (has(nextProps.options, '0.id')) { if ( - !_.isEqual(_.pluck(nextProps.options, 'id'), _.pluck(this.props.options, 'id')) || - !_.isEqual(_.pluck(nextProps.alreadySelectedOptions, 'id'), _.pluck(this.props.alreadySelectedOptions, 'id')) + nextProps.options.some((option, index) => !isEqual(option.id, this.props.options[index].id)) || + nextProps.alreadySelectedOptions.some((alreadySelectedOption, index) => !isEqual(alreadySelectedOption.id, this.props.alreadySelectedOptions[index].id)) ) { this.reset(false, nextProps.options, nextProps.alreadySelectedOptions); } - } else if (!_.isEqual(nextProps.options, this.props.options) || !_.isEqual(nextProps.alreadySelectedOptions, this.props.alreadySelectedOptions)) { + } else if (!isEqual(nextProps.options, this.props.options) || !isEqual(nextProps.alreadySelectedOptions, this.props.alreadySelectedOptions)) { this.reset(false, nextProps.options, nextProps.alreadySelectedOptions); } } - if (!_.isUndefined(nextProps.openOnInit) && !_.isEqual(nextProps.openOnInit, this.props.openOnInit)) { + if (nextProps.openOnInit !== undefined && !isEqual(nextProps.openOnInit, this.props.openOnInit)) { this.setState({ isDropdownOpen: nextProps.openOnInit, }); } - if (!_.isUndefined(nextProps.isReadOnly) && !_.isEqual(nextProps.isReadOnly, this.props.isReadOnly)) { + if (nextProps.isReadOnly !== undefined && !isEqual(nextProps.isReadOnly, this.props.isReadOnly)) { this.setState({ isDisabled: nextProps.isReadOnly, }); @@ -249,8 +248,8 @@ class Combobox extends React.Component { // Select the new item, set our new indexes, close the dropdown // Unselect all other options - let newSelectedIndex = _(this.options).findIndex({value: selectedValue}); - let currentlySelectedOption = _(this.options).findWhere({value: selectedValue}); + let newSelectedIndex = this.options.findIndex({value: selectedValue}); + let currentlySelectedOption = this.options.findWhere({value: selectedValue}); // If allowAnyValue is true and currentValue is absent then set it manually to what the user has entered. if (newSelectedIndex === -1 && this.props.allowAnyValue && selectedValue) { @@ -278,9 +277,9 @@ class Combobox extends React.Component { selectedIndex: newSelectedIndex, focusedIndex: newSelectedIndex, currentValue: selectedValue, - currentText: get(currentlySelectedOption, 'text', ''), + currentText: (currentlySelectedOption && currentlySelectedOption.text) || '', isDropdownOpen: false, - hasError: get(currentlySelectedOption, 'hasError', false), + hasError: (currentlySelectedOption && currentlySelectedOption.hasError) || false, }, stateUpdateCallback, ); @@ -448,7 +447,7 @@ class Combobox extends React.Component { const matchingOptionWithoutSMSDomain = (o) => (Str.isString(o) ? Str.removeSMSDomain(o.value) : o.value) === currentValue && !o.isFake; // We use removeSMSDomain here in case currentValue is a phone number - let defaultSelectedOption = _(this.options).find(matchingOptionWithoutSMSDomain); + let defaultSelectedOption = this.options.find(matchingOptionWithoutSMSDomain); // If no default was found and initialText was present then we can use initialText values if (!defaultSelectedOption && this.initialText) { @@ -480,13 +479,13 @@ class Combobox extends React.Component { // Get the divider index if we have one const findDivider = (option) => option.divider; - const dividerIndex = _.findIndex(this.options, findDivider); + const dividerIndex = this.options.findIndex(findDivider); // Split into two arrays everything before and after the divider (if the divider does not exist then we'll return a single array) const splitOptions = dividerIndex ? [this.options.slice(0, dividerIndex + 1), this.options.slice(dividerIndex + 1)] : [this.options]; const formatOption = (option) => ({ focused: false, - isSelected: option.selected && (_.isEqual(option.value, currentValue) || Boolean(_.findWhere(alreadySelected, {value: option.value}))), + isSelected: option.selected && (isEqual(option.value, currentValue) || Boolean(alreadySelected.find((item) => item.value === option.value))), ...option, }); @@ -500,9 +499,13 @@ class Combobox extends React.Component { }; // Take each array and format it, sort it, and move selected items to top (if applicable) - const formatOptions = (array) => _.chain(array).map(formatOption).sortBy(sortByOption).first(this.props.maxItemsToShow).value(); + const formatOptions = (array) => + array + .map(formatOption) + .sort((a, b) => sortByOption(a) - sortByOption(b)) + .slice(0, this.props.maxItemsToShow); - const truncatedOptions = _.chain(splitOptions).map(formatOptions).flatten().value(); + const truncatedOptions = splitOptions.map(formatOptions).flat(); if (!truncatedOptions.length) { truncatedOptions.push({ @@ -542,18 +545,18 @@ class Combobox extends React.Component { setValue(val) { // We need to look in `this.options` for the matching option because `this.state.options` is a truncated list // and might not have every option - const optionMatchingVal = _.findWhere(this.options, {value: val}); - const currentText = get(optionMatchingVal, 'text', ''); + const optionMatchingVal = this.options.find((option) => option.value === val); + const currentText = optionMatchingVal?.text || ''; const deselectOption = (initialOption) => { const option = initialOption; - const isSelected = _.isEqual(option.value, val); - option.isSelected = isSelected || Boolean(_.findWhere(this.props.alreadySelectedOptions, {value: option.value})); + const isSelected = isEqual(option.value, val); + option.isSelected = isSelected || Boolean(this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value)); return option; }; - const deselectOptions = (options) => _(options).map(deselectOption); + const deselectOptions = (options) => options.map(deselectOption); const setValueState = (state) => ({ currentValue: val, @@ -575,8 +578,8 @@ class Combobox extends React.Component { // See if there is a value in the options that matches the text we want to set. If the option // does exist, then use the text property of that option for the text to display. If the option // does not exist, then just display whatever value was passed - const optionMatchingVal = _.findWhere(this.options, {value: val}); - const currentText = get(optionMatchingVal, 'text', val); + const optionMatchingVal = this.options.find((option) => option.value === val); + const currentText = (optionMatchingVal && optionMatchingVal.text) || val; this.initialValue = currentText; this.initialText = currentText; this.setState({currentText}); @@ -668,7 +671,7 @@ class Combobox extends React.Component { }; const setValueState = (state) => ({ - options: _(state.options).map(resetFocusedProperty), + options: state.options.map(resetFocusedProperty), }); this.setState(setValueState); @@ -855,11 +858,13 @@ class Combobox extends React.Component { const formatOption = (option) => ({ focused: false, - isSelected: _.isEqual(option.value ? option.value.toUpperCase : '', value.toUpperCase()) || Boolean(_.findWhere(this.props.alreadySelectedOptions, {value: option.value})), + isSelected: + isEqual(option.value ? option.value.toUpperCase : '', value.toUpperCase()) || + Boolean(this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value)), ...option, }); - const options = _(matches).map(formatOption); + const options = matches.map(formatOption); // Focus the first option if there is one and show a message dependent on what options are present if (options.length) { @@ -876,7 +881,7 @@ class Combobox extends React.Component { } } else { options.push({ - text: this.props.allowAnyValue ? value : _.template(this.props.noResultsText)({value}), + text: this.props.allowAnyValue ? value : template(this.props.noResultsText)({value}), value: this.props.allowAnyValue ? value : '', isSelectable: this.props.allowAnyValue, isFake: true, @@ -933,7 +938,7 @@ class Combobox extends React.Component { aria-label="..." onChange={this.performSearch} onKeyDown={this.closeDropdownOnTabOut} - value={this.props.propertyToDisplay === 'value' ? _.unescape(this.state.currentValue) : _.unescape(this.state.currentText.replace(/ /g, ''))} + value={this.props.propertyToDisplay === 'value' ? Utils.unescape(this.state.currentValue) : Utils.unescape(this.state.currentText.replace(/ /g, ''))} onFocus={this.openDropdown} autoComplete="off" placeholder={this.props.placeholder} diff --git a/lib/components/form/element/dropdown.js b/lib/components/form/element/dropdown.js index df78770e..2b1d7d45 100644 --- a/lib/components/form/element/dropdown.js +++ b/lib/components/form/element/dropdown.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import cn from 'classnames'; -import _ from 'underscore'; +import {uniqueId} from 'lodash'; import DropDownItem from './dropdownItem'; const propTypes = { @@ -65,7 +65,7 @@ class DropDown extends React.Component { renderOption(option) { return ( {_.map(options, this.renderOption)}; + return
      {options.map(this.renderOption)}
    ; } } diff --git a/lib/components/form/element/onOffSwitch.jsx b/lib/components/form/element/onOffSwitch.jsx index b3a4da29..72780595 100644 --- a/lib/components/form/element/onOffSwitch.jsx +++ b/lib/components/form/element/onOffSwitch.jsx @@ -19,6 +19,7 @@ const propTypes = { labelOnRight: PropTypes.bool, // Classes of the label + // eslint-disable-next-line react/forbid-prop-types labelClasses: PropTypes.any, // True if the switch is on @@ -176,6 +177,7 @@ class OnOffSwitch extends Component { descriptionElm = (
    ); diff --git a/lib/components/form/element/switch.js b/lib/components/form/element/switch.js index 8ad6807b..96447352 100644 --- a/lib/components/form/element/switch.js +++ b/lib/components/form/element/switch.js @@ -3,7 +3,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import cn from 'classnames'; -import _ from 'underscore'; /** * Form Element Switch - Displays an on/off switch @@ -95,18 +94,14 @@ class Switch extends React.Component { } e.preventDefault(); - Modal.confirm( - _.extend( - { - onYesCallback: () => { - // Toggle the checked property and then fire our change handler - this.checkbox.checked = !this.getValue(); - Func.invoke(this.props.onChange, [this.getValue()]); - }, - }, - this.props.confirm, - ), - ); + Modal.confirm({ + ...(this.props.confirm ?? {}), + onYesCallback: () => { + // Toggle the checked property and then fire our change handler + this.checkbox.checked = !this.getValue(); + Func.invoke(this.props.onChange, [this.getValue()]); + }, + }); return false; } diff --git a/lib/fastMerge.d.ts b/lib/fastMerge.d.ts deleted file mode 100644 index 4355619f..00000000 --- a/lib/fastMerge.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Merges two objects and removes null values if "shouldRemoveNullObjectValues" is set to true - * - * We generally want to remove null values from objects written to disk and cache, because it decreases the amount of data stored in memory and on disk. - * On native, when merging an existing value with new changes, SQLite will use JSON_PATCH, which removes top-level nullish values. - * To be consistent with the behaviour for merge, we'll also want to remove null values for "set" operations. - */ -declare function fastMerge(target: T, source: T, shouldRemoveNullObjectValues: boolean): T; - -export default fastMerge; diff --git a/lib/fastMerge.js b/lib/fastMerge.js deleted file mode 100644 index 57352cff..00000000 --- a/lib/fastMerge.js +++ /dev/null @@ -1,88 +0,0 @@ -import _ from 'underscore'; - -// Mostly copied from https://medium.com/@lubaka.a/how-to-remove-lodash-performance-improvement-b306669ad0e1 - -/** - * @param {mixed} val - * @returns {boolean} - */ -function isMergeableObject(val) { - const nonNullObject = val != null ? typeof val === 'object' : false; - return nonNullObject && Object.prototype.toString.call(val) !== '[object RegExp]' && Object.prototype.toString.call(val) !== '[object Date]' && !_.isArray(val); -} - -/** - * @param {Object} target - * @param {Object} source - * @param {Boolean} shouldRemoveNullObjectValues - * @returns {Object} - */ -function mergeObject(target, source, shouldRemoveNullObjectValues = true) { - const destination = {}; - if (isMergeableObject(target)) { - // lodash adds a small overhead so we don't use it here - const targetKeys = _.keys(target); - for (let i = 0; i < targetKeys.length; ++i) { - const key = targetKeys[i]; - - // If shouldRemoveNullObjectValues is true, we want to remove null values from the merged object - const isSourceOrTargetNull = target[key] === null || source[key] === null; - const shouldOmitSourceKey = shouldRemoveNullObjectValues && isSourceOrTargetNull; - - if (!shouldOmitSourceKey) { - destination[key] = target[key]; - } - } - } - - // lodash adds a small overhead so we don't use it here - const sourceKeys = _.keys(source); - for (let i = 0; i < sourceKeys.length; ++i) { - const key = sourceKeys[i]; - - // If shouldRemoveNullObjectValues is true, we want to remove null values from the merged object - const shouldOmitSourceKey = shouldRemoveNullObjectValues && source[key] === null; - - // If we pass undefined as the updated value for a key, we want to generally ignore it - const isSourceKeyUndefined = source[key] === undefined; - - if (!isSourceKeyUndefined && !shouldOmitSourceKey) { - const isSourceKeyMergable = isMergeableObject(source[key]); - - if (isSourceKeyMergable && target[key]) { - if (!shouldRemoveNullObjectValues || isSourceKeyMergable) { - // eslint-disable-next-line no-use-before-define - destination[key] = fastMerge(target[key], source[key], shouldRemoveNullObjectValues); - } - } else if (!shouldRemoveNullObjectValues || source[key] !== null) { - destination[key] = source[key]; - } - } - } - - return destination; -} - -/** - * Merges two objects and removes null values if "shouldRemoveNullObjectValues" is set to true - * - * We generally want to remove null values from objects written to disk and cache, because it decreases the amount of data stored in memory and on disk. - * On native, when merging an existing value with new changes, SQLite will use JSON_PATCH, which removes top-level nullish values. - * To be consistent with the behaviour for merge, we'll also want to remove null values for "set" operations. - * - * @param {Object|Array} target - * @param {Object|Array} source - * @param {Boolean} shouldRemoveNullObjectValues - * @returns {Object|Array} - */ -function fastMerge(target, source, shouldRemoveNullObjectValues = true) { - // We have to ignore arrays and nullish values here, - // otherwise "mergeObject" will throw an error, - // because it expects an object as "source" - if (_.isArray(source) || source === null || source === undefined) { - return source; - } - return mergeObject(target, source, shouldRemoveNullObjectValues); -} - -export default fastMerge; diff --git a/lib/fastMerge.ts b/lib/fastMerge.ts new file mode 100644 index 00000000..a7ce03bd --- /dev/null +++ b/lib/fastMerge.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/prefer-for-of */ + +// Mostly copied from https://medium.com/@lubaka.a/how-to-remove-lodash-performance-improvement-b306669ad0e1 + +/** + * Checks whether the given value can be merged. It has to be an object, but not an array, RegExp or Date. + */ +function isMergeableObject(value: unknown): value is Record { + const nonNullObject = value != null ? typeof value === 'object' : false; + return nonNullObject && Object.prototype.toString.call(value) !== '[object RegExp]' && Object.prototype.toString.call(value) !== '[object Date]' && !Array.isArray(value); +} + +/** + * Merges the source object into the target object. + * @param target - The target object. + * @param source - The source object. + * @param shouldRemoveNestedNulls - If true, null object values will be removed. + * @returns - The merged object. + */ +function mergeObject>(target: TObject, source: TObject, shouldRemoveNullObjectValues = true): TObject { + const destination: Record = {}; + + if (isMergeableObject(target)) { + // lodash adds a small overhead so we don't use it here + const targetKeys = Object.keys(target); + for (let i = 0; i < targetKeys.length; ++i) { + const key = targetKeys[i]; + const sourceValue = source?.[key]; + const targetValue = target?.[key]; + + // If shouldRemoveNullObjectValues is true, we want to remove null values from the merged object + const isSourceOrTargetNull = targetValue === null || sourceValue === null; + const shouldOmitSourceKey = shouldRemoveNullObjectValues && isSourceOrTargetNull; + + if (!shouldOmitSourceKey) { + destination[key] = targetValue; + } + } + } + + // lodash adds a small overhead so we don't use it here + const sourceKeys = Object.keys(source); + for (let i = 0; i < sourceKeys.length; ++i) { + const key = sourceKeys[i]; + const sourceValue = source?.[key]; + const targetValue = target?.[key]; + + // If shouldRemoveNullObjectValues is true, we want to remove null values from the merged object + const shouldOmitSourceKey = shouldRemoveNullObjectValues && sourceValue === null; + + // If we pass undefined as the updated value for a key, we want to generally ignore it + const isSourceKeyUndefined = sourceValue === undefined; + + if (!isSourceKeyUndefined && !shouldOmitSourceKey) { + const isSourceKeyMergable = isMergeableObject(sourceValue); + + if (isSourceKeyMergable && targetValue) { + if (!shouldRemoveNullObjectValues || isSourceKeyMergable) { + // eslint-disable-next-line no-use-before-define + destination[key] = fastMerge(targetValue as TObject, sourceValue, shouldRemoveNullObjectValues); + } + } else if (!shouldRemoveNullObjectValues || sourceValue !== null) { + destination[key] = sourceValue; + } + } + } + + return destination as TObject; +} + +/** + * Merges two objects and removes null values if "shouldRemoveNullObjectValues" is set to true + * + * We generally want to remove null values from objects written to disk and cache, because it decreases the amount of data stored in memory and on disk. + * On native, when merging an existing value with new changes, SQLite will use JSON_PATCH, which removes top-level nullish values. + * To be consistent with the behaviour for merge, we'll also want to remove null values for "set" operations. + */ +function fastMerge(target: TObject, source: TObject, shouldRemoveNullObjectValues = true): TObject { + // We have to ignore arrays and nullish values here, + // otherwise "mergeObject" will throw an error, + // because it expects an object as "source" + if (Array.isArray(source) || source === null || source === undefined) { + return source; + } + return mergeObject(target as Record, source as Record, shouldRemoveNullObjectValues) as TObject; +} + +export default fastMerge; diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 00000000..dc9c5b20 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,21 @@ +// eslint-disable-next-line rulesdir/no-api-in-views +export {default as API} from './API'; +export {default as APIDeferred} from './APIDeferred'; +export {default as BrowserDetect} from './BrowserDetect'; +export {g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS} from './CONST'; + +export {default as Cookie} from './Cookie'; +export {default as CredentialsWrapper, LOGIN_PARTNER_DETAILS} from './CredentialsWrapper'; +export * as Device from './Device'; +export {default as ExpensiMark} from './ExpensiMark'; +export {default as Logger} from './Logger'; +export {default as Network} from './Network'; +export {default as Num} from './Num'; +export {default as PageEvent} from './PageEvent'; +export {default as PubSub} from './PubSub'; +export {default as ReportHistoryStore} from './ReportHistoryStore'; +export {default as Templates} from './Templates'; +export * as Url from './Url'; +export {default as fastMerge} from './fastMerge'; +export {default as Str} from './str'; +export {default as TLD_REGEX} from './tlds'; diff --git a/lib/mixins/PubSub.jsx b/lib/mixins/PubSub.jsx index 86855a5d..b5552759 100644 --- a/lib/mixins/PubSub.jsx +++ b/lib/mixins/PubSub.jsx @@ -1,7 +1,7 @@ -import _ from 'underscore'; import PubSubModule from '../PubSub'; +import * as Utils from '../utils'; -const PubSub = window.PubSub || PubSubModule; +const PubSub = (Utils.isWindowAvailable() && window.PubSub) || PubSubModule; /** * This mixin sets up automatic PubSub bindings which will be removed when @@ -17,7 +17,7 @@ const PubSub = window.PubSub || PubSubModule; * } * }); */ -export default { +const PubSubMixin = { UNSAFE_componentWillMount() { this.eventIds = []; }, @@ -44,6 +44,10 @@ export default { * When the component is unmounted, we want to subscribe from all of our event IDs */ componentWillUnmount() { - _.each(this.eventIds, _.bind(PubSub.unsubscribe, PubSub)); + this.eventIds.forEach((eventId) => { + PubSub.unsubscribe(eventId); + }); }, }; + +export default PubSubMixin; diff --git a/lib/mixins/extraClasses.js b/lib/mixins/extraClasses.js index 5588bc6b..a0cf9b9e 100644 --- a/lib/mixins/extraClasses.js +++ b/lib/mixins/extraClasses.js @@ -22,9 +22,12 @@ * return
    ; * } */ + +import * as Utils from '../utils'; + export default { propTypes: { - extraClasses: window.PropTypes.oneOfType([window.PropTypes.string, window.PropTypes.array, window.PropTypes.object]), + extraClasses: Utils.isWindowAvailable() && window.PropTypes.oneOfType([window.PropTypes.string, window.PropTypes.array, window.PropTypes.object]), }, UNSAFE_componentWillReceiveProps(nextProps) { diff --git a/lib/str.d.ts b/lib/str.d.ts deleted file mode 100644 index d703d8bf..00000000 --- a/lib/str.d.ts +++ /dev/null @@ -1,613 +0,0 @@ -declare const Str: { - /** - * Return true if the string is ending with the provided suffix - * - * @param str String ot search in - * @param suffix What to look for - */ - endsWith(str: string, suffix: string): boolean; - /** - * Converts a USD string into th number of cents it represents. - * - * @param amountStr A string representing a USD value. - * @param allowFraction Flag indicating if fractions of cents should be - * allowed in the output. - * - * @returns The cent value of the @p amountStr. - */ - fromUSDToNumber(amountStr: string, allowFraction: boolean): number; - /** - * Truncates the middle section of a string based on the max allowed length - * - * @param fullStr - * @param maxLength - */ - truncateInMiddle(fullStr: string, maxLength: number): string; - /** - * Convert new line to
    - * - * @param str - */ - nl2br(str: string): string; - /** - * Decodes the given HTML encoded string. - * - * @param s The string to decode. - * @returns The decoded string. - */ - htmlDecode(s: string): string; - /** - * HTML encodes the given string. - * - * @param s The string to encode. - * @returns @p s HTML encoded. - */ - htmlEncode(s: string): string; - /** - * Escape text while preventing any sort of double escape, so 'X & Y' -> 'X & Y' and 'X & Y' -> 'X & Y' - * - * @param s the string to escape - * @returns the escaped string - */ - safeEscape(s: string): string; - /** - * HTML encoding insensitive equals. - * - * @param first string to compare - * @param second string to compare - * @returns true when first === second, ignoring HTML encoding - */ - htmlEncodingInsensitiveEquals(first: string, second: string): boolean; - /** - * Creates an ID that can be used as an HTML attribute from @p str. - * - * @param str A string to create an ID from. - * @returns The ID string made from @p str. - */ - makeID(str: string): string; - /** - * Extracts an ID made with Str.makeID from a larger string. - * - * @param str A string containing an id made with Str.makeID - * @returns The ID string. - */ - extractID(str: string): string | null; - /** - * Modifies the string so the first letter of each word is capitalized and the - * rest lowercased. - * - * @param val The string to modify - */ - recapitalize(val: string): string; - /** - * Replace all the non alphanumerical character by _ - * - * @param input - */ - sanitizeToAlphaNumeric(input: string): string; - /** - * Strip out all the non numerical characters - * - * @param input - */ - stripNonNumeric(input: string): string; - /** - * Strips all non ascii characters from a string - * @param input - * @returns The ascii version of the string. - */ - stripNonASCIICharacters(input: string): string; - /** - * Shortens the @p text to @p length and appends an ellipses to it. - * - * The ellipses will only be appended if @p text is longer than the @p length - * given. - * - * @param val The string to reduce in size. - * @param length The maximal length desired. - * @returns The shortened @p text. - */ - shortenText(val: string, length: number): string; - /** - * Returns the byte size of a character - * @param inputChar You can input more than one character, but it will only return the size of the first - * one. - * @returns Byte size of the character - */ - getRawByteSize(inputChar: string): number; - /** - * Gets the length of a string in bytes, including non-ASCII characters - * @param input - * @returns The number of bytes used by string - */ - getByteLength(input: string): number; - /** - * Shortens the input by max byte size instead of by character length - * @param input - * @param maxSize The max size in bytes, e.g. 256 - * @returns Returns a shorted input if the input size exceeds the max - */ - shortenByByte(input: string, maxSize: number): string; - /** - * Returns true if the haystack begins with the needle - * - * @param haystack The full string to be searched - * @param needle The case-sensitive string to search for - * @returns Retruns true if the haystack starts with the needle. - */ - startsWith(haystack: string, needle: string): boolean; - /** - * Gets the textual value of the given string. - * - * @param str The string to fetch the text value from. - * @returns The text from within the HTML string. - */ - stripHTML(str: string): string; - /** - * Modifies the string so the first letter of the string is capitalized - * - * @param str The string to modify. - * @returns The recapitalized string. - */ - UCFirst(str: string): string; - /** - * Returns a string containing all the characters str from the beginning - * of str to the first occurrence of substr. - * Example: Str.cutAfter( 'hello$%world', '$%' ) // returns 'hello' - * - * @param str The string to modify. - * @param substr The substring to search for. - * @returns The cut/trimmed string. - */ - cutAfter(str: string, substr: string): string; - /** - * Returns a string containing all the characters str from after the first - * occurrence of substr to the end of the string. - * Example: Str.cutBefore( 'hello$%world', '$%' ) // returns 'world' - * - * @param str The string to modify. - * @param substr The substring to search for. - * @returns The cut/trimmed string. - */ - cutBefore(str: string, substr: string): string; - /** - * Checks that the string is a domain name (e.g. example.com) - * - * @param string The string to check for domainnameness. - * - * @returns True iff the string is a domain name - */ - isValidDomainName(string: string): boolean; - /** - * Checks that the string is a valid url - * - * @param string - * - * @returns True if the string is a valid hyperlink - */ - isValidURL(string: string): boolean; - /** - * Checks that the string is an email address. - * NOTE: TLDs are not just 2-4 characters. Keep this in sync with _inputrules.php - * - * @param string The string to check for email validity. - * - * @returns True iff the string is an email - */ - isValidEmail(string: string): boolean; - /** - * Checks if the string is an valid email address formed during comment markdown formation. - * - * @param string The string to check for email validity. - * - * @returns True if the string is an valid email created by comment markdown. - */ - isValidEmailMarkdown(string: string): boolean; - /** - * Remove trailing comma from a string. - * - * @param string The string with any trailing comma to be removed. - * - * @returns string with the trailing comma removed - */ - removeTrailingComma(string: string): string; - /** - * Checks that the string is a list of coma separated email addresss. - * - * @param str The string to check for emails validity. - * - * @returns True if all emails are valid or if input is empty - */ - areValidEmails(str: string): boolean; - /** - * Extract the email addresses from a string - * - * @param string - */ - extractEmail(string: string): RegExpMatchArray | null; - /** - * Extracts the domain name from the given email address - * (e.g. "domain.com" for "joe@domain.com"). - * - * @param email The email address. - * - * @returns The domain name in the email address. - */ - extractEmailDomain(email: string): string; - /** - * Tries to extract the company name from the given email address - * (e.g. "yelp" for "joe@yelp.co.uk"). - * - * @param email The email address. - * - * @returns The company name in the email address or null. - */ - extractCompanyNameFromEmailDomain(email: string): string | null; - /** - * Extracts the local part from the given email address - * (e.g. "joe" for "joe@domain.com"). - * - * @param email The email address. - * - * @returns The local part in the email address. - */ - extractEmailLocalPart(email: string): string; - /** - * Sanitize phone number to return only numbers. Return null if non valid phone number. - * - * @param str - */ - sanitizePhoneNumber(str: string): string | null; - /** - * Sanitize email. Return null if non valid email. - * - * @param str - */ - sanitizeEmail(str: string): string | null; - /** - * Escapes all special RegExp characters from a string - * - * @param string The subject - * - * @returns The escaped string - */ - escapeForRegExp(string: string): string; - /** - * Escapes all special RegExp characters from a string except for the period - * - * @param string The subject - * @returns The escaped string - */ - escapeForExpenseRule(string: string): string; - /** - * Adds a backslash in front of each of colon - * if they don't already have a backslash in front of them - * - * @param string The subject - * @returns The escaped string - */ - addBackslashBeforeColonsForTagNamesComingFromQBD(string: string): string; - /** - * Removes backslashes from string - * eg: myString\[\]\* -> myString[]* - * - * @param string - * - */ - stripBackslashes(string: string): string; - /** - * Checks if a string's length is in the specified range - * - * @param string The subject - * @param minimumLength - * @param [maximumLength] - * - * @returns true if the length is in the range, false otherwise - */ - isOfLength(string: string, minimumLength: number, maximumLength: number): boolean; - /** - * Count the number of occurences of needle in haystack. - * This is faster than counting the results of haystack.match( /needle/g ) - * via http://stackoverflow.com/questions/4009756/how-to-count-string-occurrence-in-string - * - * @param haystack The string to look inside of - * @param needle What we're looking for - * @param allowOverlapping Defaults to false - * - * @returns The number of times needle is in haystack. - */ - occurences(haystack: string, needle: string, allowOverlapping: boolean): number; - /** - * Uppercases the first letter of each word - * via https://github.com/kvz/phpjs/blob/master/functions/strings/ucwords.js - * - * @param str to uppercase words - * @returns Uppercase worded string - */ - ucwords(str: string): string; - /** - * Returns true if the haystack contains the needle - * - * @param haystack The full string to be searched - * @param needle The case-sensitive string to search for - * - * @returns Returns true if the haystack contains the needle - */ - contains(haystack: string, needle: string): boolean; - /** - * Returns true if the haystack contains the needle, ignoring case - * - * @param haystack The full string to be searched - * @param needle The case-insensitive string to search for - * - * @returns Returns true if the haystack contains the needle, ignoring case - */ - caseInsensitiveContains(haystack: string, needle: string): boolean; - /** - * Case insensitive compare function - * - * @param string1 string to compare - * @param string2 string to compare - * - * @returns -1 if first string < second string - * 1 if first string > second string - * 0 if first string = second string - */ - caseInsensitiveCompare(string1: string, string2: string): 1 | 0 | -1; - /** - * Case insensitive equals - * - * @param first string to compare - * @param second string to compare - * @returns true when first == second except for case - */ - caseInsensitiveEquals(first: string, second: string): boolean; - /** - * Compare function - * - * @param string1 string to compare - * @param string2 string to compare - * - * @returns -1 if first string < second string - * 1 if first string > second string - * 0 if first string = second string - */ - compare(string1: string, string2: string): 1 | 0 | -1; - /** - * Check if a file extension is supported by SmartReports - * @param filename - */ - isFileExtensionSmartReportsValid(filename: string): boolean; - /** - * Mask Permanent Account Number (PAN) the same way Auth does - * @param number account number - * @returns masked account number - */ - maskPAN(number: number | string): string; - /** - * Checks if something is a string - * Stolen from underscore - * @param obj - */ - isString(obj: unknown): boolean; - /** - * Checks if something is a number - * Stolen from underscore - * @param obj - */ - isNumber(obj: unknown): boolean; - /** - * Checks if something is a certain type - * Stolen from underscore - * @param obj - * @param type one of ['Arguments', 'Function', 'String', 'Number', 'Date', - * 'RegExp', 'Error', 'Symbol', 'Map', 'WeakMap', 'Set', 'WeakSet'] - */ - isTypeOf(obj: unknown, type: 'Arguments' | 'Function' | 'String' | 'Number' | 'Date' | 'RegExp' | 'Error' | 'Symbol' | 'Map' | 'WeakMap' | 'Set' | 'WeakSet'): boolean; - /** - * Checks to see if something is undefined - * Stolen from underscore - * @param obj - */ - isUndefined(obj: unknown): boolean; - /** - * Replace first N characters of the string with maskChar - * eg: maskFirstNCharacters( '1234567890', 6, 'X' ) yields XXXXXX7890 - * @param str string to mask - * @param n number of characters we want to mask from the string - * @param mask string we want replace the first N chars with - * @returns masked string - */ - maskFirstNCharacters(str: string, n: number, mask: string): string; - /** - * Trim a string - * - * @param str - */ - trim(str: string): string; - /** - * Convert a percentage string like '25%' to 25/ - * @param percentageString The percentage as a string - */ - percentageStringToNumber(percentageString: string): number; - /** - * Remoce all the spaces from a string - * @param input - * - */ - removeSpaces(input: string): string; - /** - * Returns the proper phrase depending on the count that is passed. - * Example: - * console.log(Str.pluralize('puppy', 'puppies', 1)); // puppy - * console.log(Str.pluralize('puppy', 'puppies', 3)); // puppies - * - * @param singular form of the phrase - * @param plural form of the phrase - * @param n the count which determines the plurality - */ - pluralize(singular: string, plural: string, n: number): string; - /** - * Returns whether or not a string is an encrypted number or not. - * - * @param number that we want to see if its encrypted or not - * - * @returns Whether or not this string is an encrpypted number - */ - isEncryptedCardNumber(number: string): boolean; - /** - * Converts a value to boolean, case-insensitive. - * @param value - */ - toBool(value: unknown): boolean; - /** - * Checks if a string could be the masked version of another one. - * - * @param first string to compare - * @param second string to compare - * @param [mask] defaults to X - * @returns true when first could be the masked version of second - */ - maskedEquals(first: string, second: string, mask: string): boolean; - /** - * Bold any word matching the regexp in the text. - * @param text, htmlEncoded - * @param regexp - */ - boldify(text: string, regexp: RegExp): string; - /** - * Check for whether a phone number is valid. - * @param phone - * @deprecated use isValidE164Phone to validate E.164 phone numbers or isValidPhoneFormat to validate phone numbers in general - */ - isValidPhone(phone: string): boolean; - /** - * Check for whether a phone number is valid according to E.164 standard. - * @param phone - */ - isValidE164Phone(phone: string): boolean; - /** - * Check for whether a phone number is valid in different formats/standards. For example: - * significant: 4404589784 - * international: +1 440-458-9784 - * e164: +14404589784 - * national: (440) 458-9784 - * 123.456.7890 - * @param phone - */ - isValidPhoneFormat(phone: string): boolean; - /** - * We validate mentions by checking if it's first character is an allowed character. - * - * @param mention - */ - isValidMention(mention: string): boolean; - /** - * Returns text without our SMS domain - * - * @param text - */ - removeSMSDomain(text: string): string; - /** - * Returns true if the text is a valid E.164 phone number with our SMS domain removed - * - * @param text - */ - isSMSLogin(text: string): boolean; - /** - * This method will return all matches of a single regex like preg_match_all() in PHP. This is not a common part of - * JS yet, so this is a good way of doing it according to - * https://github.com/airbnb/javascript/issues/1439#issuecomment-306297399 and doesn't get us in trouble with - * linting rules. - * - * @param str - * @param regex - */ - matchAll(str: string, regex: RegExp): Array; - /** - * A simple GUID generator taken from https://stackoverflow.com/a/32760401/9114791 - * - * @param [prefix] an optional prefix to put in front of the guid - * - */ - guid(prefix?: string): string; - /** - * Takes in a URL and returns it with a leading '/' - * - * @param url The URL to be formatted - * @returns The formatted URL - */ - normalizeUrl(url: string): string; - /** - * Formats a URL by converting the domain name to lowercase and adding the missing 'https://' protocol. - * - * @param url The URL to be formatted - * @returns The formatted URL - */ - sanitizeURL(url: string): string; - /** - * Checks if parameter is a string or function - * if it is a function then we will call it with - * any additional arguments. - * - * @param parameter - */ - result: { - (parameter: string): string; - (parameter: (...args: A) => R, ...args: A): R; - }; - /** - * Get file extension for a given url with or - * without query parameters - * - * @param url - */ - getExtension(url: string): string | undefined; - /** - * Takes in a URL and checks if the file extension is PDF - * - * @param url The URL to be checked - * @returns Whether file path is PDF or not - */ - isPDF(url: string): boolean; - /** - * Takes in a URL and checks if the file extension is an image - * that can be rendered by React Native. Do NOT add extensions - * to this list unless they appear in this list and are - * supported by all platforms. - * - * https://reactnative.dev/docs/image#source - * - * @param url - */ - isImage(url: string): boolean; - /** - * Takes in a URL and checks if the file extension is a video - * that can be rendered by React Native. Do NOT add extensions - * to this list unless they are supported by all platforms. - * - * https://developer.android.com/media/platform/supported-formats#video-formats - * https://developer.apple.com/documentation/coremedia/1564239-video_codec_constants - * https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs - * - * @param url - */ - isVideo(url: string): boolean; - /** - * Checks whether the given string is a +@ domain email account, such as - * +@domain.com - * - * @param email - * @returns True if is a domain account email, otherwise false. - */ - isDomainEmail(email: string): boolean; - /** - * Polyfill for String.prototype.replaceAll - * - * @param text - * @param searchValue - * @param replaceValue - */ - replaceAll(text: string, searchValue: string | RegExp, replaceValue: string | ((...args: unknown[]) => string)): string; -}; -export default Str; diff --git a/lib/str.js b/lib/str.ts similarity index 56% rename from lib/str.js rename to lib/str.ts index f9978b4e..210a72f5 100644 --- a/lib/str.js +++ b/lib/str.ts @@ -1,20 +1,35 @@ /* eslint-disable no-control-regex */ -import _ from 'underscore'; +import {parsePhoneNumber} from 'awesome-phonenumber'; import * as HtmlEntities from 'html-entities'; import * as Constants from './CONST'; import * as UrlPatterns from './Url'; +import * as Utils from './utils'; const REMOVE_SMS_DOMAIN_PATTERN = /@expensify\.sms/gi; +/** + * Checks if parameter is a string or function + * if it is a function then we will call it with + * any additional arguments. + */ +function resultFn(parameter: string): string; +function resultFn(parameter: (...args: A) => R, ...args: A): R; +function resultFn(parameter: string | ((...a: A) => R), ...args: A): string | R { + if (typeof parameter === 'function') { + return parameter(...args); + } + + return parameter; +} + const Str = { /** * Return true if the string is ending with the provided suffix * - * @param {String} str String ot search in - * @param {String} suffix What to look for - * @return {Boolean} + * @param str String ot search in + * @param suffix What to look for */ - endsWith(str, suffix) { + endsWith(str: string, suffix: string): boolean { if (!str || !suffix) { return false; } @@ -24,37 +39,28 @@ const Str = { /** * Converts a USD string into th number of cents it represents. * - * @param {String} amountStr A string representing a USD value. - * @param {Boolean} allowFraction Flag indicating if fractions of cents should be + * @param amountStr A string representing a USD value. + * @param allowFraction Flag indicating if fractions of cents should be * allowed in the output. * - * @return {Number} The cent value of the @p amountStr. + * @returns The cent value of the @p amountStr. */ - fromUSDToNumber(amountStr, allowFraction) { - let amount = String(amountStr).replace(/[^\d.\-()]+/g, ''); + fromUSDToNumber(amountStr: string, allowFraction: boolean): number { + let amount: string | number = String(amountStr).replace(/[^\d.\-()]+/g, ''); if (amount.match(/\(.*\)/)) { const modifiedAmount = amount.replace(/[()]/g, ''); amount = `-${modifiedAmount}`; } amount = Number(amount) * 100; - // We round it here to a precision of 3 because some floating point numbers, when multiplied by 100 - // don't give us a very pretty result. Try this in the JS console: - // 0.678 * 100 - // 67.80000000000001 - // 0.679 * 100 - // 67.9 amount = Math.round(amount * 1e3) / 1e3; return allowFraction ? amount : Math.round(amount); }, /** * Truncates the middle section of a string based on the max allowed length - * @param {string} fullStr - * @param {int} maxLength - * @returns {string} */ - truncateInMiddle(fullStr, maxLength) { + truncateInMiddle(fullStr: string, maxLength: number): string { if (fullStr.length <= maxLength) { return fullStr; } @@ -69,70 +75,59 @@ const Str = { /** * Convert new line to
    - * - * @param {String} str - * @returns {string} */ - nl2br(str) { + nl2br(str: string): string { return str.replace(/\n/g, '
    '); }, /** * Decodes the given HTML encoded string. * - * @param {String} s The string to decode. - * @return {String} The decoded string. + * @param s The string to decode. + * @returns The decoded string. */ - htmlDecode(s) { - // Use jQuery if it exists or else use html-entities - if (typeof jQuery !== 'undefined') { - return jQuery('