diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..ff1be89aa9707 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,51 @@ +{ + "name": "Forem DEVcontainer", + "dockerComposeFile": "../docker-compose.yml", + "service": "devcontainer", + "runServices": ["postgres", "redis"], + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}", + "GIT_EDITOR": "code --wait" + }, + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "latest", + "enableNonRootDocker": "false", + "moby": "true" + }, + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "true", + "configureZshAsDefaultShell": "true" + } + }, + "portsAttributes": { + "3000": { + "onAutoForward": "notify" + }, + "3035": { + // This port is used by the Rails' webpack-dev-server. Users won't need to access it directly. + "onAutoForward": "silent" + } + }, + "updateContentCommand": "bin/setup && bin/rails db:test:prepare RAILS_ENV=test", + "postCreateCommand": ".devcontainer/postCreateCommand-init.sh", + "postAttachCommand": ".devcontainer/postAttachCommand-init.sh", + "customizations": { + "vscode": { + // Extensions listed here will be installed unlike the one specificed in extensions.json + "extensions": [ + "Shopify.ruby-lsp", + "KoichiSasada.vscode-rdbg", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "MateuszDrewniak.ruby-test-runner", + "firsttris.vscode-jest-runner", + "eamodio.gitlens" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh" + } + } + } +} diff --git a/.devcontainer/postAttachCommand-init.sh b/.devcontainer/postAttachCommand-init.sh new file mode 100755 index 0000000000000..c0988b4c4e079 --- /dev/null +++ b/.devcontainer/postAttachCommand-init.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +if [ -n "$CODESPACE_NAME" ]; then + echo "Running updates" + + # Github Codespace prebuild caches the codebase. + # This means depending on the time the Codespace is created, + # it may not be on latest commit with latest dependency changes + # + # See https://github.com/orgs/community/discussions/58172 + + if git fetch origin "$(git rev-parse --abbrev-ref HEAD)" && git diff --quiet "HEAD..origin/$(git rev-parse --abbrev-ref HEAD)" ;then + echo "Branch is already up to date" + else + echo "Branch is not up to date, pulling latest code" + git pull origin "$(git rev-parse --abbrev-ref HEAD)" --no-rebase + echo "Updating dependencies" + bin/setup + fi +fi + +cat <> ~/.zshrc + +if [ ! -f .env ]; then + echo "Creating .env file" + cp .env_sample .env +fi + +if [ -n "$CODESPACE_NAME" ]; then + echo "Updating .env file with codespace specific values" + echo APP_DOMAIN="${CODESPACE_NAME}-3000.app.github.dev" >> .env + echo APP_PROTOCOL=https:// >> .env + echo COMMUNITY_NAME="DEV(codespace)" >> .env +fi diff --git a/.dockerdev/.psqlrc b/.dockerdev/.psqlrc new file mode 100644 index 0000000000000..05c0dcb22a50c --- /dev/null +++ b/.dockerdev/.psqlrc @@ -0,0 +1,26 @@ +-- Don't display the "helpful" message on startup. +\set QUIET 1 + +-- Allow specifying the path to history file via `PSQL_HISTFILE` env variable +-- (and fallback to the default $HOME/.psql_history otherwise) +\set HISTFILE `[ -z $PSQL_HISTFILE ] && echo $HOME/.psql_history || echo $PSQL_HISTFILE` + +-- Show how long each query takes to execute +\timing + +-- Use best available output format +\x auto + +-- Verbose error reports +\set VERBOSITY verbose + +-- If a command is run more than once in a row, +-- only store it once in the history +\set HISTCONTROL ignoredups +\set COMP_KEYWORD_CASE upper + +-- By default, NULL displays as an empty space. Is it actually an empty +-- string, or is it null? This makes that distinction visible +\pset null '[NULL]' + +\unset QUIET diff --git a/.env.test b/.env.test deleted file mode 120000 index e05d8b35495e6..0000000000000 --- a/.env.test +++ /dev/null @@ -1 +0,0 @@ -.env_sample \ No newline at end of file diff --git a/.env_sample b/.env_sample index 628d06270cad6..a095c689c9c3e 100644 --- a/.env_sample +++ b/.env_sample @@ -22,17 +22,17 @@ HEROKU_SLUG_COMMIT="" # Redis caching storage REDIS_URL="redis://localhost:6379" -# Redis sessions storage -REDIS_SESSIONS_URL="redis://localhost:6379" +# Redis sessions storage, rely on REDIS_URL if not set +# REDIS_SESSIONS_URL= SESSION_KEY="_Dev_Community_Session" # two weeks in seconds SESSION_EXPIRY_SECONDS=1209600 -# Redis Sidekiq storage -REDIS_SIDEKIQ_URL="redis://localhost:6379" +# Redis Sidekiq storage, rely on REDIS_URL if not set +# REDIS_SIDEKIQ_URL= -# Redis Devices/Rpush storage -REDIS_RPUSH_URL="redis://localhost:6379" +# Redis Devices/Rpush storage, rely on REDIS_URL if not set +# REDIS_RPUSH_URL= # OpenResty OPENRESTY_URL="" @@ -152,6 +152,11 @@ TWITCH_CLIENT_ID="Optional" TWITCH_CLIENT_SECRET="Optional" TWITCH_WEBHOOK_SECRET="Optional" +# Algolia for search +ALGOLIA_APPLICATION_ID= +ALGOLIA_API_KEY= +ALGOLIA_SEARCH_ONLY_API_KEY= + # For calling the Stack Exchange API # (https://api.stackexchange.com/docs) STACK_EXCHANGE_APP_KEY="" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cbd1dda33463c..4b272875424d8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,50 +2,27 @@ # see https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners # for further details +# Default global ownership +* @forem/core-reviewers -# File exentions based ownership -# -# They are: .builder, .css, .erb, .gif, .ico, .jbuilder, .js, .jsx, -# .map, .md, .mdx, .otf, .png, .rake, .rb, .scss, .snap, -# .svg, .toml, .xml -# -# Use the following to generate the list: -# -# * `tree app | rg "\.\w+$" --only-matching | sort | uniq` -# * `tree lib | rg "\.\w+$" --only-matching | sort | uniq` -# -**/*.builder @forem/core-reviewers -**/*.css @forem/core-reviewers -**/*.erb @forem/core-reviewers -**/*.jbuilder @forem/core-reviewers -**/*.js @forem/core-reviewers -**/*.json @forem/core-reviewers -**/*.jsx @forem/core-reviewers -**/*.md @forem/core-reviewers -**/*.rb @forem/core-reviewers -**/*.scss @forem/core-reviewers +# Dependency ownership +Gemfile @forem/core-reviewers @forem/platform +Gemfile.lock @forem/core-reviewers @forem/platform +package.json @forem/core-reviewers @forem/platform +yarn.lock @forem/core-reviewers @forem/platform -# Context based ownership -/app/assets/ @forem/core-reviewers -/app/controllers/async_info_controller.rb @forem/sre -/app/javascript/ @forem/core-reviewers -/app/services/search/ @forem/sre -/app/workers/ @forem/sre -/config/ @forem/sre -/config/locales -/db/ @forem/sre @forem/core-reviewers -/lib/data_update_scripts/ @forem/sre -/lib/sidekiq/ @forem/sre -/spec/rails_helper.rb @forem/sre @forem/core-reviewers -/spec/support/ @forem/sre -.buildkite/ @forem/systems @forem/sre -.travis.yml @forem/sre -Containerfile @forem/systems -docker-compose.yml @forem/systems -Dockerfile @forem/systems -Gemfile @forem/core-reviewers @forem/sre -Gemfile.lock @forem/core-reviewers @forem/sre -package.json @forem/core-reviewers -podman-compose.yml @forem/systems -scripts/ @forem/systems -yarn.lock @forem/core-reviewers +# Platform specific ownership +.buildkite/ @forem/platform @forem/core-reviewers +Containerfile @forem/platform @forem/core-reviewers +Dockerfile @forem/platform @forem/core-reviewers +app/controllers/async_info_controller.rb @forem/platform @forem/core-reviewers +app/services/search/ @forem/platform @forem/core-reviewers +app/workers/ @forem/platform @forem/core-reviewers +config/ @forem/platform @forem/core-reviewers +db/ @forem/platform @forem/core-reviewers +docker-compose.yml @forem/platform @forem/core-reviewers +lib/ @forem/platform @forem/core-reviewers +podman-compose.yml @forem/platform @forem/core-reviewers +scripts/ @forem/platform @forem/core-reviewers +spec/rails_helper.rb @forem/platform @forem/core-reviewers +spec/support/ @forem/platform @forem/core-reviewers diff --git a/.github/ISSUE_TEMPLATE/-core-team-only---new-approved-feature.md b/.github/ISSUE_TEMPLATE/-core-team-only---new-approved-feature.md new file mode 100644 index 0000000000000..7916cd82ebeb9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/-core-team-only---new-approved-feature.md @@ -0,0 +1,31 @@ +--- +name: "[Core Team Only]: New Approved Feature" +about: This template is for Core Team only. For feature requests, please use GitHub + Discussions. +title: '' +labels: '' +assignees: '' + +--- + + + +## Is this feature related to a problem? Please describe. + + + +## Describe the solution you’d like + + + +## Definition of Done + + + +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 + +## Additional Context + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index bbe844abed08d..1fb7109a43f7a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,11 +1,14 @@ --- name: Bug report about: Create a report to help us improve +title: '' +labels: bug +assignees: '' --- - + **Describe the bug** diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3dd7a1c9286ad..ac5cc141309ba 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Feature Request - url: https://github.com/forem/forem/discussions - about: Visit our GitHub Discussions page to suggest an idea to make Forem better. - - name: Forem Questions and Discussions - url: https://forem.dev/ - about: Please ask and answer questions, discuss features, or reach out for help on forem.dev + url: https://github.com/forem/forem/discussions/categories/feature-requests + about: Have a feature request? Please let us know via GitHub Discussions. + - name: Forem Q&A + url: https://github.com/forem/forem/discussions/categories/q-a + about: Have a general questions? Please let us know via GitHub Discussions. - name: Self-Host Bug Report url: https://github.com/forem/selfhost/issues/new about: Create a report to help us improve the Self-Host repository. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1c350bb5fc1fb..be151c1be2f36 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -49,46 +49,26 @@ _Please replace this line with instructions on how to test your changes, a note on the devices and browsers this has been tested on, as well as any relevant images for UI changes._ -### UI accessibility concerns? - -_If your PR includes UI changes, please replace this line with details on how -accessibility is impacted and tested. For more info, check out the +### UI accessibility checklist +_If your PR includes UI changes, please utilize this checklist:_ +- [ ] Semantic HTML implemented? +- [ ] Keyboard operability supported? +- [ ] Checked with [axe DevTools](https://www.deque.com/axe/) and addressed `Critical` and `Serious` issues? +- [ ] Color contrast tested? + +_For more info, check out the [Forem Accessibility Docs](https://developers.forem.com/frontend/accessibility)._ ## Added/updated tests? +_We encourage you to keep the code coverage percentage at 80% and above._ - [ ] Yes - [ ] No, and this is why: _please replace this line with details on why tests have not been included_ - [ ] I need help with writing tests -## [Forem core team only] How will this change be communicated? - -_Will this PR introduce a change that impacts Forem members or creators, the -development process, or any of our internal teams? If so, please note how you -will share this change with the people who need to know about it._ - -- [ ] I've updated the [Developer Docs](https://developers.forem.com) or - [Storybook](https://storybook.forem.com/) (for Crayons components) -- [ ] This PR changes the Forem platform and our documentation needs to be - updated. I have filled out the - [Changes Requested](https://github.com/forem/admin-docs/issues/new?assignees=&labels=&template=changes_requested.md) - issue template so Community Success can help update the Admin Docs - appropriately. -- [ ] I've updated the README or added inline documentation -- [ ] I've added an entry to - [`CHANGELOG.md`](https://github.com/forem/forem/tree/main/CHANGELOG.md) -- [ ] I will share this change in a [Changelog](https://forem.dev/t/changelog) - or in a [forem.dev](http://forem.dev) post -- [ ] I will share this change internally with the appropriate teams -- [ ] I'm not sure how best to communicate this change and need help -- [ ] This change does not need to be communicated, and this is why not: _please - replace this line with details on why this change doesn't need to be - shared_ - ## [optional] Are there any post deployment tasks we need to perform? ## [optional] What gif best describes this PR or how it makes you feel? ![alt_text](gif_link) - diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..5a22a662420a5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 +updates: + # RubyGems dependency updates (typically for Rails and other Ruby gems) + - package-ecosystem: "bundler" # for RubyGems + directory: "/" # path to the directory containing the Gemfile + schedule: + interval: "weekly" # frequency of update checks + day: "monday" # specify the day to check for updates + time: "04:00" # specify the time of day (in UTC) to check for updates + open-pull-requests-limit: 5 # limit the number of open pull requests + labels: + - "dependencies" # label to assign to pull requests + - "ruby" + milestone: 1 # ID of the milestone to assign to the pull requests if needed + + # Yarn dependency updates (for JavaScript packages) + - package-ecosystem: "yarn" # for Yarn packages + directory: "/" # path to the directory containing the package.json and yarn.lock files + schedule: + interval: "weekly" + day: "tuesday" + time: "04:00" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "javascript" + milestone: 1 + + # Configuration for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + day: "first" # first day of the month + time: "04:00" + labels: + - "dependencies" + - "github-actions" diff --git a/.github/workflows/build-base-ruby-image.yml b/.github/workflows/build-base-ruby-image.yml new file mode 100644 index 0000000000000..3aa8066fcd0b0 --- /dev/null +++ b/.github/workflows/build-base-ruby-image.yml @@ -0,0 +1,51 @@ +--- +name: Build ghcr.io/forem/ruby Container Image +on: + workflow_call: + # Allow manual runs through GitHub GUI in case of emergency. + workflow_dispatch: + push: + branches: + - 'main' + paths: + - 'Containerfile.base' + - '.ruby-version-next' + pull_request: + branches: + - 'main' + paths: + - 'Containerfile.base' + - '.ruby-version-next' + +jobs: + build-image: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: "2" # Get current and preceding commit only + - name: Detect relevant changed files in this job + id: containerfile-changed + uses: tj-actions/changed-files@v37 + with: + files: Containerfile.base + - name: Do not push to GHCR if this commit does not target the main branch + if: ${{ github.event_name != 'push' && github.event_name != 'workflow_dispatch' }} + run: echo "SKIP_PUSH=1" >> $GITHUB_ENV + - name: Set up QEMU for cross-compiling to ARM64 + uses: docker/setup-qemu-action@v2 + - name: Set up Docker BuildX + id: buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + - name: Build Images + env: + EXTERNAL_QEMU: "1" + run: scripts/build_base_ruby_image.sh diff --git a/.github/workflows/bundle-audit.yml b/.github/workflows/bundle-audit.yml deleted file mode 100644 index 06e25122f9f98..0000000000000 --- a/.github/workflows/bundle-audit.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Bundler-audit - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - audit: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: setup ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - run: bundle exec bundle-audit check --update diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000000000..df4e18dd33a2a --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,32 @@ +name: CD + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + concurrency: ${{ matrix.environment }} + environment: ${{ matrix.environment }} + + strategy: + matrix: + environment: [ production, staging ] + + steps: + - uses: actions/checkout@v4 + - uses: akhileshns/heroku-deploy@v3.12.12 + with: + heroku_api_key: ${{ secrets.HEROKU_API_KEY }} + heroku_app_name: ${{ secrets.HEROKU_APP_NAME }} + heroku_email: ${{ secrets.HEROKU_EMAIL }} + - uses: honeybadger-io/github-notify-deploy-action@v1 + with: + api_key: ${{ secrets.HONEYBADGER_API_KEY_RUBY }} + if: matrix.environment == 'production' + - uses: honeybadger-io/github-notify-deploy-action@v1 + with: + api_key: ${{ secrets.HONEYBADGER_API_KEY_JAVASCRIPT }} + if: matrix.environment == 'production' diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml deleted file mode 100644 index 1978c38460851..0000000000000 --- a/.github/workflows/ci-cd.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: CI/CD - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - RSpec: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - COVERAGE: true - RAILS_ENV: test - KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }} - KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }} - KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC }} - KNAPSACK_PRO_FIXED_QUEUE_SPLIT: true - DATABASE_URL_TEST: postgres://postgres:postgres@localhost:5432/Forem_test - DATABASE_NAME_TEST: Forem_test - - services: - postgres: - image: postgres:13-alpine - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - redis: - image: redis - ports: - - 6379:6379 - - strategy: - fail-fast: false - matrix: - ci_node_total: [9] - ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8] - - steps: - - uses: actions/checkout@v3 - - name: Cache node modules - uses: actions/cache@v3 - with: - path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-node-modules- - - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: yarn - - run: yarn install --frozen-lockfile - - name: setup ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - run: bundle exec rails db:test:prepare - - name: RSpec - run: bin/knapsack_pro_rspec - - name: Upload RSpec artifacts - uses: actions/upload-artifact@v3 - if: failure() - with: - name: rspec-artifacts - path: | - tmp/capybara - - uses: codecov/codecov-action@v3 - with: - flags: ruby - - name: Upload test results to BuildPulse for flaky test detection - if: '!cancelled()' # Run this step even when the tests fail. Skip if the workflow is cancelled. - uses: Workshop64/buildpulse-action@master - with: - account: ${{ secrets.BUILDPULSE_ACCOUNT }} - repository: ${{ secrets.BUILDPULSE_REPOSITORY }} - path: tmp/rspec_final_results.xml - key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }} - secret: ${{ secrets.BUILDPULSE_SECRET_ACCESS_KEY }} - - Jest: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - RAILS_ENV: test - NODE_ENV: test - - steps: - - uses: actions/checkout@v3 - - name: Cache node modules - uses: actions/cache@v3 - with: - path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-node-modules- - - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: yarn - - run: yarn install --frozen-lockfile - - run: yarn lint:frontend - - run: yarn test --colors --ci --reporters=jest-junit - - name: Upload test results to BuildPulse for flaky test detection - if: '!cancelled()' # Run this step even when the tests fail. Skip if the workflow is cancelled. - uses: Workshop64/buildpulse-action@master - with: - account: ${{ secrets.BUILDPULSE_ACCOUNT }} - repository: ${{ secrets.BUILDPULSE_REPOSITORY }} - path: junit.xml - key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }} - secret: ${{ secrets.BUILDPULSE_SECRET_ACCESS_KEY }} - - uses: codecov/codecov-action@v3 - with: - flags: javascript - - run: yarn build-storybook - - Build-test: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@localhost:5432/Forem_prod_test - DATABASE_NAME: Forem_prod_test - APP_PROTOCOL: http:// - APP_DOMAIN: localhost:3000 - HEROKU_APP_URL: practicaldev.herokuapp.com - SECRET_KEY_BASE: dummydummydummy - GITHUB_KEY: dummy - GITHUB_SECRET: dummy - KNAPSACK_PRO_FIXED_QUEUE_SPLIT: true - - services: - postgres: - image: postgres:13-alpine - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - redis: - image: redis - ports: - - 6379:6379 - - steps: - - uses: actions/checkout@v3 - - name: Cache node modules - uses: actions/cache@v3 - with: - path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-node-modules- - - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: yarn - - run: yarn install --frozen-lockfile - - name: setup ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - run: bundle exec rails assets:precompile - - run: yarn run postcss - - run: bin/test-console-check - - Cypress: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - RAILS_ENV: test - DATABASE_URL_TEST: postgres://postgres:postgres@localhost:5432/Forem_test - DATABASE_NAME_TEST: Forem_test - KNAPSACK_PRO_FIXED_QUEUE_SPLIT: true - - services: - postgres: - image: postgres:13-alpine - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - redis: - image: redis - ports: - - 6379:6379 - - strategy: - fail-fast: false - matrix: - ci_node_total: [9] - ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, non-seed] - - steps: - - uses: actions/checkout@v3 - - name: Cache node modules - uses: actions/cache@v3 - with: - path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-node-modules- - - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: yarn - - run: yarn install --frozen-lockfile - - name: setup ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - run: bundle exec rails db:test:prepare assets:precompile - - run: yarn cypress install - - name: cypress - env: - KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }} - KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }} - KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS }} - KNAPSACK_PRO_TEST_FILE_PATTERN: "cypress/e2e/seededFlows/**/*.spec.js" - run: bin/knapsack_pro_cypress - if: ${{ matrix.ci_node_index != 'non-seed' }} - - name: cypress non-seed - run: CREATOR_ONBOARDING_SEED_DATA=1 E2E_FOLDER=creatorOnboardingFlows E2E=true bin/rails cypress:run - if: ${{ matrix.ci_node_index == 'non-seed' }} - - name: Upload Cypress artifacts - uses: actions/upload-artifact@v3 - if: failure() - with: - name: cypress-artifacts - path: | - tmp/cypress_screenshots - cypress/logs - - name: Upload test results to BuildPulse for flaky test detection - if: '!cancelled()' # Run this step even when the tests fail. Skip if the workflow is cancelled. - uses: Workshop64/buildpulse-action@master - with: - account: ${{ secrets.BUILDPULSE_ACCOUNT }} - repository: ${{ secrets.BUILDPULSE_REPOSITORY }} - path: cypress/results - key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }} - secret: ${{ secrets.BUILDPULSE_SECRET_ACCESS_KEY }} - - - CI-status-report: - runs-on: ubuntu-latest - needs: [rspec, jest, cypress, build-test] - if: always() - - steps: - - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} - - - name: Report failure to Slack channel - uses: slackapi/slack-github-action@v1 - with: - payload: | - { "link": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.ref == 'refs/heads/main' - - Deploy: - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - runs-on: ubuntu-latest - needs: [CI-status-report] - concurrency: ${{ matrix.environment }} - environment: ${{ matrix.environment }} - - strategy: - matrix: - environment: [ production, staging ] - - steps: - - uses: actions/checkout@v3 - - uses: akhileshns/heroku-deploy@v3.12.12 - with: - heroku_api_key: ${{ secrets.HEROKU_API_KEY }} - heroku_app_name: ${{ secrets.HEROKU_APP_NAME }} - heroku_email: ${{ secrets.HEROKU_EMAIL }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000..8f0944d87344d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,355 @@ +name: CI + +on: + pull_request: + branches: + - main + merge_group: + branches: + - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + COVERAGE: true + RAILS_ENV: test + NODE_ENV: test + DATABASE_URL_TEST: postgres://postgres:postgres@localhost:5432/Forem_test + DATABASE_NAME_TEST: Forem_test + KNAPSACK_PRO_FIXED_QUEUE_SPLIT: true + POSTGRES_PASSWORD: postgres + KNAPSACK_PRO_LOG_LEVEL: info + YARN_ENABLE_HARDENED_MODE: 0 + +jobs: + build: + runs-on: ubuntu-latest + env: + E2E: true + + steps: + - uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Cache pre-compiled assets + uses: actions/cache@v4 + id: assetscache + with: + path: | + public/assets + key: ${{ runner.os }}-compiled-assets-v3-${{ hashFiles( 'app/assets/**', 'app/javascript/**', '**/package.json', '**/yarn.lock') }} + restore-keys: ${{ runner.os }}-compiled-assets-v3- + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + if: steps.assetscache.outputs.cache-hit != 'true' + - run: yarn install --immutable + if: steps.assetscache.outputs.cache-hit != 'true' + - run: bundle exec rails assets:precompile + if: steps.assetscache.outputs.cache-hit != 'true' + audit: + runs-on: ubuntu-latest + needs: [build] + + steps: + - uses: actions/checkout@v4 + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - run: yarn install --immutable + - name: rubocop + uses: reviewdog/action-rubocop@v2 + with: + rubocop_version: gemfile + rubocop_extensions: rubocop-performance:gemfile rubocop-rails:gemfile rubocop-rspec:gemfile rubocop-capybara:gemfile + reporter: github-pr-review # Default is github-pr-check + - run: yarn lint:frontend + - run: bundle exec bundle-audit check --update --ignore CVE-2023-26141 + + rspec: + runs-on: ubuntu-latest + needs: [build] + timeout-minutes: 20 + env: + KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }} + KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }} + KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC }} + + services: + postgres: + image: postgres:13-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + fail-fast: false + matrix: + ci_node_total: [8] + ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7] + + steps: + - uses: actions/checkout@v4 + - name: Restore compiled assets + uses: actions/cache/restore@v4 + with: + fail-on-cache-miss: true + path: | + public/assets + key: ${{ runner.os }}-compiled-assets-v3-${{ hashFiles('app/assets/**', 'app/javascript/**', '**/package.json', '**/yarn.lock') }} + restore-keys: ${{ runner.os }}-compiled-assets-v3- + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - run: cp .env_sample .env + - run: bundle exec rails db:test:prepare + - name: RSpec + run: bin/knapsack_pro_rspec + - name: Upload RSpec artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: rspec-artifacts-${{ matrix.ci_node_index }} + path: tmp/capybara + - name: Rename folder + run: mv coverage/simplecov coverage/simplecov-${{ matrix.ci_node_index }} + - name: Upload test coverage result + uses: actions/upload-artifact@v4 + with: + name: coverage-rspec-${{ matrix.ci_node_index }} + path: coverage/ + - name: Upload test results to BuildPulse for flaky test detection + if: '!cancelled()' # Run this step even when the tests fail. Skip if the workflow is cancelled. + uses: Workshop64/buildpulse-action@master + with: + account: ${{ secrets.BUILDPULSE_ACCOUNT }} + repository: ${{ secrets.BUILDPULSE_REPOSITORY }} + path: tmp/rspec_final_results.xml + key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }} + secret: ${{ secrets.BUILDPULSE_SECRET_ACCESS_KEY }} + + jest: + runs-on: ubuntu-latest + needs: [build] + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - run: yarn install --immutable + - run: yarn test --colors --ci --reporters="jest-junit" --reporters="default" + - name: Upload test results to BuildPulse for flaky test detection + if: '!cancelled()' # Run this step even when the tests fail. Skip if the workflow is cancelled. + uses: Workshop64/buildpulse-action@master + with: + account: ${{ secrets.BUILDPULSE_ACCOUNT }} + repository: ${{ secrets.BUILDPULSE_REPOSITORY }} + path: junit.xml + key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }} + secret: ${{ secrets.BUILDPULSE_SECRET_ACCESS_KEY }} + - name: Upload test coverage result + uses: actions/upload-artifact@v4 + with: + name: coverage-jest + path: coverage/ + + storybook: + runs-on: ubuntu-latest + needs: [build] + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - run: yarn install --immutable + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - run: yarn build-storybook + + build_test: + runs-on: ubuntu-latest + needs: [build] + timeout-minutes: 20 + env: + RAILS_ENV: production + NODE_ENV: production + DATABASE_URL: postgres://postgres:postgres@localhost:5432/Forem_prod_test + DATABASE_NAME: Forem_prod_test + APP_PROTOCOL: http:// + APP_DOMAIN: localhost:3000 + HEROKU_APP_URL: practicaldev.herokuapp.com + SECRET_KEY_BASE: dummydummydummy + GITHUB_KEY: dummy + GITHUB_SECRET: dummy + YARN_ENABLE_HARDENED_MODE: 1 + + services: + postgres: + image: postgres:13-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - run: yarn install --immutable + - run: bundle exec rails assets:precompile + - run: RAILS_ENV=test bin/test-console-check + + cypress: + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [build] + env: + E2E: true + + services: + postgres: + image: postgres:13-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + fail-fast: false + matrix: + ci_node_total: [8] + ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, non-seed] + + steps: + - uses: actions/checkout@v4 + - name: Restore compiled assets + uses: actions/cache/restore@v4 + with: + fail-on-cache-miss: true + path: | + public/assets + key: ${{ runner.os }}-compiled-assets-v3-${{ hashFiles('app/assets/**', 'app/javascript/**', '**/package.json', '**/yarn.lock') }} + restore-keys: ${{ runner.os }}-compiled-assets-v3- + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - run: yarn install --immutable + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - run: cp .env_sample .env + - run: bundle exec rails db:test:prepare + - run: yarn cypress install + - name: cypress + env: + CYPRESS_RAILS_HOST: localhost + CYPRESS_RAILS_PORT: 3000 + KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }} + KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }} + KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS }} + KNAPSACK_PRO_TEST_FILE_PATTERN: "cypress/e2e/seededFlows/**/*.spec.js" + run: bin/knapsack_pro_cypress + if: ${{ matrix.ci_node_index != 'non-seed' }} + - name: cypress non-seed + run: CREATOR_ONBOARDING_SEED_DATA=1 E2E_FOLDER=creatorOnboardingFlows E2E=true bin/rails cypress:run + if: ${{ matrix.ci_node_index == 'non-seed' }} + - name: Upload Cypress artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-artifacts-${{ matrix.ci_node_index }} + path: | + tmp/cypress_screenshots + cypress/logs + - name: Upload test results to BuildPulse for flaky test detection + if: '!cancelled()' # Run this step even when the tests fail. Skip if the workflow is cancelled. + uses: Workshop64/buildpulse-action@master + with: + account: ${{ secrets.BUILDPULSE_ACCOUNT }} + repository: ${{ secrets.BUILDPULSE_REPOSITORY }} + path: cypress/results + key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }} + secret: ${{ secrets.BUILDPULSE_SECRET_ACCESS_KEY }} + + upload-coverage: + runs-on: ubuntu-latest + needs: [rspec, jest, cypress] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: coverage-* + merge-multiple: true + - run: | + mkdir coverage coverage/simplecov coverage/cypress + mv simplecov-* coverage/simplecov + mv jest coverage/jest + - uses: Wandalen/wretry.action@master + with: + action: codecov/codecov-action@v4 + with: | + flags: ruby + directory: coverage/simplecov + fail_ci_if_error: ${{ github.ref != 'refs/heads/main' }} + token: ${{ secrets.CODECOV_TOKEN }} + attempt_limit: 5 + attempt_delay: 60000 + - uses: Wandalen/wretry.action@master + with: + action: codecov/codecov-action@v4 + with: | + flags: jest, javascript + directory: coverage/jest + fail_ci_if_error: ${{ github.ref != 'refs/heads/main' }} + token: ${{ secrets.CODECOV_TOKEN }} + attempt_limit: 5 + attempt_delay: 60000 + + CI-status-report: + runs-on: ubuntu-latest + needs: [rspec, jest, cypress, build_test, audit, storybook] + if: always() + + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000000000..544306a09b4f0 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,34 @@ +name: "CLA Assistant" +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened,closed,synchronize] + merge_group: + branches: + - main + workflow_call: + +permissions: + actions: write + contents: write + pull-requests: write + statuses: write + +jobs: + CLAAssistant: + runs-on: ubuntu-latest + steps: + - name: "CLA Assistant" + if: ((github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target') && github.repository_owner == 'forem' + uses: contributor-assistant/github-action@v2.3.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PERSONAL_ACCESS_TOKEN }} + with: + path-to-signatures: 'signatures/version1/cla.json' + path-to-document: 'https://github.com/forem/forem/blob/main/CLA.md' + branch: 'main' # branch should not be protected + allowlist: ${{ secrets.CLA_ALLOWLIST }} + remote-repository-name: ${{ secrets.CLA_REPOSITORY }} + lock-pullrequest-aftermerge: false diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml new file mode 100644 index 0000000000000..8985f34c03aa8 --- /dev/null +++ b/.github/workflows/cleanup-cache.yml @@ -0,0 +1,29 @@ +name: cleanup caches by a branch +on: + pull_request: + types: + - closed + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge diff --git a/.github/workflows/create-cache.yml b/.github/workflows/create-cache.yml new file mode 100644 index 0000000000000..b4aaf8c51cf06 --- /dev/null +++ b/.github/workflows/create-cache.yml @@ -0,0 +1,38 @@ +name: Create CI cache + +on: + push: + branches: + - main + +jobs: + build-cache: + runs-on: ubuntu-latest + env: + E2E: true + RAILS_ENV: test + NODE_ENV: test + + steps: + - uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Cache pre-compiled assets + uses: actions/cache@v4 + id: assetscache + with: + path: | + public/assets + key: ${{ runner.os }}-compiled-assets-v3-${{ hashFiles( 'app/assets/**', 'app/javascript/**', '**/package.json', '**/yarn.lock') }} + restore-keys: ${{ runner.os }}-compiled-assets-v3- + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + if: steps.assetscache.outputs.cache-hit != 'true' + - run: yarn install --immutable + if: steps.assetscache.outputs.cache-hit != 'true' + - run: bundle exec rails assets:precompile + if: steps.assetscache.outputs.cache-hit != 'true' diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index a9c1c6a4f7438..1855ebc6c8cc9 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -23,10 +23,10 @@ jobs: body: | Thanks for the issue, we will take it into consideration! Our team of engineers is busy working on many types of features, please give us time to get back to you. - Feature requests that require more discussion may be closed. Read more about our [feature request process](https://forem.dev/foremteam/heads-up-github-discussions-and-feature-requests-54ff) on forem.dev. - To our amazing contributors: [issues labeled `bug`](https://github.com/forem/forem/issues?q=is%3Aissue+is%3Aopen+label%3Abug) are always up for grabs, but for feature requests, please wait until we add a `ready for dev` before starting to work on it. + If this is a feature request from an external contributor (not core team at Forem), please close the issue and re-post via [GitHub Discussions](https://github.com/forem/forem/discussions/categories/feature-requests). + To claim an issue to work on, please leave a comment. If you've claimed the issue and need help, please ping @forem-team. The OSS Community Manager or the engineers on OSS rotation will follow up. For full info on how to contribute, please check out our [contributors guide](https://developers.forem.com/contributing-guide/forem). diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml deleted file mode 100644 index 06c8bb89ddf03..0000000000000 --- a/.github/workflows/rubocop.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Rubocop -on: [pull_request] -jobs: - rubocop: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v3 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.0.2 - - name: rubocop - uses: reviewdog/action-rubocop@v2 - with: - rubocop_version: gemfile - rubocop_extensions: rubocop-performance:gemfile rubocop-rails:gemfile rubocop-rspec:gemfile - reporter: github-pr-review # Default is github-pr-check diff --git a/.github/workflows/uffizzi-build.yml b/.github/workflows/uffizzi-build.yml index e6589430ca28c..75bacf18c8873 100644 --- a/.github/workflows/uffizzi-build.yml +++ b/.github/workflows/uffizzi-build.yml @@ -17,17 +17,17 @@ jobs: uses: docker/setup-buildx-action@v2 - name: Generate UUID image name id: uuid - run: echo "UUID_TAG_APP=$(uuidgen)" >> $GITHUB_ENV + run: echo "UUID_TAG_APP=forem-$(uuidgen --time)" >> $GITHUB_ENV - name: Docker metadata id: meta uses: docker/metadata-action@v4 with: images: registry.uffizzi.com/${{ env.UUID_TAG_APP }} - tags: type=raw,value=60d + tags: type=raw,value=30d - uses: actions/checkout@master - name: Create the .env file run: | - cp .env.test .env + cp .env_sample .env - name: Build and Push Image to registry.uffizzi.com ephemeral registry uses: docker/build-push-action@v2 with: @@ -35,9 +35,10 @@ jobs: context: . tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - file: ./uffizzi/Dockerfile + file: ./Dockerfile + target: uffizzi cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=min render-compose-file: name: Render Docker Compose File diff --git a/.github/workflows/uffizzi-preview.yml b/.github/workflows/uffizzi-preview.yml index 88c5c96cdc94d..3c47712492adb 100644 --- a/.github/workflows/uffizzi-preview.yml +++ b/.github/workflows/uffizzi-preview.yml @@ -13,8 +13,10 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} outputs: - compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }} - pr-number: ${{ env.PR_NUMBER }} + compose-file-cache-key: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }} + git-ref: ${{ steps.event.outputs.GIT_REF }} + pr-number: ${{ steps.event.outputs.PR_NUMBER }} + action: ${{ steps.event.outputs.ACTION }} steps: - name: 'Download artifacts' # Fetch output (zip archive) from the workflow run that triggered this workflow. @@ -40,34 +42,39 @@ jobs: }); let fs = require('fs'); fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data)); - - name: 'Unzip artifact' - run: unzip preview-spec.zip + + - name: 'Accept event from first stage' + run: unzip preview-spec.zip event.json + - name: Read Event into ENV + id: event run: | - echo 'EVENT_JSON<> $GITHUB_ENV - cat event.json >> $GITHUB_ENV - echo -e '\nEOF' >> $GITHUB_ENV + echo PR_NUMBER=$(jq '.number | tonumber' < event.json) >> $GITHUB_OUTPUT + echo ACTION=$(jq --raw-output '.action | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT + echo GIT_REF=$(jq --raw-output '.pull_request.head.sha | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT + - name: Hash Rendered Compose File id: hash # If the previous workflow was triggered by a PR close event, we will not have a compose file artifact. - if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }} - run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV + if: ${{ steps.event.outputs.ACTION != 'closed' }} + run: | + unzip preview-spec.zip docker-compose.rendered.yml + echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_OUTPUT + - name: Cache Rendered Compose File - if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }} + if: ${{ steps.event.outputs.ACTION != 'closed' }} uses: actions/cache@v3 with: path: docker-compose.rendered.yml - key: ${{ env.COMPOSE_FILE_HASH }} - - - name: Read PR Number From Event Object - id: pr - run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV + key: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }} - name: DEBUG - Print Job Outputs if: ${{ runner.debug }} run: | - echo "PR number: ${{ env.PR_NUMBER }}" - echo "Compose file hash: ${{ env.COMPOSE_FILE_HASH }}" + echo "PR number: ${{ steps.event.outputs.PR_NUMBER }}" + echo "Git Ref: ${{ steps.event.outputs.GIT_REF }}" + echo "Action: ${{ steps.event.outputs.ACTION }}" + echo "Compose file hash: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }}" cat event.json deploy-uffizzi-preview: diff --git a/.gitignore b/.gitignore index 9208a15c95799..b2172861a2b89 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ vendor/bundle .approvals .torus.json coverage +.nyc_output /tags # Ignore public uploads @@ -49,6 +50,16 @@ yarn-debug.log* app/javascript/storybook-static/ yarn-error.log +# Ignore yarn related files +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +.yarn/install-state.gz + # Ignore package-lock.json because we use yarn package-lock.json @@ -113,3 +124,6 @@ doc/ # spec reports /spec/reports /junit.xml + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile deleted file mode 100644 index 12595d9bdb92b..0000000000000 --- a/.gitpod.dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM gitpod/workspace-postgres - -# Install Ruby -ENV RUBY_VERSION=3.0.2 - -# Install the GitHub CLI -RUN brew install gh - -# Taken from https://www.gitpod.io/docs/languages/ruby -RUN echo "rvm_gems_path=/home/gitpod/.rvm" > ~/.rvmrc -RUN bash -lc "rvm install ruby-$RUBY_VERSION && rvm use ruby-$RUBY_VERSION --default" -RUN echo "rvm_gems_path=/workspace/.rvm" > ~/.rvmrc - -# Install Node and Yarn -ENV NODE_VERSION=16.13.1 -RUN bash -c ". .nvm/nvm.sh && \ - nvm install ${NODE_VERSION} && \ - nvm alias default ${NODE_VERSION} && \ - npm install -g yarn" -ENV PATH=/home/gitpod/.nvm/versions/node/v${NODE_VERSION}/bin:$PATH - -# Install Redis. -RUN sudo apt-get update \ - && sudo apt-get install -y \ - redis-server \ - && sudo rm -rf /var/lib/apt/lists/* diff --git a/.gitpod.yml b/.gitpod.yml index a3907df4a24aa..0c080ad4484db 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -1,6 +1,3 @@ -image: - file: .gitpod.dockerfile - ports: - port: 3000 onOpen: ignore @@ -12,25 +9,29 @@ ports: onOpen: ignore tasks: - - name: Forem Server - before: > - redis-server & - gp ports await 5432 && - sleep 1 - init: ./gitpod-init.sh - command: bin/startup - name: Open Site command: > gp ports await 3000 && printf "Waiting for local Forem development environment to load in the browser..." && - gp preview $(gp url 3000) + gp preview $(gp url 3000) && + exit + - name: Forem Server + before: | + cp .env_sample .env + echo "APP_DOMAIN=\"$(gp url 3000 | cut -f 3 -d /)\"" >> .env + echo 'APP_PROTOCOL="https://"' >> .env + echo 'COMMUNITY_NAME="DEV(Gitpod)"' >> .env + gem install dip + gem install solargraph + init: dip provision + command: dip up web github: prebuilds: # enable for the master/default branch (defaults to true) master: true # enable for all branches in this repo (defaults to false) - branches: true + branches: false # enable for pull requests coming from this repo (defaults to true) pullRequests: true # enable for pull requests coming from forks (defaults to false) diff --git a/.husky/pre-commit b/.husky/pre-commit index 2d6927b6fe16d..5a182ef106df1 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,4 @@ -#!/bin/sh -FILES=$(git diff --cached --name-only) -[ -z "$FILES" ] && exit 0 +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" -# Run lint-staged on all staged -echo "$FILES" | yarn lint-staged +yarn lint-staged diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 16a0d224c66aa..08ddd5c0c2983 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -2,9 +2,7 @@ module.exports = { '*.json': ['prettier --write'], '*.md': ['prettier --write --prose-wrap always'], - '*.rake': [ - 'bundle exec rubocop --require rubocop-rspec --autocorrect --enable-pending-cops', - ], + '*.rake': ['bundle exec rubocop --autocorrect'], '*.scss': ['prettier --write'], '*.svg': ['svgo --pretty'], '*.{js,jsx}': [ @@ -12,22 +10,14 @@ module.exports = { 'eslint --fix', 'jest --findRelatedTests --passWithNoTests', ], - './Gemfile': [ - 'bundle exec rubocop --require rubocop-rspec --autocorrect --enable-pending-cops', - ], + './Gemfile': ['bundle exec rubocop --autocorrect'], 'app/**/*.html.erb': ['bundle exec erblint --autocorrect'], 'app/assets/config/manifest.js': [ 'prettier --write', 'eslint --fix -c app/assets/javascripts/.eslintrc.js', ], - 'app/views/**/*.jbuilder': [ - 'bundle exec rubocop --require rubocop-rspec --autocorrect --enable-pending-cops', - ], + 'app/views/**/*.jbuilder': ['bundle exec rubocop --autocorrect'], // 'config/locales/*': () => 'bundle exec i18n-tasks normalize', - 'scripts/{release,stage_release}': [ - 'bundle exec rubocop --require rubocop-rspec --autocorrect --enable-pending-cops', - ], - '{app,spec,config,lib}/**/*.rb': [ - 'bundle exec rubocop --require rubocop-rspec --autocorrect --enable-pending-cops', - ], + 'scripts/{release,stage_release}': ['bundle exec rubocop --autocorrect'], + '{app,spec,config,lib}/**/*.rb': ['bundle exec rubocop --autocorrect'], }; diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000000000..236067d0f4db5 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,4 @@ +[tools] +yarn = "1" +postgres = "13.12" +redis = "6.0.9" diff --git a/.nvmrc b/.nvmrc index b6a7d89c68e0c..209e3ef4b6247 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16 +20 diff --git a/.rubocop.yml b/.rubocop.yml index d8d8dd9070418..21aec96660251 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,7 @@ require: - rubocop-performance - rubocop-rails - rubocop-rspec + - rubocop-capybara AllCops: Exclude: @@ -224,6 +225,7 @@ Lint/MissingSuper: Enabled: true Exclude: - 'app/components/**/*.rb' + - 'app/policies/**/*.rb' Lint/MixedRegexpCaptureTypes: Description: 'Do not mix named captures and numbered captures in a Regexp literal.' @@ -925,9 +927,11 @@ RSpec/DescribeClass: Enabled: true StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/DescribeClass Exclude: - - 'spec/requests/**/*' - - 'spec/system/**/*' - - 'spec/tasks/**/*' + - '**/spec/features/**/*' + - '**/spec/requests/**/*' + - '**/spec/routing/**/*' + - '**/spec/system/**/*' + - '**/spec/views/**/*' RSpec/DescribedClassModuleWrapping: Description: Avoid opening modules and defining specs within them. @@ -936,9 +940,7 @@ RSpec/DescribedClassModuleWrapping: RSpec/ExampleLength: Description: Checks for long examples. - Enabled: true - Max: 15 - StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/ExampleLength + Enabled: false RSpec/IdenticalEqualityAssertion: Description: Checks for equality assertions with identical expressions on both sides. @@ -961,7 +963,7 @@ RSpec/MultipleExpectations: Max: 8 StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/MultipleExpectations -RSpec/Rails/AvoidSetupHook: +RSpecRails/AvoidSetupHook: Description: Checks that tests use RSpec `before` hook over Rails `setup` method. Enabled: true StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Rails/AvoidSetupHook @@ -972,3 +974,6 @@ RSpec/NoExpectationExample: - ^expect_ - ^assert_ - sidekiq_assert_ + +RSpec/IndexedLet: + Enabled: false diff --git a/.ruby-version b/.ruby-version index b50214693056f..15a2799817207 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.2 +3.3.0 diff --git a/.ruby-version-next b/.ruby-version-next new file mode 100644 index 0000000000000..15a2799817207 --- /dev/null +++ b/.ruby-version-next @@ -0,0 +1 @@ +3.3.0 diff --git a/.simplecov b/.simplecov index 1444be5d7ef4d..3c3751e2bfb66 100644 --- a/.simplecov +++ b/.simplecov @@ -1,4 +1,5 @@ unless ENV["COVERAGE"] == "false" + SimpleCov.coverage_dir("coverage/simplecov") SimpleCov.start "rails" do add_filter "/spec/" add_filter "/app/controllers/admin/" diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 0dadc54ed2b3d..d18bf2f2dce65 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,13 +1,18 @@ { + // List of extensions which should be recommended for users of this workspace. + // Suggestions are welcome! "recommendations": [ - "nickytonline.dev-to-extension-pack", - "waderyan.nodejs-extension-pack", - "esbenp.prettier-vscode", + "42crunch.vscode-openapi", "donjayamanne.git-extension-pack", - "rebornix.ruby", + "esbenp.prettier-vscode", + "firefox-devtools.vscode-firefox-d", "ms-vsliveshare.vsliveshare-pack", - "42crunch.vscode-openapi", "philosowaffle.openapi-designer", - "silvenon.mdx" + "sbenp.prettier-vscode", + "unifiedjs.vscode-mdx", + "waderyan.nodejs-extension-pack", + "koichisasada.vscode-rdbg", + "ms-edgedevtools.vscode-edge-devtools", + "shopify.ruby-lsp", ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 6b648590d2f24..7d68ba3b0be56 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,70 +12,40 @@ "type": "node" }, { - "type": "edge", - "request": "attach", "name": "Attach to Edge", + "type": "vscode-edge-devtools.debug", + "request": "attach", "port": 2015, "webRoot": "${workspaceFolder}" }, { + "name": "Attach to Chrome", "type": "chrome", "request": "attach", - "name": "Attach to Chrome", "port": 9222, "webRoot": "${workspaceFolder}/app/javascript/packs" }, { - "name": "Debug Local File", - "type": "Ruby", - "request": "launch", - "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/main.rb" - }, - { - "name": "Listen for rdebug-ide", - "type": "Ruby", - "request": "attach", - "cwd": "${workspaceRoot}", - "remoteHost": "127.0.0.1", - "remotePort": "1234", - "remoteWorkspaceRoot": "${workspaceRoot}" - }, - { - "name": "Rails server", - "type": "Ruby", - "request": "launch", - "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/rails", - "args": ["server"] + "name": "Attach to Existing Server", + "type": "ruby_lsp", + "request": "attach" }, { "name": "RSpec - all", - "type": "Ruby", + "type": "ruby_lsp", "request": "launch", - "cwd": "${workspaceRoot}", "program": "${workspaceRoot}/bin/rspec", - "args": ["-I", "${workspaceRoot}"] }, { "name": "RSpec - active spec file only", - "type": "Ruby", - "request": "launch", - "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/rspec", - "args": ["-I", "${workspaceRoot}", "${file}"] - }, - { - "name": "Cucumber", - "type": "Ruby", + "type": "ruby_lsp", "request": "launch", - "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/cucumber" + "program": "${workspaceRoot}/bin/rspec ${file}", }, { + "name": "Jest Current File", "type": "node", "request": "launch", - "name": "Jest Current File", "program": "${workspaceFolder}/node_modules/.bin/jest", "args": ["${relativeFile}"], "console": "integratedTerminal", diff --git a/.vscode/settings.json b/.vscode/settings.json index 7de1b8db04feb..106e35dbd0c0e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,18 @@ { - "ruby.useBundler": true, - "ruby.useLanguageServer": true, - "ruby.lint": { - "rubocop": { - "useBundler": true - } + "rubyLsp.rubyVersionManager": { + "identifier": "auto" }, - "ruby.format": "rubocop", - "ruby.intellisense": "rubyLocate" + "rubyLsp.formatter": "rubocop", + "files.watcherExclude": { + "**/tmp/**": true, + "**/log/**": true, + "**/node_modules/**": true, + "**/public/packs/**": true + }, + "search.exclude": { + "**/tmp/**": true, + "**/log/**": true, + "**/node_modules/**": true, + "**/public/packs/**": true + } } diff --git a/.yarn/releases/yarn-1.22.18.js b/.yarn/releases/yarn-1.22.18.js deleted file mode 100644 index 48d33d03fe0db..0000000000000 --- a/.yarn/releases/yarn-1.22.18.js +++ /dev/null @@ -1,147520 +0,0 @@ -#!/usr/bin/env node -module.exports = -/******/ (function(modules) { // webpackBootstrap -/******/ // The module cache -/******/ var installedModules = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ -/******/ // Check if module is in cache -/******/ if(installedModules[moduleId]) { -/******/ return installedModules[moduleId].exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = installedModules[moduleId] = { -/******/ i: moduleId, -/******/ l: false, -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ -/******/ // Flag the module as loaded -/******/ module.l = true; -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/******/ -/******/ // expose the modules object (__webpack_modules__) -/******/ __webpack_require__.m = modules; -/******/ -/******/ // expose the module cache -/******/ __webpack_require__.c = installedModules; -/******/ -/******/ // identity function for calling harmony imports with the correct context -/******/ __webpack_require__.i = function(value) { return value; }; -/******/ -/******/ // define getter function for harmony exports -/******/ __webpack_require__.d = function(exports, name, getter) { -/******/ if(!__webpack_require__.o(exports, name)) { -/******/ Object.defineProperty(exports, name, { -/******/ configurable: false, -/******/ enumerable: true, -/******/ get: getter -/******/ }); -/******/ } -/******/ }; -/******/ -/******/ // getDefaultExport function for compatibility with non-harmony modules -/******/ __webpack_require__.n = function(module) { -/******/ var getter = module && module.__esModule ? -/******/ function getDefault() { return module['default']; } : -/******/ function getModuleExports() { return module; }; -/******/ __webpack_require__.d(getter, 'a', getter); -/******/ return getter; -/******/ }; -/******/ -/******/ // Object.prototype.hasOwnProperty.call -/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; -/******/ -/******/ // __webpack_public_path__ -/******/ __webpack_require__.p = ""; -/******/ -/******/ // Load entry module and return exports -/******/ return __webpack_require__(__webpack_require__.s = 517); -/******/ }) -/************************************************************************/ -/******/ ([ -/* 0 */ -/***/ (function(module, exports) { - -module.exports = require("path"); - -/***/ }), -/* 1 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -/* harmony export (immutable) */ __webpack_exports__["a"] = __extends; -/* unused harmony export __assign */ -/* unused harmony export __rest */ -/* unused harmony export __decorate */ -/* unused harmony export __param */ -/* unused harmony export __metadata */ -/* unused harmony export __awaiter */ -/* unused harmony export __generator */ -/* unused harmony export __exportStar */ -/* unused harmony export __values */ -/* unused harmony export __read */ -/* unused harmony export __spread */ -/* unused harmony export __await */ -/* unused harmony export __asyncGenerator */ -/* unused harmony export __asyncDelegator */ -/* unused harmony export __asyncValues */ -/* unused harmony export __makeTemplateObject */ -/* unused harmony export __importStar */ -/* unused harmony export __importDefault */ -/*! ***************************************************************************** -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the Apache License, Version 2.0 (the "License"); you may not use -this file except in compliance with the License. You may obtain a copy of the -License at http://www.apache.org/licenses/LICENSE-2.0 - -THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED -WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, -MERCHANTABLITY OR NON-INFRINGEMENT. - -See the Apache Version 2.0 License for specific language governing permissions -and limitations under the License. -***************************************************************************** */ -/* global Reflect, Promise */ - -var extendStatics = function(d, b) { - extendStatics = Object.setPrototypeOf || - ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || - function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; - return extendStatics(d, b); -}; - -function __extends(d, b) { - extendStatics(d, b); - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); -} - -var __assign = function() { - __assign = Object.assign || function __assign(t) { - for (var s, i = 1, n = arguments.length; i < n; i++) { - s = arguments[i]; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; - } - return t; - } - return __assign.apply(this, arguments); -} - -function __rest(s, e) { - var t = {}; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) - t[p] = s[p]; - if (s != null && typeof Object.getOwnPropertySymbols === "function") - for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) if (e.indexOf(p[i]) < 0) - t[p[i]] = s[p[i]]; - return t; -} - -function __decorate(decorators, target, key, desc) { - var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; - if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); - else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; - return c > 3 && r && Object.defineProperty(target, key, r), r; -} - -function __param(paramIndex, decorator) { - return function (target, key) { decorator(target, key, paramIndex); } -} - -function __metadata(metadataKey, metadataValue) { - if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(metadataKey, metadataValue); -} - -function __awaiter(thisArg, _arguments, P, generator) { - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -} - -function __generator(thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; - return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (_) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -} - -function __exportStar(m, exports) { - for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; -} - -function __values(o) { - var m = typeof Symbol === "function" && o[Symbol.iterator], i = 0; - if (m) return m.call(o); - return { - next: function () { - if (o && i >= o.length) o = void 0; - return { value: o && o[i++], done: !o }; - } - }; -} - -function __read(o, n) { - var m = typeof Symbol === "function" && o[Symbol.iterator]; - if (!m) return o; - var i = m.call(o), r, ar = [], e; - try { - while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value); - } - catch (error) { e = { error: error }; } - finally { - try { - if (r && !r.done && (m = i["return"])) m.call(i); - } - finally { if (e) throw e.error; } - } - return ar; -} - -function __spread() { - for (var ar = [], i = 0; i < arguments.length; i++) - ar = ar.concat(__read(arguments[i])); - return ar; -} - -function __await(v) { - return this instanceof __await ? (this.v = v, this) : new __await(v); -} - -function __asyncGenerator(thisArg, _arguments, generator) { - if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); - var g = generator.apply(thisArg, _arguments || []), i, q = []; - return i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i; - function verb(n) { if (g[n]) i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; } - function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } } - function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); } - function fulfill(value) { resume("next", value); } - function reject(value) { resume("throw", value); } - function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); } -} - -function __asyncDelegator(o) { - var i, p; - return i = {}, verb("next"), verb("throw", function (e) { throw e; }), verb("return"), i[Symbol.iterator] = function () { return this; }, i; - function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: n === "return" } : f ? f(v) : v; } : f; } -} - -function __asyncValues(o) { - if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); - var m = o[Symbol.asyncIterator], i; - return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i); - function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; } - function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); } -} - -function __makeTemplateObject(cooked, raw) { - if (Object.defineProperty) { Object.defineProperty(cooked, "raw", { value: raw }); } else { cooked.raw = raw; } - return cooked; -}; - -function __importStar(mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; - result.default = mod; - return result; -} - -function __importDefault(mod) { - return (mod && mod.__esModule) ? mod : { default: mod }; -} - - -/***/ }), -/* 2 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -exports.__esModule = true; - -var _promise = __webpack_require__(224); - -var _promise2 = _interopRequireDefault(_promise); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -exports.default = function (fn) { - return function () { - var gen = fn.apply(this, arguments); - return new _promise2.default(function (resolve, reject) { - function step(key, arg) { - try { - var info = gen[key](arg); - var value = info.value; - } catch (error) { - reject(error); - return; - } - - if (info.done) { - resolve(value); - } else { - return _promise2.default.resolve(value).then(function (value) { - step("next", value); - }, function (err) { - step("throw", err); - }); - } - } - - return step("next"); - }); - }; -}; - -/***/ }), -/* 3 */ -/***/ (function(module, exports) { - -module.exports = require("util"); - -/***/ }), -/* 4 */ -/***/ (function(module, exports) { - -module.exports = require("fs"); - -/***/ }), -/* 5 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.getFirstSuitableFolder = exports.readFirstAvailableStream = exports.makeTempDir = exports.hardlinksWork = exports.writeFilePreservingEol = exports.getFileSizeOnDisk = exports.walk = exports.symlink = exports.find = exports.readJsonAndFile = exports.readJson = exports.readFileAny = exports.hardlinkBulk = exports.copyBulk = exports.unlink = exports.glob = exports.link = exports.chmod = exports.lstat = exports.exists = exports.mkdirp = exports.stat = exports.access = exports.rename = exports.readdir = exports.realpath = exports.readlink = exports.writeFile = exports.open = exports.readFileBuffer = exports.lockQueue = exports.constants = undefined; - -var _asyncToGenerator2; - -function _load_asyncToGenerator() { - return _asyncToGenerator2 = _interopRequireDefault(__webpack_require__(2)); -} - -let buildActionsForCopy = (() => { - var _ref = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (queue, events, possibleExtraneous, reporter) { - - // - let build = (() => { - var _ref5 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (data) { - const src = data.src, - dest = data.dest, - type = data.type; - - const onFresh = data.onFresh || noop; - const onDone = data.onDone || noop; - - // TODO https://github.com/yarnpkg/yarn/issues/3751 - // related to bundled dependencies handling - if (files.has(dest.toLowerCase())) { - reporter.verbose(`The case-insensitive file ${dest} shouldn't be copied twice in one bulk copy`); - } else { - files.add(dest.toLowerCase()); - } - - if (type === 'symlink') { - yield mkdirp((_path || _load_path()).default.dirname(dest)); - onFresh(); - actions.symlink.push({ - dest, - linkname: src - }); - onDone(); - return; - } - - if (events.ignoreBasenames.indexOf((_path || _load_path()).default.basename(src)) >= 0) { - // ignored file - return; - } - - const srcStat = yield lstat(src); - let srcFiles; - - if (srcStat.isDirectory()) { - srcFiles = yield readdir(src); - } - - let destStat; - try { - // try accessing the destination - destStat = yield lstat(dest); - } catch (e) { - // proceed if destination doesn't exist, otherwise error - if (e.code !== 'ENOENT') { - throw e; - } - } - - // if destination exists - if (destStat) { - const bothSymlinks = srcStat.isSymbolicLink() && destStat.isSymbolicLink(); - const bothFolders = srcStat.isDirectory() && destStat.isDirectory(); - const bothFiles = srcStat.isFile() && destStat.isFile(); - - // EINVAL access errors sometimes happen which shouldn't because node shouldn't be giving - // us modes that aren't valid. investigate this, it's generally safe to proceed. - - /* if (srcStat.mode !== destStat.mode) { - try { - await access(dest, srcStat.mode); - } catch (err) {} - } */ - - if (bothFiles && artifactFiles.has(dest)) { - // this file gets changed during build, likely by a custom install script. Don't bother checking it. - onDone(); - reporter.verbose(reporter.lang('verboseFileSkipArtifact', src)); - return; - } - - if (bothFiles && srcStat.size === destStat.size && (0, (_fsNormalized || _load_fsNormalized()).fileDatesEqual)(srcStat.mtime, destStat.mtime)) { - // we can safely assume this is the same file - onDone(); - reporter.verbose(reporter.lang('verboseFileSkip', src, dest, srcStat.size, +srcStat.mtime)); - return; - } - - if (bothSymlinks) { - const srcReallink = yield readlink(src); - if (srcReallink === (yield readlink(dest))) { - // if both symlinks are the same then we can continue on - onDone(); - reporter.verbose(reporter.lang('verboseFileSkipSymlink', src, dest, srcReallink)); - return; - } - } - - if (bothFolders) { - // mark files that aren't in this folder as possibly extraneous - const destFiles = yield readdir(dest); - invariant(srcFiles, 'src files not initialised'); - - for (var _iterator4 = destFiles, _isArray4 = Array.isArray(_iterator4), _i4 = 0, _iterator4 = _isArray4 ? _iterator4 : _iterator4[Symbol.iterator]();;) { - var _ref6; - - if (_isArray4) { - if (_i4 >= _iterator4.length) break; - _ref6 = _iterator4[_i4++]; - } else { - _i4 = _iterator4.next(); - if (_i4.done) break; - _ref6 = _i4.value; - } - - const file = _ref6; - - if (srcFiles.indexOf(file) < 0) { - const loc = (_path || _load_path()).default.join(dest, file); - possibleExtraneous.add(loc); - - if ((yield lstat(loc)).isDirectory()) { - for (var _iterator5 = yield readdir(loc), _isArray5 = Array.isArray(_iterator5), _i5 = 0, _iterator5 = _isArray5 ? _iterator5 : _iterator5[Symbol.iterator]();;) { - var _ref7; - - if (_isArray5) { - if (_i5 >= _iterator5.length) break; - _ref7 = _iterator5[_i5++]; - } else { - _i5 = _iterator5.next(); - if (_i5.done) break; - _ref7 = _i5.value; - } - - const file = _ref7; - - possibleExtraneous.add((_path || _load_path()).default.join(loc, file)); - } - } - } - } - } - } - - if (destStat && destStat.isSymbolicLink()) { - yield (0, (_fsNormalized || _load_fsNormalized()).unlink)(dest); - destStat = null; - } - - if (srcStat.isSymbolicLink()) { - onFresh(); - const linkname = yield readlink(src); - actions.symlink.push({ - dest, - linkname - }); - onDone(); - } else if (srcStat.isDirectory()) { - if (!destStat) { - reporter.verbose(reporter.lang('verboseFileFolder', dest)); - yield mkdirp(dest); - } - - const destParts = dest.split((_path || _load_path()).default.sep); - while (destParts.length) { - files.add(destParts.join((_path || _load_path()).default.sep).toLowerCase()); - destParts.pop(); - } - - // push all files to queue - invariant(srcFiles, 'src files not initialised'); - let remaining = srcFiles.length; - if (!remaining) { - onDone(); - } - for (var _iterator6 = srcFiles, _isArray6 = Array.isArray(_iterator6), _i6 = 0, _iterator6 = _isArray6 ? _iterator6 : _iterator6[Symbol.iterator]();;) { - var _ref8; - - if (_isArray6) { - if (_i6 >= _iterator6.length) break; - _ref8 = _iterator6[_i6++]; - } else { - _i6 = _iterator6.next(); - if (_i6.done) break; - _ref8 = _i6.value; - } - - const file = _ref8; - - queue.push({ - dest: (_path || _load_path()).default.join(dest, file), - onFresh, - onDone: function (_onDone) { - function onDone() { - return _onDone.apply(this, arguments); - } - - onDone.toString = function () { - return _onDone.toString(); - }; - - return onDone; - }(function () { - if (--remaining === 0) { - onDone(); - } - }), - src: (_path || _load_path()).default.join(src, file) - }); - } - } else if (srcStat.isFile()) { - onFresh(); - actions.file.push({ - src, - dest, - atime: srcStat.atime, - mtime: srcStat.mtime, - mode: srcStat.mode - }); - onDone(); - } else { - throw new Error(`unsure how to copy this: ${src}`); - } - }); - - return function build(_x5) { - return _ref5.apply(this, arguments); - }; - })(); - - const artifactFiles = new Set(events.artifactFiles || []); - const files = new Set(); - - // initialise events - for (var _iterator = queue, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();;) { - var _ref2; - - if (_isArray) { - if (_i >= _iterator.length) break; - _ref2 = _iterator[_i++]; - } else { - _i = _iterator.next(); - if (_i.done) break; - _ref2 = _i.value; - } - - const item = _ref2; - - const onDone = item.onDone; - item.onDone = function () { - events.onProgress(item.dest); - if (onDone) { - onDone(); - } - }; - } - events.onStart(queue.length); - - // start building actions - const actions = { - file: [], - symlink: [], - link: [] - }; - - // custom concurrency logic as we're always executing stacks of CONCURRENT_QUEUE_ITEMS queue items - // at a time due to the requirement to push items onto the queue - while (queue.length) { - const items = queue.splice(0, CONCURRENT_QUEUE_ITEMS); - yield Promise.all(items.map(build)); - } - - // simulate the existence of some files to prevent considering them extraneous - for (var _iterator2 = artifactFiles, _isArray2 = Array.isArray(_iterator2), _i2 = 0, _iterator2 = _isArray2 ? _iterator2 : _iterator2[Symbol.iterator]();;) { - var _ref3; - - if (_isArray2) { - if (_i2 >= _iterator2.length) break; - _ref3 = _iterator2[_i2++]; - } else { - _i2 = _iterator2.next(); - if (_i2.done) break; - _ref3 = _i2.value; - } - - const file = _ref3; - - if (possibleExtraneous.has(file)) { - reporter.verbose(reporter.lang('verboseFilePhantomExtraneous', file)); - possibleExtraneous.delete(file); - } - } - - for (var _iterator3 = possibleExtraneous, _isArray3 = Array.isArray(_iterator3), _i3 = 0, _iterator3 = _isArray3 ? _iterator3 : _iterator3[Symbol.iterator]();;) { - var _ref4; - - if (_isArray3) { - if (_i3 >= _iterator3.length) break; - _ref4 = _iterator3[_i3++]; - } else { - _i3 = _iterator3.next(); - if (_i3.done) break; - _ref4 = _i3.value; - } - - const loc = _ref4; - - if (files.has(loc.toLowerCase())) { - possibleExtraneous.delete(loc); - } - } - - return actions; - }); - - return function buildActionsForCopy(_x, _x2, _x3, _x4) { - return _ref.apply(this, arguments); - }; -})(); - -let buildActionsForHardlink = (() => { - var _ref9 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (queue, events, possibleExtraneous, reporter) { - - // - let build = (() => { - var _ref13 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (data) { - const src = data.src, - dest = data.dest; - - const onFresh = data.onFresh || noop; - const onDone = data.onDone || noop; - if (files.has(dest.toLowerCase())) { - // Fixes issue https://github.com/yarnpkg/yarn/issues/2734 - // When bulk hardlinking we have A -> B structure that we want to hardlink to A1 -> B1, - // package-linker passes that modules A1 and B1 need to be hardlinked, - // the recursive linking algorithm of A1 ends up scheduling files in B1 to be linked twice which will case - // an exception. - onDone(); - return; - } - files.add(dest.toLowerCase()); - - if (events.ignoreBasenames.indexOf((_path || _load_path()).default.basename(src)) >= 0) { - // ignored file - return; - } - - const srcStat = yield lstat(src); - let srcFiles; - - if (srcStat.isDirectory()) { - srcFiles = yield readdir(src); - } - - const destExists = yield exists(dest); - if (destExists) { - const destStat = yield lstat(dest); - - const bothSymlinks = srcStat.isSymbolicLink() && destStat.isSymbolicLink(); - const bothFolders = srcStat.isDirectory() && destStat.isDirectory(); - const bothFiles = srcStat.isFile() && destStat.isFile(); - - if (srcStat.mode !== destStat.mode) { - try { - yield access(dest, srcStat.mode); - } catch (err) { - // EINVAL access errors sometimes happen which shouldn't because node shouldn't be giving - // us modes that aren't valid. investigate this, it's generally safe to proceed. - reporter.verbose(err); - } - } - - if (bothFiles && artifactFiles.has(dest)) { - // this file gets changed during build, likely by a custom install script. Don't bother checking it. - onDone(); - reporter.verbose(reporter.lang('verboseFileSkipArtifact', src)); - return; - } - - // correct hardlink - if (bothFiles && srcStat.ino !== null && srcStat.ino === destStat.ino) { - onDone(); - reporter.verbose(reporter.lang('verboseFileSkip', src, dest, srcStat.ino)); - return; - } - - if (bothSymlinks) { - const srcReallink = yield readlink(src); - if (srcReallink === (yield readlink(dest))) { - // if both symlinks are the same then we can continue on - onDone(); - reporter.verbose(reporter.lang('verboseFileSkipSymlink', src, dest, srcReallink)); - return; - } - } - - if (bothFolders) { - // mark files that aren't in this folder as possibly extraneous - const destFiles = yield readdir(dest); - invariant(srcFiles, 'src files not initialised'); - - for (var _iterator10 = destFiles, _isArray10 = Array.isArray(_iterator10), _i10 = 0, _iterator10 = _isArray10 ? _iterator10 : _iterator10[Symbol.iterator]();;) { - var _ref14; - - if (_isArray10) { - if (_i10 >= _iterator10.length) break; - _ref14 = _iterator10[_i10++]; - } else { - _i10 = _iterator10.next(); - if (_i10.done) break; - _ref14 = _i10.value; - } - - const file = _ref14; - - if (srcFiles.indexOf(file) < 0) { - const loc = (_path || _load_path()).default.join(dest, file); - possibleExtraneous.add(loc); - - if ((yield lstat(loc)).isDirectory()) { - for (var _iterator11 = yield readdir(loc), _isArray11 = Array.isArray(_iterator11), _i11 = 0, _iterator11 = _isArray11 ? _iterator11 : _iterator11[Symbol.iterator]();;) { - var _ref15; - - if (_isArray11) { - if (_i11 >= _iterator11.length) break; - _ref15 = _iterator11[_i11++]; - } else { - _i11 = _iterator11.next(); - if (_i11.done) break; - _ref15 = _i11.value; - } - - const file = _ref15; - - possibleExtraneous.add((_path || _load_path()).default.join(loc, file)); - } - } - } - } - } - } - - if (srcStat.isSymbolicLink()) { - onFresh(); - const linkname = yield readlink(src); - actions.symlink.push({ - dest, - linkname - }); - onDone(); - } else if (srcStat.isDirectory()) { - reporter.verbose(reporter.lang('verboseFileFolder', dest)); - yield mkdirp(dest); - - const destParts = dest.split((_path || _load_path()).default.sep); - while (destParts.length) { - files.add(destParts.join((_path || _load_path()).default.sep).toLowerCase()); - destParts.pop(); - } - - // push all files to queue - invariant(srcFiles, 'src files not initialised'); - let remaining = srcFiles.length; - if (!remaining) { - onDone(); - } - for (var _iterator12 = srcFiles, _isArray12 = Array.isArray(_iterator12), _i12 = 0, _iterator12 = _isArray12 ? _iterator12 : _iterator12[Symbol.iterator]();;) { - var _ref16; - - if (_isArray12) { - if (_i12 >= _iterator12.length) break; - _ref16 = _iterator12[_i12++]; - } else { - _i12 = _iterator12.next(); - if (_i12.done) break; - _ref16 = _i12.value; - } - - const file = _ref16; - - queue.push({ - onFresh, - src: (_path || _load_path()).default.join(src, file), - dest: (_path || _load_path()).default.join(dest, file), - onDone: function (_onDone2) { - function onDone() { - return _onDone2.apply(this, arguments); - } - - onDone.toString = function () { - return _onDone2.toString(); - }; - - return onDone; - }(function () { - if (--remaining === 0) { - onDone(); - } - }) - }); - } - } else if (srcStat.isFile()) { - onFresh(); - actions.link.push({ - src, - dest, - removeDest: destExists - }); - onDone(); - } else { - throw new Error(`unsure how to copy this: ${src}`); - } - }); - - return function build(_x10) { - return _ref13.apply(this, arguments); - }; - })(); - - const artifactFiles = new Set(events.artifactFiles || []); - const files = new Set(); - - // initialise events - for (var _iterator7 = queue, _isArray7 = Array.isArray(_iterator7), _i7 = 0, _iterator7 = _isArray7 ? _iterator7 : _iterator7[Symbol.iterator]();;) { - var _ref10; - - if (_isArray7) { - if (_i7 >= _iterator7.length) break; - _ref10 = _iterator7[_i7++]; - } else { - _i7 = _iterator7.next(); - if (_i7.done) break; - _ref10 = _i7.value; - } - - const item = _ref10; - - const onDone = item.onDone || noop; - item.onDone = function () { - events.onProgress(item.dest); - onDone(); - }; - } - events.onStart(queue.length); - - // start building actions - const actions = { - file: [], - symlink: [], - link: [] - }; - - // custom concurrency logic as we're always executing stacks of CONCURRENT_QUEUE_ITEMS queue items - // at a time due to the requirement to push items onto the queue - while (queue.length) { - const items = queue.splice(0, CONCURRENT_QUEUE_ITEMS); - yield Promise.all(items.map(build)); - } - - // simulate the existence of some files to prevent considering them extraneous - for (var _iterator8 = artifactFiles, _isArray8 = Array.isArray(_iterator8), _i8 = 0, _iterator8 = _isArray8 ? _iterator8 : _iterator8[Symbol.iterator]();;) { - var _ref11; - - if (_isArray8) { - if (_i8 >= _iterator8.length) break; - _ref11 = _iterator8[_i8++]; - } else { - _i8 = _iterator8.next(); - if (_i8.done) break; - _ref11 = _i8.value; - } - - const file = _ref11; - - if (possibleExtraneous.has(file)) { - reporter.verbose(reporter.lang('verboseFilePhantomExtraneous', file)); - possibleExtraneous.delete(file); - } - } - - for (var _iterator9 = possibleExtraneous, _isArray9 = Array.isArray(_iterator9), _i9 = 0, _iterator9 = _isArray9 ? _iterator9 : _iterator9[Symbol.iterator]();;) { - var _ref12; - - if (_isArray9) { - if (_i9 >= _iterator9.length) break; - _ref12 = _iterator9[_i9++]; - } else { - _i9 = _iterator9.next(); - if (_i9.done) break; - _ref12 = _i9.value; - } - - const loc = _ref12; - - if (files.has(loc.toLowerCase())) { - possibleExtraneous.delete(loc); - } - } - - return actions; - }); - - return function buildActionsForHardlink(_x6, _x7, _x8, _x9) { - return _ref9.apply(this, arguments); - }; -})(); - -let copyBulk = exports.copyBulk = (() => { - var _ref17 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (queue, reporter, _events) { - const events = { - onStart: _events && _events.onStart || noop, - onProgress: _events && _events.onProgress || noop, - possibleExtraneous: _events ? _events.possibleExtraneous : new Set(), - ignoreBasenames: _events && _events.ignoreBasenames || [], - artifactFiles: _events && _events.artifactFiles || [] - }; - - const actions = yield buildActionsForCopy(queue, events, events.possibleExtraneous, reporter); - events.onStart(actions.file.length + actions.symlink.length + actions.link.length); - - const fileActions = actions.file; - - const currentlyWriting = new Map(); - - yield (_promise || _load_promise()).queue(fileActions, (() => { - var _ref18 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (data) { - let writePromise; - while (writePromise = currentlyWriting.get(data.dest)) { - yield writePromise; - } - - reporter.verbose(reporter.lang('verboseFileCopy', data.src, data.dest)); - const copier = (0, (_fsNormalized || _load_fsNormalized()).copyFile)(data, function () { - return currentlyWriting.delete(data.dest); - }); - currentlyWriting.set(data.dest, copier); - events.onProgress(data.dest); - return copier; - }); - - return function (_x14) { - return _ref18.apply(this, arguments); - }; - })(), CONCURRENT_QUEUE_ITEMS); - - // we need to copy symlinks last as they could reference files we were copying - const symlinkActions = actions.symlink; - yield (_promise || _load_promise()).queue(symlinkActions, function (data) { - const linkname = (_path || _load_path()).default.resolve((_path || _load_path()).default.dirname(data.dest), data.linkname); - reporter.verbose(reporter.lang('verboseFileSymlink', data.dest, linkname)); - return symlink(linkname, data.dest); - }); - }); - - return function copyBulk(_x11, _x12, _x13) { - return _ref17.apply(this, arguments); - }; -})(); - -let hardlinkBulk = exports.hardlinkBulk = (() => { - var _ref19 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (queue, reporter, _events) { - const events = { - onStart: _events && _events.onStart || noop, - onProgress: _events && _events.onProgress || noop, - possibleExtraneous: _events ? _events.possibleExtraneous : new Set(), - artifactFiles: _events && _events.artifactFiles || [], - ignoreBasenames: [] - }; - - const actions = yield buildActionsForHardlink(queue, events, events.possibleExtraneous, reporter); - events.onStart(actions.file.length + actions.symlink.length + actions.link.length); - - const fileActions = actions.link; - - yield (_promise || _load_promise()).queue(fileActions, (() => { - var _ref20 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (data) { - reporter.verbose(reporter.lang('verboseFileLink', data.src, data.dest)); - if (data.removeDest) { - yield (0, (_fsNormalized || _load_fsNormalized()).unlink)(data.dest); - } - yield link(data.src, data.dest); - }); - - return function (_x18) { - return _ref20.apply(this, arguments); - }; - })(), CONCURRENT_QUEUE_ITEMS); - - // we need to copy symlinks last as they could reference files we were copying - const symlinkActions = actions.symlink; - yield (_promise || _load_promise()).queue(symlinkActions, function (data) { - const linkname = (_path || _load_path()).default.resolve((_path || _load_path()).default.dirname(data.dest), data.linkname); - reporter.verbose(reporter.lang('verboseFileSymlink', data.dest, linkname)); - return symlink(linkname, data.dest); - }); - }); - - return function hardlinkBulk(_x15, _x16, _x17) { - return _ref19.apply(this, arguments); - }; -})(); - -let readFileAny = exports.readFileAny = (() => { - var _ref21 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (files) { - for (var _iterator13 = files, _isArray13 = Array.isArray(_iterator13), _i13 = 0, _iterator13 = _isArray13 ? _iterator13 : _iterator13[Symbol.iterator]();;) { - var _ref22; - - if (_isArray13) { - if (_i13 >= _iterator13.length) break; - _ref22 = _iterator13[_i13++]; - } else { - _i13 = _iterator13.next(); - if (_i13.done) break; - _ref22 = _i13.value; - } - - const file = _ref22; - - if (yield exists(file)) { - return readFile(file); - } - } - return null; - }); - - return function readFileAny(_x19) { - return _ref21.apply(this, arguments); - }; -})(); - -let readJson = exports.readJson = (() => { - var _ref23 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (loc) { - return (yield readJsonAndFile(loc)).object; - }); - - return function readJson(_x20) { - return _ref23.apply(this, arguments); - }; -})(); - -let readJsonAndFile = exports.readJsonAndFile = (() => { - var _ref24 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (loc) { - const file = yield readFile(loc); - try { - return { - object: (0, (_map || _load_map()).default)(JSON.parse(stripBOM(file))), - content: file - }; - } catch (err) { - err.message = `${loc}: ${err.message}`; - throw err; - } - }); - - return function readJsonAndFile(_x21) { - return _ref24.apply(this, arguments); - }; -})(); - -let find = exports.find = (() => { - var _ref25 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (filename, dir) { - const parts = dir.split((_path || _load_path()).default.sep); - - while (parts.length) { - const loc = parts.concat(filename).join((_path || _load_path()).default.sep); - - if (yield exists(loc)) { - return loc; - } else { - parts.pop(); - } - } - - return false; - }); - - return function find(_x22, _x23) { - return _ref25.apply(this, arguments); - }; -})(); - -let symlink = exports.symlink = (() => { - var _ref26 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (src, dest) { - if (process.platform !== 'win32') { - // use relative paths otherwise which will be retained if the directory is moved - src = (_path || _load_path()).default.relative((_path || _load_path()).default.dirname(dest), src); - // When path.relative returns an empty string for the current directory, we should instead use - // '.', which is a valid fs.symlink target. - src = src || '.'; - } - - try { - const stats = yield lstat(dest); - if (stats.isSymbolicLink()) { - const resolved = dest; - if (resolved === src) { - return; - } - } - } catch (err) { - if (err.code !== 'ENOENT') { - throw err; - } - } - - // We use rimraf for unlink which never throws an ENOENT on missing target - yield (0, (_fsNormalized || _load_fsNormalized()).unlink)(dest); - - if (process.platform === 'win32') { - // use directory junctions if possible on win32, this requires absolute paths - yield fsSymlink(src, dest, 'junction'); - } else { - yield fsSymlink(src, dest); - } - }); - - return function symlink(_x24, _x25) { - return _ref26.apply(this, arguments); - }; -})(); - -let walk = exports.walk = (() => { - var _ref27 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (dir, relativeDir, ignoreBasenames = new Set()) { - let files = []; - - let filenames = yield readdir(dir); - if (ignoreBasenames.size) { - filenames = filenames.filter(function (name) { - return !ignoreBasenames.has(name); - }); - } - - for (var _iterator14 = filenames, _isArray14 = Array.isArray(_iterator14), _i14 = 0, _iterator14 = _isArray14 ? _iterator14 : _iterator14[Symbol.iterator]();;) { - var _ref28; - - if (_isArray14) { - if (_i14 >= _iterator14.length) break; - _ref28 = _iterator14[_i14++]; - } else { - _i14 = _iterator14.next(); - if (_i14.done) break; - _ref28 = _i14.value; - } - - const name = _ref28; - - const relative = relativeDir ? (_path || _load_path()).default.join(relativeDir, name) : name; - const loc = (_path || _load_path()).default.join(dir, name); - const stat = yield lstat(loc); - - files.push({ - relative, - basename: name, - absolute: loc, - mtime: +stat.mtime - }); - - if (stat.isDirectory()) { - files = files.concat((yield walk(loc, relative, ignoreBasenames))); - } - } - - return files; - }); - - return function walk(_x26, _x27) { - return _ref27.apply(this, arguments); - }; -})(); - -let getFileSizeOnDisk = exports.getFileSizeOnDisk = (() => { - var _ref29 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (loc) { - const stat = yield lstat(loc); - const size = stat.size, - blockSize = stat.blksize; - - - return Math.ceil(size / blockSize) * blockSize; - }); - - return function getFileSizeOnDisk(_x28) { - return _ref29.apply(this, arguments); - }; -})(); - -let getEolFromFile = (() => { - var _ref30 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (path) { - if (!(yield exists(path))) { - return undefined; - } - - const buffer = yield readFileBuffer(path); - - for (let i = 0; i < buffer.length; ++i) { - if (buffer[i] === cr) { - return '\r\n'; - } - if (buffer[i] === lf) { - return '\n'; - } - } - return undefined; - }); - - return function getEolFromFile(_x29) { - return _ref30.apply(this, arguments); - }; -})(); - -let writeFilePreservingEol = exports.writeFilePreservingEol = (() => { - var _ref31 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (path, data) { - const eol = (yield getEolFromFile(path)) || (_os || _load_os()).default.EOL; - if (eol !== '\n') { - data = data.replace(/\n/g, eol); - } - yield writeFile(path, data); - }); - - return function writeFilePreservingEol(_x30, _x31) { - return _ref31.apply(this, arguments); - }; -})(); - -let hardlinksWork = exports.hardlinksWork = (() => { - var _ref32 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (dir) { - const filename = 'test-file' + Math.random(); - const file = (_path || _load_path()).default.join(dir, filename); - const fileLink = (_path || _load_path()).default.join(dir, filename + '-link'); - try { - yield writeFile(file, 'test'); - yield link(file, fileLink); - } catch (err) { - return false; - } finally { - yield (0, (_fsNormalized || _load_fsNormalized()).unlink)(file); - yield (0, (_fsNormalized || _load_fsNormalized()).unlink)(fileLink); - } - return true; - }); - - return function hardlinksWork(_x32) { - return _ref32.apply(this, arguments); - }; -})(); - -// not a strict polyfill for Node's fs.mkdtemp - - -let makeTempDir = exports.makeTempDir = (() => { - var _ref33 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (prefix) { - const dir = (_path || _load_path()).default.join((_os || _load_os()).default.tmpdir(), `yarn-${prefix || ''}-${Date.now()}-${Math.random()}`); - yield (0, (_fsNormalized || _load_fsNormalized()).unlink)(dir); - yield mkdirp(dir); - return dir; - }); - - return function makeTempDir(_x33) { - return _ref33.apply(this, arguments); - }; -})(); - -let readFirstAvailableStream = exports.readFirstAvailableStream = (() => { - var _ref34 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (paths) { - for (var _iterator15 = paths, _isArray15 = Array.isArray(_iterator15), _i15 = 0, _iterator15 = _isArray15 ? _iterator15 : _iterator15[Symbol.iterator]();;) { - var _ref35; - - if (_isArray15) { - if (_i15 >= _iterator15.length) break; - _ref35 = _iterator15[_i15++]; - } else { - _i15 = _iterator15.next(); - if (_i15.done) break; - _ref35 = _i15.value; - } - - const path = _ref35; - - try { - const fd = yield open(path, 'r'); - return (_fs || _load_fs()).default.createReadStream(path, { fd }); - } catch (err) { - // Try the next one - } - } - return null; - }); - - return function readFirstAvailableStream(_x34) { - return _ref34.apply(this, arguments); - }; -})(); - -let getFirstSuitableFolder = exports.getFirstSuitableFolder = (() => { - var _ref36 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (paths, mode = constants.W_OK | constants.X_OK) { - const result = { - skipped: [], - folder: null - }; - - for (var _iterator16 = paths, _isArray16 = Array.isArray(_iterator16), _i16 = 0, _iterator16 = _isArray16 ? _iterator16 : _iterator16[Symbol.iterator]();;) { - var _ref37; - - if (_isArray16) { - if (_i16 >= _iterator16.length) break; - _ref37 = _iterator16[_i16++]; - } else { - _i16 = _iterator16.next(); - if (_i16.done) break; - _ref37 = _i16.value; - } - - const folder = _ref37; - - try { - yield mkdirp(folder); - yield access(folder, mode); - - result.folder = folder; - - return result; - } catch (error) { - result.skipped.push({ - error, - folder - }); - } - } - return result; - }); - - return function getFirstSuitableFolder(_x35) { - return _ref36.apply(this, arguments); - }; -})(); - -exports.copy = copy; -exports.readFile = readFile; -exports.readFileRaw = readFileRaw; -exports.normalizeOS = normalizeOS; - -var _fs; - -function _load_fs() { - return _fs = _interopRequireDefault(__webpack_require__(4)); -} - -var _glob; - -function _load_glob() { - return _glob = _interopRequireDefault(__webpack_require__(99)); -} - -var _os; - -function _load_os() { - return _os = _interopRequireDefault(__webpack_require__(46)); -} - -var _path; - -function _load_path() { - return _path = _interopRequireDefault(__webpack_require__(0)); -} - -var _blockingQueue; - -function _load_blockingQueue() { - return _blockingQueue = _interopRequireDefault(__webpack_require__(110)); -} - -var _promise; - -function _load_promise() { - return _promise = _interopRequireWildcard(__webpack_require__(51)); -} - -var _promise2; - -function _load_promise2() { - return _promise2 = __webpack_require__(51); -} - -var _map; - -function _load_map() { - return _map = _interopRequireDefault(__webpack_require__(29)); -} - -var _fsNormalized; - -function _load_fsNormalized() { - return _fsNormalized = __webpack_require__(216); -} - -function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -const constants = exports.constants = typeof (_fs || _load_fs()).default.constants !== 'undefined' ? (_fs || _load_fs()).default.constants : { - R_OK: (_fs || _load_fs()).default.R_OK, - W_OK: (_fs || _load_fs()).default.W_OK, - X_OK: (_fs || _load_fs()).default.X_OK -}; - -const lockQueue = exports.lockQueue = new (_blockingQueue || _load_blockingQueue()).default('fs lock'); - -const readFileBuffer = exports.readFileBuffer = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.readFile); -const open = exports.open = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.open); -const writeFile = exports.writeFile = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.writeFile); -const readlink = exports.readlink = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.readlink); -const realpath = exports.realpath = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.realpath); -const readdir = exports.readdir = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.readdir); -const rename = exports.rename = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.rename); -const access = exports.access = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.access); -const stat = exports.stat = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.stat); -const mkdirp = exports.mkdirp = (0, (_promise2 || _load_promise2()).promisify)(__webpack_require__(145)); -const exists = exports.exists = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.exists, true); -const lstat = exports.lstat = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.lstat); -const chmod = exports.chmod = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.chmod); -const link = exports.link = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.link); -const glob = exports.glob = (0, (_promise2 || _load_promise2()).promisify)((_glob || _load_glob()).default); -exports.unlink = (_fsNormalized || _load_fsNormalized()).unlink; - -// fs.copyFile uses the native file copying instructions on the system, performing much better -// than any JS-based solution and consumes fewer resources. Repeated testing to fine tune the -// concurrency level revealed 128 as the sweet spot on a quad-core, 16 CPU Intel system with SSD. - -const CONCURRENT_QUEUE_ITEMS = (_fs || _load_fs()).default.copyFile ? 128 : 4; - -const fsSymlink = (0, (_promise2 || _load_promise2()).promisify)((_fs || _load_fs()).default.symlink); -const invariant = __webpack_require__(9); -const stripBOM = __webpack_require__(160); - -const noop = () => {}; - -function copy(src, dest, reporter) { - return copyBulk([{ src, dest }], reporter); -} - -function _readFile(loc, encoding) { - return new Promise((resolve, reject) => { - (_fs || _load_fs()).default.readFile(loc, encoding, function (err, content) { - if (err) { - reject(err); - } else { - resolve(content); - } - }); - }); -} - -function readFile(loc) { - return _readFile(loc, 'utf8').then(normalizeOS); -} - -function readFileRaw(loc) { - return _readFile(loc, 'binary'); -} - -function normalizeOS(body) { - return body.replace(/\r\n/g, '\n'); -} - -const cr = '\r'.charCodeAt(0); -const lf = '\n'.charCodeAt(0); - -/***/ }), -/* 6 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -class MessageError extends Error { - constructor(msg, code) { - super(msg); - this.code = code; - } - -} - -exports.MessageError = MessageError; -class ProcessSpawnError extends MessageError { - constructor(msg, code, process) { - super(msg, code); - this.process = process; - } - -} - -exports.ProcessSpawnError = ProcessSpawnError; -class SecurityError extends MessageError {} - -exports.SecurityError = SecurityError; -class ProcessTermError extends MessageError {} - -exports.ProcessTermError = ProcessTermError; -class ResponseError extends Error { - constructor(msg, responseCode) { - super(msg); - this.responseCode = responseCode; - } - -} - -exports.ResponseError = ResponseError; -class OneTimePasswordError extends Error {} -exports.OneTimePasswordError = OneTimePasswordError; - -/***/ }), -/* 7 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return Subscriber; }); -/* unused harmony export SafeSubscriber */ -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_tslib__ = __webpack_require__(1); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__util_isFunction__ = __webpack_require__(154); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_2__Observer__ = __webpack_require__(420); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_3__Subscription__ = __webpack_require__(25); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_4__internal_symbol_rxSubscriber__ = __webpack_require__(321); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_5__config__ = __webpack_require__(186); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_6__util_hostReportError__ = __webpack_require__(323); -/** PURE_IMPORTS_START tslib,_util_isFunction,_Observer,_Subscription,_internal_symbol_rxSubscriber,_config,_util_hostReportError PURE_IMPORTS_END */ - - - - - - - -var Subscriber = /*@__PURE__*/ (function (_super) { - __WEBPACK_IMPORTED_MODULE_0_tslib__["a" /* __extends */](Subscriber, _super); - function Subscriber(destinationOrNext, error, complete) { - var _this = _super.call(this) || this; - _this.syncErrorValue = null; - _this.syncErrorThrown = false; - _this.syncErrorThrowable = false; - _this.isStopped = false; - _this._parentSubscription = null; - switch (arguments.length) { - case 0: - _this.destination = __WEBPACK_IMPORTED_MODULE_2__Observer__["a" /* empty */]; - break; - case 1: - if (!destinationOrNext) { - _this.destination = __WEBPACK_IMPORTED_MODULE_2__Observer__["a" /* empty */]; - break; - } - if (typeof destinationOrNext === 'object') { - if (destinationOrNext instanceof Subscriber) { - _this.syncErrorThrowable = destinationOrNext.syncErrorThrowable; - _this.destination = destinationOrNext; - destinationOrNext.add(_this); - } - else { - _this.syncErrorThrowable = true; - _this.destination = new SafeSubscriber(_this, destinationOrNext); - } - break; - } - default: - _this.syncErrorThrowable = true; - _this.destination = new SafeSubscriber(_this, destinationOrNext, error, complete); - break; - } - return _this; - } - Subscriber.prototype[__WEBPACK_IMPORTED_MODULE_4__internal_symbol_rxSubscriber__["a" /* rxSubscriber */]] = function () { return this; }; - Subscriber.create = function (next, error, complete) { - var subscriber = new Subscriber(next, error, complete); - subscriber.syncErrorThrowable = false; - return subscriber; - }; - Subscriber.prototype.next = function (value) { - if (!this.isStopped) { - this._next(value); - } - }; - Subscriber.prototype.error = function (err) { - if (!this.isStopped) { - this.isStopped = true; - this._error(err); - } - }; - Subscriber.prototype.complete = function () { - if (!this.isStopped) { - this.isStopped = true; - this._complete(); - } - }; - Subscriber.prototype.unsubscribe = function () { - if (this.closed) { - return; - } - this.isStopped = true; - _super.prototype.unsubscribe.call(this); - }; - Subscriber.prototype._next = function (value) { - this.destination.next(value); - }; - Subscriber.prototype._error = function (err) { - this.destination.error(err); - this.unsubscribe(); - }; - Subscriber.prototype._complete = function () { - this.destination.complete(); - this.unsubscribe(); - }; - Subscriber.prototype._unsubscribeAndRecycle = function () { - var _a = this, _parent = _a._parent, _parents = _a._parents; - this._parent = null; - this._parents = null; - this.unsubscribe(); - this.closed = false; - this.isStopped = false; - this._parent = _parent; - this._parents = _parents; - this._parentSubscription = null; - return this; - }; - return Subscriber; -}(__WEBPACK_IMPORTED_MODULE_3__Subscription__["a" /* Subscription */])); - -var SafeSubscriber = /*@__PURE__*/ (function (_super) { - __WEBPACK_IMPORTED_MODULE_0_tslib__["a" /* __extends */](SafeSubscriber, _super); - function SafeSubscriber(_parentSubscriber, observerOrNext, error, complete) { - var _this = _super.call(this) || this; - _this._parentSubscriber = _parentSubscriber; - var next; - var context = _this; - if (__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_1__util_isFunction__["a" /* isFunction */])(observerOrNext)) { - next = observerOrNext; - } - else if (observerOrNext) { - next = observerOrNext.next; - error = observerOrNext.error; - complete = observerOrNext.complete; - if (observerOrNext !== __WEBPACK_IMPORTED_MODULE_2__Observer__["a" /* empty */]) { - context = Object.create(observerOrNext); - if (__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_1__util_isFunction__["a" /* isFunction */])(context.unsubscribe)) { - _this.add(context.unsubscribe.bind(context)); - } - context.unsubscribe = _this.unsubscribe.bind(_this); - } - } - _this._context = context; - _this._next = next; - _this._error = error; - _this._complete = complete; - return _this; - } - SafeSubscriber.prototype.next = function (value) { - if (!this.isStopped && this._next) { - var _parentSubscriber = this._parentSubscriber; - if (!__WEBPACK_IMPORTED_MODULE_5__config__["a" /* config */].useDeprecatedSynchronousErrorHandling || !_parentSubscriber.syncErrorThrowable) { - this.__tryOrUnsub(this._next, value); - } - else if (this.__tryOrSetError(_parentSubscriber, this._next, value)) { - this.unsubscribe(); - } - } - }; - SafeSubscriber.prototype.error = function (err) { - if (!this.isStopped) { - var _parentSubscriber = this._parentSubscriber; - var useDeprecatedSynchronousErrorHandling = __WEBPACK_IMPORTED_MODULE_5__config__["a" /* config */].useDeprecatedSynchronousErrorHandling; - if (this._error) { - if (!useDeprecatedSynchronousErrorHandling || !_parentSubscriber.syncErrorThrowable) { - this.__tryOrUnsub(this._error, err); - this.unsubscribe(); - } - else { - this.__tryOrSetError(_parentSubscriber, this._error, err); - this.unsubscribe(); - } - } - else if (!_parentSubscriber.syncErrorThrowable) { - this.unsubscribe(); - if (useDeprecatedSynchronousErrorHandling) { - throw err; - } - __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_6__util_hostReportError__["a" /* hostReportError */])(err); - } - else { - if (useDeprecatedSynchronousErrorHandling) { - _parentSubscriber.syncErrorValue = err; - _parentSubscriber.syncErrorThrown = true; - } - else { - __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_6__util_hostReportError__["a" /* hostReportError */])(err); - } - this.unsubscribe(); - } - } - }; - SafeSubscriber.prototype.complete = function () { - var _this = this; - if (!this.isStopped) { - var _parentSubscriber = this._parentSubscriber; - if (this._complete) { - var wrappedComplete = function () { return _this._complete.call(_this._context); }; - if (!__WEBPACK_IMPORTED_MODULE_5__config__["a" /* config */].useDeprecatedSynchronousErrorHandling || !_parentSubscriber.syncErrorThrowable) { - this.__tryOrUnsub(wrappedComplete); - this.unsubscribe(); - } - else { - this.__tryOrSetError(_parentSubscriber, wrappedComplete); - this.unsubscribe(); - } - } - else { - this.unsubscribe(); - } - } - }; - SafeSubscriber.prototype.__tryOrUnsub = function (fn, value) { - try { - fn.call(this._context, value); - } - catch (err) { - this.unsubscribe(); - if (__WEBPACK_IMPORTED_MODULE_5__config__["a" /* config */].useDeprecatedSynchronousErrorHandling) { - throw err; - } - else { - __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_6__util_hostReportError__["a" /* hostReportError */])(err); - } - } - }; - SafeSubscriber.prototype.__tryOrSetError = function (parent, fn, value) { - if (!__WEBPACK_IMPORTED_MODULE_5__config__["a" /* config */].useDeprecatedSynchronousErrorHandling) { - throw new Error('bad call'); - } - try { - fn.call(this._context, value); - } - catch (err) { - if (__WEBPACK_IMPORTED_MODULE_5__config__["a" /* config */].useDeprecatedSynchronousErrorHandling) { - parent.syncErrorValue = err; - parent.syncErrorThrown = true; - return true; - } - else { - __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_6__util_hostReportError__["a" /* hostReportError */])(err); - return true; - } - } - return false; - }; - SafeSubscriber.prototype._unsubscribe = function () { - var _parentSubscriber = this._parentSubscriber; - this._context = null; - this._parentSubscriber = null; - _parentSubscriber.unsubscribe(); - }; - return SafeSubscriber; -}(Subscriber)); - -//# sourceMappingURL=Subscriber.js.map - - -/***/ }), -/* 8 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.getPathKey = getPathKey; -const os = __webpack_require__(46); -const path = __webpack_require__(0); -const userHome = __webpack_require__(67).default; - -var _require = __webpack_require__(222); - -const getCacheDir = _require.getCacheDir, - getConfigDir = _require.getConfigDir, - getDataDir = _require.getDataDir; - -const isWebpackBundle = __webpack_require__(278); - -const DEPENDENCY_TYPES = exports.DEPENDENCY_TYPES = ['devDependencies', 'dependencies', 'optionalDependencies', 'peerDependencies']; -const OWNED_DEPENDENCY_TYPES = exports.OWNED_DEPENDENCY_TYPES = ['devDependencies', 'dependencies', 'optionalDependencies']; - -const RESOLUTIONS = exports.RESOLUTIONS = 'resolutions'; -const MANIFEST_FIELDS = exports.MANIFEST_FIELDS = [RESOLUTIONS, ...DEPENDENCY_TYPES]; - -const SUPPORTED_NODE_VERSIONS = exports.SUPPORTED_NODE_VERSIONS = '^4.8.0 || ^5.7.0 || ^6.2.2 || >=8.0.0'; - -const YARN_REGISTRY = exports.YARN_REGISTRY = 'https://registry.yarnpkg.com'; -const NPM_REGISTRY_RE = exports.NPM_REGISTRY_RE = /https?:\/\/registry\.npmjs\.org/g; - -const YARN_DOCS = exports.YARN_DOCS = 'https://yarnpkg.com/en/docs/cli/'; -const YARN_INSTALLER_SH = exports.YARN_INSTALLER_SH = 'https://yarnpkg.com/install.sh'; -const YARN_INSTALLER_MSI = exports.YARN_INSTALLER_MSI = 'https://yarnpkg.com/latest.msi'; - -const SELF_UPDATE_VERSION_URL = exports.SELF_UPDATE_VERSION_URL = 'https://yarnpkg.com/latest-version'; - -// cache version, bump whenever we make backwards incompatible changes -const CACHE_VERSION = exports.CACHE_VERSION = 6; - -// lockfile version, bump whenever we make backwards incompatible changes -const LOCKFILE_VERSION = exports.LOCKFILE_VERSION = 1; - -// max amount of network requests to perform concurrently -const NETWORK_CONCURRENCY = exports.NETWORK_CONCURRENCY = 8; - -// HTTP timeout used when downloading packages -const NETWORK_TIMEOUT = exports.NETWORK_TIMEOUT = 30 * 1000; // in milliseconds - -// max amount of child processes to execute concurrently -const CHILD_CONCURRENCY = exports.CHILD_CONCURRENCY = 5; - -const REQUIRED_PACKAGE_KEYS = exports.REQUIRED_PACKAGE_KEYS = ['name', 'version', '_uid']; - -function getPreferredCacheDirectories() { - const preferredCacheDirectories = [getCacheDir()]; - - if (process.getuid) { - // $FlowFixMe: process.getuid exists, dammit - preferredCacheDirectories.push(path.join(os.tmpdir(), `.yarn-cache-${process.getuid()}`)); - } - - preferredCacheDirectories.push(path.join(os.tmpdir(), `.yarn-cache`)); - - return preferredCacheDirectories; -} - -const PREFERRED_MODULE_CACHE_DIRECTORIES = exports.PREFERRED_MODULE_CACHE_DIRECTORIES = getPreferredCacheDirectories(); -const CONFIG_DIRECTORY = exports.CONFIG_DIRECTORY = getConfigDir(); -const DATA_DIRECTORY = exports.DATA_DIRECTORY = getDataDir(); -const LINK_REGISTRY_DIRECTORY = exports.LINK_REGISTRY_DIRECTORY = path.join(DATA_DIRECTORY, 'link'); -const GLOBAL_MODULE_DIRECTORY = exports.GLOBAL_MODULE_DIRECTORY = path.join(DATA_DIRECTORY, 'global'); - -const NODE_BIN_PATH = exports.NODE_BIN_PATH = process.execPath; -const YARN_BIN_PATH = exports.YARN_BIN_PATH = getYarnBinPath(); - -// Webpack needs to be configured with node.__dirname/__filename = false -function getYarnBinPath() { - if (isWebpackBundle) { - return __filename; - } else { - return path.join(__dirname, '..', 'bin', 'yarn.js'); - } -} - -const NODE_MODULES_FOLDER = exports.NODE_MODULES_FOLDER = 'node_modules'; -const NODE_PACKAGE_JSON = exports.NODE_PACKAGE_JSON = 'package.json'; - -const PNP_FILENAME = exports.PNP_FILENAME = '.pnp.js'; - -const POSIX_GLOBAL_PREFIX = exports.POSIX_GLOBAL_PREFIX = `${process.env.DESTDIR || ''}/usr/local`; -const FALLBACK_GLOBAL_PREFIX = exports.FALLBACK_GLOBAL_PREFIX = path.join(userHome, '.yarn'); - -const META_FOLDER = exports.META_FOLDER = '.yarn-meta'; -const INTEGRITY_FILENAME = exports.INTEGRITY_FILENAME = '.yarn-integrity'; -const LOCKFILE_FILENAME = exports.LOCKFILE_FILENAME = 'yarn.lock'; -const METADATA_FILENAME = exports.METADATA_FILENAME = '.yarn-metadata.json'; -const TARBALL_FILENAME = exports.TARBALL_FILENAME = '.yarn-tarball.tgz'; -const CLEAN_FILENAME = exports.CLEAN_FILENAME = '.yarnclean'; - -const NPM_LOCK_FILENAME = exports.NPM_LOCK_FILENAME = 'package-lock.json'; -const NPM_SHRINKWRAP_FILENAME = exports.NPM_SHRINKWRAP_FILENAME = 'npm-shrinkwrap.json'; - -const DEFAULT_INDENT = exports.DEFAULT_INDENT = ' '; -const SINGLE_INSTANCE_PORT = exports.SINGLE_INSTANCE_PORT = 31997; -const SINGLE_INSTANCE_FILENAME = exports.SINGLE_INSTANCE_FILENAME = '.yarn-single-instance'; - -const ENV_PATH_KEY = exports.ENV_PATH_KEY = getPathKey(process.platform, process.env); - -function getPathKey(platform, env) { - let pathKey = 'PATH'; - - // windows calls its path "Path" usually, but this is not guaranteed. - if (platform === 'win32') { - pathKey = 'Path'; - - for (const key in env) { - if (key.toLowerCase() === 'path') { - pathKey = key; - } - } - } - - return pathKey; -} - -const VERSION_COLOR_SCHEME = exports.VERSION_COLOR_SCHEME = { - major: 'red', - premajor: 'red', - minor: 'yellow', - preminor: 'yellow', - patch: 'green', - prepatch: 'green', - prerelease: 'red', - unchanged: 'white', - unknown: 'red' -}; - -/***/ }), -/* 9 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; -/** - * Copyright (c) 2013-present, Facebook, Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - - - -/** - * Use invariant() to assert state which your program assumes to be true. - * - * Provide sprintf-style format (only %s is supported) and arguments - * to provide information about what broke and what you were - * expecting. - * - * The invariant message will be stripped in production, but the invariant - * will remain to ensure logic does not differ in production. - */ - -var NODE_ENV = process.env.NODE_ENV; - -var invariant = function(condition, format, a, b, c, d, e, f) { - if (NODE_ENV !== 'production') { - if (format === undefined) { - throw new Error('invariant requires an error message argument'); - } - } - - if (!condition) { - var error; - if (format === undefined) { - error = new Error( - 'Minified exception occurred; use the non-minified dev environment ' + - 'for the full error message and additional helpful warnings.' - ); - } else { - var args = [a, b, c, d, e, f]; - var argIndex = 0; - error = new Error( - format.replace(/%s/g, function() { return args[argIndex++]; }) - ); - error.name = 'Invariant Violation'; - } - - error.framesToPop = 1; // we don't care about invariant's own frame - throw error; - } -}; - -module.exports = invariant; - - -/***/ }), -/* 10 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -var YAMLException = __webpack_require__(55); - -var TYPE_CONSTRUCTOR_OPTIONS = [ - 'kind', - 'resolve', - 'construct', - 'instanceOf', - 'predicate', - 'represent', - 'defaultStyle', - 'styleAliases' -]; - -var YAML_NODE_KINDS = [ - 'scalar', - 'sequence', - 'mapping' -]; - -function compileStyleAliases(map) { - var result = {}; - - if (map !== null) { - Object.keys(map).forEach(function (style) { - map[style].forEach(function (alias) { - result[String(alias)] = style; - }); - }); - } - - return result; -} - -function Type(tag, options) { - options = options || {}; - - Object.keys(options).forEach(function (name) { - if (TYPE_CONSTRUCTOR_OPTIONS.indexOf(name) === -1) { - throw new YAMLException('Unknown option "' + name + '" is met in definition of "' + tag + '" YAML type.'); - } - }); - - // TODO: Add tag format check. - this.tag = tag; - this.kind = options['kind'] || null; - this.resolve = options['resolve'] || function () { return true; }; - this.construct = options['construct'] || function (data) { return data; }; - this.instanceOf = options['instanceOf'] || null; - this.predicate = options['predicate'] || null; - this.represent = options['represent'] || null; - this.defaultStyle = options['defaultStyle'] || null; - this.styleAliases = compileStyleAliases(options['styleAliases'] || null); - - if (YAML_NODE_KINDS.indexOf(this.kind) === -1) { - throw new YAMLException('Unknown kind "' + this.kind + '" is specified for "' + tag + '" YAML type.'); - } -} - -module.exports = Type; - - -/***/ }), -/* 11 */ -/***/ (function(module, exports) { - -module.exports = require("crypto"); - -/***/ }), -/* 12 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return Observable; }); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__util_canReportError__ = __webpack_require__(322); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__util_toSubscriber__ = __webpack_require__(932); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_2__internal_symbol_observable__ = __webpack_require__(118); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_3__util_pipe__ = __webpack_require__(324); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_4__config__ = __webpack_require__(186); -/** PURE_IMPORTS_START _util_canReportError,_util_toSubscriber,_internal_symbol_observable,_util_pipe,_config PURE_IMPORTS_END */ - - - - - -var Observable = /*@__PURE__*/ (function () { - function Observable(subscribe) { - this._isScalar = false; - if (subscribe) { - this._subscribe = subscribe; - } - } - Observable.prototype.lift = function (operator) { - var observable = new Observable(); - observable.source = this; - observable.operator = operator; - return observable; - }; - Observable.prototype.subscribe = function (observerOrNext, error, complete) { - var operator = this.operator; - var sink = __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_1__util_toSubscriber__["a" /* toSubscriber */])(observerOrNext, error, complete); - if (operator) { - operator.call(sink, this.source); - } - else { - sink.add(this.source || (__WEBPACK_IMPORTED_MODULE_4__config__["a" /* config */].useDeprecatedSynchronousErrorHandling && !sink.syncErrorThrowable) ? - this._subscribe(sink) : - this._trySubscribe(sink)); - } - if (__WEBPACK_IMPORTED_MODULE_4__config__["a" /* config */].useDeprecatedSynchronousErrorHandling) { - if (sink.syncErrorThrowable) { - sink.syncErrorThrowable = false; - if (sink.syncErrorThrown) { - throw sink.syncErrorValue; - } - } - } - return sink; - }; - Observable.prototype._trySubscribe = function (sink) { - try { - return this._subscribe(sink); - } - catch (err) { - if (__WEBPACK_IMPORTED_MODULE_4__config__["a" /* config */].useDeprecatedSynchronousErrorHandling) { - sink.syncErrorThrown = true; - sink.syncErrorValue = err; - } - if (__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_0__util_canReportError__["a" /* canReportError */])(sink)) { - sink.error(err); - } - else { - console.warn(err); - } - } - }; - Observable.prototype.forEach = function (next, promiseCtor) { - var _this = this; - promiseCtor = getPromiseCtor(promiseCtor); - return new promiseCtor(function (resolve, reject) { - var subscription; - subscription = _this.subscribe(function (value) { - try { - next(value); - } - catch (err) { - reject(err); - if (subscription) { - subscription.unsubscribe(); - } - } - }, reject, resolve); - }); - }; - Observable.prototype._subscribe = function (subscriber) { - var source = this.source; - return source && source.subscribe(subscriber); - }; - Observable.prototype[__WEBPACK_IMPORTED_MODULE_2__internal_symbol_observable__["a" /* observable */]] = function () { - return this; - }; - Observable.prototype.pipe = function () { - var operations = []; - for (var _i = 0; _i < arguments.length; _i++) { - operations[_i] = arguments[_i]; - } - if (operations.length === 0) { - return this; - } - return __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_3__util_pipe__["b" /* pipeFromArray */])(operations)(this); - }; - Observable.prototype.toPromise = function (promiseCtor) { - var _this = this; - promiseCtor = getPromiseCtor(promiseCtor); - return new promiseCtor(function (resolve, reject) { - var value; - _this.subscribe(function (x) { return value = x; }, function (err) { return reject(err); }, function () { return resolve(value); }); - }); - }; - Observable.create = function (subscribe) { - return new Observable(subscribe); - }; - return Observable; -}()); - -function getPromiseCtor(promiseCtor) { - if (!promiseCtor) { - promiseCtor = __WEBPACK_IMPORTED_MODULE_4__config__["a" /* config */].Promise || Promise; - } - if (!promiseCtor) { - throw new Error('no Promise impl found'); - } - return promiseCtor; -} -//# sourceMappingURL=Observable.js.map - - -/***/ }), -/* 13 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return OuterSubscriber; }); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_tslib__ = __webpack_require__(1); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__Subscriber__ = __webpack_require__(7); -/** PURE_IMPORTS_START tslib,_Subscriber PURE_IMPORTS_END */ - - -var OuterSubscriber = /*@__PURE__*/ (function (_super) { - __WEBPACK_IMPORTED_MODULE_0_tslib__["a" /* __extends */](OuterSubscriber, _super); - function OuterSubscriber() { - return _super !== null && _super.apply(this, arguments) || this; - } - OuterSubscriber.prototype.notifyNext = function (outerValue, innerValue, outerIndex, innerIndex, innerSub) { - this.destination.next(innerValue); - }; - OuterSubscriber.prototype.notifyError = function (error, innerSub) { - this.destination.error(error); - }; - OuterSubscriber.prototype.notifyComplete = function (innerSub) { - this.destination.complete(); - }; - return OuterSubscriber; -}(__WEBPACK_IMPORTED_MODULE_1__Subscriber__["a" /* Subscriber */])); - -//# sourceMappingURL=OuterSubscriber.js.map - - -/***/ }), -/* 14 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -/* harmony export (immutable) */ __webpack_exports__["a"] = subscribeToResult; -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__InnerSubscriber__ = __webpack_require__(84); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__subscribeTo__ = __webpack_require__(446); -/** PURE_IMPORTS_START _InnerSubscriber,_subscribeTo PURE_IMPORTS_END */ - - -function subscribeToResult(outerSubscriber, result, outerValue, outerIndex, destination) { - if (destination === void 0) { - destination = new __WEBPACK_IMPORTED_MODULE_0__InnerSubscriber__["a" /* InnerSubscriber */](outerSubscriber, outerValue, outerIndex); - } - if (destination.closed) { - return; - } - return __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_1__subscribeTo__["a" /* subscribeTo */])(result)(destination); -} -//# sourceMappingURL=subscribeToResult.js.map - - -/***/ }), -/* 15 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; -/* eslint-disable node/no-deprecated-api */ - - - -var buffer = __webpack_require__(64) -var Buffer = buffer.Buffer - -var safer = {} - -var key - -for (key in buffer) { - if (!buffer.hasOwnProperty(key)) continue - if (key === 'SlowBuffer' || key === 'Buffer') continue - safer[key] = buffer[key] -} - -var Safer = safer.Buffer = {} -for (key in Buffer) { - if (!Buffer.hasOwnProperty(key)) continue - if (key === 'allocUnsafe' || key === 'allocUnsafeSlow') continue - Safer[key] = Buffer[key] -} - -safer.Buffer.prototype = Buffer.prototype - -if (!Safer.from || Safer.from === Uint8Array.from) { - Safer.from = function (value, encodingOrOffset, length) { - if (typeof value === 'number') { - throw new TypeError('The "value" argument must not be of type number. Received type ' + typeof value) - } - if (value && typeof value.length === 'undefined') { - throw new TypeError('The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type ' + typeof value) - } - return Buffer(value, encodingOrOffset, length) - } -} - -if (!Safer.alloc) { - Safer.alloc = function (size, fill, encoding) { - if (typeof size !== 'number') { - throw new TypeError('The "size" argument must be of type number. Received type ' + typeof size) - } - if (size < 0 || size >= 2 * (1 << 30)) { - throw new RangeError('The value "' + size + '" is invalid for option "size"') - } - var buf = Buffer(size) - if (!fill || fill.length === 0) { - buf.fill(0) - } else if (typeof encoding === 'string') { - buf.fill(fill, encoding) - } else { - buf.fill(fill) - } - return buf - } -} - -if (!safer.kStringMaxLength) { - try { - safer.kStringMaxLength = process.binding('buffer').kStringMaxLength - } catch (e) { - // we can't determine kStringMaxLength in environments where process.binding - // is unsupported, so let's not set it - } -} - -if (!safer.constants) { - safer.constants = { - MAX_LENGTH: safer.kMaxLength - } - if (safer.kStringMaxLength) { - safer.constants.MAX_STRING_LENGTH = safer.kStringMaxLength - } -} - -module.exports = safer - - -/***/ }), -/* 16 */ -/***/ (function(module, exports, __webpack_require__) { - -// Copyright (c) 2012, Mark Cavage. All rights reserved. -// Copyright 2015 Joyent, Inc. - -var assert = __webpack_require__(28); -var Stream = __webpack_require__(23).Stream; -var util = __webpack_require__(3); - - -///--- Globals - -/* JSSTYLED */ -var UUID_REGEXP = /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/; - - -///--- Internal - -function _capitalize(str) { - return (str.charAt(0).toUpperCase() + str.slice(1)); -} - -function _toss(name, expected, oper, arg, actual) { - throw new assert.AssertionError({ - message: util.format('%s (%s) is required', name, expected), - actual: (actual === undefined) ? typeof (arg) : actual(arg), - expected: expected, - operator: oper || '===', - stackStartFunction: _toss.caller - }); -} - -function _getClass(arg) { - return (Object.prototype.toString.call(arg).slice(8, -1)); -} - -function noop() { - // Why even bother with asserts? -} - - -///--- Exports - -var types = { - bool: { - check: function (arg) { return typeof (arg) === 'boolean'; } - }, - func: { - check: function (arg) { return typeof (arg) === 'function'; } - }, - string: { - check: function (arg) { return typeof (arg) === 'string'; } - }, - object: { - check: function (arg) { - return typeof (arg) === 'object' && arg !== null; - } - }, - number: { - check: function (arg) { - return typeof (arg) === 'number' && !isNaN(arg); - } - }, - finite: { - check: function (arg) { - return typeof (arg) === 'number' && !isNaN(arg) && isFinite(arg); - } - }, - buffer: { - check: function (arg) { return Buffer.isBuffer(arg); }, - operator: 'Buffer.isBuffer' - }, - array: { - check: function (arg) { return Array.isArray(arg); }, - operator: 'Array.isArray' - }, - stream: { - check: function (arg) { return arg instanceof Stream; }, - operator: 'instanceof', - actual: _getClass - }, - date: { - check: function (arg) { return arg instanceof Date; }, - operator: 'instanceof', - actual: _getClass - }, - regexp: { - check: function (arg) { return arg instanceof RegExp; }, - operator: 'instanceof', - actual: _getClass - }, - uuid: { - check: function (arg) { - return typeof (arg) === 'string' && UUID_REGEXP.test(arg); - }, - operator: 'isUUID' - } -}; - -function _setExports(ndebug) { - var keys = Object.keys(types); - var out; - - /* re-export standard assert */ - if (process.env.NODE_NDEBUG) { - out = noop; - } else { - out = function (arg, msg) { - if (!arg) { - _toss(msg, 'true', arg); - } - }; - } - - /* standard checks */ - keys.forEach(function (k) { - if (ndebug) { - out[k] = noop; - return; - } - var type = types[k]; - out[k] = function (arg, msg) { - if (!type.check(arg)) { - _toss(msg, k, type.operator, arg, type.actual); - } - }; - }); - - /* optional checks */ - keys.forEach(function (k) { - var name = 'optional' + _capitalize(k); - if (ndebug) { - out[name] = noop; - return; - } - var type = types[k]; - out[name] = function (arg, msg) { - if (arg === undefined || arg === null) { - return; - } - if (!type.check(arg)) { - _toss(msg, k, type.operator, arg, type.actual); - } - }; - }); - - /* arrayOf checks */ - keys.forEach(function (k) { - var name = 'arrayOf' + _capitalize(k); - if (ndebug) { - out[name] = noop; - return; - } - var type = types[k]; - var expected = '[' + k + ']'; - out[name] = function (arg, msg) { - if (!Array.isArray(arg)) { - _toss(msg, expected, type.operator, arg, type.actual); - } - var i; - for (i = 0; i < arg.length; i++) { - if (!type.check(arg[i])) { - _toss(msg, expected, type.operator, arg, type.actual); - } - } - }; - }); - - /* optionalArrayOf checks */ - keys.forEach(function (k) { - var name = 'optionalArrayOf' + _capitalize(k); - if (ndebug) { - out[name] = noop; - return; - } - var type = types[k]; - var expected = '[' + k + ']'; - out[name] = function (arg, msg) { - if (arg === undefined || arg === null) { - return; - } - if (!Array.isArray(arg)) { - _toss(msg, expected, type.operator, arg, type.actual); - } - var i; - for (i = 0; i < arg.length; i++) { - if (!type.check(arg[i])) { - _toss(msg, expected, type.operator, arg, type.actual); - } - } - }; - }); - - /* re-export built-in assertions */ - Object.keys(assert).forEach(function (k) { - if (k === 'AssertionError') { - out[k] = assert[k]; - return; - } - if (ndebug) { - out[k] = noop; - return; - } - out[k] = assert[k]; - }); - - /* export ourselves (for unit tests _only_) */ - out._setExports = _setExports; - - return out; -} - -module.exports = _setExports(process.env.NODE_NDEBUG); - - -/***/ }), -/* 17 */ -/***/ (function(module, exports) { - -// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 -var global = module.exports = typeof window != 'undefined' && window.Math == Math - ? window : typeof self != 'undefined' && self.Math == Math ? self - // eslint-disable-next-line no-new-func - : Function('return this')(); -if (typeof __g == 'number') __g = global; // eslint-disable-line no-undef - - -/***/ }), -/* 18 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.sortAlpha = sortAlpha; -exports.sortOptionsByFlags = sortOptionsByFlags; -exports.entries = entries; -exports.removePrefix = removePrefix; -exports.removeSuffix = removeSuffix; -exports.addSuffix = addSuffix; -exports.hyphenate = hyphenate; -exports.camelCase = camelCase; -exports.compareSortedArrays = compareSortedArrays; -exports.sleep = sleep; -const _camelCase = __webpack_require__(227); - -function sortAlpha(a, b) { - // sort alphabetically in a deterministic way - const shortLen = Math.min(a.length, b.length); - for (let i = 0; i < shortLen; i++) { - const aChar = a.charCodeAt(i); - const bChar = b.charCodeAt(i); - if (aChar !== bChar) { - return aChar - bChar; - } - } - return a.length - b.length; -} - -function sortOptionsByFlags(a, b) { - const aOpt = a.flags.replace(/-/g, ''); - const bOpt = b.flags.replace(/-/g, ''); - return sortAlpha(aOpt, bOpt); -} - -function entries(obj) { - const entries = []; - if (obj) { - for (const key in obj) { - entries.push([key, obj[key]]); - } - } - return entries; -} - -function removePrefix(pattern, prefix) { - if (pattern.startsWith(prefix)) { - pattern = pattern.slice(prefix.length); - } - - return pattern; -} - -function removeSuffix(pattern, suffix) { - if (pattern.endsWith(suffix)) { - return pattern.slice(0, -suffix.length); - } - - return pattern; -} - -function addSuffix(pattern, suffix) { - if (!pattern.endsWith(suffix)) { - return pattern + suffix; - } - - return pattern; -} - -function hyphenate(str) { - return str.replace(/[A-Z]/g, match => { - return '-' + match.charAt(0).toLowerCase(); - }); -} - -function camelCase(str) { - if (/[A-Z]/.test(str)) { - return null; - } else { - return _camelCase(str); - } -} - -function compareSortedArrays(array1, array2) { - if (array1.length !== array2.length) { - return false; - } - for (let i = 0, len = array1.length; i < len; i++) { - if (array1[i] !== array2[i]) { - return false; - } - } - return true; -} - -function sleep(ms) { - return new Promise(resolve => { - setTimeout(resolve, ms); - }); -} - -/***/ }), -/* 19 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.stringify = exports.parse = undefined; - -var _asyncToGenerator2; - -function _load_asyncToGenerator() { - return _asyncToGenerator2 = _interopRequireDefault(__webpack_require__(2)); -} - -var _parse; - -function _load_parse() { - return _parse = __webpack_require__(106); -} - -Object.defineProperty(exports, 'parse', { - enumerable: true, - get: function get() { - return _interopRequireDefault(_parse || _load_parse()).default; - } -}); - -var _stringify; - -function _load_stringify() { - return _stringify = __webpack_require__(200); -} - -Object.defineProperty(exports, 'stringify', { - enumerable: true, - get: function get() { - return _interopRequireDefault(_stringify || _load_stringify()).default; - } -}); -exports.implodeEntry = implodeEntry; -exports.explodeEntry = explodeEntry; - -var _misc; - -function _load_misc() { - return _misc = __webpack_require__(18); -} - -var _normalizePattern; - -function _load_normalizePattern() { - return _normalizePattern = __webpack_require__(37); -} - -var _parse2; - -function _load_parse2() { - return _parse2 = _interopRequireDefault(__webpack_require__(106)); -} - -var _constants; - -function _load_constants() { - return _constants = __webpack_require__(8); -} - -var _fs; - -function _load_fs() { - return _fs = _interopRequireWildcard(__webpack_require__(5)); -} - -function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -const invariant = __webpack_require__(9); - -const path = __webpack_require__(0); -const ssri = __webpack_require__(65); - -function getName(pattern) { - return (0, (_normalizePattern || _load_normalizePattern()).normalizePattern)(pattern).name; -} - -function blankObjectUndefined(obj) { - return obj && Object.keys(obj).length ? obj : undefined; -} - -function keyForRemote(remote) { - return remote.resolved || (remote.reference && remote.hash ? `${remote.reference}#${remote.hash}` : null); -} - -function serializeIntegrity(integrity) { - // We need this because `Integrity.toString()` does not use sorting to ensure a stable string output - // See https://git.io/vx2Hy - return integrity.toString().split(' ').sort().join(' '); -} - -function implodeEntry(pattern, obj) { - const inferredName = getName(pattern); - const integrity = obj.integrity ? serializeIntegrity(obj.integrity) : ''; - const imploded = { - name: inferredName === obj.name ? undefined : obj.name, - version: obj.version, - uid: obj.uid === obj.version ? undefined : obj.uid, - resolved: obj.resolved, - registry: obj.registry === 'npm' ? undefined : obj.registry, - dependencies: blankObjectUndefined(obj.dependencies), - optionalDependencies: blankObjectUndefined(obj.optionalDependencies), - permissions: blankObjectUndefined(obj.permissions), - prebuiltVariants: blankObjectUndefined(obj.prebuiltVariants) - }; - if (integrity) { - imploded.integrity = integrity; - } - return imploded; -} - -function explodeEntry(pattern, obj) { - obj.optionalDependencies = obj.optionalDependencies || {}; - obj.dependencies = obj.dependencies || {}; - obj.uid = obj.uid || obj.version; - obj.permissions = obj.permissions || {}; - obj.registry = obj.registry || 'npm'; - obj.name = obj.name || getName(pattern); - const integrity = obj.integrity; - if (integrity && integrity.isIntegrity) { - obj.integrity = ssri.parse(integrity); - } - return obj; -} - -class Lockfile { - constructor({ cache, source, parseResultType } = {}) { - this.source = source || ''; - this.cache = cache; - this.parseResultType = parseResultType; - } - - // source string if the `cache` was parsed - - - // if true, we're parsing an old yarn file and need to update integrity fields - hasEntriesExistWithoutIntegrity() { - if (!this.cache) { - return false; - } - - for (const key in this.cache) { - // $FlowFixMe - `this.cache` is clearly defined at this point - if (!/^.*@(file:|http)/.test(key) && this.cache[key] && !this.cache[key].integrity) { - return true; - } - } - - return false; - } - - static fromDirectory(dir, reporter) { - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - // read the manifest in this directory - const lockfileLoc = path.join(dir, (_constants || _load_constants()).LOCKFILE_FILENAME); - - let lockfile; - let rawLockfile = ''; - let parseResult; - - if (yield (_fs || _load_fs()).exists(lockfileLoc)) { - rawLockfile = yield (_fs || _load_fs()).readFile(lockfileLoc); - parseResult = (0, (_parse2 || _load_parse2()).default)(rawLockfile, lockfileLoc); - - if (reporter) { - if (parseResult.type === 'merge') { - reporter.info(reporter.lang('lockfileMerged')); - } else if (parseResult.type === 'conflict') { - reporter.warn(reporter.lang('lockfileConflict')); - } - } - - lockfile = parseResult.object; - } else if (reporter) { - reporter.info(reporter.lang('noLockfileFound')); - } - - if (lockfile && lockfile.__metadata) { - const lockfilev2 = lockfile; - lockfile = {}; - } - - return new Lockfile({ cache: lockfile, source: rawLockfile, parseResultType: parseResult && parseResult.type }); - })(); - } - - getLocked(pattern) { - const cache = this.cache; - if (!cache) { - return undefined; - } - - const shrunk = pattern in cache && cache[pattern]; - - if (typeof shrunk === 'string') { - return this.getLocked(shrunk); - } else if (shrunk) { - explodeEntry(pattern, shrunk); - return shrunk; - } - - return undefined; - } - - removePattern(pattern) { - const cache = this.cache; - if (!cache) { - return; - } - delete cache[pattern]; - } - - getLockfile(patterns) { - const lockfile = {}; - const seen = new Map(); - - // order by name so that lockfile manifest is assigned to the first dependency with this manifest - // the others that have the same remoteKey will just refer to the first - // ordering allows for consistency in lockfile when it is serialized - const sortedPatternsKeys = Object.keys(patterns).sort((_misc || _load_misc()).sortAlpha); - - for (var _iterator = sortedPatternsKeys, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();;) { - var _ref; - - if (_isArray) { - if (_i >= _iterator.length) break; - _ref = _iterator[_i++]; - } else { - _i = _iterator.next(); - if (_i.done) break; - _ref = _i.value; - } - - const pattern = _ref; - - const pkg = patterns[pattern]; - const remote = pkg._remote, - ref = pkg._reference; - - invariant(ref, 'Package is missing a reference'); - invariant(remote, 'Package is missing a remote'); - - const remoteKey = keyForRemote(remote); - const seenPattern = remoteKey && seen.get(remoteKey); - if (seenPattern) { - // no point in duplicating it - lockfile[pattern] = seenPattern; - - // if we're relying on our name being inferred and two of the patterns have - // different inferred names then we need to set it - if (!seenPattern.name && getName(pattern) !== pkg.name) { - seenPattern.name = pkg.name; - } - continue; - } - const obj = implodeEntry(pattern, { - name: pkg.name, - version: pkg.version, - uid: pkg._uid, - resolved: remote.resolved, - integrity: remote.integrity, - registry: remote.registry, - dependencies: pkg.dependencies, - peerDependencies: pkg.peerDependencies, - optionalDependencies: pkg.optionalDependencies, - permissions: ref.permissions, - prebuiltVariants: pkg.prebuiltVariants - }); - - lockfile[pattern] = obj; - - if (remoteKey) { - seen.set(remoteKey, obj); - } - } - - return lockfile; - } -} -exports.default = Lockfile; - -/***/ }), -/* 20 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -exports.__esModule = true; - -var _assign = __webpack_require__(559); - -var _assign2 = _interopRequireDefault(_assign); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -exports.default = _assign2.default || function (target) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i]; - - for (var key in source) { - if (Object.prototype.hasOwnProperty.call(source, key)) { - target[key] = source[key]; - } - } - } - - return target; -}; - -/***/ }), -/* 21 */ -/***/ (function(module, exports, __webpack_require__) { - -var store = __webpack_require__(133)('wks'); -var uid = __webpack_require__(137); -var Symbol = __webpack_require__(17).Symbol; -var USE_SYMBOL = typeof Symbol == 'function'; - -var $exports = module.exports = function (name) { - return store[name] || (store[name] = - USE_SYMBOL && Symbol[name] || (USE_SYMBOL ? Symbol : uid)('Symbol.' + name)); -}; - -$exports.store = store; - - -/***/ }), -/* 22 */ -/***/ (function(module, exports) { - -exports = module.exports = SemVer; - -// The debug function is excluded entirely from the minified version. -/* nomin */ var debug; -/* nomin */ if (typeof process === 'object' && - /* nomin */ process.env && - /* nomin */ process.env.NODE_DEBUG && - /* nomin */ /\bsemver\b/i.test(process.env.NODE_DEBUG)) - /* nomin */ debug = function() { - /* nomin */ var args = Array.prototype.slice.call(arguments, 0); - /* nomin */ args.unshift('SEMVER'); - /* nomin */ console.log.apply(console, args); - /* nomin */ }; -/* nomin */ else - /* nomin */ debug = function() {}; - -// Note: this is the semver.org version of the spec that it implements -// Not necessarily the package version of this code. -exports.SEMVER_SPEC_VERSION = '2.0.0'; - -var MAX_LENGTH = 256; -var MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; - -// Max safe segment length for coercion. -var MAX_SAFE_COMPONENT_LENGTH = 16; - -// The actual regexps go on exports.re -var re = exports.re = []; -var src = exports.src = []; -var R = 0; - -// The following Regular Expressions can be used for tokenizing, -// validating, and parsing SemVer version strings. - -// ## Numeric Identifier -// A single `0`, or a non-zero digit followed by zero or more digits. - -var NUMERICIDENTIFIER = R++; -src[NUMERICIDENTIFIER] = '0|[1-9]\\d*'; -var NUMERICIDENTIFIERLOOSE = R++; -src[NUMERICIDENTIFIERLOOSE] = '[0-9]+'; - - -// ## Non-numeric Identifier -// Zero or more digits, followed by a letter or hyphen, and then zero or -// more letters, digits, or hyphens. - -var NONNUMERICIDENTIFIER = R++; -src[NONNUMERICIDENTIFIER] = '\\d*[a-zA-Z-][a-zA-Z0-9-]*'; - - -// ## Main Version -// Three dot-separated numeric identifiers. - -var MAINVERSION = R++; -src[MAINVERSION] = '(' + src[NUMERICIDENTIFIER] + ')\\.' + - '(' + src[NUMERICIDENTIFIER] + ')\\.' + - '(' + src[NUMERICIDENTIFIER] + ')'; - -var MAINVERSIONLOOSE = R++; -src[MAINVERSIONLOOSE] = '(' + src[NUMERICIDENTIFIERLOOSE] + ')\\.' + - '(' + src[NUMERICIDENTIFIERLOOSE] + ')\\.' + - '(' + src[NUMERICIDENTIFIERLOOSE] + ')'; - -// ## Pre-release Version Identifier -// A numeric identifier, or a non-numeric identifier. - -var PRERELEASEIDENTIFIER = R++; -src[PRERELEASEIDENTIFIER] = '(?:' + src[NUMERICIDENTIFIER] + - '|' + src[NONNUMERICIDENTIFIER] + ')'; - -var PRERELEASEIDENTIFIERLOOSE = R++; -src[PRERELEASEIDENTIFIERLOOSE] = '(?:' + src[NUMERICIDENTIFIERLOOSE] + - '|' + src[NONNUMERICIDENTIFIER] + ')'; - - -// ## Pre-release Version -// Hyphen, followed by one or more dot-separated pre-release version -// identifiers. - -var PRERELEASE = R++; -src[PRERELEASE] = '(?:-(' + src[PRERELEASEIDENTIFIER] + - '(?:\\.' + src[PRERELEASEIDENTIFIER] + ')*))'; - -var PRERELEASELOOSE = R++; -src[PRERELEASELOOSE] = '(?:-?(' + src[PRERELEASEIDENTIFIERLOOSE] + - '(?:\\.' + src[PRERELEASEIDENTIFIERLOOSE] + ')*))'; - -// ## Build Metadata Identifier -// Any combination of digits, letters, or hyphens. - -var BUILDIDENTIFIER = R++; -src[BUILDIDENTIFIER] = '[0-9A-Za-z-]+'; - -// ## Build Metadata -// Plus sign, followed by one or more period-separated build metadata -// identifiers. - -var BUILD = R++; -src[BUILD] = '(?:\\+(' + src[BUILDIDENTIFIER] + - '(?:\\.' + src[BUILDIDENTIFIER] + ')*))'; - - -// ## Full Version String -// A main version, followed optionally by a pre-release version and -// build metadata. - -// Note that the only major, minor, patch, and pre-release sections of -// the version string are capturing groups. The build metadata is not a -// capturing group, because it should not ever be used in version -// comparison. - -var FULL = R++; -var FULLPLAIN = 'v?' + src[MAINVERSION] + - src[PRERELEASE] + '?' + - src[BUILD] + '?'; - -src[FULL] = '^' + FULLPLAIN + '$'; - -// like full, but allows v1.2.3 and =1.2.3, which people do sometimes. -// also, 1.0.0alpha1 (prerelease without the hyphen) which is pretty -// common in the npm registry. -var LOOSEPLAIN = '[v=\\s]*' + src[MAINVERSIONLOOSE] + - src[PRERELEASELOOSE] + '?' + - src[BUILD] + '?'; - -var LOOSE = R++; -src[LOOSE] = '^' + LOOSEPLAIN + '$'; - -var GTLT = R++; -src[GTLT] = '((?:<|>)?=?)'; - -// Something like "2.*" or "1.2.x". -// Note that "x.x" is a valid xRange identifer, meaning "any version" -// Only the first item is strictly required. -var XRANGEIDENTIFIERLOOSE = R++; -src[XRANGEIDENTIFIERLOOSE] = src[NUMERICIDENTIFIERLOOSE] + '|x|X|\\*'; -var XRANGEIDENTIFIER = R++; -src[XRANGEIDENTIFIER] = src[NUMERICIDENTIFIER] + '|x|X|\\*'; - -var XRANGEPLAIN = R++; -src[XRANGEPLAIN] = '[v=\\s]*(' + src[XRANGEIDENTIFIER] + ')' + - '(?:\\.(' + src[XRANGEIDENTIFIER] + ')' + - '(?:\\.(' + src[XRANGEIDENTIFIER] + ')' + - '(?:' + src[PRERELEASE] + ')?' + - src[BUILD] + '?' + - ')?)?'; - -var XRANGEPLAINLOOSE = R++; -src[XRANGEPLAINLOOSE] = '[v=\\s]*(' + src[XRANGEIDENTIFIERLOOSE] + ')' + - '(?:\\.(' + src[XRANGEIDENTIFIERLOOSE] + ')' + - '(?:\\.(' + src[XRANGEIDENTIFIERLOOSE] + ')' + - '(?:' + src[PRERELEASELOOSE] + ')?' + - src[BUILD] + '?' + - ')?)?'; - -var XRANGE = R++; -src[XRANGE] = '^' + src[GTLT] + '\\s*' + src[XRANGEPLAIN] + '$'; -var XRANGELOOSE = R++; -src[XRANGELOOSE] = '^' + src[GTLT] + '\\s*' + src[XRANGEPLAINLOOSE] + '$'; - -// Coercion. -// Extract anything that could conceivably be a part of a valid semver -var COERCE = R++; -src[COERCE] = '(?:^|[^\\d])' + - '(\\d{1,' + MAX_SAFE_COMPONENT_LENGTH + '})' + - '(?:\\.(\\d{1,' + MAX_SAFE_COMPONENT_LENGTH + '}))?' + - '(?:\\.(\\d{1,' + MAX_SAFE_COMPONENT_LENGTH + '}))?' + - '(?:$|[^\\d])'; - -// Tilde ranges. -// Meaning is "reasonably at or greater than" -var LONETILDE = R++; -src[LONETILDE] = '(?:~>?)'; - -var TILDETRIM = R++; -src[TILDETRIM] = '(\\s*)' + src[LONETILDE] + '\\s+'; -re[TILDETRIM] = new RegExp(src[TILDETRIM], 'g'); -var tildeTrimReplace = '$1~'; - -var TILDE = R++; -src[TILDE] = '^' + src[LONETILDE] + src[XRANGEPLAIN] + '$'; -var TILDELOOSE = R++; -src[TILDELOOSE] = '^' + src[LONETILDE] + src[XRANGEPLAINLOOSE] + '$'; - -// Caret ranges. -// Meaning is "at least and backwards compatible with" -var LONECARET = R++; -src[LONECARET] = '(?:\\^)'; - -var CARETTRIM = R++; -src[CARETTRIM] = '(\\s*)' + src[LONECARET] + '\\s+'; -re[CARETTRIM] = new RegExp(src[CARETTRIM], 'g'); -var caretTrimReplace = '$1^'; - -var CARET = R++; -src[CARET] = '^' + src[LONECARET] + src[XRANGEPLAIN] + '$'; -var CARETLOOSE = R++; -src[CARETLOOSE] = '^' + src[LONECARET] + src[XRANGEPLAINLOOSE] + '$'; - -// A simple gt/lt/eq thing, or just "" to indicate "any version" -var COMPARATORLOOSE = R++; -src[COMPARATORLOOSE] = '^' + src[GTLT] + '\\s*(' + LOOSEPLAIN + ')$|^$'; -var COMPARATOR = R++; -src[COMPARATOR] = '^' + src[GTLT] + '\\s*(' + FULLPLAIN + ')$|^$'; - - -// An expression to strip any whitespace between the gtlt and the thing -// it modifies, so that `> 1.2.3` ==> `>1.2.3` -var COMPARATORTRIM = R++; -src[COMPARATORTRIM] = '(\\s*)' + src[GTLT] + - '\\s*(' + LOOSEPLAIN + '|' + src[XRANGEPLAIN] + ')'; - -// this one has to use the /g flag -re[COMPARATORTRIM] = new RegExp(src[COMPARATORTRIM], 'g'); -var comparatorTrimReplace = '$1$2$3'; - - -// Something like `1.2.3 - 1.2.4` -// Note that these all use the loose form, because they'll be -// checked against either the strict or loose comparator form -// later. -var HYPHENRANGE = R++; -src[HYPHENRANGE] = '^\\s*(' + src[XRANGEPLAIN] + ')' + - '\\s+-\\s+' + - '(' + src[XRANGEPLAIN] + ')' + - '\\s*$'; - -var HYPHENRANGELOOSE = R++; -src[HYPHENRANGELOOSE] = '^\\s*(' + src[XRANGEPLAINLOOSE] + ')' + - '\\s+-\\s+' + - '(' + src[XRANGEPLAINLOOSE] + ')' + - '\\s*$'; - -// Star ranges basically just allow anything at all. -var STAR = R++; -src[STAR] = '(<|>)?=?\\s*\\*'; - -// Compile to actual regexp objects. -// All are flag-free, unless they were created above with a flag. -for (var i = 0; i < R; i++) { - debug(i, src[i]); - if (!re[i]) - re[i] = new RegExp(src[i]); -} - -exports.parse = parse; -function parse(version, loose) { - if (version instanceof SemVer) - return version; - - if (typeof version !== 'string') - return null; - - if (version.length > MAX_LENGTH) - return null; - - var r = loose ? re[LOOSE] : re[FULL]; - if (!r.test(version)) - return null; - - try { - return new SemVer(version, loose); - } catch (er) { - return null; - } -} - -exports.valid = valid; -function valid(version, loose) { - var v = parse(version, loose); - return v ? v.version : null; -} - - -exports.clean = clean; -function clean(version, loose) { - var s = parse(version.trim().replace(/^[=v]+/, ''), loose); - return s ? s.version : null; -} - -exports.SemVer = SemVer; - -function SemVer(version, loose) { - if (version instanceof SemVer) { - if (version.loose === loose) - return version; - else - version = version.version; - } else if (typeof version !== 'string') { - throw new TypeError('Invalid Version: ' + version); - } - - if (version.length > MAX_LENGTH) - throw new TypeError('version is longer than ' + MAX_LENGTH + ' characters') - - if (!(this instanceof SemVer)) - return new SemVer(version, loose); - - debug('SemVer', version, loose); - this.loose = loose; - var m = version.trim().match(loose ? re[LOOSE] : re[FULL]); - - if (!m) - throw new TypeError('Invalid Version: ' + version); - - this.raw = version; - - // these are actually numbers - this.major = +m[1]; - this.minor = +m[2]; - this.patch = +m[3]; - - if (this.major > MAX_SAFE_INTEGER || this.major < 0) - throw new TypeError('Invalid major version') - - if (this.minor > MAX_SAFE_INTEGER || this.minor < 0) - throw new TypeError('Invalid minor version') - - if (this.patch > MAX_SAFE_INTEGER || this.patch < 0) - throw new TypeError('Invalid patch version') - - // numberify any prerelease numeric ids - if (!m[4]) - this.prerelease = []; - else - this.prerelease = m[4].split('.').map(function(id) { - if (/^[0-9]+$/.test(id)) { - var num = +id; - if (num >= 0 && num < MAX_SAFE_INTEGER) - return num; - } - return id; - }); - - this.build = m[5] ? m[5].split('.') : []; - this.format(); -} - -SemVer.prototype.format = function() { - this.version = this.major + '.' + this.minor + '.' + this.patch; - if (this.prerelease.length) - this.version += '-' + this.prerelease.join('.'); - return this.version; -}; - -SemVer.prototype.toString = function() { - return this.version; -}; - -SemVer.prototype.compare = function(other) { - debug('SemVer.compare', this.version, this.loose, other); - if (!(other instanceof SemVer)) - other = new SemVer(other, this.loose); - - return this.compareMain(other) || this.comparePre(other); -}; - -SemVer.prototype.compareMain = function(other) { - if (!(other instanceof SemVer)) - other = new SemVer(other, this.loose); - - return compareIdentifiers(this.major, other.major) || - compareIdentifiers(this.minor, other.minor) || - compareIdentifiers(this.patch, other.patch); -}; - -SemVer.prototype.comparePre = function(other) { - if (!(other instanceof SemVer)) - other = new SemVer(other, this.loose); - - // NOT having a prerelease is > having one - if (this.prerelease.length && !other.prerelease.length) - return -1; - else if (!this.prerelease.length && other.prerelease.length) - return 1; - else if (!this.prerelease.length && !other.prerelease.length) - return 0; - - var i = 0; - do { - var a = this.prerelease[i]; - var b = other.prerelease[i]; - debug('prerelease compare', i, a, b); - if (a === undefined && b === undefined) - return 0; - else if (b === undefined) - return 1; - else if (a === undefined) - return -1; - else if (a === b) - continue; - else - return compareIdentifiers(a, b); - } while (++i); -}; - -// preminor will bump the version up to the next minor release, and immediately -// down to pre-release. premajor and prepatch work the same way. -SemVer.prototype.inc = function(release, identifier) { - switch (release) { - case 'premajor': - this.prerelease.length = 0; - this.patch = 0; - this.minor = 0; - this.major++; - this.inc('pre', identifier); - break; - case 'preminor': - this.prerelease.length = 0; - this.patch = 0; - this.minor++; - this.inc('pre', identifier); - break; - case 'prepatch': - // If this is already a prerelease, it will bump to the next version - // drop any prereleases that might already exist, since they are not - // relevant at this point. - this.prerelease.length = 0; - this.inc('patch', identifier); - this.inc('pre', identifier); - break; - // If the input is a non-prerelease version, this acts the same as - // prepatch. - case 'prerelease': - if (this.prerelease.length === 0) - this.inc('patch', identifier); - this.inc('pre', identifier); - break; - - case 'major': - // If this is a pre-major version, bump up to the same major version. - // Otherwise increment major. - // 1.0.0-5 bumps to 1.0.0 - // 1.1.0 bumps to 2.0.0 - if (this.minor !== 0 || this.patch !== 0 || this.prerelease.length === 0) - this.major++; - this.minor = 0; - this.patch = 0; - this.prerelease = []; - break; - case 'minor': - // If this is a pre-minor version, bump up to the same minor version. - // Otherwise increment minor. - // 1.2.0-5 bumps to 1.2.0 - // 1.2.1 bumps to 1.3.0 - if (this.patch !== 0 || this.prerelease.length === 0) - this.minor++; - this.patch = 0; - this.prerelease = []; - break; - case 'patch': - // If this is not a pre-release version, it will increment the patch. - // If it is a pre-release it will bump up to the same patch version. - // 1.2.0-5 patches to 1.2.0 - // 1.2.0 patches to 1.2.1 - if (this.prerelease.length === 0) - this.patch++; - this.prerelease = []; - break; - // This probably shouldn't be used publicly. - // 1.0.0 "pre" would become 1.0.0-0 which is the wrong direction. - case 'pre': - if (this.prerelease.length === 0) - this.prerelease = [0]; - else { - var i = this.prerelease.length; - while (--i >= 0) { - if (typeof this.prerelease[i] === 'number') { - this.prerelease[i]++; - i = -2; - } - } - if (i === -1) // didn't increment anything - this.prerelease.push(0); - } - if (identifier) { - // 1.2.0-beta.1 bumps to 1.2.0-beta.2, - // 1.2.0-beta.fooblz or 1.2.0-beta bumps to 1.2.0-beta.0 - if (this.prerelease[0] === identifier) { - if (isNaN(this.prerelease[1])) - this.prerelease = [identifier, 0]; - } else - this.prerelease = [identifier, 0]; - } - break; - - default: - throw new Error('invalid increment argument: ' + release); - } - this.format(); - this.raw = this.version; - return this; -}; - -exports.inc = inc; -function inc(version, release, loose, identifier) { - if (typeof(loose) === 'string') { - identifier = loose; - loose = undefined; - } - - try { - return new SemVer(version, loose).inc(release, identifier).version; - } catch (er) { - return null; - } -} - -exports.diff = diff; -function diff(version1, version2) { - if (eq(version1, version2)) { - return null; - } else { - var v1 = parse(version1); - var v2 = parse(version2); - if (v1.prerelease.length || v2.prerelease.length) { - for (var key in v1) { - if (key === 'major' || key === 'minor' || key === 'patch') { - if (v1[key] !== v2[key]) { - return 'pre'+key; - } - } - } - return 'prerelease'; - } - for (var key in v1) { - if (key === 'major' || key === 'minor' || key === 'patch') { - if (v1[key] !== v2[key]) { - return key; - } - } - } - } -} - -exports.compareIdentifiers = compareIdentifiers; - -var numeric = /^[0-9]+$/; -function compareIdentifiers(a, b) { - var anum = numeric.test(a); - var bnum = numeric.test(b); - - if (anum && bnum) { - a = +a; - b = +b; - } - - return (anum && !bnum) ? -1 : - (bnum && !anum) ? 1 : - a < b ? -1 : - a > b ? 1 : - 0; -} - -exports.rcompareIdentifiers = rcompareIdentifiers; -function rcompareIdentifiers(a, b) { - return compareIdentifiers(b, a); -} - -exports.major = major; -function major(a, loose) { - return new SemVer(a, loose).major; -} - -exports.minor = minor; -function minor(a, loose) { - return new SemVer(a, loose).minor; -} - -exports.patch = patch; -function patch(a, loose) { - return new SemVer(a, loose).patch; -} - -exports.compare = compare; -function compare(a, b, loose) { - return new SemVer(a, loose).compare(new SemVer(b, loose)); -} - -exports.compareLoose = compareLoose; -function compareLoose(a, b) { - return compare(a, b, true); -} - -exports.rcompare = rcompare; -function rcompare(a, b, loose) { - return compare(b, a, loose); -} - -exports.sort = sort; -function sort(list, loose) { - return list.sort(function(a, b) { - return exports.compare(a, b, loose); - }); -} - -exports.rsort = rsort; -function rsort(list, loose) { - return list.sort(function(a, b) { - return exports.rcompare(a, b, loose); - }); -} - -exports.gt = gt; -function gt(a, b, loose) { - return compare(a, b, loose) > 0; -} - -exports.lt = lt; -function lt(a, b, loose) { - return compare(a, b, loose) < 0; -} - -exports.eq = eq; -function eq(a, b, loose) { - return compare(a, b, loose) === 0; -} - -exports.neq = neq; -function neq(a, b, loose) { - return compare(a, b, loose) !== 0; -} - -exports.gte = gte; -function gte(a, b, loose) { - return compare(a, b, loose) >= 0; -} - -exports.lte = lte; -function lte(a, b, loose) { - return compare(a, b, loose) <= 0; -} - -exports.cmp = cmp; -function cmp(a, op, b, loose) { - var ret; - switch (op) { - case '===': - if (typeof a === 'object') a = a.version; - if (typeof b === 'object') b = b.version; - ret = a === b; - break; - case '!==': - if (typeof a === 'object') a = a.version; - if (typeof b === 'object') b = b.version; - ret = a !== b; - break; - case '': case '=': case '==': ret = eq(a, b, loose); break; - case '!=': ret = neq(a, b, loose); break; - case '>': ret = gt(a, b, loose); break; - case '>=': ret = gte(a, b, loose); break; - case '<': ret = lt(a, b, loose); break; - case '<=': ret = lte(a, b, loose); break; - default: throw new TypeError('Invalid operator: ' + op); - } - return ret; -} - -exports.Comparator = Comparator; -function Comparator(comp, loose) { - if (comp instanceof Comparator) { - if (comp.loose === loose) - return comp; - else - comp = comp.value; - } - - if (!(this instanceof Comparator)) - return new Comparator(comp, loose); - - debug('comparator', comp, loose); - this.loose = loose; - this.parse(comp); - - if (this.semver === ANY) - this.value = ''; - else - this.value = this.operator + this.semver.version; - - debug('comp', this); -} - -var ANY = {}; -Comparator.prototype.parse = function(comp) { - var r = this.loose ? re[COMPARATORLOOSE] : re[COMPARATOR]; - var m = comp.match(r); - - if (!m) - throw new TypeError('Invalid comparator: ' + comp); - - this.operator = m[1]; - if (this.operator === '=') - this.operator = ''; - - // if it literally is just '>' or '' then allow anything. - if (!m[2]) - this.semver = ANY; - else - this.semver = new SemVer(m[2], this.loose); -}; - -Comparator.prototype.toString = function() { - return this.value; -}; - -Comparator.prototype.test = function(version) { - debug('Comparator.test', version, this.loose); - - if (this.semver === ANY) - return true; - - if (typeof version === 'string') - version = new SemVer(version, this.loose); - - return cmp(version, this.operator, this.semver, this.loose); -}; - -Comparator.prototype.intersects = function(comp, loose) { - if (!(comp instanceof Comparator)) { - throw new TypeError('a Comparator is required'); - } - - var rangeTmp; - - if (this.operator === '') { - rangeTmp = new Range(comp.value, loose); - return satisfies(this.value, rangeTmp, loose); - } else if (comp.operator === '') { - rangeTmp = new Range(this.value, loose); - return satisfies(comp.semver, rangeTmp, loose); - } - - var sameDirectionIncreasing = - (this.operator === '>=' || this.operator === '>') && - (comp.operator === '>=' || comp.operator === '>'); - var sameDirectionDecreasing = - (this.operator === '<=' || this.operator === '<') && - (comp.operator === '<=' || comp.operator === '<'); - var sameSemVer = this.semver.version === comp.semver.version; - var differentDirectionsInclusive = - (this.operator === '>=' || this.operator === '<=') && - (comp.operator === '>=' || comp.operator === '<='); - var oppositeDirectionsLessThan = - cmp(this.semver, '<', comp.semver, loose) && - ((this.operator === '>=' || this.operator === '>') && - (comp.operator === '<=' || comp.operator === '<')); - var oppositeDirectionsGreaterThan = - cmp(this.semver, '>', comp.semver, loose) && - ((this.operator === '<=' || this.operator === '<') && - (comp.operator === '>=' || comp.operator === '>')); - - return sameDirectionIncreasing || sameDirectionDecreasing || - (sameSemVer && differentDirectionsInclusive) || - oppositeDirectionsLessThan || oppositeDirectionsGreaterThan; -}; - - -exports.Range = Range; -function Range(range, loose) { - if (range instanceof Range) { - if (range.loose === loose) { - return range; - } else { - return new Range(range.raw, loose); - } - } - - if (range instanceof Comparator) { - return new Range(range.value, loose); - } - - if (!(this instanceof Range)) - return new Range(range, loose); - - this.loose = loose; - - // First, split based on boolean or || - this.raw = range; - this.set = range.split(/\s*\|\|\s*/).map(function(range) { - return this.parseRange(range.trim()); - }, this).filter(function(c) { - // throw out any that are not relevant for whatever reason - return c.length; - }); - - if (!this.set.length) { - throw new TypeError('Invalid SemVer Range: ' + range); - } - - this.format(); -} - -Range.prototype.format = function() { - this.range = this.set.map(function(comps) { - return comps.join(' ').trim(); - }).join('||').trim(); - return this.range; -}; - -Range.prototype.toString = function() { - return this.range; -}; - -Range.prototype.parseRange = function(range) { - var loose = this.loose; - range = range.trim(); - debug('range', range, loose); - // `1.2.3 - 1.2.4` => `>=1.2.3 <=1.2.4` - var hr = loose ? re[HYPHENRANGELOOSE] : re[HYPHENRANGE]; - range = range.replace(hr, hyphenReplace); - debug('hyphen replace', range); - // `> 1.2.3 < 1.2.5` => `>1.2.3 <1.2.5` - range = range.replace(re[COMPARATORTRIM], comparatorTrimReplace); - debug('comparator trim', range, re[COMPARATORTRIM]); - - // `~ 1.2.3` => `~1.2.3` - range = range.replace(re[TILDETRIM], tildeTrimReplace); - - // `^ 1.2.3` => `^1.2.3` - range = range.replace(re[CARETTRIM], caretTrimReplace); - - // normalize spaces - range = range.split(/\s+/).join(' '); - - // At this point, the range is completely trimmed and - // ready to be split into comparators. - - var compRe = loose ? re[COMPARATORLOOSE] : re[COMPARATOR]; - var set = range.split(' ').map(function(comp) { - return parseComparator(comp, loose); - }).join(' ').split(/\s+/); - if (this.loose) { - // in loose mode, throw out any that are not valid comparators - set = set.filter(function(comp) { - return !!comp.match(compRe); - }); - } - set = set.map(function(comp) { - return new Comparator(comp, loose); - }); - - return set; -}; - -Range.prototype.intersects = function(range, loose) { - if (!(range instanceof Range)) { - throw new TypeError('a Range is required'); - } - - return this.set.some(function(thisComparators) { - return thisComparators.every(function(thisComparator) { - return range.set.some(function(rangeComparators) { - return rangeComparators.every(function(rangeComparator) { - return thisComparator.intersects(rangeComparator, loose); - }); - }); - }); - }); -}; - -// Mostly just for testing and legacy API reasons -exports.toComparators = toComparators; -function toComparators(range, loose) { - return new Range(range, loose).set.map(function(comp) { - return comp.map(function(c) { - return c.value; - }).join(' ').trim().split(' '); - }); -} - -// comprised of xranges, tildes, stars, and gtlt's at this point. -// already replaced the hyphen ranges -// turn into a set of JUST comparators. -function parseComparator(comp, loose) { - debug('comp', comp); - comp = replaceCarets(comp, loose); - debug('caret', comp); - comp = replaceTildes(comp, loose); - debug('tildes', comp); - comp = replaceXRanges(comp, loose); - debug('xrange', comp); - comp = replaceStars(comp, loose); - debug('stars', comp); - return comp; -} - -function isX(id) { - return !id || id.toLowerCase() === 'x' || id === '*'; -} - -// ~, ~> --> * (any, kinda silly) -// ~2, ~2.x, ~2.x.x, ~>2, ~>2.x ~>2.x.x --> >=2.0.0 <3.0.0 -// ~2.0, ~2.0.x, ~>2.0, ~>2.0.x --> >=2.0.0 <2.1.0 -// ~1.2, ~1.2.x, ~>1.2, ~>1.2.x --> >=1.2.0 <1.3.0 -// ~1.2.3, ~>1.2.3 --> >=1.2.3 <1.3.0 -// ~1.2.0, ~>1.2.0 --> >=1.2.0 <1.3.0 -function replaceTildes(comp, loose) { - return comp.trim().split(/\s+/).map(function(comp) { - return replaceTilde(comp, loose); - }).join(' '); -} - -function replaceTilde(comp, loose) { - var r = loose ? re[TILDELOOSE] : re[TILDE]; - return comp.replace(r, function(_, M, m, p, pr) { - debug('tilde', comp, _, M, m, p, pr); - var ret; - - if (isX(M)) - ret = ''; - else if (isX(m)) - ret = '>=' + M + '.0.0 <' + (+M + 1) + '.0.0'; - else if (isX(p)) - // ~1.2 == >=1.2.0 <1.3.0 - ret = '>=' + M + '.' + m + '.0 <' + M + '.' + (+m + 1) + '.0'; - else if (pr) { - debug('replaceTilde pr', pr); - if (pr.charAt(0) !== '-') - pr = '-' + pr; - ret = '>=' + M + '.' + m + '.' + p + pr + - ' <' + M + '.' + (+m + 1) + '.0'; - } else - // ~1.2.3 == >=1.2.3 <1.3.0 - ret = '>=' + M + '.' + m + '.' + p + - ' <' + M + '.' + (+m + 1) + '.0'; - - debug('tilde return', ret); - return ret; - }); -} - -// ^ --> * (any, kinda silly) -// ^2, ^2.x, ^2.x.x --> >=2.0.0 <3.0.0 -// ^2.0, ^2.0.x --> >=2.0.0 <3.0.0 -// ^1.2, ^1.2.x --> >=1.2.0 <2.0.0 -// ^1.2.3 --> >=1.2.3 <2.0.0 -// ^1.2.0 --> >=1.2.0 <2.0.0 -function replaceCarets(comp, loose) { - return comp.trim().split(/\s+/).map(function(comp) { - return replaceCaret(comp, loose); - }).join(' '); -} - -function replaceCaret(comp, loose) { - debug('caret', comp, loose); - var r = loose ? re[CARETLOOSE] : re[CARET]; - return comp.replace(r, function(_, M, m, p, pr) { - debug('caret', comp, _, M, m, p, pr); - var ret; - - if (isX(M)) - ret = ''; - else if (isX(m)) - ret = '>=' + M + '.0.0 <' + (+M + 1) + '.0.0'; - else if (isX(p)) { - if (M === '0') - ret = '>=' + M + '.' + m + '.0 <' + M + '.' + (+m + 1) + '.0'; - else - ret = '>=' + M + '.' + m + '.0 <' + (+M + 1) + '.0.0'; - } else if (pr) { - debug('replaceCaret pr', pr); - if (pr.charAt(0) !== '-') - pr = '-' + pr; - if (M === '0') { - if (m === '0') - ret = '>=' + M + '.' + m + '.' + p + pr + - ' <' + M + '.' + m + '.' + (+p + 1); - else - ret = '>=' + M + '.' + m + '.' + p + pr + - ' <' + M + '.' + (+m + 1) + '.0'; - } else - ret = '>=' + M + '.' + m + '.' + p + pr + - ' <' + (+M + 1) + '.0.0'; - } else { - debug('no pr'); - if (M === '0') { - if (m === '0') - ret = '>=' + M + '.' + m + '.' + p + - ' <' + M + '.' + m + '.' + (+p + 1); - else - ret = '>=' + M + '.' + m + '.' + p + - ' <' + M + '.' + (+m + 1) + '.0'; - } else - ret = '>=' + M + '.' + m + '.' + p + - ' <' + (+M + 1) + '.0.0'; - } - - debug('caret return', ret); - return ret; - }); -} - -function replaceXRanges(comp, loose) { - debug('replaceXRanges', comp, loose); - return comp.split(/\s+/).map(function(comp) { - return replaceXRange(comp, loose); - }).join(' '); -} - -function replaceXRange(comp, loose) { - comp = comp.trim(); - var r = loose ? re[XRANGELOOSE] : re[XRANGE]; - return comp.replace(r, function(ret, gtlt, M, m, p, pr) { - debug('xRange', comp, ret, gtlt, M, m, p, pr); - var xM = isX(M); - var xm = xM || isX(m); - var xp = xm || isX(p); - var anyX = xp; - - if (gtlt === '=' && anyX) - gtlt = ''; - - if (xM) { - if (gtlt === '>' || gtlt === '<') { - // nothing is allowed - ret = '<0.0.0'; - } else { - // nothing is forbidden - ret = '*'; - } - } else if (gtlt && anyX) { - // replace X with 0 - if (xm) - m = 0; - if (xp) - p = 0; - - if (gtlt === '>') { - // >1 => >=2.0.0 - // >1.2 => >=1.3.0 - // >1.2.3 => >= 1.2.4 - gtlt = '>='; - if (xm) { - M = +M + 1; - m = 0; - p = 0; - } else if (xp) { - m = +m + 1; - p = 0; - } - } else if (gtlt === '<=') { - // <=0.7.x is actually <0.8.0, since any 0.7.x should - // pass. Similarly, <=7.x is actually <8.0.0, etc. - gtlt = '<'; - if (xm) - M = +M + 1; - else - m = +m + 1; - } - - ret = gtlt + M + '.' + m + '.' + p; - } else if (xm) { - ret = '>=' + M + '.0.0 <' + (+M + 1) + '.0.0'; - } else if (xp) { - ret = '>=' + M + '.' + m + '.0 <' + M + '.' + (+m + 1) + '.0'; - } - - debug('xRange return', ret); - - return ret; - }); -} - -// Because * is AND-ed with everything else in the comparator, -// and '' means "any version", just remove the *s entirely. -function replaceStars(comp, loose) { - debug('replaceStars', comp, loose); - // Looseness is ignored here. star is always as loose as it gets! - return comp.trim().replace(re[STAR], ''); -} - -// This function is passed to string.replace(re[HYPHENRANGE]) -// M, m, patch, prerelease, build -// 1.2 - 3.4.5 => >=1.2.0 <=3.4.5 -// 1.2.3 - 3.4 => >=1.2.0 <3.5.0 Any 3.4.x will do -// 1.2 - 3.4 => >=1.2.0 <3.5.0 -function hyphenReplace($0, - from, fM, fm, fp, fpr, fb, - to, tM, tm, tp, tpr, tb) { - - if (isX(fM)) - from = ''; - else if (isX(fm)) - from = '>=' + fM + '.0.0'; - else if (isX(fp)) - from = '>=' + fM + '.' + fm + '.0'; - else - from = '>=' + from; - - if (isX(tM)) - to = ''; - else if (isX(tm)) - to = '<' + (+tM + 1) + '.0.0'; - else if (isX(tp)) - to = '<' + tM + '.' + (+tm + 1) + '.0'; - else if (tpr) - to = '<=' + tM + '.' + tm + '.' + tp + '-' + tpr; - else - to = '<=' + to; - - return (from + ' ' + to).trim(); -} - - -// if ANY of the sets match ALL of its comparators, then pass -Range.prototype.test = function(version) { - if (!version) - return false; - - if (typeof version === 'string') - version = new SemVer(version, this.loose); - - for (var i = 0; i < this.set.length; i++) { - if (testSet(this.set[i], version)) - return true; - } - return false; -}; - -function testSet(set, version) { - for (var i = 0; i < set.length; i++) { - if (!set[i].test(version)) - return false; - } - - if (version.prerelease.length) { - // Find the set of versions that are allowed to have prereleases - // For example, ^1.2.3-pr.1 desugars to >=1.2.3-pr.1 <2.0.0 - // That should allow `1.2.3-pr.2` to pass. - // However, `1.2.4-alpha.notready` should NOT be allowed, - // even though it's within the range set by the comparators. - for (var i = 0; i < set.length; i++) { - debug(set[i].semver); - if (set[i].semver === ANY) - continue; - - if (set[i].semver.prerelease.length > 0) { - var allowed = set[i].semver; - if (allowed.major === version.major && - allowed.minor === version.minor && - allowed.patch === version.patch) - return true; - } - } - - // Version has a -pre, but it's not one of the ones we like. - return false; - } - - return true; -} - -exports.satisfies = satisfies; -function satisfies(version, range, loose) { - try { - range = new Range(range, loose); - } catch (er) { - return false; - } - return range.test(version); -} - -exports.maxSatisfying = maxSatisfying; -function maxSatisfying(versions, range, loose) { - var max = null; - var maxSV = null; - try { - var rangeObj = new Range(range, loose); - } catch (er) { - return null; - } - versions.forEach(function (v) { - if (rangeObj.test(v)) { // satisfies(v, range, loose) - if (!max || maxSV.compare(v) === -1) { // compare(max, v, true) - max = v; - maxSV = new SemVer(max, loose); - } - } - }) - return max; -} - -exports.minSatisfying = minSatisfying; -function minSatisfying(versions, range, loose) { - var min = null; - var minSV = null; - try { - var rangeObj = new Range(range, loose); - } catch (er) { - return null; - } - versions.forEach(function (v) { - if (rangeObj.test(v)) { // satisfies(v, range, loose) - if (!min || minSV.compare(v) === 1) { // compare(min, v, true) - min = v; - minSV = new SemVer(min, loose); - } - } - }) - return min; -} - -exports.validRange = validRange; -function validRange(range, loose) { - try { - // Return '*' instead of '' so that truthiness works. - // This will throw if it's invalid anyway - return new Range(range, loose).range || '*'; - } catch (er) { - return null; - } -} - -// Determine if version is less than all the versions possible in the range -exports.ltr = ltr; -function ltr(version, range, loose) { - return outside(version, range, '<', loose); -} - -// Determine if version is greater than all the versions possible in the range. -exports.gtr = gtr; -function gtr(version, range, loose) { - return outside(version, range, '>', loose); -} - -exports.outside = outside; -function outside(version, range, hilo, loose) { - version = new SemVer(version, loose); - range = new Range(range, loose); - - var gtfn, ltefn, ltfn, comp, ecomp; - switch (hilo) { - case '>': - gtfn = gt; - ltefn = lte; - ltfn = lt; - comp = '>'; - ecomp = '>='; - break; - case '<': - gtfn = lt; - ltefn = gte; - ltfn = gt; - comp = '<'; - ecomp = '<='; - break; - default: - throw new TypeError('Must provide a hilo val of "<" or ">"'); - } - - // If it satisifes the range it is not outside - if (satisfies(version, range, loose)) { - return false; - } - - // From now on, variable terms are as if we're in "gtr" mode. - // but note that everything is flipped for the "ltr" function. - - for (var i = 0; i < range.set.length; ++i) { - var comparators = range.set[i]; - - var high = null; - var low = null; - - comparators.forEach(function(comparator) { - if (comparator.semver === ANY) { - comparator = new Comparator('>=0.0.0') - } - high = high || comparator; - low = low || comparator; - if (gtfn(comparator.semver, high.semver, loose)) { - high = comparator; - } else if (ltfn(comparator.semver, low.semver, loose)) { - low = comparator; - } - }); - - // If the edge version comparator has a operator then our version - // isn't outside it - if (high.operator === comp || high.operator === ecomp) { - return false; - } - - // If the lowest version comparator has an operator and our version - // is less than it then it isn't higher than the range - if ((!low.operator || low.operator === comp) && - ltefn(version, low.semver)) { - return false; - } else if (low.operator === ecomp && ltfn(version, low.semver)) { - return false; - } - } - return true; -} - -exports.prerelease = prerelease; -function prerelease(version, loose) { - var parsed = parse(version, loose); - return (parsed && parsed.prerelease.length) ? parsed.prerelease : null; -} - -exports.intersects = intersects; -function intersects(r1, r2, loose) { - r1 = new Range(r1, loose) - r2 = new Range(r2, loose) - return r1.intersects(r2) -} - -exports.coerce = coerce; -function coerce(version) { - if (version instanceof SemVer) - return version; - - if (typeof version !== 'string') - return null; - - var match = version.match(re[COERCE]); - - if (match == null) - return null; - - return parse((match[1] || '0') + '.' + (match[2] || '0') + '.' + (match[3] || '0')); -} - - -/***/ }), -/* 23 */ -/***/ (function(module, exports) { - -module.exports = require("stream"); - -/***/ }), -/* 24 */ -/***/ (function(module, exports) { - -module.exports = require("url"); - -/***/ }), -/* 25 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return Subscription; }); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__util_isArray__ = __webpack_require__(41); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__util_isObject__ = __webpack_require__(444); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_2__util_isFunction__ = __webpack_require__(154); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_3__util_tryCatch__ = __webpack_require__(57); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_4__util_errorObject__ = __webpack_require__(48); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_5__util_UnsubscriptionError__ = __webpack_require__(441); -/** PURE_IMPORTS_START _util_isArray,_util_isObject,_util_isFunction,_util_tryCatch,_util_errorObject,_util_UnsubscriptionError PURE_IMPORTS_END */ - - - - - - -var Subscription = /*@__PURE__*/ (function () { - function Subscription(unsubscribe) { - this.closed = false; - this._parent = null; - this._parents = null; - this._subscriptions = null; - if (unsubscribe) { - this._unsubscribe = unsubscribe; - } - } - Subscription.prototype.unsubscribe = function () { - var hasErrors = false; - var errors; - if (this.closed) { - return; - } - var _a = this, _parent = _a._parent, _parents = _a._parents, _unsubscribe = _a._unsubscribe, _subscriptions = _a._subscriptions; - this.closed = true; - this._parent = null; - this._parents = null; - this._subscriptions = null; - var index = -1; - var len = _parents ? _parents.length : 0; - while (_parent) { - _parent.remove(this); - _parent = ++index < len && _parents[index] || null; - } - if (__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_2__util_isFunction__["a" /* isFunction */])(_unsubscribe)) { - var trial = __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_3__util_tryCatch__["a" /* tryCatch */])(_unsubscribe).call(this); - if (trial === __WEBPACK_IMPORTED_MODULE_4__util_errorObject__["a" /* errorObject */]) { - hasErrors = true; - errors = errors || (__WEBPACK_IMPORTED_MODULE_4__util_errorObject__["a" /* errorObject */].e instanceof __WEBPACK_IMPORTED_MODULE_5__util_UnsubscriptionError__["a" /* UnsubscriptionError */] ? - flattenUnsubscriptionErrors(__WEBPACK_IMPORTED_MODULE_4__util_errorObject__["a" /* errorObject */].e.errors) : [__WEBPACK_IMPORTED_MODULE_4__util_errorObject__["a" /* errorObject */].e]); - } - } - if (__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_0__util_isArray__["a" /* isArray */])(_subscriptions)) { - index = -1; - len = _subscriptions.length; - while (++index < len) { - var sub = _subscriptions[index]; - if (__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_1__util_isObject__["a" /* isObject */])(sub)) { - var trial = __webpack_require__.i(__WEBPACK_IMPORTED_MODULE_3__util_tryCatch__["a" /* tryCatch */])(sub.unsubscribe).call(sub); - if (trial === __WEBPACK_IMPORTED_MODULE_4__util_errorObject__["a" /* errorObject */]) { - hasErrors = true; - errors = errors || []; - var err = __WEBPACK_IMPORTED_MODULE_4__util_errorObject__["a" /* errorObject */].e; - if (err instanceof __WEBPACK_IMPORTED_MODULE_5__util_UnsubscriptionError__["a" /* UnsubscriptionError */]) { - errors = errors.concat(flattenUnsubscriptionErrors(err.errors)); - } - else { - errors.push(err); - } - } - } - } - } - if (hasErrors) { - throw new __WEBPACK_IMPORTED_MODULE_5__util_UnsubscriptionError__["a" /* UnsubscriptionError */](errors); - } - }; - Subscription.prototype.add = function (teardown) { - if (!teardown || (teardown === Subscription.EMPTY)) { - return Subscription.EMPTY; - } - if (teardown === this) { - return this; - } - var subscription = teardown; - switch (typeof teardown) { - case 'function': - subscription = new Subscription(teardown); - case 'object': - if (subscription.closed || typeof subscription.unsubscribe !== 'function') { - return subscription; - } - else if (this.closed) { - subscription.unsubscribe(); - return subscription; - } - else if (typeof subscription._addParent !== 'function') { - var tmp = subscription; - subscription = new Subscription(); - subscription._subscriptions = [tmp]; - } - break; - default: - throw new Error('unrecognized teardown ' + teardown + ' added to Subscription.'); - } - var subscriptions = this._subscriptions || (this._subscriptions = []); - subscriptions.push(subscription); - subscription._addParent(this); - return subscription; - }; - Subscription.prototype.remove = function (subscription) { - var subscriptions = this._subscriptions; - if (subscriptions) { - var subscriptionIndex = subscriptions.indexOf(subscription); - if (subscriptionIndex !== -1) { - subscriptions.splice(subscriptionIndex, 1); - } - } - }; - Subscription.prototype._addParent = function (parent) { - var _a = this, _parent = _a._parent, _parents = _a._parents; - if (!_parent || _parent === parent) { - this._parent = parent; - } - else if (!_parents) { - this._parents = [parent]; - } - else if (_parents.indexOf(parent) === -1) { - _parents.push(parent); - } - }; - Subscription.EMPTY = (function (empty) { - empty.closed = true; - return empty; - }(new Subscription())); - return Subscription; -}()); - -function flattenUnsubscriptionErrors(errors) { - return errors.reduce(function (errs, err) { return errs.concat((err instanceof __WEBPACK_IMPORTED_MODULE_5__util_UnsubscriptionError__["a" /* UnsubscriptionError */]) ? err.errors : err); }, []); -} -//# sourceMappingURL=Subscription.js.map - - -/***/ }), -/* 26 */ -/***/ (function(module, exports, __webpack_require__) { - -// Copyright 2015 Joyent, Inc. - -module.exports = { - bufferSplit: bufferSplit, - addRSAMissing: addRSAMissing, - calculateDSAPublic: calculateDSAPublic, - calculateED25519Public: calculateED25519Public, - calculateX25519Public: calculateX25519Public, - mpNormalize: mpNormalize, - mpDenormalize: mpDenormalize, - ecNormalize: ecNormalize, - countZeros: countZeros, - assertCompatible: assertCompatible, - isCompatible: isCompatible, - opensslKeyDeriv: opensslKeyDeriv, - opensshCipherInfo: opensshCipherInfo, - publicFromPrivateECDSA: publicFromPrivateECDSA, - zeroPadToLength: zeroPadToLength, - writeBitString: writeBitString, - readBitString: readBitString -}; - -var assert = __webpack_require__(16); -var Buffer = __webpack_require__(15).Buffer; -var PrivateKey = __webpack_require__(33); -var Key = __webpack_require__(27); -var crypto = __webpack_require__(11); -var algs = __webpack_require__(32); -var asn1 = __webpack_require__(66); - -var ec, jsbn; -var nacl; - -var MAX_CLASS_DEPTH = 3; - -function isCompatible(obj, klass, needVer) { - if (obj === null || typeof (obj) !== 'object') - return (false); - if (needVer === undefined) - needVer = klass.prototype._sshpkApiVersion; - if (obj instanceof klass && - klass.prototype._sshpkApiVersion[0] == needVer[0]) - return (true); - var proto = Object.getPrototypeOf(obj); - var depth = 0; - while (proto.constructor.name !== klass.name) { - proto = Object.getPrototypeOf(proto); - if (!proto || ++depth > MAX_CLASS_DEPTH) - return (false); - } - if (proto.constructor.name !== klass.name) - return (false); - var ver = proto._sshpkApiVersion; - if (ver === undefined) - ver = klass._oldVersionDetect(obj); - if (ver[0] != needVer[0] || ver[1] < needVer[1]) - return (false); - return (true); -} - -function assertCompatible(obj, klass, needVer, name) { - if (name === undefined) - name = 'object'; - assert.ok(obj, name + ' must not be null'); - assert.object(obj, name + ' must be an object'); - if (needVer === undefined) - needVer = klass.prototype._sshpkApiVersion; - if (obj instanceof klass && - klass.prototype._sshpkApiVersion[0] == needVer[0]) - return; - var proto = Object.getPrototypeOf(obj); - var depth = 0; - while (proto.constructor.name !== klass.name) { - proto = Object.getPrototypeOf(proto); - assert.ok(proto && ++depth <= MAX_CLASS_DEPTH, - name + ' must be a ' + klass.name + ' instance'); - } - assert.strictEqual(proto.constructor.name, klass.name, - name + ' must be a ' + klass.name + ' instance'); - var ver = proto._sshpkApiVersion; - if (ver === undefined) - ver = klass._oldVersionDetect(obj); - assert.ok(ver[0] == needVer[0] && ver[1] >= needVer[1], - name + ' must be compatible with ' + klass.name + ' klass ' + - 'version ' + needVer[0] + '.' + needVer[1]); -} - -var CIPHER_LEN = { - 'des-ede3-cbc': { key: 7, iv: 8 }, - 'aes-128-cbc': { key: 16, iv: 16 } -}; -var PKCS5_SALT_LEN = 8; - -function opensslKeyDeriv(cipher, salt, passphrase, count) { - assert.buffer(salt, 'salt'); - assert.buffer(passphrase, 'passphrase'); - assert.number(count, 'iteration count'); - - var clen = CIPHER_LEN[cipher]; - assert.object(clen, 'supported cipher'); - - salt = salt.slice(0, PKCS5_SALT_LEN); - - var D, D_prev, bufs; - var material = Buffer.alloc(0); - while (material.length < clen.key + clen.iv) { - bufs = []; - if (D_prev) - bufs.push(D_prev); - bufs.push(passphrase); - bufs.push(salt); - D = Buffer.concat(bufs); - for (var j = 0; j < count; ++j) - D = crypto.createHash('md5').update(D).digest(); - material = Buffer.concat([material, D]); - D_prev = D; - } - - return ({ - key: material.slice(0, clen.key), - iv: material.slice(clen.key, clen.key + clen.iv) - }); -} - -/* Count leading zero bits on a buffer */ -function countZeros(buf) { - var o = 0, obit = 8; - while (o < buf.length) { - var mask = (1 << obit); - if ((buf[o] & mask) === mask) - break; - obit--; - if (obit < 0) { - o++; - obit = 8; - } - } - return (o*8 + (8 - obit) - 1); -} - -function bufferSplit(buf, chr) { - assert.buffer(buf); - assert.string(chr); - - var parts = []; - var lastPart = 0; - var matches = 0; - for (var i = 0; i < buf.length; ++i) { - if (buf[i] === chr.charCodeAt(matches)) - ++matches; - else if (buf[i] === chr.charCodeAt(0)) - matches = 1; - else - matches = 0; - - if (matches >= chr.length) { - var newPart = i + 1; - parts.push(buf.slice(lastPart, newPart - matches)); - lastPart = newPart; - matches = 0; - } - } - if (lastPart <= buf.length) - parts.push(buf.slice(lastPart, buf.length)); - - return (parts); -} - -function ecNormalize(buf, addZero) { - assert.buffer(buf); - if (buf[0] === 0x00 && buf[1] === 0x04) { - if (addZero) - return (buf); - return (buf.slice(1)); - } else if (buf[0] === 0x04) { - if (!addZero) - return (buf); - } else { - while (buf[0] === 0x00) - buf = buf.slice(1); - if (buf[0] === 0x02 || buf[0] === 0x03) - throw (new Error('Compressed elliptic curve points ' + - 'are not supported')); - if (buf[0] !== 0x04) - throw (new Error('Not a valid elliptic curve point')); - if (!addZero) - return (buf); - } - var b = Buffer.alloc(buf.length + 1); - b[0] = 0x0; - buf.copy(b, 1); - return (b); -} - -function readBitString(der, tag) { - if (tag === undefined) - tag = asn1.Ber.BitString; - var buf = der.readString(tag, true); - assert.strictEqual(buf[0], 0x00, 'bit strings with unused bits are ' + - 'not supported (0x' + buf[0].toString(16) + ')'); - return (buf.slice(1)); -} - -function writeBitString(der, buf, tag) { - if (tag === undefined) - tag = asn1.Ber.BitString; - var b = Buffer.alloc(buf.length + 1); - b[0] = 0x00; - buf.copy(b, 1); - der.writeBuffer(b, tag); -} - -function mpNormalize(buf) { - assert.buffer(buf); - while (buf.length > 1 && buf[0] === 0x00 && (buf[1] & 0x80) === 0x00) - buf = buf.slice(1); - if ((buf[0] & 0x80) === 0x80) { - var b = Buffer.alloc(buf.length + 1); - b[0] = 0x00; - buf.copy(b, 1); - buf = b; - } - return (buf); -} - -function mpDenormalize(buf) { - assert.buffer(buf); - while (buf.length > 1 && buf[0] === 0x00) - buf = buf.slice(1); - return (buf); -} - -function zeroPadToLength(buf, len) { - assert.buffer(buf); - assert.number(len); - while (buf.length > len) { - assert.equal(buf[0], 0x00); - buf = buf.slice(1); - } - while (buf.length < len) { - var b = Buffer.alloc(buf.length + 1); - b[0] = 0x00; - buf.copy(b, 1); - buf = b; - } - return (buf); -} - -function bigintToMpBuf(bigint) { - var buf = Buffer.from(bigint.toByteArray()); - buf = mpNormalize(buf); - return (buf); -} - -function calculateDSAPublic(g, p, x) { - assert.buffer(g); - assert.buffer(p); - assert.buffer(x); - try { - var bigInt = __webpack_require__(81).BigInteger; - } catch (e) { - throw (new Error('To load a PKCS#8 format DSA private key, ' + - 'the node jsbn library is required.')); - } - g = new bigInt(g); - p = new bigInt(p); - x = new bigInt(x); - var y = g.modPow(x, p); - var ybuf = bigintToMpBuf(y); - return (ybuf); -} - -function calculateED25519Public(k) { - assert.buffer(k); - - if (nacl === undefined) - nacl = __webpack_require__(76); - - var kp = nacl.sign.keyPair.fromSeed(new Uint8Array(k)); - return (Buffer.from(kp.publicKey)); -} - -function calculateX25519Public(k) { - assert.buffer(k); - - if (nacl === undefined) - nacl = __webpack_require__(76); - - var kp = nacl.box.keyPair.fromSeed(new Uint8Array(k)); - return (Buffer.from(kp.publicKey)); -} - -function addRSAMissing(key) { - assert.object(key); - assertCompatible(key, PrivateKey, [1, 1]); - try { - var bigInt = __webpack_require__(81).BigInteger; - } catch (e) { - throw (new Error('To write a PEM private key from ' + - 'this source, the node jsbn lib is required.')); - } - - var d = new bigInt(key.part.d.data); - var buf; - - if (!key.part.dmodp) { - var p = new bigInt(key.part.p.data); - var dmodp = d.mod(p.subtract(1)); - - buf = bigintToMpBuf(dmodp); - key.part.dmodp = {name: 'dmodp', data: buf}; - key.parts.push(key.part.dmodp); - } - if (!key.part.dmodq) { - var q = new bigInt(key.part.q.data); - var dmodq = d.mod(q.subtract(1)); - - buf = bigintToMpBuf(dmodq); - key.part.dmodq = {name: 'dmodq', data: buf}; - key.parts.push(key.part.dmodq); - } -} - -function publicFromPrivateECDSA(curveName, priv) { - assert.string(curveName, 'curveName'); - assert.buffer(priv); - if (ec === undefined) - ec = __webpack_require__(139); - if (jsbn === undefined) - jsbn = __webpack_require__(81).BigInteger; - var params = algs.curves[curveName]; - var p = new jsbn(params.p); - var a = new jsbn(params.a); - var b = new jsbn(params.b); - var curve = new ec.ECCurveFp(p, a, b); - var G = curve.decodePointHex(params.G.toString('hex')); - - var d = new jsbn(mpNormalize(priv)); - var pub = G.multiply(d); - pub = Buffer.from(curve.encodePointHex(pub), 'hex'); - - var parts = []; - parts.push({name: 'curve', data: Buffer.from(curveName)}); - parts.push({name: 'Q', data: pub}); - - var key = new Key({type: 'ecdsa', curve: curve, parts: parts}); - return (key); -} - -function opensshCipherInfo(cipher) { - var inf = {}; - switch (cipher) { - case '3des-cbc': - inf.keySize = 24; - inf.blockSize = 8; - inf.opensslName = 'des-ede3-cbc'; - break; - case 'blowfish-cbc': - inf.keySize = 16; - inf.blockSize = 8; - inf.opensslName = 'bf-cbc'; - break; - case 'aes128-cbc': - case 'aes128-ctr': - case 'aes128-gcm@openssh.com': - inf.keySize = 16; - inf.blockSize = 16; - inf.opensslName = 'aes-128-' + cipher.slice(7, 10); - break; - case 'aes192-cbc': - case 'aes192-ctr': - case 'aes192-gcm@openssh.com': - inf.keySize = 24; - inf.blockSize = 16; - inf.opensslName = 'aes-192-' + cipher.slice(7, 10); - break; - case 'aes256-cbc': - case 'aes256-ctr': - case 'aes256-gcm@openssh.com': - inf.keySize = 32; - inf.blockSize = 16; - inf.opensslName = 'aes-256-' + cipher.slice(7, 10); - break; - default: - throw (new Error( - 'Unsupported openssl cipher "' + cipher + '"')); - } - return (inf); -} - - -/***/ }), -/* 27 */ -/***/ (function(module, exports, __webpack_require__) { - -// Copyright 2017 Joyent, Inc. - -module.exports = Key; - -var assert = __webpack_require__(16); -var algs = __webpack_require__(32); -var crypto = __webpack_require__(11); -var Fingerprint = __webpack_require__(156); -var Signature = __webpack_require__(75); -var DiffieHellman = __webpack_require__(325).DiffieHellman; -var errs = __webpack_require__(74); -var utils = __webpack_require__(26); -var PrivateKey = __webpack_require__(33); -var edCompat; - -try { - edCompat = __webpack_require__(454); -} catch (e) { - /* Just continue through, and bail out if we try to use it. */ -} - -var InvalidAlgorithmError = errs.InvalidAlgorithmError; -var KeyParseError = errs.KeyParseError; - -var formats = {}; -formats['auto'] = __webpack_require__(455); -formats['pem'] = __webpack_require__(86); -formats['pkcs1'] = __webpack_require__(327); -formats['pkcs8'] = __webpack_require__(157); -formats['rfc4253'] = __webpack_require__(103); -formats['ssh'] = __webpack_require__(456); -formats['ssh-private'] = __webpack_require__(193); -formats['openssh'] = formats['ssh-private']; -formats['dnssec'] = __webpack_require__(326); - -function Key(opts) { - assert.object(opts, 'options'); - assert.arrayOfObject(opts.parts, 'options.parts'); - assert.string(opts.type, 'options.type'); - assert.optionalString(opts.comment, 'options.comment'); - - var algInfo = algs.info[opts.type]; - if (typeof (algInfo) !== 'object') - throw (new InvalidAlgorithmError(opts.type)); - - var partLookup = {}; - for (var i = 0; i < opts.parts.length; ++i) { - var part = opts.parts[i]; - partLookup[part.name] = part; - } - - this.type = opts.type; - this.parts = opts.parts; - this.part = partLookup; - this.comment = undefined; - this.source = opts.source; - - /* for speeding up hashing/fingerprint operations */ - this._rfc4253Cache = opts._rfc4253Cache; - this._hashCache = {}; - - var sz; - this.curve = undefined; - if (this.type === 'ecdsa') { - var curve = this.part.curve.data.toString(); - this.curve = curve; - sz = algs.curves[curve].size; - } else if (this.type === 'ed25519' || this.type === 'curve25519') { - sz = 256; - this.curve = 'curve25519'; - } else { - var szPart = this.part[algInfo.sizePart]; - sz = szPart.data.length; - sz = sz * 8 - utils.countZeros(szPart.data); - } - this.size = sz; -} - -Key.formats = formats; - -Key.prototype.toBuffer = function (format, options) { - if (format === undefined) - format = 'ssh'; - assert.string(format, 'format'); - assert.object(formats[format], 'formats[format]'); - assert.optionalObject(options, 'options'); - - if (format === 'rfc4253') { - if (this._rfc4253Cache === undefined) - this._rfc4253Cache = formats['rfc4253'].write(this); - return (this._rfc4253Cache); - } - - return (formats[format].write(this, options)); -}; - -Key.prototype.toString = function (format, options) { - return (this.toBuffer(format, options).toString()); -}; - -Key.prototype.hash = function (algo) { - assert.string(algo, 'algorithm'); - algo = algo.toLowerCase(); - if (algs.hashAlgs[algo] === undefined) - throw (new InvalidAlgorithmError(algo)); - - if (this._hashCache[algo]) - return (this._hashCache[algo]); - var hash = crypto.createHash(algo). - update(this.toBuffer('rfc4253')).digest(); - this._hashCache[algo] = hash; - return (hash); -}; - -Key.prototype.fingerprint = function (algo) { - if (algo === undefined) - algo = 'sha256'; - assert.string(algo, 'algorithm'); - var opts = { - type: 'key', - hash: this.hash(algo), - algorithm: algo - }; - return (new Fingerprint(opts)); -}; - -Key.prototype.defaultHashAlgorithm = function () { - var hashAlgo = 'sha1'; - if (this.type === 'rsa') - hashAlgo = 'sha256'; - if (this.type === 'dsa' && this.size > 1024) - hashAlgo = 'sha256'; - if (this.type === 'ed25519') - hashAlgo = 'sha512'; - if (this.type === 'ecdsa') { - if (this.size <= 256) - hashAlgo = 'sha256'; - else if (this.size <= 384) - hashAlgo = 'sha384'; - else - hashAlgo = 'sha512'; - } - return (hashAlgo); -}; - -Key.prototype.createVerify = function (hashAlgo) { - if (hashAlgo === undefined) - hashAlgo = this.defaultHashAlgorithm(); - assert.string(hashAlgo, 'hash algorithm'); - - /* ED25519 is not supported by OpenSSL, use a javascript impl. */ - if (this.type === 'ed25519' && edCompat !== undefined) - return (new edCompat.Verifier(this, hashAlgo)); - if (this.type === 'curve25519') - throw (new Error('Curve25519 keys are not suitable for ' + - 'signing or verification')); - - var v, nm, err; - try { - nm = hashAlgo.toUpperCase(); - v = crypto.createVerify(nm); - } catch (e) { - err = e; - } - if (v === undefined || (err instanceof Error && - err.message.match(/Unknown message digest/))) { - nm = 'RSA-'; - nm += hashAlgo.toUpperCase(); - v = crypto.createVerify(nm); - } - assert.ok(v, 'failed to create verifier'); - var oldVerify = v.verify.bind(v); - var key = this.toBuffer('pkcs8'); - var curve = this.curve; - var self = this; - v.verify = function (signature, fmt) { - if (Signature.isSignature(signature, [2, 0])) { - if (signature.type !== self.type) - return (false); - if (signature.hashAlgorithm && - signature.hashAlgorithm !== hashAlgo) - return (false); - if (signature.curve && self.type === 'ecdsa' && - signature.curve !== curve) - return (false); - return (oldVerify(key, signature.toBuffer('asn1'))); - - } else if (typeof (signature) === 'string' || - Buffer.isBuffer(signature)) { - return (oldVerify(key, signature, fmt)); - - /* - * Avoid doing this on valid arguments, walking the prototype - * chain can be quite slow. - */ - } else if (Signature.isSignature(signature, [1, 0])) { - throw (new Error('signature was created by too old ' + - 'a version of sshpk and cannot be verified')); - - } else { - throw (new TypeError('signature must be a string, ' + - 'Buffer, or Signature object')); - } - }; - return (v); -}; - -Key.prototype.createDiffieHellman = function () { - if (this.type === 'rsa') - throw (new Error('RSA keys do not support Diffie-Hellman')); - - return (new DiffieHellman(this)); -}; -Key.prototype.createDH = Key.prototype.createDiffieHellman; - -Key.parse = function (data, format, options) { - if (typeof (data) !== 'string') - assert.buffer(data, 'data'); - if (format === undefined) - format = 'auto'; - assert.string(format, 'format'); - if (typeof (options) === 'string') - options = { filename: options }; - assert.optionalObject(options, 'options'); - if (options === undefined) - options = {}; - assert.optionalString(options.filename, 'options.filename'); - if (options.filename === undefined) - options.filename = '(unnamed)'; - - assert.object(formats[format], 'formats[format]'); - - try { - var k = formats[format].read(data, options); - if (k instanceof PrivateKey) - k = k.toPublic(); - if (!k.comment) - k.comment = options.filename; - return (k); - } catch (e) { - if (e.name === 'KeyEncryptedError') - throw (e); - throw (new KeyParseError(options.filename, format, e)); - } -}; - -Key.isKey = function (obj, ver) { - return (utils.isCompatible(obj, Key, ver)); -}; - -/* - * API versions for Key: - * [1,0] -- initial ver, may take Signature for createVerify or may not - * [1,1] -- added pkcs1, pkcs8 formats - * [1,2] -- added auto, ssh-private, openssh formats - * [1,3] -- added defaultHashAlgorithm - * [1,4] -- added ed support, createDH - * [1,5] -- first explicitly tagged version - * [1,6] -- changed ed25519 part names - */ -Key.prototype._sshpkApiVersion = [1, 6]; - -Key._oldVersionDetect = function (obj) { - assert.func(obj.toBuffer); - assert.func(obj.fingerprint); - if (obj.createDH) - return ([1, 4]); - if (obj.defaultHashAlgorithm) - return ([1, 3]); - if (obj.formats['auto']) - return ([1, 2]); - if (obj.formats['pkcs1']) - return ([1, 1]); - return ([1, 0]); -}; - - -/***/ }), -/* 28 */ -/***/ (function(module, exports) { - -module.exports = require("assert"); - -/***/ }), -/* 29 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.default = nullify; -function nullify(obj = {}) { - if (Array.isArray(obj)) { - for (var _iterator = obj, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();;) { - var _ref; - - if (_isArray) { - if (_i >= _iterator.length) break; - _ref = _iterator[_i++]; - } else { - _i = _iterator.next(); - if (_i.done) break; - _ref = _i.value; - } - - const item = _ref; - - nullify(item); - } - } else if (obj !== null && typeof obj === 'object' || typeof obj === 'function') { - Object.setPrototypeOf(obj, null); - - // for..in can only be applied to 'object', not 'function' - if (typeof obj === 'object') { - for (const key in obj) { - nullify(obj[key]); - } - } - } - - return obj; -} - -/***/ }), -/* 30 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - -const escapeStringRegexp = __webpack_require__(382); -const ansiStyles = __webpack_require__(474); -const stdoutColor = __webpack_require__(566).stdout; - -const template = __webpack_require__(567); - -const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); - -// `supportsColor.level` → `ansiStyles.color[name]` mapping -const levelMapping = ['ansi', 'ansi', 'ansi256', 'ansi16m']; - -// `color-convert` models to exclude from the Chalk API due to conflicts and such -const skipModels = new Set(['gray']); - -const styles = Object.create(null); - -function applyOptions(obj, options) { - options = options || {}; - - // Detect level if not set manually - const scLevel = stdoutColor ? stdoutColor.level : 0; - obj.level = options.level === undefined ? scLevel : options.level; - obj.enabled = 'enabled' in options ? options.enabled : obj.level > 0; -} - -function Chalk(options) { - // We check for this.template here since calling `chalk.constructor()` - // by itself will have a `this` of a previously constructed chalk object - if (!this || !(this instanceof Chalk) || this.template) { - const chalk = {}; - applyOptions(chalk, options); - - chalk.template = function () { - const args = [].slice.call(arguments); - return chalkTag.apply(null, [chalk.template].concat(args)); - }; - - Object.setPrototypeOf(chalk, Chalk.prototype); - Object.setPrototypeOf(chalk.template, chalk); - - chalk.template.constructor = Chalk; - - return chalk.template; - } - - applyOptions(this, options); -} - -// Use bright blue on Windows as the normal blue color is illegible -if (isSimpleWindowsTerm) { - ansiStyles.blue.open = '\u001B[94m'; -} - -for (const key of Object.keys(ansiStyles)) { - ansiStyles[key].closeRe = new RegExp(escapeStringRegexp(ansiStyles[key].close), 'g'); - - styles[key] = { - get() { - const codes = ansiStyles[key]; - return build.call(this, this._styles ? this._styles.concat(codes) : [codes], this._empty, key); - } - }; -} - -styles.visible = { - get() { - return build.call(this, this._styles || [], true, 'visible'); - } -}; - -ansiStyles.color.closeRe = new RegExp(escapeStringRegexp(ansiStyles.color.close), 'g'); -for (const model of Object.keys(ansiStyles.color.ansi)) { - if (skipModels.has(model)) { - continue; - } - - styles[model] = { - get() { - const level = this.level; - return function () { - const open = ansiStyles.color[levelMapping[level]][model].apply(null, arguments); - const codes = { - open, - close: ansiStyles.color.close, - closeRe: ansiStyles.color.closeRe - }; - return build.call(this, this._styles ? this._styles.concat(codes) : [codes], this._empty, model); - }; - } - }; -} - -ansiStyles.bgColor.closeRe = new RegExp(escapeStringRegexp(ansiStyles.bgColor.close), 'g'); -for (const model of Object.keys(ansiStyles.bgColor.ansi)) { - if (skipModels.has(model)) { - continue; - } - - const bgModel = 'bg' + model[0].toUpperCase() + model.slice(1); - styles[bgModel] = { - get() { - const level = this.level; - return function () { - const open = ansiStyles.bgColor[levelMapping[level]][model].apply(null, arguments); - const codes = { - open, - close: ansiStyles.bgColor.close, - closeRe: ansiStyles.bgColor.closeRe - }; - return build.call(this, this._styles ? this._styles.concat(codes) : [codes], this._empty, model); - }; - } - }; -} - -const proto = Object.defineProperties(() => {}, styles); - -function build(_styles, _empty, key) { - const builder = function () { - return applyStyle.apply(builder, arguments); - }; - - builder._styles = _styles; - builder._empty = _empty; - - const self = this; - - Object.defineProperty(builder, 'level', { - enumerable: true, - get() { - return self.level; - }, - set(level) { - self.level = level; - } - }); - - Object.defineProperty(builder, 'enabled', { - enumerable: true, - get() { - return self.enabled; - }, - set(enabled) { - self.enabled = enabled; - } - }); - - // See below for fix regarding invisible grey/dim combination on Windows - builder.hasGrey = this.hasGrey || key === 'gray' || key === 'grey'; - - // `__proto__` is used because we must return a function, but there is - // no way to create a function with a different prototype - builder.__proto__ = proto; // eslint-disable-line no-proto - - return builder; -} - -function applyStyle() { - // Support varags, but simply cast to string in case there's only one arg - const args = arguments; - const argsLen = args.length; - let str = String(arguments[0]); - - if (argsLen === 0) { - return ''; - } - - if (argsLen > 1) { - // Don't slice `arguments`, it prevents V8 optimizations - for (let a = 1; a < argsLen; a++) { - str += ' ' + args[a]; - } - } - - if (!this.enabled || this.level <= 0 || !str) { - return this._empty ? '' : str; - } - - // Turns out that on Windows dimmed gray text becomes invisible in cmd.exe, - // see https://github.com/chalk/chalk/issues/58 - // If we're on Windows and we're dealing with a gray color, temporarily make 'dim' a noop. - const originalDim = ansiStyles.dim.open; - if (isSimpleWindowsTerm && this.hasGrey) { - ansiStyles.dim.open = ''; - } - - for (const code of this._styles.slice().reverse()) { - // Replace any instances already present with a re-opening code - // otherwise only the part of the string until said closing code - // will be colored, and the rest will simply be 'plain'. - str = code.open + str.replace(code.closeRe, code.open) + code.close; - - // Close the styling before a linebreak and reopen - // after next line to fix a bleed issue on macOS - // https://github.com/chalk/chalk/pull/92 - str = str.replace(/\r?\n/g, `${code.close}$&${code.open}`); - } - - // Reset the original `dim` if we changed it to work around the Windows dimmed gray issue - ansiStyles.dim.open = originalDim; - - return str; -} - -function chalkTag(chalk, strings) { - if (!Array.isArray(strings)) { - // If chalk() was called by itself or with a string, - // return the string itself as a string. - return [].slice.call(arguments, 1).join(' '); - } - - const args = [].slice.call(arguments, 2); - const parts = [strings.raw[0]]; - - for (let i = 1; i < strings.length; i++) { - parts.push(String(args[i - 1]).replace(/[{}\\]/g, '\\$&')); - parts.push(String(strings.raw[i])); - } - - return template(chalk, parts.join('')); -} - -Object.defineProperties(Chalk.prototype, styles); - -module.exports = Chalk(); // eslint-disable-line new-cap -module.exports.supportsColor = stdoutColor; -module.exports.default = module.exports; // For TypeScript - - -/***/ }), -/* 31 */ -/***/ (function(module, exports) { - -var core = module.exports = { version: '2.5.7' }; -if (typeof __e == 'number') __e = core; // eslint-disable-line no-undef - - -/***/ }), -/* 32 */ -/***/ (function(module, exports, __webpack_require__) { - -// Copyright 2015 Joyent, Inc. - -var Buffer = __webpack_require__(15).Buffer; - -var algInfo = { - 'dsa': { - parts: ['p', 'q', 'g', 'y'], - sizePart: 'p' - }, - 'rsa': { - parts: ['e', 'n'], - sizePart: 'n' - }, - 'ecdsa': { - parts: ['curve', 'Q'], - sizePart: 'Q' - }, - 'ed25519': { - parts: ['A'], - sizePart: 'A' - } -}; -algInfo['curve25519'] = algInfo['ed25519']; - -var algPrivInfo = { - 'dsa': { - parts: ['p', 'q', 'g', 'y', 'x'] - }, - 'rsa': { - parts: ['n', 'e', 'd', 'iqmp', 'p', 'q'] - }, - 'ecdsa': { - parts: ['curve', 'Q', 'd'] - }, - 'ed25519': { - parts: ['A', 'k'] - } -}; -algPrivInfo['curve25519'] = algPrivInfo['ed25519']; - -var hashAlgs = { - 'md5': true, - 'sha1': true, - 'sha256': true, - 'sha384': true, - 'sha512': true -}; - -/* - * Taken from - * http://csrc.nist.gov/groups/ST/toolkit/documents/dss/NISTReCur.pdf - */ -var curves = { - 'nistp256': { - size: 256, - pkcs8oid: '1.2.840.10045.3.1.7', - p: Buffer.from(('00' + - 'ffffffff 00000001 00000000 00000000' + - '00000000 ffffffff ffffffff ffffffff'). - replace(/ /g, ''), 'hex'), - a: Buffer.from(('00' + - 'FFFFFFFF 00000001 00000000 00000000' + - '00000000 FFFFFFFF FFFFFFFF FFFFFFFC'). - replace(/ /g, ''), 'hex'), - b: Buffer.from(( - '5ac635d8 aa3a93e7 b3ebbd55 769886bc' + - '651d06b0 cc53b0f6 3bce3c3e 27d2604b'). - replace(/ /g, ''), 'hex'), - s: Buffer.from(('00' + - 'c49d3608 86e70493 6a6678e1 139d26b7' + - '819f7e90'). - replace(/ /g, ''), 'hex'), - n: Buffer.from(('00' + - 'ffffffff 00000000 ffffffff ffffffff' + - 'bce6faad a7179e84 f3b9cac2 fc632551'). - replace(/ /g, ''), 'hex'), - G: Buffer.from(('04' + - '6b17d1f2 e12c4247 f8bce6e5 63a440f2' + - '77037d81 2deb33a0 f4a13945 d898c296' + - '4fe342e2 fe1a7f9b 8ee7eb4a 7c0f9e16' + - '2bce3357 6b315ece cbb64068 37bf51f5'). - replace(/ /g, ''), 'hex') - }, - 'nistp384': { - size: 384, - pkcs8oid: '1.3.132.0.34', - p: Buffer.from(('00' + - 'ffffffff ffffffff ffffffff ffffffff' + - 'ffffffff ffffffff ffffffff fffffffe' + - 'ffffffff 00000000 00000000 ffffffff'). - replace(/ /g, ''), 'hex'), - a: Buffer.from(('00' + - 'FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF' + - 'FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE' + - 'FFFFFFFF 00000000 00000000 FFFFFFFC'). - replace(/ /g, ''), 'hex'), - b: Buffer.from(( - 'b3312fa7 e23ee7e4 988e056b e3f82d19' + - '181d9c6e fe814112 0314088f 5013875a' + - 'c656398d 8a2ed19d 2a85c8ed d3ec2aef'). - replace(/ /g, ''), 'hex'), - s: Buffer.from(('00' + - 'a335926a a319a27a 1d00896a 6773a482' + - '7acdac73'). - replace(/ /g, ''), 'hex'), - n: Buffer.from(('00' + - 'ffffffff ffffffff ffffffff ffffffff' + - 'ffffffff ffffffff c7634d81 f4372ddf' + - '581a0db2 48b0a77a ecec196a ccc52973'). - replace(/ /g, ''), 'hex'), - G: Buffer.from(('04' + - 'aa87ca22 be8b0537 8eb1c71e f320ad74' + - '6e1d3b62 8ba79b98 59f741e0 82542a38' + - '5502f25d bf55296c 3a545e38 72760ab7' + - '3617de4a 96262c6f 5d9e98bf 9292dc29' + - 'f8f41dbd 289a147c e9da3113 b5f0b8c0' + - '0a60b1ce 1d7e819d 7a431d7c 90ea0e5f'). - replace(/ /g, ''), 'hex') - }, - 'nistp521': { - size: 521, - pkcs8oid: '1.3.132.0.35', - p: Buffer.from(( - '01ffffff ffffffff ffffffff ffffffff' + - 'ffffffff ffffffff ffffffff ffffffff' + - 'ffffffff ffffffff ffffffff ffffffff' + - 'ffffffff ffffffff ffffffff ffffffff' + - 'ffff').replace(/ /g, ''), 'hex'), - a: Buffer.from(('01FF' + - 'FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF' + - 'FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF' + - 'FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF' + - 'FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFC'). - replace(/ /g, ''), 'hex'), - b: Buffer.from(('51' + - '953eb961 8e1c9a1f 929a21a0 b68540ee' + - 'a2da725b 99b315f3 b8b48991 8ef109e1' + - '56193951 ec7e937b 1652c0bd 3bb1bf07' + - '3573df88 3d2c34f1 ef451fd4 6b503f00'). - replace(/ /g, ''), 'hex'), - s: Buffer.from(('00' + - 'd09e8800 291cb853 96cc6717 393284aa' + - 'a0da64ba').replace(/ /g, ''), 'hex'), - n: Buffer.from(('01ff' + - 'ffffffff ffffffff ffffffff ffffffff' + - 'ffffffff ffffffff ffffffff fffffffa' + - '51868783 bf2f966b 7fcc0148 f709a5d0' + - '3bb5c9b8 899c47ae bb6fb71e 91386409'). - replace(/ /g, ''), 'hex'), - G: Buffer.from(('04' + - '00c6 858e06b7 0404e9cd 9e3ecb66 2395b442' + - '9c648139 053fb521 f828af60 6b4d3dba' + - 'a14b5e77 efe75928 fe1dc127 a2ffa8de' + - '3348b3c1 856a429b f97e7e31 c2e5bd66' + - '0118 39296a78 9a3bc004 5c8a5fb4 2c7d1bd9' + - '98f54449 579b4468 17afbd17 273e662c' + - '97ee7299 5ef42640 c550b901 3fad0761' + - '353c7086 a272c240 88be9476 9fd16650'). - replace(/ /g, ''), 'hex') - } -}; - -module.exports = { - info: algInfo, - privInfo: algPrivInfo, - hashAlgs: hashAlgs, - curves: curves -}; - - -/***/ }), -/* 33 */ -/***/ (function(module, exports, __webpack_require__) { - -// Copyright 2017 Joyent, Inc. - -module.exports = PrivateKey; - -var assert = __webpack_require__(16); -var Buffer = __webpack_require__(15).Buffer; -var algs = __webpack_require__(32); -var crypto = __webpack_require__(11); -var Fingerprint = __webpack_require__(156); -var Signature = __webpack_require__(75); -var errs = __webpack_require__(74); -var util = __webpack_require__(3); -var utils = __webpack_require__(26); -var dhe = __webpack_require__(325); -var generateECDSA = dhe.generateECDSA; -var generateED25519 = dhe.generateED25519; -var edCompat; -var nacl; - -try { - edCompat = __webpack_require__(454); -} catch (e) { - /* Just continue through, and bail out if we try to use it. */ -} - -var Key = __webpack_require__(27); - -var InvalidAlgorithmError = errs.InvalidAlgorithmError; -var KeyParseError = errs.KeyParseError; -var KeyEncryptedError = errs.KeyEncryptedError; - -var formats = {}; -formats['auto'] = __webpack_require__(455); -formats['pem'] = __webpack_require__(86); -formats['pkcs1'] = __webpack_require__(327); -formats['pkcs8'] = __webpack_require__(157); -formats['rfc4253'] = __webpack_require__(103); -formats['ssh-private'] = __webpack_require__(193); -formats['openssh'] = formats['ssh-private']; -formats['ssh'] = formats['ssh-private']; -formats['dnssec'] = __webpack_require__(326); - -function PrivateKey(opts) { - assert.object(opts, 'options'); - Key.call(this, opts); - - this._pubCache = undefined; -} -util.inherits(PrivateKey, Key); - -PrivateKey.formats = formats; - -PrivateKey.prototype.toBuffer = function (format, options) { - if (format === undefined) - format = 'pkcs1'; - assert.string(format, 'format'); - assert.object(formats[format], 'formats[format]'); - assert.optionalObject(options, 'options'); - - return (formats[format].write(this, options)); -}; - -PrivateKey.prototype.hash = function (algo) { - return (this.toPublic().hash(algo)); -}; - -PrivateKey.prototype.toPublic = function () { - if (this._pubCache) - return (this._pubCache); - - var algInfo = algs.info[this.type]; - var pubParts = []; - for (var i = 0; i < algInfo.parts.length; ++i) { - var p = algInfo.parts[i]; - pubParts.push(this.part[p]); - } - - this._pubCache = new Key({ - type: this.type, - source: this, - parts: pubParts - }); - if (this.comment) - this._pubCache.comment = this.comment; - return (this._pubCache); -}; - -PrivateKey.prototype.derive = function (newType) { - assert.string(newType, 'type'); - var priv, pub, pair; - - if (this.type === 'ed25519' && newType === 'curve25519') { - if (nacl === undefined) - nacl = __webpack_require__(76); - - priv = this.part.k.data; - if (priv[0] === 0x00) - priv = priv.slice(1); - - pair = nacl.box.keyPair.fromSecretKey(new Uint8Array(priv)); - pub = Buffer.from(pair.publicKey); - - return (new PrivateKey({ - type: 'curve25519', - parts: [ - { name: 'A', data: utils.mpNormalize(pub) }, - { name: 'k', data: utils.mpNormalize(priv) } - ] - })); - } else if (this.type === 'curve25519' && newType === 'ed25519') { - if (nacl === undefined) - nacl = __webpack_require__(76); - - priv = this.part.k.data; - if (priv[0] === 0x00) - priv = priv.slice(1); - - pair = nacl.sign.keyPair.fromSeed(new Uint8Array(priv)); - pub = Buffer.from(pair.publicKey); - - return (new PrivateKey({ - type: 'ed25519', - parts: [ - { name: 'A', data: utils.mpNormalize(pub) }, - { name: 'k', data: utils.mpNormalize(priv) } - ] - })); - } - throw (new Error('Key derivation not supported from ' + this.type + - ' to ' + newType)); -}; - -PrivateKey.prototype.createVerify = function (hashAlgo) { - return (this.toPublic().createVerify(hashAlgo)); -}; - -PrivateKey.prototype.createSign = function (hashAlgo) { - if (hashAlgo === undefined) - hashAlgo = this.defaultHashAlgorithm(); - assert.string(hashAlgo, 'hash algorithm'); - - /* ED25519 is not supported by OpenSSL, use a javascript impl. */ - if (this.type === 'ed25519' && edCompat !== undefined) - return (new edCompat.Signer(this, hashAlgo)); - if (this.type === 'curve25519') - throw (new Error('Curve25519 keys are not suitable for ' + - 'signing or verification')); - - var v, nm, err; - try { - nm = hashAlgo.toUpperCase(); - v = crypto.createSign(nm); - } catch (e) { - err = e; - } - if (v === undefined || (err instanceof Error && - err.message.match(/Unknown message digest/))) { - nm = 'RSA-'; - nm += hashAlgo.toUpperCase(); - v = crypto.createSign(nm); - } - assert.ok(v, 'failed to create verifier'); - var oldSign = v.sign.bind(v); - var key = this.toBuffer('pkcs1'); - var type = this.type; - var curve = this.curve; - v.sign = function () { - var sig = oldSign(key); - if (typeof (sig) === 'string') - sig = Buffer.from(sig, 'binary'); - sig = Signature.parse(sig, type, 'asn1'); - sig.hashAlgorithm = hashAlgo; - sig.curve = curve; - return (sig); - }; - return (v); -}; - -PrivateKey.parse = function (data, format, options) { - if (typeof (data) !== 'string') - assert.buffer(data, 'data'); - if (format === undefined) - format = 'auto'; - assert.string(format, 'format'); - if (typeof (options) === 'string') - options = { filename: options }; - assert.optionalObject(options, 'options'); - if (options === undefined) - options = {}; - assert.optionalString(options.filename, 'options.filename'); - if (options.filename === undefined) - options.filename = '(unnamed)'; - - assert.object(formats[format], 'formats[format]'); - - try { - var k = formats[format].read(data, options); - assert.ok(k instanceof PrivateKey, 'key is not a private key'); - if (!k.comment) - k.comment = options.filename; - return (k); - } catch (e) { - if (e.name === 'KeyEncryptedError') - throw (e); - throw (new KeyParseError(options.filename, format, e)); - } -}; - -PrivateKey.isPrivateKey = function (obj, ver) { - return (utils.isCompatible(obj, PrivateKey, ver)); -}; - -PrivateKey.generate = function (type, options) { - if (options === undefined) - options = {}; - assert.object(options, 'options'); - - switch (type) { - case 'ecdsa': - if (options.curve === undefined) - options.curve = 'nistp256'; - assert.string(options.curve, 'options.curve'); - return (generateECDSA(options.curve)); - case 'ed25519': - return (generateED25519()); - default: - throw (new Error('Key generation not supported with key ' + - 'type "' + type + '"')); - } -}; - -/* - * API versions for PrivateKey: - * [1,0] -- initial ver - * [1,1] -- added auto, pkcs[18], openssh/ssh-private formats - * [1,2] -- added defaultHashAlgorithm - * [1,3] -- added derive, ed, createDH - * [1,4] -- first tagged version - * [1,5] -- changed ed25519 part names and format - */ -PrivateKey.prototype._sshpkApiVersion = [1, 5]; - -PrivateKey._oldVersionDetect = function (obj) { - assert.func(obj.toPublic); - assert.func(obj.createSign); - if (obj.derive) - return ([1, 3]); - if (obj.defaultHashAlgorithm) - return ([1, 2]); - if (obj.formats['auto']) - return ([1, 1]); - return ([1, 0]); -}; - - -/***/ }), -/* 34 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.wrapLifecycle = exports.run = exports.install = exports.Install = undefined; - -var _extends2; - -function _load_extends() { - return _extends2 = _interopRequireDefault(__webpack_require__(20)); -} - -var _asyncToGenerator2; - -function _load_asyncToGenerator() { - return _asyncToGenerator2 = _interopRequireDefault(__webpack_require__(2)); -} - -let install = exports.install = (() => { - var _ref29 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (config, reporter, flags, lockfile) { - yield wrapLifecycle(config, flags, (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - const install = new Install(flags, config, reporter, lockfile); - yield install.init(); - })); - }); - - return function install(_x7, _x8, _x9, _x10) { - return _ref29.apply(this, arguments); - }; -})(); - -let run = exports.run = (() => { - var _ref31 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (config, reporter, flags, args) { - let lockfile; - let error = 'installCommandRenamed'; - if (flags.lockfile === false) { - lockfile = new (_lockfile || _load_lockfile()).default(); - } else { - lockfile = yield (_lockfile || _load_lockfile()).default.fromDirectory(config.lockfileFolder, reporter); - } - - if (args.length) { - const exampleArgs = args.slice(); - - if (flags.saveDev) { - exampleArgs.push('--dev'); - } - if (flags.savePeer) { - exampleArgs.push('--peer'); - } - if (flags.saveOptional) { - exampleArgs.push('--optional'); - } - if (flags.saveExact) { - exampleArgs.push('--exact'); - } - if (flags.saveTilde) { - exampleArgs.push('--tilde'); - } - let command = 'add'; - if (flags.global) { - error = 'globalFlagRemoved'; - command = 'global add'; - } - throw new (_errors || _load_errors()).MessageError(reporter.lang(error, `yarn ${command} ${exampleArgs.join(' ')}`)); - } - - yield install(config, reporter, flags, lockfile); - }); - - return function run(_x11, _x12, _x13, _x14) { - return _ref31.apply(this, arguments); - }; -})(); - -let wrapLifecycle = exports.wrapLifecycle = (() => { - var _ref32 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (config, flags, factory) { - yield config.executeLifecycleScript('preinstall'); - - yield factory(); - - // npm behaviour, seems kinda funky but yay compatibility - yield config.executeLifecycleScript('install'); - yield config.executeLifecycleScript('postinstall'); - - if (!config.production) { - if (!config.disablePrepublish) { - yield config.executeLifecycleScript('prepublish'); - } - yield config.executeLifecycleScript('prepare'); - } - }); - - return function wrapLifecycle(_x15, _x16, _x17) { - return _ref32.apply(this, arguments); - }; -})(); - -exports.hasWrapper = hasWrapper; -exports.setFlags = setFlags; - -var _objectPath; - -function _load_objectPath() { - return _objectPath = _interopRequireDefault(__webpack_require__(304)); -} - -var _hooks; - -function _load_hooks() { - return _hooks = __webpack_require__(368); -} - -var _index; - -function _load_index() { - return _index = _interopRequireDefault(__webpack_require__(218)); -} - -var _errors; - -function _load_errors() { - return _errors = __webpack_require__(6); -} - -var _integrityChecker; - -function _load_integrityChecker() { - return _integrityChecker = _interopRequireDefault(__webpack_require__(206)); -} - -var _lockfile; - -function _load_lockfile() { - return _lockfile = _interopRequireDefault(__webpack_require__(19)); -} - -var _lockfile2; - -function _load_lockfile2() { - return _lockfile2 = __webpack_require__(19); -} - -var _packageFetcher; - -function _load_packageFetcher() { - return _packageFetcher = _interopRequireWildcard(__webpack_require__(208)); -} - -var _packageInstallScripts; - -function _load_packageInstallScripts() { - return _packageInstallScripts = _interopRequireDefault(__webpack_require__(525)); -} - -var _packageCompatibility; - -function _load_packageCompatibility() { - return _packageCompatibility = _interopRequireWildcard(__webpack_require__(207)); -} - -var _packageResolver; - -function _load_packageResolver() { - return _packageResolver = _interopRequireDefault(__webpack_require__(360)); -} - -var _packageLinker; - -function _load_packageLinker() { - return _packageLinker = _interopRequireDefault(__webpack_require__(209)); -} - -var _index2; - -function _load_index2() { - return _index2 = __webpack_require__(58); -} - -var _index3; - -function _load_index3() { - return _index3 = __webpack_require__(78); -} - -var _autoclean; - -function _load_autoclean() { - return _autoclean = __webpack_require__(348); -} - -var _constants; - -function _load_constants() { - return _constants = _interopRequireWildcard(__webpack_require__(8)); -} - -var _normalizePattern; - -function _load_normalizePattern() { - return _normalizePattern = __webpack_require__(37); -} - -var _fs; - -function _load_fs() { - return _fs = _interopRequireWildcard(__webpack_require__(5)); -} - -var _map; - -function _load_map() { - return _map = _interopRequireDefault(__webpack_require__(29)); -} - -var _yarnVersion; - -function _load_yarnVersion() { - return _yarnVersion = __webpack_require__(105); -} - -var _generatePnpMap; - -function _load_generatePnpMap() { - return _generatePnpMap = __webpack_require__(547); -} - -var _workspaceLayout; - -function _load_workspaceLayout() { - return _workspaceLayout = _interopRequireDefault(__webpack_require__(90)); -} - -var _resolutionMap; - -function _load_resolutionMap() { - return _resolutionMap = _interopRequireDefault(__webpack_require__(212)); -} - -var _guessName; - -function _load_guessName() { - return _guessName = _interopRequireDefault(__webpack_require__(169)); -} - -var _audit; - -function _load_audit() { - return _audit = _interopRequireDefault(__webpack_require__(347)); -} - -function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -const deepEqual = __webpack_require__(599); - -const emoji = __webpack_require__(302); -const invariant = __webpack_require__(9); -const path = __webpack_require__(0); -const semver = __webpack_require__(22); -const uuid = __webpack_require__(120); -const ssri = __webpack_require__(65); - -const ONE_DAY = 1000 * 60 * 60 * 24; - -/** - * Try and detect the installation method for Yarn and provide a command to update it with. - */ - -function getUpdateCommand(installationMethod) { - if (installationMethod === 'tar') { - return `curl --compressed -o- -L ${(_constants || _load_constants()).YARN_INSTALLER_SH} | bash`; - } - - if (installationMethod === 'homebrew') { - return 'brew upgrade yarn'; - } - - if (installationMethod === 'deb') { - return 'sudo apt-get update && sudo apt-get install yarn'; - } - - if (installationMethod === 'rpm') { - return 'sudo yum install yarn'; - } - - if (installationMethod === 'npm') { - return 'npm install --global yarn'; - } - - if (installationMethod === 'chocolatey') { - return 'choco upgrade yarn'; - } - - if (installationMethod === 'apk') { - return 'apk update && apk add -u yarn'; - } - - if (installationMethod === 'portage') { - return 'sudo emerge --sync && sudo emerge -au sys-apps/yarn'; - } - - return null; -} - -function getUpdateInstaller(installationMethod) { - // Windows - if (installationMethod === 'msi') { - return (_constants || _load_constants()).YARN_INSTALLER_MSI; - } - - return null; -} - -function normalizeFlags(config, rawFlags) { - const flags = { - // install - har: !!rawFlags.har, - ignorePlatform: !!rawFlags.ignorePlatform, - ignoreEngines: !!rawFlags.ignoreEngines, - ignoreScripts: !!rawFlags.ignoreScripts, - ignoreOptional: !!rawFlags.ignoreOptional, - force: !!rawFlags.force, - flat: !!rawFlags.flat, - lockfile: rawFlags.lockfile !== false, - pureLockfile: !!rawFlags.pureLockfile, - updateChecksums: !!rawFlags.updateChecksums, - skipIntegrityCheck: !!rawFlags.skipIntegrityCheck, - frozenLockfile: !!rawFlags.frozenLockfile, - linkDuplicates: !!rawFlags.linkDuplicates, - checkFiles: !!rawFlags.checkFiles, - audit: !!rawFlags.audit, - - // add - peer: !!rawFlags.peer, - dev: !!rawFlags.dev, - optional: !!rawFlags.optional, - exact: !!rawFlags.exact, - tilde: !!rawFlags.tilde, - ignoreWorkspaceRootCheck: !!rawFlags.ignoreWorkspaceRootCheck, - - // outdated, update-interactive - includeWorkspaceDeps: !!rawFlags.includeWorkspaceDeps, - - // add, remove, update - workspaceRootIsCwd: rawFlags.workspaceRootIsCwd !== false - }; - - if (config.getOption('ignore-scripts')) { - flags.ignoreScripts = true; - } - - if (config.getOption('ignore-platform')) { - flags.ignorePlatform = true; - } - - if (config.getOption('ignore-engines')) { - flags.ignoreEngines = true; - } - - if (config.getOption('ignore-optional')) { - flags.ignoreOptional = true; - } - - if (config.getOption('force')) { - flags.force = true; - } - - return flags; -} - -class Install { - constructor(flags, config, reporter, lockfile) { - this.rootManifestRegistries = []; - this.rootPatternsToOrigin = (0, (_map || _load_map()).default)(); - this.lockfile = lockfile; - this.reporter = reporter; - this.config = config; - this.flags = normalizeFlags(config, flags); - this.resolutions = (0, (_map || _load_map()).default)(); // Legacy resolutions field used for flat install mode - this.resolutionMap = new (_resolutionMap || _load_resolutionMap()).default(config); // Selective resolutions for nested dependencies - this.resolver = new (_packageResolver || _load_packageResolver()).default(config, lockfile, this.resolutionMap); - this.integrityChecker = new (_integrityChecker || _load_integrityChecker()).default(config); - this.linker = new (_packageLinker || _load_packageLinker()).default(config, this.resolver); - this.scripts = new (_packageInstallScripts || _load_packageInstallScripts()).default(config, this.resolver, this.flags.force); - } - - /** - * Create a list of dependency requests from the current directories manifests. - */ - - fetchRequestFromCwd(excludePatterns = [], ignoreUnusedPatterns = false) { - var _this = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - const patterns = []; - const deps = []; - let resolutionDeps = []; - const manifest = {}; - - const ignorePatterns = []; - const usedPatterns = []; - let workspaceLayout; - - // some commands should always run in the context of the entire workspace - const cwd = _this.flags.includeWorkspaceDeps || _this.flags.workspaceRootIsCwd ? _this.config.lockfileFolder : _this.config.cwd; - - // non-workspaces are always root, otherwise check for workspace root - const cwdIsRoot = !_this.config.workspaceRootFolder || _this.config.lockfileFolder === cwd; - - // exclude package names that are in install args - const excludeNames = []; - for (var _iterator = excludePatterns, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();;) { - var _ref; - - if (_isArray) { - if (_i >= _iterator.length) break; - _ref = _iterator[_i++]; - } else { - _i = _iterator.next(); - if (_i.done) break; - _ref = _i.value; - } - - const pattern = _ref; - - if ((0, (_index3 || _load_index3()).getExoticResolver)(pattern)) { - excludeNames.push((0, (_guessName || _load_guessName()).default)(pattern)); - } else { - // extract the name - const parts = (0, (_normalizePattern || _load_normalizePattern()).normalizePattern)(pattern); - excludeNames.push(parts.name); - } - } - - const stripExcluded = function stripExcluded(manifest) { - for (var _iterator2 = excludeNames, _isArray2 = Array.isArray(_iterator2), _i2 = 0, _iterator2 = _isArray2 ? _iterator2 : _iterator2[Symbol.iterator]();;) { - var _ref2; - - if (_isArray2) { - if (_i2 >= _iterator2.length) break; - _ref2 = _iterator2[_i2++]; - } else { - _i2 = _iterator2.next(); - if (_i2.done) break; - _ref2 = _i2.value; - } - - const exclude = _ref2; - - if (manifest.dependencies && manifest.dependencies[exclude]) { - delete manifest.dependencies[exclude]; - } - if (manifest.devDependencies && manifest.devDependencies[exclude]) { - delete manifest.devDependencies[exclude]; - } - if (manifest.optionalDependencies && manifest.optionalDependencies[exclude]) { - delete manifest.optionalDependencies[exclude]; - } - } - }; - - for (var _iterator3 = Object.keys((_index2 || _load_index2()).registries), _isArray3 = Array.isArray(_iterator3), _i3 = 0, _iterator3 = _isArray3 ? _iterator3 : _iterator3[Symbol.iterator]();;) { - var _ref3; - - if (_isArray3) { - if (_i3 >= _iterator3.length) break; - _ref3 = _iterator3[_i3++]; - } else { - _i3 = _iterator3.next(); - if (_i3.done) break; - _ref3 = _i3.value; - } - - const registry = _ref3; - - const filename = (_index2 || _load_index2()).registries[registry].filename; - - const loc = path.join(cwd, filename); - if (!(yield (_fs || _load_fs()).exists(loc))) { - continue; - } - - _this.rootManifestRegistries.push(registry); - - const projectManifestJson = yield _this.config.readJson(loc); - yield (0, (_index || _load_index()).default)(projectManifestJson, cwd, _this.config, cwdIsRoot); - - Object.assign(_this.resolutions, projectManifestJson.resolutions); - Object.assign(manifest, projectManifestJson); - - _this.resolutionMap.init(_this.resolutions); - for (var _iterator4 = Object.keys(_this.resolutionMap.resolutionsByPackage), _isArray4 = Array.isArray(_iterator4), _i4 = 0, _iterator4 = _isArray4 ? _iterator4 : _iterator4[Symbol.iterator]();;) { - var _ref4; - - if (_isArray4) { - if (_i4 >= _iterator4.length) break; - _ref4 = _iterator4[_i4++]; - } else { - _i4 = _iterator4.next(); - if (_i4.done) break; - _ref4 = _i4.value; - } - - const packageName = _ref4; - - const optional = (_objectPath || _load_objectPath()).default.has(manifest.optionalDependencies, packageName) && _this.flags.ignoreOptional; - for (var _iterator8 = _this.resolutionMap.resolutionsByPackage[packageName], _isArray8 = Array.isArray(_iterator8), _i8 = 0, _iterator8 = _isArray8 ? _iterator8 : _iterator8[Symbol.iterator]();;) { - var _ref9; - - if (_isArray8) { - if (_i8 >= _iterator8.length) break; - _ref9 = _iterator8[_i8++]; - } else { - _i8 = _iterator8.next(); - if (_i8.done) break; - _ref9 = _i8.value; - } - - const _ref8 = _ref9; - const pattern = _ref8.pattern; - - resolutionDeps = [...resolutionDeps, { registry, pattern, optional, hint: 'resolution' }]; - } - } - - const pushDeps = function pushDeps(depType, manifest, { hint, optional }, isUsed) { - if (ignoreUnusedPatterns && !isUsed) { - return; - } - // We only take unused dependencies into consideration to get deterministic hoisting. - // Since flat mode doesn't care about hoisting and everything is top level and specified then we can safely - // leave these out. - if (_this.flags.flat && !isUsed) { - return; - } - const depMap = manifest[depType]; - for (const name in depMap) { - if (excludeNames.indexOf(name) >= 0) { - continue; - } - - let pattern = name; - if (!_this.lockfile.getLocked(pattern)) { - // when we use --save we save the dependency to the lockfile with just the name rather than the - // version combo - pattern += '@' + depMap[name]; - } - - // normalization made sure packages are mentioned only once - if (isUsed) { - usedPatterns.push(pattern); - } else { - ignorePatterns.push(pattern); - } - - _this.rootPatternsToOrigin[pattern] = depType; - patterns.push(pattern); - deps.push({ pattern, registry, hint, optional, workspaceName: manifest.name, workspaceLoc: manifest._loc }); - } - }; - - if (cwdIsRoot) { - pushDeps('dependencies', projectManifestJson, { hint: null, optional: false }, true); - pushDeps('devDependencies', projectManifestJson, { hint: 'dev', optional: false }, !_this.config.production); - pushDeps('optionalDependencies', projectManifestJson, { hint: 'optional', optional: true }, true); - } - - if (_this.config.workspaceRootFolder) { - const workspaceLoc = cwdIsRoot ? loc : path.join(_this.config.lockfileFolder, filename); - const workspacesRoot = path.dirname(workspaceLoc); - - let workspaceManifestJson = projectManifestJson; - if (!cwdIsRoot) { - // the manifest we read before was a child workspace, so get the root - workspaceManifestJson = yield _this.config.readJson(workspaceLoc); - yield (0, (_index || _load_index()).default)(workspaceManifestJson, workspacesRoot, _this.config, true); - } - - const workspaces = yield _this.config.resolveWorkspaces(workspacesRoot, workspaceManifestJson); - workspaceLayout = new (_workspaceLayout || _load_workspaceLayout()).default(workspaces, _this.config); - - // add virtual manifest that depends on all workspaces, this way package hoisters and resolvers will work fine - const workspaceDependencies = (0, (_extends2 || _load_extends()).default)({}, workspaceManifestJson.dependencies); - for (var _iterator5 = Object.keys(workspaces), _isArray5 = Array.isArray(_iterator5), _i5 = 0, _iterator5 = _isArray5 ? _iterator5 : _iterator5[Symbol.iterator]();;) { - var _ref5; - - if (_isArray5) { - if (_i5 >= _iterator5.length) break; - _ref5 = _iterator5[_i5++]; - } else { - _i5 = _iterator5.next(); - if (_i5.done) break; - _ref5 = _i5.value; - } - - const workspaceName = _ref5; - - const workspaceManifest = workspaces[workspaceName].manifest; - workspaceDependencies[workspaceName] = workspaceManifest.version; - - // include dependencies from all workspaces - if (_this.flags.includeWorkspaceDeps) { - pushDeps('dependencies', workspaceManifest, { hint: null, optional: false }, true); - pushDeps('devDependencies', workspaceManifest, { hint: 'dev', optional: false }, !_this.config.production); - pushDeps('optionalDependencies', workspaceManifest, { hint: 'optional', optional: true }, true); - } - } - const virtualDependencyManifest = { - _uid: '', - name: `workspace-aggregator-${uuid.v4()}`, - version: '1.0.0', - _registry: 'npm', - _loc: workspacesRoot, - dependencies: workspaceDependencies, - devDependencies: (0, (_extends2 || _load_extends()).default)({}, workspaceManifestJson.devDependencies), - optionalDependencies: (0, (_extends2 || _load_extends()).default)({}, workspaceManifestJson.optionalDependencies), - private: workspaceManifestJson.private, - workspaces: workspaceManifestJson.workspaces - }; - workspaceLayout.virtualManifestName = virtualDependencyManifest.name; - const virtualDep = {}; - virtualDep[virtualDependencyManifest.name] = virtualDependencyManifest.version; - workspaces[virtualDependencyManifest.name] = { loc: workspacesRoot, manifest: virtualDependencyManifest }; - - // ensure dependencies that should be excluded are stripped from the correct manifest - stripExcluded(cwdIsRoot ? virtualDependencyManifest : workspaces[projectManifestJson.name].manifest); - - pushDeps('workspaces', { workspaces: virtualDep }, { hint: 'workspaces', optional: false }, true); - - const implicitWorkspaceDependencies = (0, (_extends2 || _load_extends()).default)({}, workspaceDependencies); - - for (var _iterator6 = (_constants || _load_constants()).OWNED_DEPENDENCY_TYPES, _isArray6 = Array.isArray(_iterator6), _i6 = 0, _iterator6 = _isArray6 ? _iterator6 : _iterator6[Symbol.iterator]();;) { - var _ref6; - - if (_isArray6) { - if (_i6 >= _iterator6.length) break; - _ref6 = _iterator6[_i6++]; - } else { - _i6 = _iterator6.next(); - if (_i6.done) break; - _ref6 = _i6.value; - } - - const type = _ref6; - - for (var _iterator7 = Object.keys(projectManifestJson[type] || {}), _isArray7 = Array.isArray(_iterator7), _i7 = 0, _iterator7 = _isArray7 ? _iterator7 : _iterator7[Symbol.iterator]();;) { - var _ref7; - - if (_isArray7) { - if (_i7 >= _iterator7.length) break; - _ref7 = _iterator7[_i7++]; - } else { - _i7 = _iterator7.next(); - if (_i7.done) break; - _ref7 = _i7.value; - } - - const dependencyName = _ref7; - - delete implicitWorkspaceDependencies[dependencyName]; - } - } - - pushDeps('dependencies', { dependencies: implicitWorkspaceDependencies }, { hint: 'workspaces', optional: false }, true); - } - - break; - } - - // inherit root flat flag - if (manifest.flat) { - _this.flags.flat = true; - } - - return { - requests: [...resolutionDeps, ...deps], - patterns, - manifest, - usedPatterns, - ignorePatterns, - workspaceLayout - }; - })(); - } - - /** - * TODO description - */ - - prepareRequests(requests) { - return requests; - } - - preparePatterns(patterns) { - return patterns; - } - preparePatternsForLinking(patterns, cwdManifest, cwdIsRoot) { - return patterns; - } - - prepareManifests() { - var _this2 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - const manifests = yield _this2.config.getRootManifests(); - return manifests; - })(); - } - - bailout(patterns, workspaceLayout) { - var _this3 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - // We don't want to skip the audit - it could yield important errors - if (_this3.flags.audit) { - return false; - } - // PNP is so fast that the integrity check isn't pertinent - if (_this3.config.plugnplayEnabled) { - return false; - } - if (_this3.flags.skipIntegrityCheck || _this3.flags.force) { - return false; - } - const lockfileCache = _this3.lockfile.cache; - if (!lockfileCache) { - return false; - } - const lockfileClean = _this3.lockfile.parseResultType === 'success'; - const match = yield _this3.integrityChecker.check(patterns, lockfileCache, _this3.flags, workspaceLayout); - if (_this3.flags.frozenLockfile && (!lockfileClean || match.missingPatterns.length > 0)) { - throw new (_errors || _load_errors()).MessageError(_this3.reporter.lang('frozenLockfileError')); - } - - const haveLockfile = yield (_fs || _load_fs()).exists(path.join(_this3.config.lockfileFolder, (_constants || _load_constants()).LOCKFILE_FILENAME)); - - const lockfileIntegrityPresent = !_this3.lockfile.hasEntriesExistWithoutIntegrity(); - const integrityBailout = lockfileIntegrityPresent || !_this3.config.autoAddIntegrity; - - if (match.integrityMatches && haveLockfile && lockfileClean && integrityBailout) { - _this3.reporter.success(_this3.reporter.lang('upToDate')); - return true; - } - - if (match.integrityFileMissing && haveLockfile) { - // Integrity file missing, force script installations - _this3.scripts.setForce(true); - return false; - } - - if (match.hardRefreshRequired) { - // e.g. node version doesn't match, force script installations - _this3.scripts.setForce(true); - return false; - } - - if (!patterns.length && !match.integrityFileMissing) { - _this3.reporter.success(_this3.reporter.lang('nothingToInstall')); - yield _this3.createEmptyManifestFolders(); - yield _this3.saveLockfileAndIntegrity(patterns, workspaceLayout); - return true; - } - - return false; - })(); - } - - /** - * Produce empty folders for all used root manifests. - */ - - createEmptyManifestFolders() { - var _this4 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - if (_this4.config.modulesFolder) { - // already created - return; - } - - for (var _iterator9 = _this4.rootManifestRegistries, _isArray9 = Array.isArray(_iterator9), _i9 = 0, _iterator9 = _isArray9 ? _iterator9 : _iterator9[Symbol.iterator]();;) { - var _ref10; - - if (_isArray9) { - if (_i9 >= _iterator9.length) break; - _ref10 = _iterator9[_i9++]; - } else { - _i9 = _iterator9.next(); - if (_i9.done) break; - _ref10 = _i9.value; - } - - const registryName = _ref10; - const folder = _this4.config.registries[registryName].folder; - - yield (_fs || _load_fs()).mkdirp(path.join(_this4.config.lockfileFolder, folder)); - } - })(); - } - - /** - * TODO description - */ - - markIgnored(patterns) { - for (var _iterator10 = patterns, _isArray10 = Array.isArray(_iterator10), _i10 = 0, _iterator10 = _isArray10 ? _iterator10 : _iterator10[Symbol.iterator]();;) { - var _ref11; - - if (_isArray10) { - if (_i10 >= _iterator10.length) break; - _ref11 = _iterator10[_i10++]; - } else { - _i10 = _iterator10.next(); - if (_i10.done) break; - _ref11 = _i10.value; - } - - const pattern = _ref11; - - const manifest = this.resolver.getStrictResolvedPattern(pattern); - const ref = manifest._reference; - invariant(ref, 'expected package reference'); - - // just mark the package as ignored. if the package is used by a required package, the hoister - // will take care of that. - ref.ignore = true; - } - } - - /** - * helper method that gets only recent manifests - * used by global.ls command - */ - getFlattenedDeps() { - var _this5 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - var _ref12 = yield _this5.fetchRequestFromCwd(); - - const depRequests = _ref12.requests, - rawPatterns = _ref12.patterns; - - - yield _this5.resolver.init(depRequests, {}); - - const manifests = yield (_packageFetcher || _load_packageFetcher()).fetch(_this5.resolver.getManifests(), _this5.config); - _this5.resolver.updateManifests(manifests); - - return _this5.flatten(rawPatterns); - })(); - } - - /** - * TODO description - */ - - init() { - var _this6 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - _this6.checkUpdate(); - - // warn if we have a shrinkwrap - if (yield (_fs || _load_fs()).exists(path.join(_this6.config.lockfileFolder, (_constants || _load_constants()).NPM_SHRINKWRAP_FILENAME))) { - _this6.reporter.warn(_this6.reporter.lang('shrinkwrapWarning')); - } - - // warn if we have an npm lockfile - if (yield (_fs || _load_fs()).exists(path.join(_this6.config.lockfileFolder, (_constants || _load_constants()).NPM_LOCK_FILENAME))) { - _this6.reporter.warn(_this6.reporter.lang('npmLockfileWarning')); - } - - if (_this6.config.plugnplayEnabled) { - _this6.reporter.info(_this6.reporter.lang('plugnplaySuggestV2L1')); - _this6.reporter.info(_this6.reporter.lang('plugnplaySuggestV2L2')); - } - - let flattenedTopLevelPatterns = []; - const steps = []; - - var _ref13 = yield _this6.fetchRequestFromCwd(); - - const depRequests = _ref13.requests, - rawPatterns = _ref13.patterns, - ignorePatterns = _ref13.ignorePatterns, - workspaceLayout = _ref13.workspaceLayout, - manifest = _ref13.manifest; - - let topLevelPatterns = []; - - const artifacts = yield _this6.integrityChecker.getArtifacts(); - if (artifacts) { - _this6.linker.setArtifacts(artifacts); - _this6.scripts.setArtifacts(artifacts); - } - - if ((_packageCompatibility || _load_packageCompatibility()).shouldCheck(manifest, _this6.flags)) { - steps.push((() => { - var _ref14 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (curr, total) { - _this6.reporter.step(curr, total, _this6.reporter.lang('checkingManifest'), emoji.get('mag')); - yield _this6.checkCompatibility(); - }); - - return function (_x, _x2) { - return _ref14.apply(this, arguments); - }; - })()); - } - - const audit = new (_audit || _load_audit()).default(_this6.config, _this6.reporter, { groups: (_constants || _load_constants()).OWNED_DEPENDENCY_TYPES }); - let auditFoundProblems = false; - - steps.push(function (curr, total) { - return (0, (_hooks || _load_hooks()).callThroughHook)('resolveStep', (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - _this6.reporter.step(curr, total, _this6.reporter.lang('resolvingPackages'), emoji.get('mag')); - yield _this6.resolver.init(_this6.prepareRequests(depRequests), { - isFlat: _this6.flags.flat, - isFrozen: _this6.flags.frozenLockfile, - workspaceLayout - }); - topLevelPatterns = _this6.preparePatterns(rawPatterns); - flattenedTopLevelPatterns = yield _this6.flatten(topLevelPatterns); - return { bailout: !_this6.flags.audit && (yield _this6.bailout(topLevelPatterns, workspaceLayout)) }; - })); - }); - - if (_this6.flags.audit) { - steps.push(function (curr, total) { - return (0, (_hooks || _load_hooks()).callThroughHook)('auditStep', (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - _this6.reporter.step(curr, total, _this6.reporter.lang('auditRunning'), emoji.get('mag')); - if (_this6.flags.offline) { - _this6.reporter.warn(_this6.reporter.lang('auditOffline')); - return { bailout: false }; - } - const preparedManifests = yield _this6.prepareManifests(); - // $FlowFixMe - Flow considers `m` in the map operation to be "mixed", so does not recognize `m.object` - const mergedManifest = Object.assign({}, ...Object.values(preparedManifests).map(function (m) { - return m.object; - })); - const auditVulnerabilityCounts = yield audit.performAudit(mergedManifest, _this6.lockfile, _this6.resolver, _this6.linker, topLevelPatterns); - auditFoundProblems = auditVulnerabilityCounts.info || auditVulnerabilityCounts.low || auditVulnerabilityCounts.moderate || auditVulnerabilityCounts.high || auditVulnerabilityCounts.critical; - return { bailout: yield _this6.bailout(topLevelPatterns, workspaceLayout) }; - })); - }); - } - - steps.push(function (curr, total) { - return (0, (_hooks || _load_hooks()).callThroughHook)('fetchStep', (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - _this6.markIgnored(ignorePatterns); - _this6.reporter.step(curr, total, _this6.reporter.lang('fetchingPackages'), emoji.get('truck')); - const manifests = yield (_packageFetcher || _load_packageFetcher()).fetch(_this6.resolver.getManifests(), _this6.config); - _this6.resolver.updateManifests(manifests); - yield (_packageCompatibility || _load_packageCompatibility()).check(_this6.resolver.getManifests(), _this6.config, _this6.flags.ignoreEngines); - })); - }); - - steps.push(function (curr, total) { - return (0, (_hooks || _load_hooks()).callThroughHook)('linkStep', (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - // remove integrity hash to make this operation atomic - yield _this6.integrityChecker.removeIntegrityFile(); - _this6.reporter.step(curr, total, _this6.reporter.lang('linkingDependencies'), emoji.get('link')); - flattenedTopLevelPatterns = _this6.preparePatternsForLinking(flattenedTopLevelPatterns, manifest, _this6.config.lockfileFolder === _this6.config.cwd); - yield _this6.linker.init(flattenedTopLevelPatterns, workspaceLayout, { - linkDuplicates: _this6.flags.linkDuplicates, - ignoreOptional: _this6.flags.ignoreOptional - }); - })); - }); - - if (_this6.config.plugnplayEnabled) { - steps.push(function (curr, total) { - return (0, (_hooks || _load_hooks()).callThroughHook)('pnpStep', (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - const pnpPath = `${_this6.config.lockfileFolder}/${(_constants || _load_constants()).PNP_FILENAME}`; - - const code = yield (0, (_generatePnpMap || _load_generatePnpMap()).generatePnpMap)(_this6.config, flattenedTopLevelPatterns, { - resolver: _this6.resolver, - reporter: _this6.reporter, - targetPath: pnpPath, - workspaceLayout - }); - - try { - const file = yield (_fs || _load_fs()).readFile(pnpPath); - if (file === code) { - return; - } - } catch (error) {} - - yield (_fs || _load_fs()).writeFile(pnpPath, code); - yield (_fs || _load_fs()).chmod(pnpPath, 0o755); - })); - }); - } - - steps.push(function (curr, total) { - return (0, (_hooks || _load_hooks()).callThroughHook)('buildStep', (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - _this6.reporter.step(curr, total, _this6.flags.force ? _this6.reporter.lang('rebuildingPackages') : _this6.reporter.lang('buildingFreshPackages'), emoji.get('hammer')); - - if (_this6.config.ignoreScripts) { - _this6.reporter.warn(_this6.reporter.lang('ignoredScripts')); - } else { - yield _this6.scripts.init(flattenedTopLevelPatterns); - } - })); - }); - - if (_this6.flags.har) { - steps.push((() => { - var _ref21 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (curr, total) { - const formattedDate = new Date().toISOString().replace(/:/g, '-'); - const filename = `yarn-install_${formattedDate}.har`; - _this6.reporter.step(curr, total, _this6.reporter.lang('savingHar', filename), emoji.get('black_circle_for_record')); - yield _this6.config.requestManager.saveHar(filename); - }); - - return function (_x3, _x4) { - return _ref21.apply(this, arguments); - }; - })()); - } - - if (yield _this6.shouldClean()) { - steps.push((() => { - var _ref22 = (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* (curr, total) { - _this6.reporter.step(curr, total, _this6.reporter.lang('cleaningModules'), emoji.get('recycle')); - yield (0, (_autoclean || _load_autoclean()).clean)(_this6.config, _this6.reporter); - }); - - return function (_x5, _x6) { - return _ref22.apply(this, arguments); - }; - })()); - } - - let currentStep = 0; - for (var _iterator11 = steps, _isArray11 = Array.isArray(_iterator11), _i11 = 0, _iterator11 = _isArray11 ? _iterator11 : _iterator11[Symbol.iterator]();;) { - var _ref23; - - if (_isArray11) { - if (_i11 >= _iterator11.length) break; - _ref23 = _iterator11[_i11++]; - } else { - _i11 = _iterator11.next(); - if (_i11.done) break; - _ref23 = _i11.value; - } - - const step = _ref23; - - const stepResult = yield step(++currentStep, steps.length); - if (stepResult && stepResult.bailout) { - if (_this6.flags.audit) { - audit.summary(); - } - if (auditFoundProblems) { - _this6.reporter.warn(_this6.reporter.lang('auditRunAuditForDetails')); - } - _this6.maybeOutputUpdate(); - return flattenedTopLevelPatterns; - } - } - - // fin! - if (_this6.flags.audit) { - audit.summary(); - } - if (auditFoundProblems) { - _this6.reporter.warn(_this6.reporter.lang('auditRunAuditForDetails')); - } - yield _this6.saveLockfileAndIntegrity(topLevelPatterns, workspaceLayout); - yield _this6.persistChanges(); - _this6.maybeOutputUpdate(); - _this6.config.requestManager.clearCache(); - return flattenedTopLevelPatterns; - })(); - } - - checkCompatibility() { - var _this7 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - var _ref24 = yield _this7.fetchRequestFromCwd(); - - const manifest = _ref24.manifest; - - yield (_packageCompatibility || _load_packageCompatibility()).checkOne(manifest, _this7.config, _this7.flags.ignoreEngines); - })(); - } - - persistChanges() { - var _this8 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - // get all the different registry manifests in this folder - const manifests = yield _this8.config.getRootManifests(); - - if (yield _this8.applyChanges(manifests)) { - yield _this8.config.saveRootManifests(manifests); - } - })(); - } - - applyChanges(manifests) { - let hasChanged = false; - - if (this.config.plugnplayPersist) { - const object = manifests.npm.object; - - - if (typeof object.installConfig !== 'object') { - object.installConfig = {}; - } - - if (this.config.plugnplayEnabled && object.installConfig.pnp !== true) { - object.installConfig.pnp = true; - hasChanged = true; - } else if (!this.config.plugnplayEnabled && typeof object.installConfig.pnp !== 'undefined') { - delete object.installConfig.pnp; - hasChanged = true; - } - - if (Object.keys(object.installConfig).length === 0) { - delete object.installConfig; - } - } - - return Promise.resolve(hasChanged); - } - - /** - * Check if we should run the cleaning step. - */ - - shouldClean() { - return (_fs || _load_fs()).exists(path.join(this.config.lockfileFolder, (_constants || _load_constants()).CLEAN_FILENAME)); - } - - /** - * TODO - */ - - flatten(patterns) { - var _this9 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - if (!_this9.flags.flat) { - return patterns; - } - - const flattenedPatterns = []; - - for (var _iterator12 = _this9.resolver.getAllDependencyNamesByLevelOrder(patterns), _isArray12 = Array.isArray(_iterator12), _i12 = 0, _iterator12 = _isArray12 ? _iterator12 : _iterator12[Symbol.iterator]();;) { - var _ref25; - - if (_isArray12) { - if (_i12 >= _iterator12.length) break; - _ref25 = _iterator12[_i12++]; - } else { - _i12 = _iterator12.next(); - if (_i12.done) break; - _ref25 = _i12.value; - } - - const name = _ref25; - - const infos = _this9.resolver.getAllInfoForPackageName(name).filter(function (manifest) { - const ref = manifest._reference; - invariant(ref, 'expected package reference'); - return !ref.ignore; - }); - - if (infos.length === 0) { - continue; - } - - if (infos.length === 1) { - // single version of this package - // take out a single pattern as multiple patterns may have resolved to this package - flattenedPatterns.push(_this9.resolver.patternsByPackage[name][0]); - continue; - } - - const options = infos.map(function (info) { - const ref = info._reference; - invariant(ref, 'expected reference'); - return { - // TODO `and is required by {PARENT}`, - name: _this9.reporter.lang('manualVersionResolutionOption', ref.patterns.join(', '), info.version), - - value: info.version - }; - }); - const versions = infos.map(function (info) { - return info.version; - }); - let version; - - const resolutionVersion = _this9.resolutions[name]; - if (resolutionVersion && versions.indexOf(resolutionVersion) >= 0) { - // use json `resolution` version - version = resolutionVersion; - } else { - version = yield _this9.reporter.select(_this9.reporter.lang('manualVersionResolution', name), _this9.reporter.lang('answer'), options); - _this9.resolutions[name] = version; - } - - flattenedPatterns.push(_this9.resolver.collapseAllVersionsOfPackage(name, version)); - } - - // save resolutions to their appropriate root manifest - if (Object.keys(_this9.resolutions).length) { - const manifests = yield _this9.config.getRootManifests(); - - for (const name in _this9.resolutions) { - const version = _this9.resolutions[name]; - - const patterns = _this9.resolver.patternsByPackage[name]; - if (!patterns) { - continue; - } - - let manifest; - for (var _iterator13 = patterns, _isArray13 = Array.isArray(_iterator13), _i13 = 0, _iterator13 = _isArray13 ? _iterator13 : _iterator13[Symbol.iterator]();;) { - var _ref26; - - if (_isArray13) { - if (_i13 >= _iterator13.length) break; - _ref26 = _iterator13[_i13++]; - } else { - _i13 = _iterator13.next(); - if (_i13.done) break; - _ref26 = _i13.value; - } - - const pattern = _ref26; - - manifest = _this9.resolver.getResolvedPattern(pattern); - if (manifest) { - break; - } - } - invariant(manifest, 'expected manifest'); - - const ref = manifest._reference; - invariant(ref, 'expected reference'); - - const object = manifests[ref.registry].object; - object.resolutions = object.resolutions || {}; - object.resolutions[name] = version; - } - - yield _this9.config.saveRootManifests(manifests); - } - - return flattenedPatterns; - })(); - } - - /** - * Remove offline tarballs that are no longer required - */ - - pruneOfflineMirror(lockfile) { - var _this10 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - const mirror = _this10.config.getOfflineMirrorPath(); - if (!mirror) { - return; - } - - const requiredTarballs = new Set(); - for (const dependency in lockfile) { - const resolved = lockfile[dependency].resolved; - if (resolved) { - const basename = path.basename(resolved.split('#')[0]); - if (dependency[0] === '@' && basename[0] !== '@') { - requiredTarballs.add(`${dependency.split('/')[0]}-${basename}`); - } - requiredTarballs.add(basename); - } - } - - const mirrorFiles = yield (_fs || _load_fs()).walk(mirror); - for (var _iterator14 = mirrorFiles, _isArray14 = Array.isArray(_iterator14), _i14 = 0, _iterator14 = _isArray14 ? _iterator14 : _iterator14[Symbol.iterator]();;) { - var _ref27; - - if (_isArray14) { - if (_i14 >= _iterator14.length) break; - _ref27 = _iterator14[_i14++]; - } else { - _i14 = _iterator14.next(); - if (_i14.done) break; - _ref27 = _i14.value; - } - - const file = _ref27; - - const isTarball = path.extname(file.basename) === '.tgz'; - // if using experimental-pack-script-packages-in-mirror flag, don't unlink prebuilt packages - const hasPrebuiltPackage = file.relative.startsWith('prebuilt/'); - if (isTarball && !hasPrebuiltPackage && !requiredTarballs.has(file.basename)) { - yield (_fs || _load_fs()).unlink(file.absolute); - } - } - })(); - } - - /** - * Save updated integrity and lockfiles. - */ - - saveLockfileAndIntegrity(patterns, workspaceLayout) { - var _this11 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - const resolvedPatterns = {}; - Object.keys(_this11.resolver.patterns).forEach(function (pattern) { - if (!workspaceLayout || !workspaceLayout.getManifestByPattern(pattern)) { - resolvedPatterns[pattern] = _this11.resolver.patterns[pattern]; - } - }); - - // TODO this code is duplicated in a few places, need a common way to filter out workspace patterns from lockfile - patterns = patterns.filter(function (p) { - return !workspaceLayout || !workspaceLayout.getManifestByPattern(p); - }); - - const lockfileBasedOnResolver = _this11.lockfile.getLockfile(resolvedPatterns); - - if (_this11.config.pruneOfflineMirror) { - yield _this11.pruneOfflineMirror(lockfileBasedOnResolver); - } - - // write integrity hash - if (!_this11.config.plugnplayEnabled) { - yield _this11.integrityChecker.save(patterns, lockfileBasedOnResolver, _this11.flags, workspaceLayout, _this11.scripts.getArtifacts()); - } - - // --no-lockfile or --pure-lockfile or --frozen-lockfile - if (_this11.flags.lockfile === false || _this11.flags.pureLockfile || _this11.flags.frozenLockfile) { - return; - } - - const lockFileHasAllPatterns = patterns.every(function (p) { - return _this11.lockfile.getLocked(p); - }); - const lockfilePatternsMatch = Object.keys(_this11.lockfile.cache || {}).every(function (p) { - return lockfileBasedOnResolver[p]; - }); - const resolverPatternsAreSameAsInLockfile = Object.keys(lockfileBasedOnResolver).every(function (pattern) { - const manifest = _this11.lockfile.getLocked(pattern); - return manifest && manifest.resolved === lockfileBasedOnResolver[pattern].resolved && deepEqual(manifest.prebuiltVariants, lockfileBasedOnResolver[pattern].prebuiltVariants); - }); - const integrityPatternsAreSameAsInLockfile = Object.keys(lockfileBasedOnResolver).every(function (pattern) { - const existingIntegrityInfo = lockfileBasedOnResolver[pattern].integrity; - if (!existingIntegrityInfo) { - // if this entry does not have an integrity, no need to re-write the lockfile because of it - return true; - } - const manifest = _this11.lockfile.getLocked(pattern); - if (manifest && manifest.integrity) { - const manifestIntegrity = ssri.stringify(manifest.integrity); - return manifestIntegrity === existingIntegrityInfo; - } - return false; - }); - - // remove command is followed by install with force, lockfile will be rewritten in any case then - if (!_this11.flags.force && _this11.lockfile.parseResultType === 'success' && lockFileHasAllPatterns && lockfilePatternsMatch && resolverPatternsAreSameAsInLockfile && integrityPatternsAreSameAsInLockfile && patterns.length) { - return; - } - - // build lockfile location - const loc = path.join(_this11.config.lockfileFolder, (_constants || _load_constants()).LOCKFILE_FILENAME); - - // write lockfile - const lockSource = (0, (_lockfile2 || _load_lockfile2()).stringify)(lockfileBasedOnResolver, false, _this11.config.enableLockfileVersions); - yield (_fs || _load_fs()).writeFilePreservingEol(loc, lockSource); - - _this11._logSuccessSaveLockfile(); - })(); - } - - _logSuccessSaveLockfile() { - this.reporter.success(this.reporter.lang('savedLockfile')); - } - - /** - * Load the dependency graph of the current install. Only does package resolving and wont write to the cwd. - */ - hydrate(ignoreUnusedPatterns) { - var _this12 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - const request = yield _this12.fetchRequestFromCwd([], ignoreUnusedPatterns); - const depRequests = request.requests, - rawPatterns = request.patterns, - ignorePatterns = request.ignorePatterns, - workspaceLayout = request.workspaceLayout; - - - yield _this12.resolver.init(depRequests, { - isFlat: _this12.flags.flat, - isFrozen: _this12.flags.frozenLockfile, - workspaceLayout - }); - yield _this12.flatten(rawPatterns); - _this12.markIgnored(ignorePatterns); - - // fetch packages, should hit cache most of the time - const manifests = yield (_packageFetcher || _load_packageFetcher()).fetch(_this12.resolver.getManifests(), _this12.config); - _this12.resolver.updateManifests(manifests); - yield (_packageCompatibility || _load_packageCompatibility()).check(_this12.resolver.getManifests(), _this12.config, _this12.flags.ignoreEngines); - - // expand minimal manifests - for (var _iterator15 = _this12.resolver.getManifests(), _isArray15 = Array.isArray(_iterator15), _i15 = 0, _iterator15 = _isArray15 ? _iterator15 : _iterator15[Symbol.iterator]();;) { - var _ref28; - - if (_isArray15) { - if (_i15 >= _iterator15.length) break; - _ref28 = _iterator15[_i15++]; - } else { - _i15 = _iterator15.next(); - if (_i15.done) break; - _ref28 = _i15.value; - } - - const manifest = _ref28; - - const ref = manifest._reference; - invariant(ref, 'expected reference'); - const type = ref.remote.type; - // link specifier won't ever hit cache - - let loc = ''; - if (type === 'link') { - continue; - } else if (type === 'workspace') { - if (!ref.remote.reference) { - continue; - } - loc = ref.remote.reference; - } else { - loc = _this12.config.generateModuleCachePath(ref); - } - const newPkg = yield _this12.config.readManifest(loc); - yield _this12.resolver.updateManifest(ref, newPkg); - } - - return request; - })(); - } - - /** - * Check for updates every day and output a nag message if there's a newer version. - */ - - checkUpdate() { - if (this.config.nonInteractive) { - // don't show upgrade dialog on CI or non-TTY terminals - return; - } - - // don't check if disabled - if (this.config.getOption('disable-self-update-check')) { - return; - } - - // only check for updates once a day - const lastUpdateCheck = Number(this.config.getOption('lastUpdateCheck')) || 0; - if (lastUpdateCheck && Date.now() - lastUpdateCheck < ONE_DAY) { - return; - } - - // don't bug for updates on tagged releases - if ((_yarnVersion || _load_yarnVersion()).version.indexOf('-') >= 0) { - return; - } - - this._checkUpdate().catch(() => { - // swallow errors - }); - } - - _checkUpdate() { - var _this13 = this; - - return (0, (_asyncToGenerator2 || _load_asyncToGenerator()).default)(function* () { - let latestVersion = yield _this13.config.requestManager.request({ - url: (_constants || _load_constants()).SELF_UPDATE_VERSION_URL - }); - invariant(typeof latestVersion === 'string', 'expected string'); - latestVersion = latestVersion.trim(); - if (!semver.valid(latestVersion)) { - return; - } - - // ensure we only check for updates periodically - _this13.config.registries.yarn.saveHomeConfig({ - lastUpdateCheck: Date.now() - }); - - if (semver.gt(latestVersion, (_yarnVersion || _load_yarnVersion()).version)) { - const installationMethod = yield (0, (_yarnVersion || _load_yarnVersion()).getInstallationMethod)(); - _this13.maybeOutputUpdate = function () { - _this13.reporter.warn(_this13.reporter.lang('yarnOutdated', latestVersion, (_yarnVersion || _load_yarnVersion()).version)); - - const command = getUpdateCommand(installationMethod); - if (command) { - _this13.reporter.info(_this13.reporter.lang('yarnOutdatedCommand')); - _this13.reporter.command(command); - } else { - const installer = getUpdateInstaller(installationMethod); - if (installer) { - _this13.reporter.info(_this13.reporter.lang('yarnOutdatedInstaller', installer)); - } - } - }; - } - })(); - } - - /** - * Method to override with a possible upgrade message. - */ - - maybeOutputUpdate() {} -} - -exports.Install = Install; -function hasWrapper(commander, args) { - return true; -} - -function setFlags(commander) { - commander.description('Yarn install is used to install all dependencies for a project.'); - commander.usage('install [flags]'); - commander.option('-A, --audit', 'Run vulnerability audit on installed packages'); - commander.option('-g, --global', 'DEPRECATED'); - commander.option('-S, --save', 'DEPRECATED - save package to your `dependencies`'); - commander.option('-D, --save-dev', 'DEPRECATED - save package to your `devDependencies`'); - commander.option('-P, --save-peer', 'DEPRECATED - save package to your `peerDependencies`'); - commander.option('-O, --save-optional', 'DEPRECATED - save package to your `optionalDependencies`'); - commander.option('-E, --save-exact', 'DEPRECATED'); - commander.option('-T, --save-tilde', 'DEPRECATED'); -} - -/***/ }), -/* 35 */ -/***/ (function(module, exports, __webpack_require__) { - -var isObject = __webpack_require__(53); -module.exports = function (it) { - if (!isObject(it)) throw TypeError(it + ' is not an object!'); - return it; -}; - - -/***/ }), -/* 36 */ -/***/ (function(module, __webpack_exports__, __webpack_require__) { - -"use strict"; -/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "b", function() { return SubjectSubscriber; }); -/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return Subject; }); -/* unused harmony export AnonymousSubject */ -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_tslib__ = __webpack_require__(1); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__Observable__ = __webpack_require__(12); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_2__Subscriber__ = __webpack_require__(7); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_3__Subscription__ = __webpack_require__(25); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_4__util_ObjectUnsubscribedError__ = __webpack_require__(190); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_5__SubjectSubscription__ = __webpack_require__(422); -/* harmony import */ var __WEBPACK_IMPORTED_MODULE_6__internal_symbol_rxSubscriber__ = __webpack_require__(321); -/** PURE_IMPORTS_START tslib,_Observable,_Subscriber,_Subscription,_util_ObjectUnsubscribedError,_SubjectSubscription,_internal_symbol_rxSubscriber PURE_IMPORTS_END */ - - - - - - - -var SubjectSubscriber = /*@__PURE__*/ (function (_super) { - __WEBPACK_IMPORTED_MODULE_0_tslib__["a" /* __extends */](SubjectSubscriber, _super); - function SubjectSubscriber(destination) { - var _this = _super.call(this, destination) || this; - _this.destination = destination; - return _this; - } - return SubjectSubscriber; -}(__WEBPACK_IMPORTED_MODULE_2__Subscriber__["a" /* Subscriber */])); - -var Subject = /*@__PURE__*/ (function (_super) { - __WEBPACK_IMPORTED_MODULE_0_tslib__["a" /* __extends */](Subject, _super); - function Subject() { - var _this = _super.call(this) || this; - _this.observers = []; - _this.closed = false; - _this.isStopped = false; - _this.hasError = false; - _this.thrownError = null; - return _this; - } - Subject.prototype[__WEBPACK_IMPORTED_MODULE_6__internal_symbol_rxSubscriber__["a" /* rxSubscriber */]] = function () { - return new SubjectSubscriber(this); - }; - Subject.prototype.lift = function (operator) { - var subject = new AnonymousSubject(this, this); - subject.operator = operator; - return subject; - }; - Subject.prototype.next = function (value) { - if (this.closed) { - throw new __WEBPACK_IMPORTED_MODULE_4__util_ObjectUnsubscribedError__["a" /* ObjectUnsubscribedError */](); - } - if (!this.isStopped) { - var observers = this.observers; - var len = observers.length; - var copy = observers.slice(); - for (var i = 0; i < len; i++) { - copy[i].next(value); - } - } - }; - Subject.prototype.error = function (err) { - if (this.closed) { - throw new __WEBPACK_IMPORTED_MODULE_4__util_ObjectUnsubscribedError__["a" /* ObjectUnsubscribedError */](); - } - this.hasError = true; - this.thrownError = err; - this.isStopped = true; - var observers = this.observers; - var len = observers.length; - var copy = observers.slice(); - for (var i = 0; i < len; i++) { - copy[i].error(err); - } - this.observers.length = 0; - }; - Subject.prototype.complete = function () { - if (this.closed) { - throw new __WEBPACK_IMPORTED_MODULE_4__util_ObjectUnsubscribedError__["a" /* ObjectUnsubscribedError */](); - } - this.isStopped = true; - var observers = this.observers; - var len = observers.length; - var copy = observers.slice(); - for (var i = 0; i < len; i++) { - copy[i].complete(); - } - this.observers.length = 0; - }; - Subject.prototype.unsubscribe = function () { - this.isStopped = true; - this.closed = true; - this.observers = null; - }; - Subject.prototype._trySubscribe = function (subscriber) { - if (this.closed) { - throw new __WEBPACK_IMPORTED_MODULE_4__util_ObjectUnsubscribedError__["a" /* ObjectUnsubscribedError */](); - } - else { - return _super.prototype._trySubscribe.call(this, subscriber); - } - }; - Subject.prototype._subscribe = function (subscriber) { - if (this.closed) { - throw new __WEBPACK_IMPORTED_MODULE_4__util_ObjectUnsubscribedError__["a" /* ObjectUnsubscribedError */](); - } - else if (this.hasError) { - subscriber.error(this.thrownError); - return __WEBPACK_IMPORTED_MODULE_3__Subscription__["a" /* Subscription */].EMPTY; - } - else if (this.isStopped) { - subscriber.complete(); - return __WEBPACK_IMPORTED_MODULE_3__Subscription__["a" /* Subscription */].EMPTY; - } - else { - this.observers.push(subscriber); - return new __WEBPACK_IMPORTED_MODULE_5__SubjectSubscription__["a" /* SubjectSubscription */](this, subscriber); - } - }; - Subject.prototype.asObservable = function () { - var observable = new __WEBPACK_IMPORTED_MODULE_1__Observable__["a" /* Observable */](); - observable.source = this; - return observable; - }; - Subject.create = function (destination, source) { - return new AnonymousSubject(destination, source); - }; - return Subject; -}(__WEBPACK_IMPORTED_MODULE_1__Observable__["a" /* Observable */])); - -var AnonymousSubject = /*@__PURE__*/ (function (_super) { - __WEBPACK_IMPORTED_MODULE_0_tslib__["a" /* __extends */](AnonymousSubject, _super); - function AnonymousSubject(destination, source) { - var _this = _super.call(this) || this; - _this.destination = destination; - _this.source = source; - return _this; - } - AnonymousSubject.prototype.next = function (value) { - var destination = this.destination; - if (destination && destination.next) { - destination.next(value); - } - }; - AnonymousSubject.prototype.error = function (err) { - var destination = this.destination; - if (destination && destination.error) { - this.destination.error(err); - } - }; - AnonymousSubject.prototype.complete = function () { - var destination = this.destination; - if (destination && destination.complete) { - this.destination.complete(); - } - }; - AnonymousSubject.prototype._subscribe = function (subscriber) { - var source = this.source; - if (source) { - return this.source.subscribe(subscriber); - } - else { - return __WEBPACK_IMPORTED_MODULE_3__Subscription__["a" /* Subscription */].EMPTY; - } - }; - return AnonymousSubject; -}(Subject)); - -//# sourceMappingURL=Subject.js.map - - -/***/ }), -/* 37 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.normalizePattern = normalizePattern; - -/** - * Explode and normalize a pattern into its name and range. - */ - -function normalizePattern(pattern) { - let hasVersion = false; - let range = 'latest'; - let name = pattern; - - // if we're a scope then remove the @ and add it back later - let isScoped = false; - if (name[0] === '@') { - isScoped = true; - name = name.slice(1); - } - - // take first part as the name - const parts = name.split('@'); - if (parts.length > 1) { - name = parts.shift(); - range = parts.join('@'); - - if (range) { - hasVersion = true; - } else { - range = '*'; - } - } - - // add back @ scope suffix - if (isScoped) { - name = `@${name}`; - } - - return { name, range, hasVersion }; -} - -/***/ }), -/* 38 */ -/***/ (function(module, exports, __webpack_require__) { - -/* WEBPACK VAR INJECTION */(function(module) {var __WEBPACK_AMD_DEFINE_RESULT__;/** - * @license - * Lodash - * Copyright JS Foundation and other contributors - * Released under MIT license - * Based on Underscore.js 1.8.3 - * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors - */ -;(function() { - - /** Used as a safe reference for `undefined` in pre-ES5 environments. */ - var undefined; - - /** Used as the semantic version number. */ - var VERSION = '4.17.10'; - - /** Used as the size to enable large array optimizations. */ - var LARGE_ARRAY_SIZE = 200; - - /** Error message constants. */ - var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.', - FUNC_ERROR_TEXT = 'Expected a function'; - - /** Used to stand-in for `undefined` hash values. */ - var HASH_UNDEFINED = '__lodash_hash_undefined__'; - - /** Used as the maximum memoize cache size. */ - var MAX_MEMOIZE_SIZE = 500; - - /** Used as the internal argument placeholder. */ - var PLACEHOLDER = '__lodash_placeholder__'; - - /** Used to compose bitmasks for cloning. */ - var CLONE_DEEP_FLAG = 1, - CLONE_FLAT_FLAG = 2, - CLONE_SYMBOLS_FLAG = 4; - - /** Used to compose bitmasks for value comparisons. */ - var COMPARE_PARTIAL_FLAG = 1, - COMPARE_UNORDERED_FLAG = 2; - - /** Used to compose bitmasks for function metadata. */ - var WRAP_BIND_FLAG = 1, - WRAP_BIND_KEY_FLAG = 2, - WRAP_CURRY_BOUND_FLAG = 4, - WRAP_CURRY_FLAG = 8, - WRAP_CURRY_RIGHT_FLAG = 16, - WRAP_PARTIAL_FLAG = 32, - WRAP_PARTIAL_RIGHT_FLAG = 64, - WRAP_ARY_FLAG = 128, - WRAP_REARG_FLAG = 256, - WRAP_FLIP_FLAG = 512; - - /** Used as default options for `_.truncate`. */ - var DEFAULT_TRUNC_LENGTH = 30, - DEFAULT_TRUNC_OMISSION = '...'; - - /** Used to detect hot functions by number of calls within a span of milliseconds. */ - var HOT_COUNT = 800, - HOT_SPAN = 16; - - /** Used to indicate the type of lazy iteratees. */ - var LAZY_FILTER_FLAG = 1, - LAZY_MAP_FLAG = 2, - LAZY_WHILE_FLAG = 3; - - /** Used as references for various `Number` constants. */ - var INFINITY = 1 / 0, - MAX_SAFE_INTEGER = 9007199254740991, - MAX_INTEGER = 1.7976931348623157e+308, - NAN = 0 / 0; - - /** Used as references for the maximum length and index of an array. */ - var MAX_ARRAY_LENGTH = 4294967295, - MAX_ARRAY_INDEX = MAX_ARRAY_LENGTH - 1, - HALF_MAX_ARRAY_LENGTH = MAX_ARRAY_LENGTH >>> 1; - - /** Used to associate wrap methods with their bit flags. */ - var wrapFlags = [ - ['ary', WRAP_ARY_FLAG], - ['bind', WRAP_BIND_FLAG], - ['bindKey', WRAP_BIND_KEY_FLAG], - ['curry', WRAP_CURRY_FLAG], - ['curryRight', WRAP_CURRY_RIGHT_FLAG], - ['flip', WRAP_FLIP_FLAG], - ['partial', WRAP_PARTIAL_FLAG], - ['partialRight', WRAP_PARTIAL_RIGHT_FLAG], - ['rearg', WRAP_REARG_FLAG] - ]; - - /** `Object#toString` result references. */ - var argsTag = '[object Arguments]', - arrayTag = '[object Array]', - asyncTag = '[object AsyncFunction]', - boolTag = '[object Boolean]', - dateTag = '[object Date]', - domExcTag = '[object DOMException]', - errorTag = '[object Error]', - funcTag = '[object Function]', - genTag = '[object GeneratorFunction]', - mapTag = '[object Map]', - numberTag = '[object Number]', - nullTag = '[object Null]', - objectTag = '[object Object]', - promiseTag = '[object Promise]', - proxyTag = '[object Proxy]', - regexpTag = '[object RegExp]', - setTag = '[object Set]', - stringTag = '[object String]', - symbolTag = '[object Symbol]', - undefinedTag = '[object Undefined]', - weakMapTag = '[object WeakMap]', - weakSetTag = '[object WeakSet]'; - - var arrayBufferTag = '[object ArrayBuffer]', - dataViewTag = '[object DataView]', - float32Tag = '[object Float32Array]', - float64Tag = '[object Float64Array]', - int8Tag = '[object Int8Array]', - int16Tag = '[object Int16Array]', - int32Tag = '[object Int32Array]', - uint8Tag = '[object Uint8Array]', - uint8ClampedTag = '[object Uint8ClampedArray]', - uint16Tag = '[object Uint16Array]', - uint32Tag = '[object Uint32Array]'; - - /** Used to match empty string literals in compiled template source. */ - var reEmptyStringLeading = /\b__p \+= '';/g, - reEmptyStringMiddle = /\b(__p \+=) '' \+/g, - reEmptyStringTrailing = /(__e\(.*?\)|\b__t\)) \+\n'';/g; - - /** Used to match HTML entities and HTML characters. */ - var reEscapedHtml = /&(?:amp|lt|gt|quot|#39);/g, - reUnescapedHtml = /[&<>"']/g, - reHasEscapedHtml = RegExp(reEscapedHtml.source), - reHasUnescapedHtml = RegExp(reUnescapedHtml.source); - - /** Used to match template delimiters. */ - var reEscape = /<%-([\s\S]+?)%>/g, - reEvaluate = /<%([\s\S]+?)%>/g, - reInterpolate = /<%=([\s\S]+?)%>/g; - - /** Used to match property names within property paths. */ - var reIsDeepProp = /\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/, - reIsPlainProp = /^\w*$/, - rePropName = /[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g; - - /** - * Used to match `RegExp` - * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns). - */ - var reRegExpChar = /[\\^$.*+?()[\]{}|]/g, - reHasRegExpChar = RegExp(reRegExpChar.source); - - /** Used to match leading and trailing whitespace. */ - var reTrim = /^\s+|\s+$/g, - reTrimStart = /^\s+/, - reTrimEnd = /\s+$/; - - /** Used to match wrap detail comments. */ - var reWrapComment = /\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/, - reWrapDetails = /\{\n\/\* \[wrapped with (.+)\] \*/, - reSplitDetails = /,? & /; - - /** Used to match words composed of alphanumeric characters. */ - var reAsciiWord = /[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g; - - /** Used to match backslashes in property paths. */ - var reEscapeChar = /\\(\\)?/g; - - /** - * Used to match - * [ES template delimiters](http://ecma-international.org/ecma-262/7.0/#sec-template-literal-lexical-components). - */ - var reEsTemplate = /\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g; - - /** Used to match `RegExp` flags from their coerced string values. */ - var reFlags = /\w*$/; - - /** Used to detect bad signed hexadecimal string values. */ - var reIsBadHex = /^[-+]0x[0-9a-f]+$/i; - - /** Used to detect binary string values. */ - var reIsBinary = /^0b[01]+$/i; - - /** Used to detect host constructors (Safari). */ - var reIsHostCtor = /^\[object .+?Constructor\]$/; - - /** Used to detect octal string values. */ - var reIsOctal = /^0o[0-7]+$/i; - - /** Used to detect unsigned integer values. */ - var reIsUint = /^(?:0|[1-9]\d*)$/; - - /** Used to match Latin Unicode letters (excluding mathematical operators). */ - var reLatin = /[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g; - - /** Used to ensure capturing order of template delimiters. */ - var reNoMatch = /($^)/; - - /** Used to match unescaped characters in compiled string literals. */ - var reUnescapedString = /['\n\r\u2028\u2029\\]/g; - - /** Used to compose unicode character classes. */ - var rsAstralRange = '\\ud800-\\udfff', - rsComboMarksRange = '\\u0300-\\u036f', - reComboHalfMarksRange = '\\ufe20-\\ufe2f', - rsComboSymbolsRange = '\\u20d0-\\u20ff', - rsComboRange = rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange, - rsDingbatRange = '\\u2700-\\u27bf', - rsLowerRange = 'a-z\\xdf-\\xf6\\xf8-\\xff', - rsMathOpRange = '\\xac\\xb1\\xd7\\xf7', - rsNonCharRange = '\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf', - rsPunctuationRange = '\\u2000-\\u206f', - rsSpaceRange = ' \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000', - rsUpperRange = 'A-Z\\xc0-\\xd6\\xd8-\\xde', - rsVarRange = '\\ufe0e\\ufe0f', - rsBreakRange = rsMathOpRange + rsNonCharRange + rsPunctuationRange + rsSpaceRange; - - /** Used to compose unicode capture groups. */ - var rsApos = "['\u2019]", - rsAstral = '[' + rsAstralRange + ']', - rsBreak = '[' + rsBreakRange + ']', - rsCombo = '[' + rsComboRange + ']', - rsDigits = '\\d+', - rsDingbat = '[' + rsDingbatRange + ']', - rsLower = '[' + rsLowerRange + ']', - rsMisc = '[^' + rsAstralRange + rsBreakRange + rsDigits + rsDingbatRange + rsLowerRange + rsUpperRange + ']', - rsFitz = '\\ud83c[\\udffb-\\udfff]', - rsModifier = '(?:' + rsCombo + '|' + rsFitz + ')', - rsNonAstral = '[^' + rsAstralRange + ']', - rsRegional = '(?:\\ud83c[\\udde6-\\uddff]){2}', - rsSurrPair = '[\\ud800-\\udbff][\\udc00-\\udfff]', - rsUpper = '[' + rsUpperRange + ']', - rsZWJ = '\\u200d'; - - /** Used to compose unicode regexes. */ - var rsMiscLower = '(?:' + rsLower + '|' + rsMisc + ')', - rsMiscUpper = '(?:' + rsUpper + '|' + rsMisc + ')', - rsOptContrLower = '(?:' + rsApos + '(?:d|ll|m|re|s|t|ve))?', - rsOptContrUpper = '(?:' + rsApos + '(?:D|LL|M|RE|S|T|VE))?', - reOptMod = rsModifier + '?', - rsOptVar = '[' + rsVarRange + ']?', - rsOptJoin = '(?:' + rsZWJ + '(?:' + [rsNonAstral, rsRegional, rsSurrPair].join('|') + ')' + rsOptVar + reOptMod + ')*', - rsOrdLower = '\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])', - rsOrdUpper = '\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])', - rsSeq = rsOptVar + reOptMod + rsOptJoin, - rsEmoji = '(?:' + [rsDingbat, rsRegional, rsSurrPair].join('|') + ')' + rsSeq, - rsSymbol = '(?:' + [rsNonAstral + rsCombo + '?', rsCombo, rsRegional, rsSurrPair, rsAstral].join('|') + ')'; - - /** Used to match apostrophes. */ - var reApos = RegExp(rsApos, 'g'); - - /** - * Used to match [combining diacritical marks](https://en.wikipedia.org/wiki/Combining_Diacritical_Marks) and - * [combining diacritical marks for symbols](https://en.wikipedia.org/wiki/Combining_Diacritical_Marks_for_Symbols). - */ - var reComboMark = RegExp(rsCombo, 'g'); - - /** Used to match [string symbols](https://mathiasbynens.be/notes/javascript-unicode). */ - var reUnicode = RegExp(rsFitz + '(?=' + rsFitz + ')|' + rsSymbol + rsSeq, 'g'); - - /** Used to match complex or compound words. */ - var reUnicodeWord = RegExp([ - rsUpper + '?' + rsLower + '+' + rsOptContrLower + '(?=' + [rsBreak, rsUpper, '$'].join('|') + ')', - rsMiscUpper + '+' + rsOptContrUpper + '(?=' + [rsBreak, rsUpper + rsMiscLower, '$'].join('|') + ')', - rsUpper + '?' + rsMiscLower + '+' + rsOptContrLower, - rsUpper + '+' + rsOptContrUpper, - rsOrdUpper, - rsOrdLower, - rsDigits, - rsEmoji - ].join('|'), 'g'); - - /** Used to detect strings with [zero-width joiners or code points from the astral planes](http://eev.ee/blog/2015/09/12/dark-corners-of-unicode/). */ - var reHasUnicode = RegExp('[' + rsZWJ + rsAstralRange + rsComboRange + rsVarRange + ']'); - - /** Used to detect strings that need a more robust regexp to match words. */ - var reHasUnicodeWord = /[a-z][A-Z]|[A-Z]{2,}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/; - - /** Used to assign default `context` object properties. */ - var contextProps = [ - 'Array', 'Buffer', 'DataView', 'Date', 'Error', 'Float32Array', 'Float64Array', - 'Function', 'Int8Array', 'Int16Array', 'Int32Array', 'Map', 'Math', 'Object', - 'Promise', 'RegExp', 'Set', 'String', 'Symbol', 'TypeError', 'Uint8Array', - 'Uint8ClampedArray', 'Uint16Array', 'Uint32Array', 'WeakMap', - '_', 'clearTimeout', 'isFinite', 'parseInt', 'setTimeout' - ]; - - /** Used to make template sourceURLs easier to identify. */ - var templateCounter = -1; - - /** Used to identify `toStringTag` values of typed arrays. */ - var typedArrayTags = {}; - typedArrayTags[float32Tag] = typedArrayTags[float64Tag] = - typedArrayTags[int8Tag] = typedArrayTags[int16Tag] = - typedArrayTags[int32Tag] = typedArrayTags[uint8Tag] = - typedArrayTags[uint8ClampedTag] = typedArrayTags[uint16Tag] = - typedArrayTags[uint32Tag] = true; - typedArrayTags[argsTag] = typedArrayTags[arrayTag] = - typedArrayTags[arrayBufferTag] = typedArrayTags[boolTag] = - typedArrayTags[dataViewTag] = typedArrayTags[dateTag] = - typedArrayTags[errorTag] = typedArrayTags[funcTag] = - typedArrayTags[mapTag] = typedArrayTags[numberTag] = - typedArrayTags[objectTag] = typedArrayTags[regexpTag] = - typedArrayTags[setTag] = typedArrayTags[stringTag] = - typedArrayTags[weakMapTag] = false; - - /** Used to identify `toStringTag` values supported by `_.clone`. */ - var cloneableTags = {}; - cloneableTags[argsTag] = cloneableTags[arrayTag] = - cloneableTags[arrayBufferTag] = cloneableTags[dataViewTag] = - cloneableTags[boolTag] = cloneableTags[dateTag] = - cloneableTags[float32Tag] = cloneableTags[float64Tag] = - cloneableTags[int8Tag] = cloneableTags[int16Tag] = - cloneableTags[int32Tag] = cloneableTags[mapTag] = - cloneableTags[numberTag] = cloneableTags[objectTag] = - cloneableTags[regexpTag] = cloneableTags[setTag] = - cloneableTags[stringTag] = cloneableTags[symbolTag] = - cloneableTags[uint8Tag] = cloneableTags[uint8ClampedTag] = - cloneableTags[uint16Tag] = cloneableTags[uint32Tag] = true; - cloneableTags[errorTag] = cloneableTags[funcTag] = - cloneableTags[weakMapTag] = false; - - /** Used to map Latin Unicode letters to basic Latin letters. */ - var deburredLetters = { - // Latin-1 Supplement block. - '\xc0': 'A', '\xc1': 'A', '\xc2': 'A', '\xc3': 'A', '\xc4': 'A', '\xc5': 'A', - '\xe0': 'a', '\xe1': 'a', '\xe2': 'a', '\xe3': 'a', '\xe4': 'a', '\xe5': 'a', - '\xc7': 'C', '\xe7': 'c', - '\xd0': 'D', '\xf0': 'd', - '\xc8': 'E', '\xc9': 'E', '\xca': 'E', '\xcb': 'E', - '\xe8': 'e', '\xe9': 'e', '\xea': 'e', '\xeb': 'e', - '\xcc': 'I', '\xcd': 'I', '\xce': 'I', '\xcf': 'I', - '\xec': 'i', '\xed': 'i', '\xee': 'i', '\xef': 'i', - '\xd1': 'N', '\xf1': 'n', - '\xd2': 'O', '\xd3': 'O', '\xd4': 'O', '\xd5': 'O', '\xd6': 'O', '\xd8': 'O', - '\xf2': 'o', '\xf3': 'o', '\xf4': 'o', '\xf5': 'o', '\xf6': 'o', '\xf8': 'o', - '\xd9': 'U', '\xda': 'U', '\xdb': 'U', '\xdc': 'U', - '\xf9': 'u', '\xfa': 'u', '\xfb': 'u', '\xfc': 'u', - '\xdd': 'Y', '\xfd': 'y', '\xff': 'y', - '\xc6': 'Ae', '\xe6': 'ae', - '\xde': 'Th', '\xfe': 'th', - '\xdf': 'ss', - // Latin Extended-A block. - '\u0100': 'A', '\u0102': 'A', '\u0104': 'A', - '\u0101': 'a', '\u0103': 'a', '\u0105': 'a', - '\u0106': 'C', '\u0108': 'C', '\u010a': 'C', '\u010c': 'C', - '\u0107': 'c', '\u0109': 'c', '\u010b': 'c', '\u010d': 'c', - '\u010e': 'D', '\u0110': 'D', '\u010f': 'd', '\u0111': 'd', - '\u0112': 'E', '\u0114': 'E', '\u0116': 'E', '\u0118': 'E', '\u011a': 'E', - '\u0113': 'e', '\u0115': 'e', '\u0117': 'e', '\u0119': 'e', '\u011b': 'e', - '\u011c': 'G', '\u011e': 'G', '\u0120': 'G', '\u0122': 'G', - '\u011d': 'g', '\u011f': 'g', '\u0121': 'g', '\u0123': 'g', - '\u0124': 'H', '\u0126': 'H', '\u0125': 'h', '\u0127': 'h', - '\u0128': 'I', '\u012a': 'I', '\u012c': 'I', '\u012e': 'I', '\u0130': 'I', - '\u0129': 'i', '\u012b': 'i', '\u012d': 'i', '\u012f': 'i', '\u0131': 'i', - '\u0134': 'J', '\u0135': 'j', - '\u0136': 'K', '\u0137': 'k', '\u0138': 'k', - '\u0139': 'L', '\u013b': 'L', '\u013d': 'L', '\u013f': 'L', '\u0141': 'L', - '\u013a': 'l', '\u013c': 'l', '\u013e': 'l', '\u0140': 'l', '\u0142': 'l', - '\u0143': 'N', '\u0145': 'N', '\u0147': 'N', '\u014a': 'N', - '\u0144': 'n', '\u0146': 'n', '\u0148': 'n', '\u014b': 'n', - '\u014c': 'O', '\u014e': 'O', '\u0150': 'O', - '\u014d': 'o', '\u014f': 'o', '\u0151': 'o', - '\u0154': 'R', '\u0156': 'R', '\u0158': 'R', - '\u0155': 'r', '\u0157': 'r', '\u0159': 'r', - '\u015a': 'S', '\u015c': 'S', '\u015e': 'S', '\u0160': 'S', - '\u015b': 's', '\u015d': 's', '\u015f': 's', '\u0161': 's', - '\u0162': 'T', '\u0164': 'T', '\u0166': 'T', - '\u0163': 't', '\u0165': 't', '\u0167': 't', - '\u0168': 'U', '\u016a': 'U', '\u016c': 'U', '\u016e': 'U', '\u0170': 'U', '\u0172': 'U', - '\u0169': 'u', '\u016b': 'u', '\u016d': 'u', '\u016f': 'u', '\u0171': 'u', '\u0173': 'u', - '\u0174': 'W', '\u0175': 'w', - '\u0176': 'Y', '\u0177': 'y', '\u0178': 'Y', - '\u0179': 'Z', '\u017b': 'Z', '\u017d': 'Z', - '\u017a': 'z', '\u017c': 'z', '\u017e': 'z', - '\u0132': 'IJ', '\u0133': 'ij', - '\u0152': 'Oe', '\u0153': 'oe', - '\u0149': "'n", '\u017f': 's' - }; - - /** Used to map characters to HTML entities. */ - var htmlEscapes = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - - /** Used to map HTML entities to characters. */ - var htmlUnescapes = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': "'" - }; - - /** Used to escape characters for inclusion in compiled string literals. */ - var stringEscapes = { - '\\': '\\', - "'": "'", - '\n': 'n', - '\r': 'r', - '\u2028': 'u2028', - '\u2029': 'u2029' - }; - - /** Built-in method references without a dependency on `root`. */ - var freeParseFloat = parseFloat, - freeParseInt = parseInt; - - /** Detect free variable `global` from Node.js. */ - var freeGlobal = typeof global == 'object' && global && global.Object === Object && global; - - /** Detect free variable `self`. */ - var freeSelf = typeof self == 'object' && self && self.Object === Object && self; - - /** Used as a reference to the global object. */ - var root = freeGlobal || freeSelf || Function('return this')(); - - /** Detect free variable `exports`. */ - var freeExports = typeof exports == 'object' && exports && !exports.nodeType && exports; - - /** Detect free variable `module`. */ - var freeModule = freeExports && typeof module == 'object' && module && !module.nodeType && module; - - /** Detect the popular CommonJS extension `module.exports`. */ - var moduleExports = freeModule && freeModule.exports === freeExports; - - /** Detect free variable `process` from Node.js. */ - var freeProcess = moduleExports && freeGlobal.process; - - /** Used to access faster Node.js helpers. */ - var nodeUtil = (function() { - try { - // Use `util.types` for Node.js 10+. - var types = freeModule && freeModule.require && freeModule.require('util').types; - - if (types) { - return types; - } - - // Legacy `process.binding('util')` for Node.js < 10. - return freeProcess && freeProcess.binding && freeProcess.binding('util'); - } catch (e) {} - }()); - - /* Node.js helper references. */ - var nodeIsArrayBuffer = nodeUtil && nodeUtil.isArrayBuffer, - nodeIsDate = nodeUtil && nodeUtil.isDate, - nodeIsMap = nodeUtil && nodeUtil.isMap, - nodeIsRegExp = nodeUtil && nodeUtil.isRegExp, - nodeIsSet = nodeUtil && nodeUtil.isSet, - nodeIsTypedArray = nodeUtil && nodeUtil.isTypedArray; - - /*--------------------------------------------------------------------------*/ - - /** - * A faster alternative to `Function#apply`, this function invokes `func` - * with the `this` binding of `thisArg` and the arguments of `args`. - * - * @private - * @param {Function} func The function to invoke. - * @param {*} thisArg The `this` binding of `func`. - * @param {Array} args The arguments to invoke `func` with. - * @returns {*} Returns the result of `func`. - */ - function apply(func, thisArg, args) { - switch (args.length) { - case 0: return func.call(thisArg); - case 1: return func.call(thisArg, args[0]); - case 2: return func.call(thisArg, args[0], args[1]); - case 3: return func.call(thisArg, args[0], args[1], args[2]); - } - return func.apply(thisArg, args); - } - - /** - * A specialized version of `baseAggregator` for arrays. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} setter The function to set `accumulator` values. - * @param {Function} iteratee The iteratee to transform keys. - * @param {Object} accumulator The initial aggregated object. - * @returns {Function} Returns `accumulator`. - */ - function arrayAggregator(array, setter, iteratee, accumulator) { - var index = -1, - length = array == null ? 0 : array.length; - - while (++index < length) { - var value = array[index]; - setter(accumulator, value, iteratee(value), array); - } - return accumulator; - } - - /** - * A specialized version of `_.forEach` for arrays without support for - * iteratee shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array} Returns `array`. - */ - function arrayEach(array, iteratee) { - var index = -1, - length = array == null ? 0 : array.length; - - while (++index < length) { - if (iteratee(array[index], index, array) === false) { - break; - } - } - return array; - } - - /** - * A specialized version of `_.forEachRight` for arrays without support for - * iteratee shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array} Returns `array`. - */ - function arrayEachRight(array, iteratee) { - var length = array == null ? 0 : array.length; - - while (length--) { - if (iteratee(array[length], length, array) === false) { - break; - } - } - return array; - } - - /** - * A specialized version of `_.every` for arrays without support for - * iteratee shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {boolean} Returns `true` if all elements pass the predicate check, - * else `false`. - */ - function arrayEvery(array, predicate) { - var index = -1, - length = array == null ? 0 : array.length; - - while (++index < length) { - if (!predicate(array[index], index, array)) { - return false; - } - } - return true; - } - - /** - * A specialized version of `_.filter` for arrays without support for - * iteratee shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {Array} Returns the new filtered array. - */ - function arrayFilter(array, predicate) { - var index = -1, - length = array == null ? 0 : array.length, - resIndex = 0, - result = []; - - while (++index < length) { - var value = array[index]; - if (predicate(value, index, array)) { - result[resIndex++] = value; - } - } - return result; - } - - /** - * A specialized version of `_.includes` for arrays without support for - * specifying an index to search from. - * - * @private - * @param {Array} [array] The array to inspect. - * @param {*} target The value to search for. - * @returns {boolean} Returns `true` if `target` is found, else `false`. - */ - function arrayIncludes(array, value) { - var length = array == null ? 0 : array.length; - return !!length && baseIndexOf(array, value, 0) > -1; - } - - /** - * This function is like `arrayIncludes` except that it accepts a comparator. - * - * @private - * @param {Array} [array] The array to inspect. - * @param {*} target The value to search for. - * @param {Function} comparator The comparator invoked per element. - * @returns {boolean} Returns `true` if `target` is found, else `false`. - */ - function arrayIncludesWith(array, value, comparator) { - var index = -1, - length = array == null ? 0 : array.length; - - while (++index < length) { - if (comparator(value, array[index])) { - return true; - } - } - return false; - } - - /** - * A specialized version of `_.map` for arrays without support for iteratee - * shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array} Returns the new mapped array. - */ - function arrayMap(array, iteratee) { - var index = -1, - length = array == null ? 0 : array.length, - result = Array(length); - - while (++index < length) { - result[index] = iteratee(array[index], index, array); - } - return result; - } - - /** - * Appends the elements of `values` to `array`. - * - * @private - * @param {Array} array The array to modify. - * @param {Array} values The values to append. - * @returns {Array} Returns `array`. - */ - function arrayPush(array, values) { - var index = -1, - length = values.length, - offset = array.length; - - while (++index < length) { - array[offset + index] = values[index]; - } - return array; - } - - /** - * A specialized version of `_.reduce` for arrays without support for - * iteratee shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @param {*} [accumulator] The initial value. - * @param {boolean} [initAccum] Specify using the first element of `array` as - * the initial value. - * @returns {*} Returns the accumulated value. - */ - function arrayReduce(array, iteratee, accumulator, initAccum) { - var index = -1, - length = array == null ? 0 : array.length; - - if (initAccum && length) { - accumulator = array[++index]; - } - while (++index < length) { - accumulator = iteratee(accumulator, array[index], index, array); - } - return accumulator; - } - - /** - * A specialized version of `_.reduceRight` for arrays without support for - * iteratee shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @param {*} [accumulator] The initial value. - * @param {boolean} [initAccum] Specify using the last element of `array` as - * the initial value. - * @returns {*} Returns the accumulated value. - */ - function arrayReduceRight(array, iteratee, accumulator, initAccum) { - var length = array == null ? 0 : array.length; - if (initAccum && length) { - accumulator = array[--length]; - } - while (length--) { - accumulator = iteratee(accumulator, array[length], length, array); - } - return accumulator; - } - - /** - * A specialized version of `_.some` for arrays without support for iteratee - * shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {boolean} Returns `true` if any element passes the predicate check, - * else `false`. - */ - function arraySome(array, predicate) { - var index = -1, - length = array == null ? 0 : array.length; - - while (++index < length) { - if (predicate(array[index], index, array)) { - return true; - } - } - return false; - } - - /** - * Gets the size of an ASCII `string`. - * - * @private - * @param {string} string The string inspect. - * @returns {number} Returns the string size. - */ - var asciiSize = baseProperty('length'); - - /** - * Converts an ASCII `string` to an array. - * - * @private - * @param {string} string The string to convert. - * @returns {Array} Returns the converted array. - */ - function asciiToArray(string) { - return string.split(''); - } - - /** - * Splits an ASCII `string` into an array of its words. - * - * @private - * @param {string} The string to inspect. - * @returns {Array} Returns the words of `string`. - */ - function asciiWords(string) { - return string.match(reAsciiWord) || []; - } - - /** - * The base implementation of methods like `_.findKey` and `_.findLastKey`, - * without support for iteratee shorthands, which iterates over `collection` - * using `eachFunc`. - * - * @private - * @param {Array|Object} collection The collection to inspect. - * @param {Function} predicate The function invoked per iteration. - * @param {Function} eachFunc The function to iterate over `collection`. - * @returns {*} Returns the found element or its key, else `undefined`. - */ - function baseFindKey(collection, predicate, eachFunc) { - var result; - eachFunc(collection, function(value, key, collection) { - if (predicate(value, key, collection)) { - result = key; - return false; - } - }); - return result; - } - - /** - * The base implementation of `_.findIndex` and `_.findLastIndex` without - * support for iteratee shorthands. - * - * @private - * @param {Array} array The array to inspect. - * @param {Function} predicate The function invoked per iteration. - * @param {number} fromIndex The index to search from. - * @param {boolean} [fromRight] Specify iterating from right to left. - * @returns {number} Returns the index of the matched value, else `-1`. - */ - function baseFindIndex(array, predicate, fromIndex, fromRight) { - var length = array.length, - index = fromIndex + (fromRight ? 1 : -1); - - while ((fromRight ? index-- : ++index < length)) { - if (predicate(array[index], index, array)) { - return index; - } - } - return -1; - } - - /** - * The base implementation of `_.indexOf` without `fromIndex` bounds checks. - * - * @private - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @param {number} fromIndex The index to search from. - * @returns {number} Returns the index of the matched value, else `-1`. - */ - function baseIndexOf(array, value, fromIndex) { - return value === value - ? strictIndexOf(array, value, fromIndex) - : baseFindIndex(array, baseIsNaN, fromIndex); - } - - /** - * This function is like `baseIndexOf` except that it accepts a comparator. - * - * @private - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @param {number} fromIndex The index to search from. - * @param {Function} comparator The comparator invoked per element. - * @returns {number} Returns the index of the matched value, else `-1`. - */ - function baseIndexOfWith(array, value, fromIndex, comparator) { - var index = fromIndex - 1, - length = array.length; - - while (++index < length) { - if (comparator(array[index], value)) { - return index; - } - } - return -1; - } - - /** - * The base implementation of `_.isNaN` without support for number objects. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is `NaN`, else `false`. - */ - function baseIsNaN(value) { - return value !== value; - } - - /** - * The base implementation of `_.mean` and `_.meanBy` without support for - * iteratee shorthands. - * - * @private - * @param {Array} array The array to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {number} Returns the mean. - */ - function baseMean(array, iteratee) { - var length = array == null ? 0 : array.length; - return length ? (baseSum(array, iteratee) / length) : NAN; - } - - /** - * The base implementation of `_.property` without support for deep paths. - * - * @private - * @param {string} key The key of the property to get. - * @returns {Function} Returns the new accessor function. - */ - function baseProperty(key) { - return function(object) { - return object == null ? undefined : object[key]; - }; - } - - /** - * The base implementation of `_.propertyOf` without support for deep paths. - * - * @private - * @param {Object} object The object to query. - * @returns {Function} Returns the new accessor function. - */ - function basePropertyOf(object) { - return function(key) { - return object == null ? undefined : object[key]; - }; - } - - /** - * The base implementation of `_.reduce` and `_.reduceRight`, without support - * for iteratee shorthands, which iterates over `collection` using `eachFunc`. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @param {*} accumulator The initial value. - * @param {boolean} initAccum Specify using the first or last element of - * `collection` as the initial value. - * @param {Function} eachFunc The function to iterate over `collection`. - * @returns {*} Returns the accumulated value. - */ - function baseReduce(collection, iteratee, accumulator, initAccum, eachFunc) { - eachFunc(collection, function(value, index, collection) { - accumulator = initAccum - ? (initAccum = false, value) - : iteratee(accumulator, value, index, collection); - }); - return accumulator; - } - - /** - * The base implementation of `_.sortBy` which uses `comparer` to define the - * sort order of `array` and replaces criteria objects with their corresponding - * values. - * - * @private - * @param {Array} array The array to sort. - * @param {Function} comparer The function to define sort order. - * @returns {Array} Returns `array`. - */ - function baseSortBy(array, comparer) { - var length = array.length; - - array.sort(comparer); - while (length--) { - array[length] = array[length].value; - } - return array; - } - - /** - * The base implementation of `_.sum` and `_.sumBy` without support for - * iteratee shorthands. - * - * @private - * @param {Array} array The array to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {number} Returns the sum. - */ - function baseSum(array, iteratee) { - var result, - index = -1, - length = array.length; - - while (++index < length) { - var current = iteratee(array[index]); - if (current !== undefined) { - result = result === undefined ? current : (result + current); - } - } - return result; - } - - /** - * The base implementation of `_.times` without support for iteratee shorthands - * or max array length checks. - * - * @private - * @param {number} n The number of times to invoke `iteratee`. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array} Returns the array of results. - */ - function baseTimes(n, iteratee) { - var index = -1, - result = Array(n); - - while (++index < n) { - result[index] = iteratee(index); - } - return result; - } - - /** - * The base implementation of `_.toPairs` and `_.toPairsIn` which creates an array - * of key-value pairs for `object` corresponding to the property names of `props`. - * - * @private - * @param {Object} object The object to query. - * @param {Array} props The property names to get values for. - * @returns {Object} Returns the key-value pairs. - */ - function baseToPairs(object, props) { - return arrayMap(props, function(key) { - return [key, object[key]]; - }); - } - - /** - * The base implementation of `_.unary` without support for storing metadata. - * - * @private - * @param {Function} func The function to cap arguments for. - * @returns {Function} Returns the new capped function. - */ - function baseUnary(func) { - return function(value) { - return func(value); - }; - } - - /** - * The base implementation of `_.values` and `_.valuesIn` which creates an - * array of `object` property values corresponding to the property names - * of `props`. - * - * @private - * @param {Object} object The object to query. - * @param {Array} props The property names to get values for. - * @returns {Object} Returns the array of property values. - */ - function baseValues(object, props) { - return arrayMap(props, function(key) { - return object[key]; - }); - } - - /** - * Checks if a `cache` value for `key` exists. - * - * @private - * @param {Object} cache The cache to query. - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ - function cacheHas(cache, key) { - return cache.has(key); - } - - /** - * Used by `_.trim` and `_.trimStart` to get the index of the first string symbol - * that is not found in the character symbols. - * - * @private - * @param {Array} strSymbols The string symbols to inspect. - * @param {Array} chrSymbols The character symbols to find. - * @returns {number} Returns the index of the first unmatched string symbol. - */ - function charsStartIndex(strSymbols, chrSymbols) { - var index = -1, - length = strSymbols.length; - - while (++index < length && baseIndexOf(chrSymbols, strSymbols[index], 0) > -1) {} - return index; - } - - /** - * Used by `_.trim` and `_.trimEnd` to get the index of the last string symbol - * that is not found in the character symbols. - * - * @private - * @param {Array} strSymbols The string symbols to inspect. - * @param {Array} chrSymbols The character symbols to find. - * @returns {number} Returns the index of the last unmatched string symbol. - */ - function charsEndIndex(strSymbols, chrSymbols) { - var index = strSymbols.length; - - while (index-- && baseIndexOf(chrSymbols, strSymbols[index], 0) > -1) {} - return index; - } - - /** - * Gets the number of `placeholder` occurrences in `array`. - * - * @private - * @param {Array} array The array to inspect. - * @param {*} placeholder The placeholder to search for. - * @returns {number} Returns the placeholder count. - */ - function countHolders(array, placeholder) { - var length = array.length, - result = 0; - - while (length--) { - if (array[length] === placeholder) { - ++result; - } - } - return result; - } - - /** - * Used by `_.deburr` to convert Latin-1 Supplement and Latin Extended-A - * letters to basic Latin letters. - * - * @private - * @param {string} letter The matched letter to deburr. - * @returns {string} Returns the deburred letter. - */ - var deburrLetter = basePropertyOf(deburredLetters); - - /** - * Used by `_.escape` to convert characters to HTML entities. - * - * @private - * @param {string} chr The matched character to escape. - * @returns {string} Returns the escaped character. - */ - var escapeHtmlChar = basePropertyOf(htmlEscapes); - - /** - * Used by `_.template` to escape characters for inclusion in compiled string literals. - * - * @private - * @param {string} chr The matched character to escape. - * @returns {string} Returns the escaped character. - */ - function escapeStringChar(chr) { - return '\\' + stringEscapes[chr]; - } - - /** - * Gets the value at `key` of `object`. - * - * @private - * @param {Object} [object] The object to query. - * @param {string} key The key of the property to get. - * @returns {*} Returns the property value. - */ - function getValue(object, key) { - return object == null ? undefined : object[key]; - } - - /** - * Checks if `string` contains Unicode symbols. - * - * @private - * @param {string} string The string to inspect. - * @returns {boolean} Returns `true` if a symbol is found, else `false`. - */ - function hasUnicode(string) { - return reHasUnicode.test(string); - } - - /** - * Checks if `string` contains a word composed of Unicode symbols. - * - * @private - * @param {string} string The string to inspect. - * @returns {boolean} Returns `true` if a word is found, else `false`. - */ - function hasUnicodeWord(string) { - return reHasUnicodeWord.test(string); - } - - /** - * Converts `iterator` to an array. - * - * @private - * @param {Object} iterator The iterator to convert. - * @returns {Array} Returns the converted array. - */ - function iteratorToArray(iterator) { - var data, - result = []; - - while (!(data = iterator.next()).done) { - result.push(data.value); - } - return result; - } - - /** - * Converts `map` to its key-value pairs. - * - * @private - * @param {Object} map The map to convert. - * @returns {Array} Returns the key-value pairs. - */ - function mapToArray(map) { - var index = -1, - result = Array(map.size); - - map.forEach(function(value, key) { - result[++index] = [key, value]; - }); - return result; - } - - /** - * Creates a unary function that invokes `func` with its argument transformed. - * - * @private - * @param {Function} func The function to wrap. - * @param {Function} transform The argument transform. - * @returns {Function} Returns the new function. - */ - function overArg(func, transform) { - return function(arg) { - return func(transform(arg)); - }; - } - - /** - * Replaces all `placeholder` elements in `array` with an internal placeholder - * and returns an array of their indexes. - * - * @private - * @param {Array} array The array to modify. - * @param {*} placeholder The placeholder to replace. - * @returns {Array} Returns the new array of placeholder indexes. - */ - function replaceHolders(array, placeholder) { - var index = -1, - length = array.length, - resIndex = 0, - result = []; - - while (++index < length) { - var value = array[index]; - if (value === placeholder || value === PLACEHOLDER) { - array[index] = PLACEHOLDER; - result[resIndex++] = index; - } - } - return result; - } - - /** - * Gets the value at `key`, unless `key` is "__proto__". - * - * @private - * @param {Object} object The object to query. - * @param {string} key The key of the property to get. - * @returns {*} Returns the property value. - */ - function safeGet(object, key) { - return key == '__proto__' - ? undefined - : object[key]; - } - - /** - * Converts `set` to an array of its values. - * - * @private - * @param {Object} set The set to convert. - * @returns {Array} Returns the values. - */ - function setToArray(set) { - var index = -1, - result = Array(set.size); - - set.forEach(function(value) { - result[++index] = value; - }); - return result; - } - - /** - * Converts `set` to its value-value pairs. - * - * @private - * @param {Object} set The set to convert. - * @returns {Array} Returns the value-value pairs. - */ - function setToPairs(set) { - var index = -1, - result = Array(set.size); - - set.forEach(function(value) { - result[++index] = [value, value]; - }); - return result; - } - - /** - * A specialized version of `_.indexOf` which performs strict equality - * comparisons of values, i.e. `===`. - * - * @private - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @param {number} fromIndex The index to search from. - * @returns {number} Returns the index of the matched value, else `-1`. - */ - function strictIndexOf(array, value, fromIndex) { - var index = fromIndex - 1, - length = array.length; - - while (++index < length) { - if (array[index] === value) { - return index; - } - } - return -1; - } - - /** - * A specialized version of `_.lastIndexOf` which performs strict equality - * comparisons of values, i.e. `===`. - * - * @private - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @param {number} fromIndex The index to search from. - * @returns {number} Returns the index of the matched value, else `-1`. - */ - function strictLastIndexOf(array, value, fromIndex) { - var index = fromIndex + 1; - while (index--) { - if (array[index] === value) { - return index; - } - } - return index; - } - - /** - * Gets the number of symbols in `string`. - * - * @private - * @param {string} string The string to inspect. - * @returns {number} Returns the string size. - */ - function stringSize(string) { - return hasUnicode(string) - ? unicodeSize(string) - : asciiSize(string); - } - - /** - * Converts `string` to an array. - * - * @private - * @param {string} string The string to convert. - * @returns {Array} Returns the converted array. - */ - function stringToArray(string) { - return hasUnicode(string) - ? unicodeToArray(string) - : asciiToArray(string); - } - - /** - * Used by `_.unescape` to convert HTML entities to characters. - * - * @private - * @param {string} chr The matched character to unescape. - * @returns {string} Returns the unescaped character. - */ - var unescapeHtmlChar = basePropertyOf(htmlUnescapes); - - /** - * Gets the size of a Unicode `string`. - * - * @private - * @param {string} string The string inspect. - * @returns {number} Returns the string size. - */ - function unicodeSize(string) { - var result = reUnicode.lastIndex = 0; - while (reUnicode.test(string)) { - ++result; - } - return result; - } - - /** - * Converts a Unicode `string` to an array. - * - * @private - * @param {string} string The string to convert. - * @returns {Array} Returns the converted array. - */ - function unicodeToArray(string) { - return string.match(reUnicode) || []; - } - - /** - * Splits a Unicode `string` into an array of its words. - * - * @private - * @param {string} The string to inspect. - * @returns {Array} Returns the words of `string`. - */ - function unicodeWords(string) { - return string.match(reUnicodeWord) || []; - } - - /*--------------------------------------------------------------------------*/ - - /** - * Create a new pristine `lodash` function using the `context` object. - * - * @static - * @memberOf _ - * @since 1.1.0 - * @category Util - * @param {Object} [context=root] The context object. - * @returns {Function} Returns a new `lodash` function. - * @example - * - * _.mixin({ 'foo': _.constant('foo') }); - * - * var lodash = _.runInContext(); - * lodash.mixin({ 'bar': lodash.constant('bar') }); - * - * _.isFunction(_.foo); - * // => true - * _.isFunction(_.bar); - * // => false - * - * lodash.isFunction(lodash.foo); - * // => false - * lodash.isFunction(lodash.bar); - * // => true - * - * // Create a suped-up `defer` in Node.js. - * var defer = _.runInContext({ 'setTimeout': setImmediate }).defer; - */ - var runInContext = (function runInContext(context) { - context = context == null ? root : _.defaults(root.Object(), context, _.pick(root, contextProps)); - - /** Built-in constructor references. */ - var Array = context.Array, - Date = context.Date, - Error = context.Error, - Function = context.Function, - Math = context.Math, - Object = context.Object, - RegExp = context.RegExp, - String = context.String, - TypeError = context.TypeError; - - /** Used for built-in method references. */ - var arrayProto = Array.prototype, - funcProto = Function.prototype, - objectProto = Object.prototype; - - /** Used to detect overreaching core-js shims. */ - var coreJsData = context['__core-js_shared__']; - - /** Used to resolve the decompiled source of functions. */ - var funcToString = funcProto.toString; - - /** Used to check objects for own properties. */ - var hasOwnProperty = objectProto.hasOwnProperty; - - /** Used to generate unique IDs. */ - var idCounter = 0; - - /** Used to detect methods masquerading as native. */ - var maskSrcKey = (function() { - var uid = /[^.]+$/.exec(coreJsData && coreJsData.keys && coreJsData.keys.IE_PROTO || ''); - return uid ? ('Symbol(src)_1.' + uid) : ''; - }()); - - /** - * Used to resolve the - * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) - * of values. - */ - var nativeObjectToString = objectProto.toString; - - /** Used to infer the `Object` constructor. */ - var objectCtorString = funcToString.call(Object); - - /** Used to restore the original `_` reference in `_.noConflict`. */ - var oldDash = root._; - - /** Used to detect if a method is native. */ - var reIsNative = RegExp('^' + - funcToString.call(hasOwnProperty).replace(reRegExpChar, '\\$&') - .replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, '$1.*?') + '$' - ); - - /** Built-in value references. */ - var Buffer = moduleExports ? context.Buffer : undefined, - Symbol = context.Symbol, - Uint8Array = context.Uint8Array, - allocUnsafe = Buffer ? Buffer.allocUnsafe : undefined, - getPrototype = overArg(Object.getPrototypeOf, Object), - objectCreate = Object.create, - propertyIsEnumerable = objectProto.propertyIsEnumerable, - splice = arrayProto.splice, - spreadableSymbol = Symbol ? Symbol.isConcatSpreadable : undefined, - symIterator = Symbol ? Symbol.iterator : undefined, - symToStringTag = Symbol ? Symbol.toStringTag : undefined; - - var defineProperty = (function() { - try { - var func = getNative(Object, 'defineProperty'); - func({}, '', {}); - return func; - } catch (e) {} - }()); - - /** Mocked built-ins. */ - var ctxClearTimeout = context.clearTimeout !== root.clearTimeout && context.clearTimeout, - ctxNow = Date && Date.now !== root.Date.now && Date.now, - ctxSetTimeout = context.setTimeout !== root.setTimeout && context.setTimeout; - - /* Built-in method references for those with the same name as other `lodash` methods. */ - var nativeCeil = Math.ceil, - nativeFloor = Math.floor, - nativeGetSymbols = Object.getOwnPropertySymbols, - nativeIsBuffer = Buffer ? Buffer.isBuffer : undefined, - nativeIsFinite = context.isFinite, - nativeJoin = arrayProto.join, - nativeKeys = overArg(Object.keys, Object), - nativeMax = Math.max, - nativeMin = Math.min, - nativeNow = Date.now, - nativeParseInt = context.parseInt, - nativeRandom = Math.random, - nativeReverse = arrayProto.reverse; - - /* Built-in method references that are verified to be native. */ - var DataView = getNative(context, 'DataView'), - Map = getNative(context, 'Map'), - Promise = getNative(context, 'Promise'), - Set = getNative(context, 'Set'), - WeakMap = getNative(context, 'WeakMap'), - nativeCreate = getNative(Object, 'create'); - - /** Used to store function metadata. */ - var metaMap = WeakMap && new WeakMap; - - /** Used to lookup unminified function names. */ - var realNames = {}; - - /** Used to detect maps, sets, and weakmaps. */ - var dataViewCtorString = toSource(DataView), - mapCtorString = toSource(Map), - promiseCtorString = toSource(Promise), - setCtorString = toSource(Set), - weakMapCtorString = toSource(WeakMap); - - /** Used to convert symbols to primitives and strings. */ - var symbolProto = Symbol ? Symbol.prototype : undefined, - symbolValueOf = symbolProto ? symbolProto.valueOf : undefined, - symbolToString = symbolProto ? symbolProto.toString : undefined; - - /*------------------------------------------------------------------------*/ - - /** - * Creates a `lodash` object which wraps `value` to enable implicit method - * chain sequences. Methods that operate on and return arrays, collections, - * and functions can be chained together. Methods that retrieve a single value - * or may return a primitive value will automatically end the chain sequence - * and return the unwrapped value. Otherwise, the value must be unwrapped - * with `_#value`. - * - * Explicit chain sequences, which must be unwrapped with `_#value`, may be - * enabled using `_.chain`. - * - * The execution of chained methods is lazy, that is, it's deferred until - * `_#value` is implicitly or explicitly called. - * - * Lazy evaluation allows several methods to support shortcut fusion. - * Shortcut fusion is an optimization to merge iteratee calls; this avoids - * the creation of intermediate arrays and can greatly reduce the number of - * iteratee executions. Sections of a chain sequence qualify for shortcut - * fusion if the section is applied to an array and iteratees accept only - * one argument. The heuristic for whether a section qualifies for shortcut - * fusion is subject to change. - * - * Chaining is supported in custom builds as long as the `_#value` method is - * directly or indirectly included in the build. - * - * In addition to lodash methods, wrappers have `Array` and `String` methods. - * - * The wrapper `Array` methods are: - * `concat`, `join`, `pop`, `push`, `shift`, `sort`, `splice`, and `unshift` - * - * The wrapper `String` methods are: - * `replace` and `split` - * - * The wrapper methods that support shortcut fusion are: - * `at`, `compact`, `drop`, `dropRight`, `dropWhile`, `filter`, `find`, - * `findLast`, `head`, `initial`, `last`, `map`, `reject`, `reverse`, `slice`, - * `tail`, `take`, `takeRight`, `takeRightWhile`, `takeWhile`, and `toArray` - * - * The chainable wrapper methods are: - * `after`, `ary`, `assign`, `assignIn`, `assignInWith`, `assignWith`, `at`, - * `before`, `bind`, `bindAll`, `bindKey`, `castArray`, `chain`, `chunk`, - * `commit`, `compact`, `concat`, `conforms`, `constant`, `countBy`, `create`, - * `curry`, `debounce`, `defaults`, `defaultsDeep`, `defer`, `delay`, - * `difference`, `differenceBy`, `differenceWith`, `drop`, `dropRight`, - * `dropRightWhile`, `dropWhile`, `extend`, `extendWith`, `fill`, `filter`, - * `flatMap`, `flatMapDeep`, `flatMapDepth`, `flatten`, `flattenDeep`, - * `flattenDepth`, `flip`, `flow`, `flowRight`, `fromPairs`, `functions`, - * `functionsIn`, `groupBy`, `initial`, `intersection`, `intersectionBy`, - * `intersectionWith`, `invert`, `invertBy`, `invokeMap`, `iteratee`, `keyBy`, - * `keys`, `keysIn`, `map`, `mapKeys`, `mapValues`, `matches`, `matchesProperty`, - * `memoize`, `merge`, `mergeWith`, `method`, `methodOf`, `mixin`, `negate`, - * `nthArg`, `omit`, `omitBy`, `once`, `orderBy`, `over`, `overArgs`, - * `overEvery`, `overSome`, `partial`, `partialRight`, `partition`, `pick`, - * `pickBy`, `plant`, `property`, `propertyOf`, `pull`, `pullAll`, `pullAllBy`, - * `pullAllWith`, `pullAt`, `push`, `range`, `rangeRight`, `rearg`, `reject`, - * `remove`, `rest`, `reverse`, `sampleSize`, `set`, `setWith`, `shuffle`, - * `slice`, `sort`, `sortBy`, `splice`, `spread`, `tail`, `take`, `takeRight`, - * `takeRightWhile`, `takeWhile`, `tap`, `throttle`, `thru`, `toArray`, - * `toPairs`, `toPairsIn`, `toPath`, `toPlainObject`, `transform`, `unary`, - * `union`, `unionBy`, `unionWith`, `uniq`, `uniqBy`, `uniqWith`, `unset`, - * `unshift`, `unzip`, `unzipWith`, `update`, `updateWith`, `values`, - * `valuesIn`, `without`, `wrap`, `xor`, `xorBy`, `xorWith`, `zip`, - * `zipObject`, `zipObjectDeep`, and `zipWith` - * - * The wrapper methods that are **not** chainable by default are: - * `add`, `attempt`, `camelCase`, `capitalize`, `ceil`, `clamp`, `clone`, - * `cloneDeep`, `cloneDeepWith`, `cloneWith`, `conformsTo`, `deburr`, - * `defaultTo`, `divide`, `each`, `eachRight`, `endsWith`, `eq`, `escape`, - * `escapeRegExp`, `every`, `find`, `findIndex`, `findKey`, `findLast`, - * `findLastIndex`, `findLastKey`, `first`, `floor`, `forEach`, `forEachRight`, - * `forIn`, `forInRight`, `forOwn`, `forOwnRight`, `get`, `gt`, `gte`, `has`, - * `hasIn`, `head`, `identity`, `includes`, `indexOf`, `inRange`, `invoke`, - * `isArguments`, `isArray`, `isArrayBuffer`, `isArrayLike`, `isArrayLikeObject`, - * `isBoolean`, `isBuffer`, `isDate`, `isElement`, `isEmpty`, `isEqual`, - * `isEqualWith`, `isError`, `isFinite`, `isFunction`, `isInteger`, `isLength`, - * `isMap`, `isMatch`, `isMatchWith`, `isNaN`, `isNative`, `isNil`, `isNull`, - * `isNumber`, `isObject`, `isObjectLike`, `isPlainObject`, `isRegExp`, - * `isSafeInteger`, `isSet`, `isString`, `isUndefined`, `isTypedArray`, - * `isWeakMap`, `isWeakSet`, `join`, `kebabCase`, `last`, `lastIndexOf`, - * `lowerCase`, `lowerFirst`, `lt`, `lte`, `max`, `maxBy`, `mean`, `meanBy`, - * `min`, `minBy`, `multiply`, `noConflict`, `noop`, `now`, `nth`, `pad`, - * `padEnd`, `padStart`, `parseInt`, `pop`, `random`, `reduce`, `reduceRight`, - * `repeat`, `result`, `round`, `runInContext`, `sample`, `shift`, `size`, - * `snakeCase`, `some`, `sortedIndex`, `sortedIndexBy`, `sortedLastIndex`, - * `sortedLastIndexBy`, `startCase`, `startsWith`, `stubArray`, `stubFalse`, - * `stubObject`, `stubString`, `stubTrue`, `subtract`, `sum`, `sumBy`, - * `template`, `times`, `toFinite`, `toInteger`, `toJSON`, `toLength`, - * `toLower`, `toNumber`, `toSafeInteger`, `toString`, `toUpper`, `trim`, - * `trimEnd`, `trimStart`, `truncate`, `unescape`, `uniqueId`, `upperCase`, - * `upperFirst`, `value`, and `words` - * - * @name _ - * @constructor - * @category Seq - * @param {*} value The value to wrap in a `lodash` instance. - * @returns {Object} Returns the new `lodash` wrapper instance. - * @example - * - * function square(n) { - * return n * n; - * } - * - * var wrapped = _([1, 2, 3]); - * - * // Returns an unwrapped value. - * wrapped.reduce(_.add); - * // => 6 - * - * // Returns a wrapped value. - * var squares = wrapped.map(square); - * - * _.isArray(squares); - * // => false - * - * _.isArray(squares.value()); - * // => true - */ - function lodash(value) { - if (isObjectLike(value) && !isArray(value) && !(value instanceof LazyWrapper)) { - if (value instanceof LodashWrapper) { - return value; - } - if (hasOwnProperty.call(value, '__wrapped__')) { - return wrapperClone(value); - } - } - return new LodashWrapper(value); - } - - /** - * The base implementation of `_.create` without support for assigning - * properties to the created object. - * - * @private - * @param {Object} proto The object to inherit from. - * @returns {Object} Returns the new object. - */ - var baseCreate = (function() { - function object() {} - return function(proto) { - if (!isObject(proto)) { - return {}; - } - if (objectCreate) { - return objectCreate(proto); - } - object.prototype = proto; - var result = new object; - object.prototype = undefined; - return result; - }; - }()); - - /** - * The function whose prototype chain sequence wrappers inherit from. - * - * @private - */ - function baseLodash() { - // No operation performed. - } - - /** - * The base constructor for creating `lodash` wrapper objects. - * - * @private - * @param {*} value The value to wrap. - * @param {boolean} [chainAll] Enable explicit method chain sequences. - */ - function LodashWrapper(value, chainAll) { - this.__wrapped__ = value; - this.__actions__ = []; - this.__chain__ = !!chainAll; - this.__index__ = 0; - this.__values__ = undefined; - } - - /** - * By default, the template delimiters used by lodash are like those in - * embedded Ruby (ERB) as well as ES2015 template strings. Change the - * following template settings to use alternative delimiters. - * - * @static - * @memberOf _ - * @type {Object} - */ - lodash.templateSettings = { - - /** - * Used to detect `data` property values to be HTML-escaped. - * - * @memberOf _.templateSettings - * @type {RegExp} - */ - 'escape': reEscape, - - /** - * Used to detect code to be evaluated. - * - * @memberOf _.templateSettings - * @type {RegExp} - */ - 'evaluate': reEvaluate, - - /** - * Used to detect `data` property values to inject. - * - * @memberOf _.templateSettings - * @type {RegExp} - */ - 'interpolate': reInterpolate, - - /** - * Used to reference the data object in the template text. - * - * @memberOf _.templateSettings - * @type {string} - */ - 'variable': '', - - /** - * Used to import variables into the compiled template. - * - * @memberOf _.templateSettings - * @type {Object} - */ - 'imports': { - - /** - * A reference to the `lodash` function. - * - * @memberOf _.templateSettings.imports - * @type {Function} - */ - '_': lodash - } - }; - - // Ensure wrappers are instances of `baseLodash`. - lodash.prototype = baseLodash.prototype; - lodash.prototype.constructor = lodash; - - LodashWrapper.prototype = baseCreate(baseLodash.prototype); - LodashWrapper.prototype.constructor = LodashWrapper; - - /*------------------------------------------------------------------------*/ - - /** - * Creates a lazy wrapper object which wraps `value` to enable lazy evaluation. - * - * @private - * @constructor - * @param {*} value The value to wrap. - */ - function LazyWrapper(value) { - this.__wrapped__ = value; - this.__actions__ = []; - this.__dir__ = 1; - this.__filtered__ = false; - this.__iteratees__ = []; - this.__takeCount__ = MAX_ARRAY_LENGTH; - this.__views__ = []; - } - - /** - * Creates a clone of the lazy wrapper object. - * - * @private - * @name clone - * @memberOf LazyWrapper - * @returns {Object} Returns the cloned `LazyWrapper` object. - */ - function lazyClone() { - var result = new LazyWrapper(this.__wrapped__); - result.__actions__ = copyArray(this.__actions__); - result.__dir__ = this.__dir__; - result.__filtered__ = this.__filtered__; - result.__iteratees__ = copyArray(this.__iteratees__); - result.__takeCount__ = this.__takeCount__; - result.__views__ = copyArray(this.__views__); - return result; - } - - /** - * Reverses the direction of lazy iteration. - * - * @private - * @name reverse - * @memberOf LazyWrapper - * @returns {Object} Returns the new reversed `LazyWrapper` object. - */ - function lazyReverse() { - if (this.__filtered__) { - var result = new LazyWrapper(this); - result.__dir__ = -1; - result.__filtered__ = true; - } else { - result = this.clone(); - result.__dir__ *= -1; - } - return result; - } - - /** - * Extracts the unwrapped value from its lazy wrapper. - * - * @private - * @name value - * @memberOf LazyWrapper - * @returns {*} Returns the unwrapped value. - */ - function lazyValue() { - var array = this.__wrapped__.value(), - dir = this.__dir__, - isArr = isArray(array), - isRight = dir < 0, - arrLength = isArr ? array.length : 0, - view = getView(0, arrLength, this.__views__), - start = view.start, - end = view.end, - length = end - start, - index = isRight ? end : (start - 1), - iteratees = this.__iteratees__, - iterLength = iteratees.length, - resIndex = 0, - takeCount = nativeMin(length, this.__takeCount__); - - if (!isArr || (!isRight && arrLength == length && takeCount == length)) { - return baseWrapperValue(array, this.__actions__); - } - var result = []; - - outer: - while (length-- && resIndex < takeCount) { - index += dir; - - var iterIndex = -1, - value = array[index]; - - while (++iterIndex < iterLength) { - var data = iteratees[iterIndex], - iteratee = data.iteratee, - type = data.type, - computed = iteratee(value); - - if (type == LAZY_MAP_FLAG) { - value = computed; - } else if (!computed) { - if (type == LAZY_FILTER_FLAG) { - continue outer; - } else { - break outer; - } - } - } - result[resIndex++] = value; - } - return result; - } - - // Ensure `LazyWrapper` is an instance of `baseLodash`. - LazyWrapper.prototype = baseCreate(baseLodash.prototype); - LazyWrapper.prototype.constructor = LazyWrapper; - - /*------------------------------------------------------------------------*/ - - /** - * Creates a hash object. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ - function Hash(entries) { - var index = -1, - length = entries == null ? 0 : entries.length; - - this.clear(); - while (++index < length) { - var entry = entries[index]; - this.set(entry[0], entry[1]); - } - } - - /** - * Removes all key-value entries from the hash. - * - * @private - * @name clear - * @memberOf Hash - */ - function hashClear() { - this.__data__ = nativeCreate ? nativeCreate(null) : {}; - this.size = 0; - } - - /** - * Removes `key` and its value from the hash. - * - * @private - * @name delete - * @memberOf Hash - * @param {Object} hash The hash to modify. - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ - function hashDelete(key) { - var result = this.has(key) && delete this.__data__[key]; - this.size -= result ? 1 : 0; - return result; - } - - /** - * Gets the hash value for `key`. - * - * @private - * @name get - * @memberOf Hash - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ - function hashGet(key) { - var data = this.__data__; - if (nativeCreate) { - var result = data[key]; - return result === HASH_UNDEFINED ? undefined : result; - } - return hasOwnProperty.call(data, key) ? data[key] : undefined; - } - - /** - * Checks if a hash value for `key` exists. - * - * @private - * @name has - * @memberOf Hash - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ - function hashHas(key) { - var data = this.__data__; - return nativeCreate ? (data[key] !== undefined) : hasOwnProperty.call(data, key); - } - - /** - * Sets the hash `key` to `value`. - * - * @private - * @name set - * @memberOf Hash - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the hash instance. - */ - function hashSet(key, value) { - var data = this.__data__; - this.size += this.has(key) ? 0 : 1; - data[key] = (nativeCreate && value === undefined) ? HASH_UNDEFINED : value; - return this; - } - - // Add methods to `Hash`. - Hash.prototype.clear = hashClear; - Hash.prototype['delete'] = hashDelete; - Hash.prototype.get = hashGet; - Hash.prototype.has = hashHas; - Hash.prototype.set = hashSet; - - /*------------------------------------------------------------------------*/ - - /** - * Creates an list cache object. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ - function ListCache(entries) { - var index = -1, - length = entries == null ? 0 : entries.length; - - this.clear(); - while (++index < length) { - var entry = entries[index]; - this.set(entry[0], entry[1]); - } - } - - /** - * Removes all key-value entries from the list cache. - * - * @private - * @name clear - * @memberOf ListCache - */ - function listCacheClear() { - this.__data__ = []; - this.size = 0; - } - - /** - * Removes `key` and its value from the list cache. - * - * @private - * @name delete - * @memberOf ListCache - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ - function listCacheDelete(key) { - var data = this.__data__, - index = assocIndexOf(data, key); - - if (index < 0) { - return false; - } - var lastIndex = data.length - 1; - if (index == lastIndex) { - data.pop(); - } else { - splice.call(data, index, 1); - } - --this.size; - return true; - } - - /** - * Gets the list cache value for `key`. - * - * @private - * @name get - * @memberOf ListCache - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ - function listCacheGet(key) { - var data = this.__data__, - index = assocIndexOf(data, key); - - return index < 0 ? undefined : data[index][1]; - } - - /** - * Checks if a list cache value for `key` exists. - * - * @private - * @name has - * @memberOf ListCache - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ - function listCacheHas(key) { - return assocIndexOf(this.__data__, key) > -1; - } - - /** - * Sets the list cache `key` to `value`. - * - * @private - * @name set - * @memberOf ListCache - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the list cache instance. - */ - function listCacheSet(key, value) { - var data = this.__data__, - index = assocIndexOf(data, key); - - if (index < 0) { - ++this.size; - data.push([key, value]); - } else { - data[index][1] = value; - } - return this; - } - - // Add methods to `ListCache`. - ListCache.prototype.clear = listCacheClear; - ListCache.prototype['delete'] = listCacheDelete; - ListCache.prototype.get = listCacheGet; - ListCache.prototype.has = listCacheHas; - ListCache.prototype.set = listCacheSet; - - /*------------------------------------------------------------------------*/ - - /** - * Creates a map cache object to store key-value pairs. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ - function MapCache(entries) { - var index = -1, - length = entries == null ? 0 : entries.length; - - this.clear(); - while (++index < length) { - var entry = entries[index]; - this.set(entry[0], entry[1]); - } - } - - /** - * Removes all key-value entries from the map. - * - * @private - * @name clear - * @memberOf MapCache - */ - function mapCacheClear() { - this.size = 0; - this.__data__ = { - 'hash': new Hash, - 'map': new (Map || ListCache), - 'string': new Hash - }; - } - - /** - * Removes `key` and its value from the map. - * - * @private - * @name delete - * @memberOf MapCache - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ - function mapCacheDelete(key) { - var result = getMapData(this, key)['delete'](key); - this.size -= result ? 1 : 0; - return result; - } - - /** - * Gets the map value for `key`. - * - * @private - * @name get - * @memberOf MapCache - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ - function mapCacheGet(key) { - return getMapData(this, key).get(key); - } - - /** - * Checks if a map value for `key` exists. - * - * @private - * @name has - * @memberOf MapCache - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ - function mapCacheHas(key) { - return getMapData(this, key).has(key); - } - - /** - * Sets the map `key` to `value`. - * - * @private - * @name set - * @memberOf MapCache - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the map cache instance. - */ - function mapCacheSet(key, value) { - var data = getMapData(this, key), - size = data.size; - - data.set(key, value); - this.size += data.size == size ? 0 : 1; - return this; - } - - // Add methods to `MapCache`. - MapCache.prototype.clear = mapCacheClear; - MapCache.prototype['delete'] = mapCacheDelete; - MapCache.prototype.get = mapCacheGet; - MapCache.prototype.has = mapCacheHas; - MapCache.prototype.set = mapCacheSet; - - /*------------------------------------------------------------------------*/ - - /** - * - * Creates an array cache object to store unique values. - * - * @private - * @constructor - * @param {Array} [values] The values to cache. - */ - function SetCache(values) { - var index = -1, - length = values == null ? 0 : values.length; - - this.__data__ = new MapCache; - while (++index < length) { - this.add(values[index]); - } - } - - /** - * Adds `value` to the array cache. - * - * @private - * @name add - * @memberOf SetCache - * @alias push - * @param {*} value The value to cache. - * @returns {Object} Returns the cache instance. - */ - function setCacheAdd(value) { - this.__data__.set(value, HASH_UNDEFINED); - return this; - } - - /** - * Checks if `value` is in the array cache. - * - * @private - * @name has - * @memberOf SetCache - * @param {*} value The value to search for. - * @returns {number} Returns `true` if `value` is found, else `false`. - */ - function setCacheHas(value) { - return this.__data__.has(value); - } - - // Add methods to `SetCache`. - SetCache.prototype.add = SetCache.prototype.push = setCacheAdd; - SetCache.prototype.has = setCacheHas; - - /*------------------------------------------------------------------------*/ - - /** - * Creates a stack cache object to store key-value pairs. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ - function Stack(entries) { - var data = this.__data__ = new ListCache(entries); - this.size = data.size; - } - - /** - * Removes all key-value entries from the stack. - * - * @private - * @name clear - * @memberOf Stack - */ - function stackClear() { - this.__data__ = new ListCache; - this.size = 0; - } - - /** - * Removes `key` and its value from the stack. - * - * @private - * @name delete - * @memberOf Stack - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ - function stackDelete(key) { - var data = this.__data__, - result = data['delete'](key); - - this.size = data.size; - return result; - } - - /** - * Gets the stack value for `key`. - * - * @private - * @name get - * @memberOf Stack - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ - function stackGet(key) { - return this.__data__.get(key); - } - - /** - * Checks if a stack value for `key` exists. - * - * @private - * @name has - * @memberOf Stack - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ - function stackHas(key) { - return this.__data__.has(key); - } - - /** - * Sets the stack `key` to `value`. - * - * @private - * @name set - * @memberOf Stack - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the stack cache instance. - */ - function stackSet(key, value) { - var data = this.__data__; - if (data instanceof ListCache) { - var pairs = data.__data__; - if (!Map || (pairs.length < LARGE_ARRAY_SIZE - 1)) { - pairs.push([key, value]); - this.size = ++data.size; - return this; - } - data = this.__data__ = new MapCache(pairs); - } - data.set(key, value); - this.size = data.size; - return this; - } - - // Add methods to `Stack`. - Stack.prototype.clear = stackClear; - Stack.prototype['delete'] = stackDelete; - Stack.prototype.get = stackGet; - Stack.prototype.has = stackHas; - Stack.prototype.set = stackSet; - - /*------------------------------------------------------------------------*/ - - /** - * Creates an array of the enumerable property names of the array-like `value`. - * - * @private - * @param {*} value The value to query. - * @param {boolean} inherited Specify returning inherited property names. - * @returns {Array} Returns the array of property names. - */ - function arrayLikeKeys(value, inherited) { - var isArr = isArray(value), - isArg = !isArr && isArguments(value), - isBuff = !isArr && !isArg && isBuffer(value), - isType = !isArr && !isArg && !isBuff && isTypedArray(value), - skipIndexes = isArr || isArg || isBuff || isType, - result = skipIndexes ? baseTimes(value.length, String) : [], - length = result.length; - - for (var key in value) { - if ((inherited || hasOwnProperty.call(value, key)) && - !(skipIndexes && ( - // Safari 9 has enumerable `arguments.length` in strict mode. - key == 'length' || - // Node.js 0.10 has enumerable non-index properties on buffers. - (isBuff && (key == 'offset' || key == 'parent')) || - // PhantomJS 2 has enumerable non-index properties on typed arrays. - (isType && (key == 'buffer' || key == 'byteLength' || key == 'byteOffset')) || - // Skip index properties. - isIndex(key, length) - ))) { - result.push(key); - } - } - return result; - } - - /** - * A specialized version of `_.sample` for arrays. - * - * @private - * @param {Array} array The array to sample. - * @returns {*} Returns the random element. - */ - function arraySample(array) { - var length = array.length; - return length ? array[baseRandom(0, length - 1)] : undefined; - } - - /** - * A specialized version of `_.sampleSize` for arrays. - * - * @private - * @param {Array} array The array to sample. - * @param {number} n The number of elements to sample. - * @returns {Array} Returns the random elements. - */ - function arraySampleSize(array, n) { - return shuffleSelf(copyArray(array), baseClamp(n, 0, array.length)); - } - - /** - * A specialized version of `_.shuffle` for arrays. - * - * @private - * @param {Array} array The array to shuffle. - * @returns {Array} Returns the new shuffled array. - */ - function arrayShuffle(array) { - return shuffleSelf(copyArray(array)); - } - - /** - * This function is like `assignValue` except that it doesn't assign - * `undefined` values. - * - * @private - * @param {Object} object The object to modify. - * @param {string} key The key of the property to assign. - * @param {*} value The value to assign. - */ - function assignMergeValue(object, key, value) { - if ((value !== undefined && !eq(object[key], value)) || - (value === undefined && !(key in object))) { - baseAssignValue(object, key, value); - } - } - - /** - * Assigns `value` to `key` of `object` if the existing value is not equivalent - * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons. - * - * @private - * @param {Object} object The object to modify. - * @param {string} key The key of the property to assign. - * @param {*} value The value to assign. - */ - function assignValue(object, key, value) { - var objValue = object[key]; - if (!(hasOwnProperty.call(object, key) && eq(objValue, value)) || - (value === undefined && !(key in object))) { - baseAssignValue(object, key, value); - } - } - - /** - * Gets the index at which the `key` is found in `array` of key-value pairs. - * - * @private - * @param {Array} array The array to inspect. - * @param {*} key The key to search for. - * @returns {number} Returns the index of the matched value, else `-1`. - */ - function assocIndexOf(array, key) { - var length = array.length; - while (length--) { - if (eq(array[length][0], key)) { - return length; - } - } - return -1; - } - - /** - * Aggregates elements of `collection` on `accumulator` with keys transformed - * by `iteratee` and values set by `setter`. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} setter The function to set `accumulator` values. - * @param {Function} iteratee The iteratee to transform keys. - * @param {Object} accumulator The initial aggregated object. - * @returns {Function} Returns `accumulator`. - */ - function baseAggregator(collection, setter, iteratee, accumulator) { - baseEach(collection, function(value, key, collection) { - setter(accumulator, value, iteratee(value), collection); - }); - return accumulator; - } - - /** - * The base implementation of `_.assign` without support for multiple sources - * or `customizer` functions. - * - * @private - * @param {Object} object The destination object. - * @param {Object} source The source object. - * @returns {Object} Returns `object`. - */ - function baseAssign(object, source) { - return object && copyObject(source, keys(source), object); - } - - /** - * The base implementation of `_.assignIn` without support for multiple sources - * or `customizer` functions. - * - * @private - * @param {Object} object The destination object. - * @param {Object} source The source object. - * @returns {Object} Returns `object`. - */ - function baseAssignIn(object, source) { - return object && copyObject(source, keysIn(source), object); - } - - /** - * The base implementation of `assignValue` and `assignMergeValue` without - * value checks. - * - * @private - * @param {Object} object The object to modify. - * @param {string} key The key of the property to assign. - * @param {*} value The value to assign. - */ - function baseAssignValue(object, key, value) { - if (key == '__proto__' && defineProperty) { - defineProperty(object, key, { - 'configurable': true, - 'enumerable': true, - 'value': value, - 'writable': true - }); - } else { - object[key] = value; - } - } - - /** - * The base implementation of `_.at` without support for individual paths. - * - * @private - * @param {Object} object The object to iterate over. - * @param {string[]} paths The property paths to pick. - * @returns {Array} Returns the picked elements. - */ - function baseAt(object, paths) { - var index = -1, - length = paths.length, - result = Array(length), - skip = object == null; - - while (++index < length) { - result[index] = skip ? undefined : get(object, paths[index]); - } - return result; - } - - /** - * The base implementation of `_.clamp` which doesn't coerce arguments. - * - * @private - * @param {number} number The number to clamp. - * @param {number} [lower] The lower bound. - * @param {number} upper The upper bound. - * @returns {number} Returns the clamped number. - */ - function baseClamp(number, lower, upper) { - if (number === number) { - if (upper !== undefined) { - number = number <= upper ? number : upper; - } - if (lower !== undefined) { - number = number >= lower ? number : lower; - } - } - return number; - } - - /** - * The base implementation of `_.clone` and `_.cloneDeep` which tracks - * traversed objects. - * - * @private - * @param {*} value The value to clone. - * @param {boolean} bitmask The bitmask flags. - * 1 - Deep clone - * 2 - Flatten inherited properties - * 4 - Clone symbols - * @param {Function} [customizer] The function to customize cloning. - * @param {string} [key] The key of `value`. - * @param {Object} [object] The parent object of `value`. - * @param {Object} [stack] Tracks traversed objects and their clone counterparts. - * @returns {*} Returns the cloned value. - */ - function baseClone(value, bitmask, customizer, key, object, stack) { - var result, - isDeep = bitmask & CLONE_DEEP_FLAG, - isFlat = bitmask & CLONE_FLAT_FLAG, - isFull = bitmask & CLONE_SYMBOLS_FLAG; - - if (customizer) { - result = object ? customizer(value, key, object, stack) : customizer(value); - } - if (result !== undefined) { - return result; - } - if (!isObject(value)) { - return value; - } - var isArr = isArray(value); - if (isArr) { - result = initCloneArray(value); - if (!isDeep) { - return copyArray(value, result); - } - } else { - var tag = getTag(value), - isFunc = tag == funcTag || tag == genTag; - - if (isBuffer(value)) { - return cloneBuffer(value, isDeep); - } - if (tag == objectTag || tag == argsTag || (isFunc && !object)) { - result = (isFlat || isFunc) ? {} : initCloneObject(value); - if (!isDeep) { - return isFlat - ? copySymbolsIn(value, baseAssignIn(result, value)) - : copySymbols(value, baseAssign(result, value)); - } - } else { - if (!cloneableTags[tag]) { - return object ? value : {}; - } - result = initCloneByTag(value, tag, isDeep); - } - } - // Check for circular references and return its corresponding clone. - stack || (stack = new Stack); - var stacked = stack.get(value); - if (stacked) { - return stacked; - } - stack.set(value, result); - - if (isSet(value)) { - value.forEach(function(subValue) { - result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack)); - }); - - return result; - } - - if (isMap(value)) { - value.forEach(function(subValue, key) { - result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack)); - }); - - return result; - } - - var keysFunc = isFull - ? (isFlat ? getAllKeysIn : getAllKeys) - : (isFlat ? keysIn : keys); - - var props = isArr ? undefined : keysFunc(value); - arrayEach(props || value, function(subValue, key) { - if (props) { - key = subValue; - subValue = value[key]; - } - // Recursively populate clone (susceptible to call stack limits). - assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack)); - }); - return result; - } - - /** - * The base implementation of `_.conforms` which doesn't clone `source`. - * - * @private - * @param {Object} source The object of property predicates to conform to. - * @returns {Function} Returns the new spec function. - */ - function baseConforms(source) { - var props = keys(source); - return function(object) { - return baseConformsTo(object, source, props); - }; - } - - /** - * The base implementation of `_.conformsTo` which accepts `props` to check. - * - * @private - * @param {Object} object The object to inspect. - * @param {Object} source The object of property predicates to conform to. - * @returns {boolean} Returns `true` if `object` conforms, else `false`. - */ - function baseConformsTo(object, source, props) { - var length = props.length; - if (object == null) { - return !length; - } - object = Object(object); - while (length--) { - var key = props[length], - predicate = source[key], - value = object[key]; - - if ((value === undefined && !(key in object)) || !predicate(value)) { - return false; - } - } - return true; - } - - /** - * The base implementation of `_.delay` and `_.defer` which accepts `args` - * to provide to `func`. - * - * @private - * @param {Function} func The function to delay. - * @param {number} wait The number of milliseconds to delay invocation. - * @param {Array} args The arguments to provide to `func`. - * @returns {number|Object} Returns the timer id or timeout object. - */ - function baseDelay(func, wait, args) { - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - return setTimeout(function() { func.apply(undefined, args); }, wait); - } - - /** - * The base implementation of methods like `_.difference` without support - * for excluding multiple arrays or iteratee shorthands. - * - * @private - * @param {Array} array The array to inspect. - * @param {Array} values The values to exclude. - * @param {Function} [iteratee] The iteratee invoked per element. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new array of filtered values. - */ - function baseDifference(array, values, iteratee, comparator) { - var index = -1, - includes = arrayIncludes, - isCommon = true, - length = array.length, - result = [], - valuesLength = values.length; - - if (!length) { - return result; - } - if (iteratee) { - values = arrayMap(values, baseUnary(iteratee)); - } - if (comparator) { - includes = arrayIncludesWith; - isCommon = false; - } - else if (values.length >= LARGE_ARRAY_SIZE) { - includes = cacheHas; - isCommon = false; - values = new SetCache(values); - } - outer: - while (++index < length) { - var value = array[index], - computed = iteratee == null ? value : iteratee(value); - - value = (comparator || value !== 0) ? value : 0; - if (isCommon && computed === computed) { - var valuesIndex = valuesLength; - while (valuesIndex--) { - if (values[valuesIndex] === computed) { - continue outer; - } - } - result.push(value); - } - else if (!includes(values, computed, comparator)) { - result.push(value); - } - } - return result; - } - - /** - * The base implementation of `_.forEach` without support for iteratee shorthands. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array|Object} Returns `collection`. - */ - var baseEach = createBaseEach(baseForOwn); - - /** - * The base implementation of `_.forEachRight` without support for iteratee shorthands. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array|Object} Returns `collection`. - */ - var baseEachRight = createBaseEach(baseForOwnRight, true); - - /** - * The base implementation of `_.every` without support for iteratee shorthands. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {boolean} Returns `true` if all elements pass the predicate check, - * else `false` - */ - function baseEvery(collection, predicate) { - var result = true; - baseEach(collection, function(value, index, collection) { - result = !!predicate(value, index, collection); - return result; - }); - return result; - } - - /** - * The base implementation of methods like `_.max` and `_.min` which accepts a - * `comparator` to determine the extremum value. - * - * @private - * @param {Array} array The array to iterate over. - * @param {Function} iteratee The iteratee invoked per iteration. - * @param {Function} comparator The comparator used to compare values. - * @returns {*} Returns the extremum value. - */ - function baseExtremum(array, iteratee, comparator) { - var index = -1, - length = array.length; - - while (++index < length) { - var value = array[index], - current = iteratee(value); - - if (current != null && (computed === undefined - ? (current === current && !isSymbol(current)) - : comparator(current, computed) - )) { - var computed = current, - result = value; - } - } - return result; - } - - /** - * The base implementation of `_.fill` without an iteratee call guard. - * - * @private - * @param {Array} array The array to fill. - * @param {*} value The value to fill `array` with. - * @param {number} [start=0] The start position. - * @param {number} [end=array.length] The end position. - * @returns {Array} Returns `array`. - */ - function baseFill(array, value, start, end) { - var length = array.length; - - start = toInteger(start); - if (start < 0) { - start = -start > length ? 0 : (length + start); - } - end = (end === undefined || end > length) ? length : toInteger(end); - if (end < 0) { - end += length; - } - end = start > end ? 0 : toLength(end); - while (start < end) { - array[start++] = value; - } - return array; - } - - /** - * The base implementation of `_.filter` without support for iteratee shorthands. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {Array} Returns the new filtered array. - */ - function baseFilter(collection, predicate) { - var result = []; - baseEach(collection, function(value, index, collection) { - if (predicate(value, index, collection)) { - result.push(value); - } - }); - return result; - } - - /** - * The base implementation of `_.flatten` with support for restricting flattening. - * - * @private - * @param {Array} array The array to flatten. - * @param {number} depth The maximum recursion depth. - * @param {boolean} [predicate=isFlattenable] The function invoked per iteration. - * @param {boolean} [isStrict] Restrict to values that pass `predicate` checks. - * @param {Array} [result=[]] The initial result value. - * @returns {Array} Returns the new flattened array. - */ - function baseFlatten(array, depth, predicate, isStrict, result) { - var index = -1, - length = array.length; - - predicate || (predicate = isFlattenable); - result || (result = []); - - while (++index < length) { - var value = array[index]; - if (depth > 0 && predicate(value)) { - if (depth > 1) { - // Recursively flatten arrays (susceptible to call stack limits). - baseFlatten(value, depth - 1, predicate, isStrict, result); - } else { - arrayPush(result, value); - } - } else if (!isStrict) { - result[result.length] = value; - } - } - return result; - } - - /** - * The base implementation of `baseForOwn` which iterates over `object` - * properties returned by `keysFunc` and invokes `iteratee` for each property. - * Iteratee functions may exit iteration early by explicitly returning `false`. - * - * @private - * @param {Object} object The object to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @param {Function} keysFunc The function to get the keys of `object`. - * @returns {Object} Returns `object`. - */ - var baseFor = createBaseFor(); - - /** - * This function is like `baseFor` except that it iterates over properties - * in the opposite order. - * - * @private - * @param {Object} object The object to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @param {Function} keysFunc The function to get the keys of `object`. - * @returns {Object} Returns `object`. - */ - var baseForRight = createBaseFor(true); - - /** - * The base implementation of `_.forOwn` without support for iteratee shorthands. - * - * @private - * @param {Object} object The object to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Object} Returns `object`. - */ - function baseForOwn(object, iteratee) { - return object && baseFor(object, iteratee, keys); - } - - /** - * The base implementation of `_.forOwnRight` without support for iteratee shorthands. - * - * @private - * @param {Object} object The object to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Object} Returns `object`. - */ - function baseForOwnRight(object, iteratee) { - return object && baseForRight(object, iteratee, keys); - } - - /** - * The base implementation of `_.functions` which creates an array of - * `object` function property names filtered from `props`. - * - * @private - * @param {Object} object The object to inspect. - * @param {Array} props The property names to filter. - * @returns {Array} Returns the function names. - */ - function baseFunctions(object, props) { - return arrayFilter(props, function(key) { - return isFunction(object[key]); - }); - } - - /** - * The base implementation of `_.get` without support for default values. - * - * @private - * @param {Object} object The object to query. - * @param {Array|string} path The path of the property to get. - * @returns {*} Returns the resolved value. - */ - function baseGet(object, path) { - path = castPath(path, object); - - var index = 0, - length = path.length; - - while (object != null && index < length) { - object = object[toKey(path[index++])]; - } - return (index && index == length) ? object : undefined; - } - - /** - * The base implementation of `getAllKeys` and `getAllKeysIn` which uses - * `keysFunc` and `symbolsFunc` to get the enumerable property names and - * symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @param {Function} keysFunc The function to get the keys of `object`. - * @param {Function} symbolsFunc The function to get the symbols of `object`. - * @returns {Array} Returns the array of property names and symbols. - */ - function baseGetAllKeys(object, keysFunc, symbolsFunc) { - var result = keysFunc(object); - return isArray(object) ? result : arrayPush(result, symbolsFunc(object)); - } - - /** - * The base implementation of `getTag` without fallbacks for buggy environments. - * - * @private - * @param {*} value The value to query. - * @returns {string} Returns the `toStringTag`. - */ - function baseGetTag(value) { - if (value == null) { - return value === undefined ? undefinedTag : nullTag; - } - return (symToStringTag && symToStringTag in Object(value)) - ? getRawTag(value) - : objectToString(value); - } - - /** - * The base implementation of `_.gt` which doesn't coerce arguments. - * - * @private - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if `value` is greater than `other`, - * else `false`. - */ - function baseGt(value, other) { - return value > other; - } - - /** - * The base implementation of `_.has` without support for deep paths. - * - * @private - * @param {Object} [object] The object to query. - * @param {Array|string} key The key to check. - * @returns {boolean} Returns `true` if `key` exists, else `false`. - */ - function baseHas(object, key) { - return object != null && hasOwnProperty.call(object, key); - } - - /** - * The base implementation of `_.hasIn` without support for deep paths. - * - * @private - * @param {Object} [object] The object to query. - * @param {Array|string} key The key to check. - * @returns {boolean} Returns `true` if `key` exists, else `false`. - */ - function baseHasIn(object, key) { - return object != null && key in Object(object); - } - - /** - * The base implementation of `_.inRange` which doesn't coerce arguments. - * - * @private - * @param {number} number The number to check. - * @param {number} start The start of the range. - * @param {number} end The end of the range. - * @returns {boolean} Returns `true` if `number` is in the range, else `false`. - */ - function baseInRange(number, start, end) { - return number >= nativeMin(start, end) && number < nativeMax(start, end); - } - - /** - * The base implementation of methods like `_.intersection`, without support - * for iteratee shorthands, that accepts an array of arrays to inspect. - * - * @private - * @param {Array} arrays The arrays to inspect. - * @param {Function} [iteratee] The iteratee invoked per element. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new array of shared values. - */ - function baseIntersection(arrays, iteratee, comparator) { - var includes = comparator ? arrayIncludesWith : arrayIncludes, - length = arrays[0].length, - othLength = arrays.length, - othIndex = othLength, - caches = Array(othLength), - maxLength = Infinity, - result = []; - - while (othIndex--) { - var array = arrays[othIndex]; - if (othIndex && iteratee) { - array = arrayMap(array, baseUnary(iteratee)); - } - maxLength = nativeMin(array.length, maxLength); - caches[othIndex] = !comparator && (iteratee || (length >= 120 && array.length >= 120)) - ? new SetCache(othIndex && array) - : undefined; - } - array = arrays[0]; - - var index = -1, - seen = caches[0]; - - outer: - while (++index < length && result.length < maxLength) { - var value = array[index], - computed = iteratee ? iteratee(value) : value; - - value = (comparator || value !== 0) ? value : 0; - if (!(seen - ? cacheHas(seen, computed) - : includes(result, computed, comparator) - )) { - othIndex = othLength; - while (--othIndex) { - var cache = caches[othIndex]; - if (!(cache - ? cacheHas(cache, computed) - : includes(arrays[othIndex], computed, comparator)) - ) { - continue outer; - } - } - if (seen) { - seen.push(computed); - } - result.push(value); - } - } - return result; - } - - /** - * The base implementation of `_.invert` and `_.invertBy` which inverts - * `object` with values transformed by `iteratee` and set by `setter`. - * - * @private - * @param {Object} object The object to iterate over. - * @param {Function} setter The function to set `accumulator` values. - * @param {Function} iteratee The iteratee to transform values. - * @param {Object} accumulator The initial inverted object. - * @returns {Function} Returns `accumulator`. - */ - function baseInverter(object, setter, iteratee, accumulator) { - baseForOwn(object, function(value, key, object) { - setter(accumulator, iteratee(value), key, object); - }); - return accumulator; - } - - /** - * The base implementation of `_.invoke` without support for individual - * method arguments. - * - * @private - * @param {Object} object The object to query. - * @param {Array|string} path The path of the method to invoke. - * @param {Array} args The arguments to invoke the method with. - * @returns {*} Returns the result of the invoked method. - */ - function baseInvoke(object, path, args) { - path = castPath(path, object); - object = parent(object, path); - var func = object == null ? object : object[toKey(last(path))]; - return func == null ? undefined : apply(func, object, args); - } - - /** - * The base implementation of `_.isArguments`. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an `arguments` object, - */ - function baseIsArguments(value) { - return isObjectLike(value) && baseGetTag(value) == argsTag; - } - - /** - * The base implementation of `_.isArrayBuffer` without Node.js optimizations. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an array buffer, else `false`. - */ - function baseIsArrayBuffer(value) { - return isObjectLike(value) && baseGetTag(value) == arrayBufferTag; - } - - /** - * The base implementation of `_.isDate` without Node.js optimizations. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a date object, else `false`. - */ - function baseIsDate(value) { - return isObjectLike(value) && baseGetTag(value) == dateTag; - } - - /** - * The base implementation of `_.isEqual` which supports partial comparisons - * and tracks traversed objects. - * - * @private - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @param {boolean} bitmask The bitmask flags. - * 1 - Unordered comparison - * 2 - Partial comparison - * @param {Function} [customizer] The function to customize comparisons. - * @param {Object} [stack] Tracks traversed `value` and `other` objects. - * @returns {boolean} Returns `true` if the values are equivalent, else `false`. - */ - function baseIsEqual(value, other, bitmask, customizer, stack) { - if (value === other) { - return true; - } - if (value == null || other == null || (!isObjectLike(value) && !isObjectLike(other))) { - return value !== value && other !== other; - } - return baseIsEqualDeep(value, other, bitmask, customizer, baseIsEqual, stack); - } - - /** - * A specialized version of `baseIsEqual` for arrays and objects which performs - * deep comparisons and tracks traversed objects enabling objects with circular - * references to be compared. - * - * @private - * @param {Object} object The object to compare. - * @param {Object} other The other object to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} [stack] Tracks traversed `object` and `other` objects. - * @returns {boolean} Returns `true` if the objects are equivalent, else `false`. - */ - function baseIsEqualDeep(object, other, bitmask, customizer, equalFunc, stack) { - var objIsArr = isArray(object), - othIsArr = isArray(other), - objTag = objIsArr ? arrayTag : getTag(object), - othTag = othIsArr ? arrayTag : getTag(other); - - objTag = objTag == argsTag ? objectTag : objTag; - othTag = othTag == argsTag ? objectTag : othTag; - - var objIsObj = objTag == objectTag, - othIsObj = othTag == objectTag, - isSameTag = objTag == othTag; - - if (isSameTag && isBuffer(object)) { - if (!isBuffer(other)) { - return false; - } - objIsArr = true; - objIsObj = false; - } - if (isSameTag && !objIsObj) { - stack || (stack = new Stack); - return (objIsArr || isTypedArray(object)) - ? equalArrays(object, other, bitmask, customizer, equalFunc, stack) - : equalByTag(object, other, objTag, bitmask, customizer, equalFunc, stack); - } - if (!(bitmask & COMPARE_PARTIAL_FLAG)) { - var objIsWrapped = objIsObj && hasOwnProperty.call(object, '__wrapped__'), - othIsWrapped = othIsObj && hasOwnProperty.call(other, '__wrapped__'); - - if (objIsWrapped || othIsWrapped) { - var objUnwrapped = objIsWrapped ? object.value() : object, - othUnwrapped = othIsWrapped ? other.value() : other; - - stack || (stack = new Stack); - return equalFunc(objUnwrapped, othUnwrapped, bitmask, customizer, stack); - } - } - if (!isSameTag) { - return false; - } - stack || (stack = new Stack); - return equalObjects(object, other, bitmask, customizer, equalFunc, stack); - } - - /** - * The base implementation of `_.isMap` without Node.js optimizations. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a map, else `false`. - */ - function baseIsMap(value) { - return isObjectLike(value) && getTag(value) == mapTag; - } - - /** - * The base implementation of `_.isMatch` without support for iteratee shorthands. - * - * @private - * @param {Object} object The object to inspect. - * @param {Object} source The object of property values to match. - * @param {Array} matchData The property names, values, and compare flags to match. - * @param {Function} [customizer] The function to customize comparisons. - * @returns {boolean} Returns `true` if `object` is a match, else `false`. - */ - function baseIsMatch(object, source, matchData, customizer) { - var index = matchData.length, - length = index, - noCustomizer = !customizer; - - if (object == null) { - return !length; - } - object = Object(object); - while (index--) { - var data = matchData[index]; - if ((noCustomizer && data[2]) - ? data[1] !== object[data[0]] - : !(data[0] in object) - ) { - return false; - } - } - while (++index < length) { - data = matchData[index]; - var key = data[0], - objValue = object[key], - srcValue = data[1]; - - if (noCustomizer && data[2]) { - if (objValue === undefined && !(key in object)) { - return false; - } - } else { - var stack = new Stack; - if (customizer) { - var result = customizer(objValue, srcValue, key, object, source, stack); - } - if (!(result === undefined - ? baseIsEqual(srcValue, objValue, COMPARE_PARTIAL_FLAG | COMPARE_UNORDERED_FLAG, customizer, stack) - : result - )) { - return false; - } - } - } - return true; - } - - /** - * The base implementation of `_.isNative` without bad shim checks. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a native function, - * else `false`. - */ - function baseIsNative(value) { - if (!isObject(value) || isMasked(value)) { - return false; - } - var pattern = isFunction(value) ? reIsNative : reIsHostCtor; - return pattern.test(toSource(value)); - } - - /** - * The base implementation of `_.isRegExp` without Node.js optimizations. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a regexp, else `false`. - */ - function baseIsRegExp(value) { - return isObjectLike(value) && baseGetTag(value) == regexpTag; - } - - /** - * The base implementation of `_.isSet` without Node.js optimizations. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a set, else `false`. - */ - function baseIsSet(value) { - return isObjectLike(value) && getTag(value) == setTag; - } - - /** - * The base implementation of `_.isTypedArray` without Node.js optimizations. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a typed array, else `false`. - */ - function baseIsTypedArray(value) { - return isObjectLike(value) && - isLength(value.length) && !!typedArrayTags[baseGetTag(value)]; - } - - /** - * The base implementation of `_.iteratee`. - * - * @private - * @param {*} [value=_.identity] The value to convert to an iteratee. - * @returns {Function} Returns the iteratee. - */ - function baseIteratee(value) { - // Don't store the `typeof` result in a variable to avoid a JIT bug in Safari 9. - // See https://bugs.webkit.org/show_bug.cgi?id=156034 for more details. - if (typeof value == 'function') { - return value; - } - if (value == null) { - return identity; - } - if (typeof value == 'object') { - return isArray(value) - ? baseMatchesProperty(value[0], value[1]) - : baseMatches(value); - } - return property(value); - } - - /** - * The base implementation of `_.keys` which doesn't treat sparse arrays as dense. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names. - */ - function baseKeys(object) { - if (!isPrototype(object)) { - return nativeKeys(object); - } - var result = []; - for (var key in Object(object)) { - if (hasOwnProperty.call(object, key) && key != 'constructor') { - result.push(key); - } - } - return result; - } - - /** - * The base implementation of `_.keysIn` which doesn't treat sparse arrays as dense. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names. - */ - function baseKeysIn(object) { - if (!isObject(object)) { - return nativeKeysIn(object); - } - var isProto = isPrototype(object), - result = []; - - for (var key in object) { - if (!(key == 'constructor' && (isProto || !hasOwnProperty.call(object, key)))) { - result.push(key); - } - } - return result; - } - - /** - * The base implementation of `_.lt` which doesn't coerce arguments. - * - * @private - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if `value` is less than `other`, - * else `false`. - */ - function baseLt(value, other) { - return value < other; - } - - /** - * The base implementation of `_.map` without support for iteratee shorthands. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array} Returns the new mapped array. - */ - function baseMap(collection, iteratee) { - var index = -1, - result = isArrayLike(collection) ? Array(collection.length) : []; - - baseEach(collection, function(value, key, collection) { - result[++index] = iteratee(value, key, collection); - }); - return result; - } - - /** - * The base implementation of `_.matches` which doesn't clone `source`. - * - * @private - * @param {Object} source The object of property values to match. - * @returns {Function} Returns the new spec function. - */ - function baseMatches(source) { - var matchData = getMatchData(source); - if (matchData.length == 1 && matchData[0][2]) { - return matchesStrictComparable(matchData[0][0], matchData[0][1]); - } - return function(object) { - return object === source || baseIsMatch(object, source, matchData); - }; - } - - /** - * The base implementation of `_.matchesProperty` which doesn't clone `srcValue`. - * - * @private - * @param {string} path The path of the property to get. - * @param {*} srcValue The value to match. - * @returns {Function} Returns the new spec function. - */ - function baseMatchesProperty(path, srcValue) { - if (isKey(path) && isStrictComparable(srcValue)) { - return matchesStrictComparable(toKey(path), srcValue); - } - return function(object) { - var objValue = get(object, path); - return (objValue === undefined && objValue === srcValue) - ? hasIn(object, path) - : baseIsEqual(srcValue, objValue, COMPARE_PARTIAL_FLAG | COMPARE_UNORDERED_FLAG); - }; - } - - /** - * The base implementation of `_.merge` without support for multiple sources. - * - * @private - * @param {Object} object The destination object. - * @param {Object} source The source object. - * @param {number} srcIndex The index of `source`. - * @param {Function} [customizer] The function to customize merged values. - * @param {Object} [stack] Tracks traversed source values and their merged - * counterparts. - */ - function baseMerge(object, source, srcIndex, customizer, stack) { - if (object === source) { - return; - } - baseFor(source, function(srcValue, key) { - if (isObject(srcValue)) { - stack || (stack = new Stack); - baseMergeDeep(object, source, key, srcIndex, baseMerge, customizer, stack); - } - else { - var newValue = customizer - ? customizer(safeGet(object, key), srcValue, (key + ''), object, source, stack) - : undefined; - - if (newValue === undefined) { - newValue = srcValue; - } - assignMergeValue(object, key, newValue); - } - }, keysIn); - } - - /** - * A specialized version of `baseMerge` for arrays and objects which performs - * deep merges and tracks traversed objects enabling objects with circular - * references to be merged. - * - * @private - * @param {Object} object The destination object. - * @param {Object} source The source object. - * @param {string} key The key of the value to merge. - * @param {number} srcIndex The index of `source`. - * @param {Function} mergeFunc The function to merge values. - * @param {Function} [customizer] The function to customize assigned values. - * @param {Object} [stack] Tracks traversed source values and their merged - * counterparts. - */ - function baseMergeDeep(object, source, key, srcIndex, mergeFunc, customizer, stack) { - var objValue = safeGet(object, key), - srcValue = safeGet(source, key), - stacked = stack.get(srcValue); - - if (stacked) { - assignMergeValue(object, key, stacked); - return; - } - var newValue = customizer - ? customizer(objValue, srcValue, (key + ''), object, source, stack) - : undefined; - - var isCommon = newValue === undefined; - - if (isCommon) { - var isArr = isArray(srcValue), - isBuff = !isArr && isBuffer(srcValue), - isTyped = !isArr && !isBuff && isTypedArray(srcValue); - - newValue = srcValue; - if (isArr || isBuff || isTyped) { - if (isArray(objValue)) { - newValue = objValue; - } - else if (isArrayLikeObject(objValue)) { - newValue = copyArray(objValue); - } - else if (isBuff) { - isCommon = false; - newValue = cloneBuffer(srcValue, true); - } - else if (isTyped) { - isCommon = false; - newValue = cloneTypedArray(srcValue, true); - } - else { - newValue = []; - } - } - else if (isPlainObject(srcValue) || isArguments(srcValue)) { - newValue = objValue; - if (isArguments(objValue)) { - newValue = toPlainObject(objValue); - } - else if (!isObject(objValue) || (srcIndex && isFunction(objValue))) { - newValue = initCloneObject(srcValue); - } - } - else { - isCommon = false; - } - } - if (isCommon) { - // Recursively merge objects and arrays (susceptible to call stack limits). - stack.set(srcValue, newValue); - mergeFunc(newValue, srcValue, srcIndex, customizer, stack); - stack['delete'](srcValue); - } - assignMergeValue(object, key, newValue); - } - - /** - * The base implementation of `_.nth` which doesn't coerce arguments. - * - * @private - * @param {Array} array The array to query. - * @param {number} n The index of the element to return. - * @returns {*} Returns the nth element of `array`. - */ - function baseNth(array, n) { - var length = array.length; - if (!length) { - return; - } - n += n < 0 ? length : 0; - return isIndex(n, length) ? array[n] : undefined; - } - - /** - * The base implementation of `_.orderBy` without param guards. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function[]|Object[]|string[]} iteratees The iteratees to sort by. - * @param {string[]} orders The sort orders of `iteratees`. - * @returns {Array} Returns the new sorted array. - */ - function baseOrderBy(collection, iteratees, orders) { - var index = -1; - iteratees = arrayMap(iteratees.length ? iteratees : [identity], baseUnary(getIteratee())); - - var result = baseMap(collection, function(value, key, collection) { - var criteria = arrayMap(iteratees, function(iteratee) { - return iteratee(value); - }); - return { 'criteria': criteria, 'index': ++index, 'value': value }; - }); - - return baseSortBy(result, function(object, other) { - return compareMultiple(object, other, orders); - }); - } - - /** - * The base implementation of `_.pick` without support for individual - * property identifiers. - * - * @private - * @param {Object} object The source object. - * @param {string[]} paths The property paths to pick. - * @returns {Object} Returns the new object. - */ - function basePick(object, paths) { - return basePickBy(object, paths, function(value, path) { - return hasIn(object, path); - }); - } - - /** - * The base implementation of `_.pickBy` without support for iteratee shorthands. - * - * @private - * @param {Object} object The source object. - * @param {string[]} paths The property paths to pick. - * @param {Function} predicate The function invoked per property. - * @returns {Object} Returns the new object. - */ - function basePickBy(object, paths, predicate) { - var index = -1, - length = paths.length, - result = {}; - - while (++index < length) { - var path = paths[index], - value = baseGet(object, path); - - if (predicate(value, path)) { - baseSet(result, castPath(path, object), value); - } - } - return result; - } - - /** - * A specialized version of `baseProperty` which supports deep paths. - * - * @private - * @param {Array|string} path The path of the property to get. - * @returns {Function} Returns the new accessor function. - */ - function basePropertyDeep(path) { - return function(object) { - return baseGet(object, path); - }; - } - - /** - * The base implementation of `_.pullAllBy` without support for iteratee - * shorthands. - * - * @private - * @param {Array} array The array to modify. - * @param {Array} values The values to remove. - * @param {Function} [iteratee] The iteratee invoked per element. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns `array`. - */ - function basePullAll(array, values, iteratee, comparator) { - var indexOf = comparator ? baseIndexOfWith : baseIndexOf, - index = -1, - length = values.length, - seen = array; - - if (array === values) { - values = copyArray(values); - } - if (iteratee) { - seen = arrayMap(array, baseUnary(iteratee)); - } - while (++index < length) { - var fromIndex = 0, - value = values[index], - computed = iteratee ? iteratee(value) : value; - - while ((fromIndex = indexOf(seen, computed, fromIndex, comparator)) > -1) { - if (seen !== array) { - splice.call(seen, fromIndex, 1); - } - splice.call(array, fromIndex, 1); - } - } - return array; - } - - /** - * The base implementation of `_.pullAt` without support for individual - * indexes or capturing the removed elements. - * - * @private - * @param {Array} array The array to modify. - * @param {number[]} indexes The indexes of elements to remove. - * @returns {Array} Returns `array`. - */ - function basePullAt(array, indexes) { - var length = array ? indexes.length : 0, - lastIndex = length - 1; - - while (length--) { - var index = indexes[length]; - if (length == lastIndex || index !== previous) { - var previous = index; - if (isIndex(index)) { - splice.call(array, index, 1); - } else { - baseUnset(array, index); - } - } - } - return array; - } - - /** - * The base implementation of `_.random` without support for returning - * floating-point numbers. - * - * @private - * @param {number} lower The lower bound. - * @param {number} upper The upper bound. - * @returns {number} Returns the random number. - */ - function baseRandom(lower, upper) { - return lower + nativeFloor(nativeRandom() * (upper - lower + 1)); - } - - /** - * The base implementation of `_.range` and `_.rangeRight` which doesn't - * coerce arguments. - * - * @private - * @param {number} start The start of the range. - * @param {number} end The end of the range. - * @param {number} step The value to increment or decrement by. - * @param {boolean} [fromRight] Specify iterating from right to left. - * @returns {Array} Returns the range of numbers. - */ - function baseRange(start, end, step, fromRight) { - var index = -1, - length = nativeMax(nativeCeil((end - start) / (step || 1)), 0), - result = Array(length); - - while (length--) { - result[fromRight ? length : ++index] = start; - start += step; - } - return result; - } - - /** - * The base implementation of `_.repeat` which doesn't coerce arguments. - * - * @private - * @param {string} string The string to repeat. - * @param {number} n The number of times to repeat the string. - * @returns {string} Returns the repeated string. - */ - function baseRepeat(string, n) { - var result = ''; - if (!string || n < 1 || n > MAX_SAFE_INTEGER) { - return result; - } - // Leverage the exponentiation by squaring algorithm for a faster repeat. - // See https://en.wikipedia.org/wiki/Exponentiation_by_squaring for more details. - do { - if (n % 2) { - result += string; - } - n = nativeFloor(n / 2); - if (n) { - string += string; - } - } while (n); - - return result; - } - - /** - * The base implementation of `_.rest` which doesn't validate or coerce arguments. - * - * @private - * @param {Function} func The function to apply a rest parameter to. - * @param {number} [start=func.length-1] The start position of the rest parameter. - * @returns {Function} Returns the new function. - */ - function baseRest(func, start) { - return setToString(overRest(func, start, identity), func + ''); - } - - /** - * The base implementation of `_.sample`. - * - * @private - * @param {Array|Object} collection The collection to sample. - * @returns {*} Returns the random element. - */ - function baseSample(collection) { - return arraySample(values(collection)); - } - - /** - * The base implementation of `_.sampleSize` without param guards. - * - * @private - * @param {Array|Object} collection The collection to sample. - * @param {number} n The number of elements to sample. - * @returns {Array} Returns the random elements. - */ - function baseSampleSize(collection, n) { - var array = values(collection); - return shuffleSelf(array, baseClamp(n, 0, array.length)); - } - - /** - * The base implementation of `_.set`. - * - * @private - * @param {Object} object The object to modify. - * @param {Array|string} path The path of the property to set. - * @param {*} value The value to set. - * @param {Function} [customizer] The function to customize path creation. - * @returns {Object} Returns `object`. - */ - function baseSet(object, path, value, customizer) { - if (!isObject(object)) { - return object; - } - path = castPath(path, object); - - var index = -1, - length = path.length, - lastIndex = length - 1, - nested = object; - - while (nested != null && ++index < length) { - var key = toKey(path[index]), - newValue = value; - - if (index != lastIndex) { - var objValue = nested[key]; - newValue = customizer ? customizer(objValue, key, nested) : undefined; - if (newValue === undefined) { - newValue = isObject(objValue) - ? objValue - : (isIndex(path[index + 1]) ? [] : {}); - } - } - assignValue(nested, key, newValue); - nested = nested[key]; - } - return object; - } - - /** - * The base implementation of `setData` without support for hot loop shorting. - * - * @private - * @param {Function} func The function to associate metadata with. - * @param {*} data The metadata. - * @returns {Function} Returns `func`. - */ - var baseSetData = !metaMap ? identity : function(func, data) { - metaMap.set(func, data); - return func; - }; - - /** - * The base implementation of `setToString` without support for hot loop shorting. - * - * @private - * @param {Function} func The function to modify. - * @param {Function} string The `toString` result. - * @returns {Function} Returns `func`. - */ - var baseSetToString = !defineProperty ? identity : function(func, string) { - return defineProperty(func, 'toString', { - 'configurable': true, - 'enumerable': false, - 'value': constant(string), - 'writable': true - }); - }; - - /** - * The base implementation of `_.shuffle`. - * - * @private - * @param {Array|Object} collection The collection to shuffle. - * @returns {Array} Returns the new shuffled array. - */ - function baseShuffle(collection) { - return shuffleSelf(values(collection)); - } - - /** - * The base implementation of `_.slice` without an iteratee call guard. - * - * @private - * @param {Array} array The array to slice. - * @param {number} [start=0] The start position. - * @param {number} [end=array.length] The end position. - * @returns {Array} Returns the slice of `array`. - */ - function baseSlice(array, start, end) { - var index = -1, - length = array.length; - - if (start < 0) { - start = -start > length ? 0 : (length + start); - } - end = end > length ? length : end; - if (end < 0) { - end += length; - } - length = start > end ? 0 : ((end - start) >>> 0); - start >>>= 0; - - var result = Array(length); - while (++index < length) { - result[index] = array[index + start]; - } - return result; - } - - /** - * The base implementation of `_.some` without support for iteratee shorthands. - * - * @private - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {boolean} Returns `true` if any element passes the predicate check, - * else `false`. - */ - function baseSome(collection, predicate) { - var result; - - baseEach(collection, function(value, index, collection) { - result = predicate(value, index, collection); - return !result; - }); - return !!result; - } - - /** - * The base implementation of `_.sortedIndex` and `_.sortedLastIndex` which - * performs a binary search of `array` to determine the index at which `value` - * should be inserted into `array` in order to maintain its sort order. - * - * @private - * @param {Array} array The sorted array to inspect. - * @param {*} value The value to evaluate. - * @param {boolean} [retHighest] Specify returning the highest qualified index. - * @returns {number} Returns the index at which `value` should be inserted - * into `array`. - */ - function baseSortedIndex(array, value, retHighest) { - var low = 0, - high = array == null ? low : array.length; - - if (typeof value == 'number' && value === value && high <= HALF_MAX_ARRAY_LENGTH) { - while (low < high) { - var mid = (low + high) >>> 1, - computed = array[mid]; - - if (computed !== null && !isSymbol(computed) && - (retHighest ? (computed <= value) : (computed < value))) { - low = mid + 1; - } else { - high = mid; - } - } - return high; - } - return baseSortedIndexBy(array, value, identity, retHighest); - } - - /** - * The base implementation of `_.sortedIndexBy` and `_.sortedLastIndexBy` - * which invokes `iteratee` for `value` and each element of `array` to compute - * their sort ranking. The iteratee is invoked with one argument; (value). - * - * @private - * @param {Array} array The sorted array to inspect. - * @param {*} value The value to evaluate. - * @param {Function} iteratee The iteratee invoked per element. - * @param {boolean} [retHighest] Specify returning the highest qualified index. - * @returns {number} Returns the index at which `value` should be inserted - * into `array`. - */ - function baseSortedIndexBy(array, value, iteratee, retHighest) { - value = iteratee(value); - - var low = 0, - high = array == null ? 0 : array.length, - valIsNaN = value !== value, - valIsNull = value === null, - valIsSymbol = isSymbol(value), - valIsUndefined = value === undefined; - - while (low < high) { - var mid = nativeFloor((low + high) / 2), - computed = iteratee(array[mid]), - othIsDefined = computed !== undefined, - othIsNull = computed === null, - othIsReflexive = computed === computed, - othIsSymbol = isSymbol(computed); - - if (valIsNaN) { - var setLow = retHighest || othIsReflexive; - } else if (valIsUndefined) { - setLow = othIsReflexive && (retHighest || othIsDefined); - } else if (valIsNull) { - setLow = othIsReflexive && othIsDefined && (retHighest || !othIsNull); - } else if (valIsSymbol) { - setLow = othIsReflexive && othIsDefined && !othIsNull && (retHighest || !othIsSymbol); - } else if (othIsNull || othIsSymbol) { - setLow = false; - } else { - setLow = retHighest ? (computed <= value) : (computed < value); - } - if (setLow) { - low = mid + 1; - } else { - high = mid; - } - } - return nativeMin(high, MAX_ARRAY_INDEX); - } - - /** - * The base implementation of `_.sortedUniq` and `_.sortedUniqBy` without - * support for iteratee shorthands. - * - * @private - * @param {Array} array The array to inspect. - * @param {Function} [iteratee] The iteratee invoked per element. - * @returns {Array} Returns the new duplicate free array. - */ - function baseSortedUniq(array, iteratee) { - var index = -1, - length = array.length, - resIndex = 0, - result = []; - - while (++index < length) { - var value = array[index], - computed = iteratee ? iteratee(value) : value; - - if (!index || !eq(computed, seen)) { - var seen = computed; - result[resIndex++] = value === 0 ? 0 : value; - } - } - return result; - } - - /** - * The base implementation of `_.toNumber` which doesn't ensure correct - * conversions of binary, hexadecimal, or octal string values. - * - * @private - * @param {*} value The value to process. - * @returns {number} Returns the number. - */ - function baseToNumber(value) { - if (typeof value == 'number') { - return value; - } - if (isSymbol(value)) { - return NAN; - } - return +value; - } - - /** - * The base implementation of `_.toString` which doesn't convert nullish - * values to empty strings. - * - * @private - * @param {*} value The value to process. - * @returns {string} Returns the string. - */ - function baseToString(value) { - // Exit early for strings to avoid a performance hit in some environments. - if (typeof value == 'string') { - return value; - } - if (isArray(value)) { - // Recursively convert values (susceptible to call stack limits). - return arrayMap(value, baseToString) + ''; - } - if (isSymbol(value)) { - return symbolToString ? symbolToString.call(value) : ''; - } - var result = (value + ''); - return (result == '0' && (1 / value) == -INFINITY) ? '-0' : result; - } - - /** - * The base implementation of `_.uniqBy` without support for iteratee shorthands. - * - * @private - * @param {Array} array The array to inspect. - * @param {Function} [iteratee] The iteratee invoked per element. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new duplicate free array. - */ - function baseUniq(array, iteratee, comparator) { - var index = -1, - includes = arrayIncludes, - length = array.length, - isCommon = true, - result = [], - seen = result; - - if (comparator) { - isCommon = false; - includes = arrayIncludesWith; - } - else if (length >= LARGE_ARRAY_SIZE) { - var set = iteratee ? null : createSet(array); - if (set) { - return setToArray(set); - } - isCommon = false; - includes = cacheHas; - seen = new SetCache; - } - else { - seen = iteratee ? [] : result; - } - outer: - while (++index < length) { - var value = array[index], - computed = iteratee ? iteratee(value) : value; - - value = (comparator || value !== 0) ? value : 0; - if (isCommon && computed === computed) { - var seenIndex = seen.length; - while (seenIndex--) { - if (seen[seenIndex] === computed) { - continue outer; - } - } - if (iteratee) { - seen.push(computed); - } - result.push(value); - } - else if (!includes(seen, computed, comparator)) { - if (seen !== result) { - seen.push(computed); - } - result.push(value); - } - } - return result; - } - - /** - * The base implementation of `_.unset`. - * - * @private - * @param {Object} object The object to modify. - * @param {Array|string} path The property path to unset. - * @returns {boolean} Returns `true` if the property is deleted, else `false`. - */ - function baseUnset(object, path) { - path = castPath(path, object); - object = parent(object, path); - return object == null || delete object[toKey(last(path))]; - } - - /** - * The base implementation of `_.update`. - * - * @private - * @param {Object} object The object to modify. - * @param {Array|string} path The path of the property to update. - * @param {Function} updater The function to produce the updated value. - * @param {Function} [customizer] The function to customize path creation. - * @returns {Object} Returns `object`. - */ - function baseUpdate(object, path, updater, customizer) { - return baseSet(object, path, updater(baseGet(object, path)), customizer); - } - - /** - * The base implementation of methods like `_.dropWhile` and `_.takeWhile` - * without support for iteratee shorthands. - * - * @private - * @param {Array} array The array to query. - * @param {Function} predicate The function invoked per iteration. - * @param {boolean} [isDrop] Specify dropping elements instead of taking them. - * @param {boolean} [fromRight] Specify iterating from right to left. - * @returns {Array} Returns the slice of `array`. - */ - function baseWhile(array, predicate, isDrop, fromRight) { - var length = array.length, - index = fromRight ? length : -1; - - while ((fromRight ? index-- : ++index < length) && - predicate(array[index], index, array)) {} - - return isDrop - ? baseSlice(array, (fromRight ? 0 : index), (fromRight ? index + 1 : length)) - : baseSlice(array, (fromRight ? index + 1 : 0), (fromRight ? length : index)); - } - - /** - * The base implementation of `wrapperValue` which returns the result of - * performing a sequence of actions on the unwrapped `value`, where each - * successive action is supplied the return value of the previous. - * - * @private - * @param {*} value The unwrapped value. - * @param {Array} actions Actions to perform to resolve the unwrapped value. - * @returns {*} Returns the resolved value. - */ - function baseWrapperValue(value, actions) { - var result = value; - if (result instanceof LazyWrapper) { - result = result.value(); - } - return arrayReduce(actions, function(result, action) { - return action.func.apply(action.thisArg, arrayPush([result], action.args)); - }, result); - } - - /** - * The base implementation of methods like `_.xor`, without support for - * iteratee shorthands, that accepts an array of arrays to inspect. - * - * @private - * @param {Array} arrays The arrays to inspect. - * @param {Function} [iteratee] The iteratee invoked per element. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new array of values. - */ - function baseXor(arrays, iteratee, comparator) { - var length = arrays.length; - if (length < 2) { - return length ? baseUniq(arrays[0]) : []; - } - var index = -1, - result = Array(length); - - while (++index < length) { - var array = arrays[index], - othIndex = -1; - - while (++othIndex < length) { - if (othIndex != index) { - result[index] = baseDifference(result[index] || array, arrays[othIndex], iteratee, comparator); - } - } - } - return baseUniq(baseFlatten(result, 1), iteratee, comparator); - } - - /** - * This base implementation of `_.zipObject` which assigns values using `assignFunc`. - * - * @private - * @param {Array} props The property identifiers. - * @param {Array} values The property values. - * @param {Function} assignFunc The function to assign values. - * @returns {Object} Returns the new object. - */ - function baseZipObject(props, values, assignFunc) { - var index = -1, - length = props.length, - valsLength = values.length, - result = {}; - - while (++index < length) { - var value = index < valsLength ? values[index] : undefined; - assignFunc(result, props[index], value); - } - return result; - } - - /** - * Casts `value` to an empty array if it's not an array like object. - * - * @private - * @param {*} value The value to inspect. - * @returns {Array|Object} Returns the cast array-like object. - */ - function castArrayLikeObject(value) { - return isArrayLikeObject(value) ? value : []; - } - - /** - * Casts `value` to `identity` if it's not a function. - * - * @private - * @param {*} value The value to inspect. - * @returns {Function} Returns cast function. - */ - function castFunction(value) { - return typeof value == 'function' ? value : identity; - } - - /** - * Casts `value` to a path array if it's not one. - * - * @private - * @param {*} value The value to inspect. - * @param {Object} [object] The object to query keys on. - * @returns {Array} Returns the cast property path array. - */ - function castPath(value, object) { - if (isArray(value)) { - return value; - } - return isKey(value, object) ? [value] : stringToPath(toString(value)); - } - - /** - * A `baseRest` alias which can be replaced with `identity` by module - * replacement plugins. - * - * @private - * @type {Function} - * @param {Function} func The function to apply a rest parameter to. - * @returns {Function} Returns the new function. - */ - var castRest = baseRest; - - /** - * Casts `array` to a slice if it's needed. - * - * @private - * @param {Array} array The array to inspect. - * @param {number} start The start position. - * @param {number} [end=array.length] The end position. - * @returns {Array} Returns the cast slice. - */ - function castSlice(array, start, end) { - var length = array.length; - end = end === undefined ? length : end; - return (!start && end >= length) ? array : baseSlice(array, start, end); - } - - /** - * A simple wrapper around the global [`clearTimeout`](https://mdn.io/clearTimeout). - * - * @private - * @param {number|Object} id The timer id or timeout object of the timer to clear. - */ - var clearTimeout = ctxClearTimeout || function(id) { - return root.clearTimeout(id); - }; - - /** - * Creates a clone of `buffer`. - * - * @private - * @param {Buffer} buffer The buffer to clone. - * @param {boolean} [isDeep] Specify a deep clone. - * @returns {Buffer} Returns the cloned buffer. - */ - function cloneBuffer(buffer, isDeep) { - if (isDeep) { - return buffer.slice(); - } - var length = buffer.length, - result = allocUnsafe ? allocUnsafe(length) : new buffer.constructor(length); - - buffer.copy(result); - return result; - } - - /** - * Creates a clone of `arrayBuffer`. - * - * @private - * @param {ArrayBuffer} arrayBuffer The array buffer to clone. - * @returns {ArrayBuffer} Returns the cloned array buffer. - */ - function cloneArrayBuffer(arrayBuffer) { - var result = new arrayBuffer.constructor(arrayBuffer.byteLength); - new Uint8Array(result).set(new Uint8Array(arrayBuffer)); - return result; - } - - /** - * Creates a clone of `dataView`. - * - * @private - * @param {Object} dataView The data view to clone. - * @param {boolean} [isDeep] Specify a deep clone. - * @returns {Object} Returns the cloned data view. - */ - function cloneDataView(dataView, isDeep) { - var buffer = isDeep ? cloneArrayBuffer(dataView.buffer) : dataView.buffer; - return new dataView.constructor(buffer, dataView.byteOffset, dataView.byteLength); - } - - /** - * Creates a clone of `regexp`. - * - * @private - * @param {Object} regexp The regexp to clone. - * @returns {Object} Returns the cloned regexp. - */ - function cloneRegExp(regexp) { - var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)); - result.lastIndex = regexp.lastIndex; - return result; - } - - /** - * Creates a clone of the `symbol` object. - * - * @private - * @param {Object} symbol The symbol object to clone. - * @returns {Object} Returns the cloned symbol object. - */ - function cloneSymbol(symbol) { - return symbolValueOf ? Object(symbolValueOf.call(symbol)) : {}; - } - - /** - * Creates a clone of `typedArray`. - * - * @private - * @param {Object} typedArray The typed array to clone. - * @param {boolean} [isDeep] Specify a deep clone. - * @returns {Object} Returns the cloned typed array. - */ - function cloneTypedArray(typedArray, isDeep) { - var buffer = isDeep ? cloneArrayBuffer(typedArray.buffer) : typedArray.buffer; - return new typedArray.constructor(buffer, typedArray.byteOffset, typedArray.length); - } - - /** - * Compares values to sort them in ascending order. - * - * @private - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {number} Returns the sort order indicator for `value`. - */ - function compareAscending(value, other) { - if (value !== other) { - var valIsDefined = value !== undefined, - valIsNull = value === null, - valIsReflexive = value === value, - valIsSymbol = isSymbol(value); - - var othIsDefined = other !== undefined, - othIsNull = other === null, - othIsReflexive = other === other, - othIsSymbol = isSymbol(other); - - if ((!othIsNull && !othIsSymbol && !valIsSymbol && value > other) || - (valIsSymbol && othIsDefined && othIsReflexive && !othIsNull && !othIsSymbol) || - (valIsNull && othIsDefined && othIsReflexive) || - (!valIsDefined && othIsReflexive) || - !valIsReflexive) { - return 1; - } - if ((!valIsNull && !valIsSymbol && !othIsSymbol && value < other) || - (othIsSymbol && valIsDefined && valIsReflexive && !valIsNull && !valIsSymbol) || - (othIsNull && valIsDefined && valIsReflexive) || - (!othIsDefined && valIsReflexive) || - !othIsReflexive) { - return -1; - } - } - return 0; - } - - /** - * Used by `_.orderBy` to compare multiple properties of a value to another - * and stable sort them. - * - * If `orders` is unspecified, all values are sorted in ascending order. Otherwise, - * specify an order of "desc" for descending or "asc" for ascending sort order - * of corresponding values. - * - * @private - * @param {Object} object The object to compare. - * @param {Object} other The other object to compare. - * @param {boolean[]|string[]} orders The order to sort by for each property. - * @returns {number} Returns the sort order indicator for `object`. - */ - function compareMultiple(object, other, orders) { - var index = -1, - objCriteria = object.criteria, - othCriteria = other.criteria, - length = objCriteria.length, - ordersLength = orders.length; - - while (++index < length) { - var result = compareAscending(objCriteria[index], othCriteria[index]); - if (result) { - if (index >= ordersLength) { - return result; - } - var order = orders[index]; - return result * (order == 'desc' ? -1 : 1); - } - } - // Fixes an `Array#sort` bug in the JS engine embedded in Adobe applications - // that causes it, under certain circumstances, to provide the same value for - // `object` and `other`. See https://github.com/jashkenas/underscore/pull/1247 - // for more details. - // - // This also ensures a stable sort in V8 and other engines. - // See https://bugs.chromium.org/p/v8/issues/detail?id=90 for more details. - return object.index - other.index; - } - - /** - * Creates an array that is the composition of partially applied arguments, - * placeholders, and provided arguments into a single array of arguments. - * - * @private - * @param {Array} args The provided arguments. - * @param {Array} partials The arguments to prepend to those provided. - * @param {Array} holders The `partials` placeholder indexes. - * @params {boolean} [isCurried] Specify composing for a curried function. - * @returns {Array} Returns the new array of composed arguments. - */ - function composeArgs(args, partials, holders, isCurried) { - var argsIndex = -1, - argsLength = args.length, - holdersLength = holders.length, - leftIndex = -1, - leftLength = partials.length, - rangeLength = nativeMax(argsLength - holdersLength, 0), - result = Array(leftLength + rangeLength), - isUncurried = !isCurried; - - while (++leftIndex < leftLength) { - result[leftIndex] = partials[leftIndex]; - } - while (++argsIndex < holdersLength) { - if (isUncurried || argsIndex < argsLength) { - result[holders[argsIndex]] = args[argsIndex]; - } - } - while (rangeLength--) { - result[leftIndex++] = args[argsIndex++]; - } - return result; - } - - /** - * This function is like `composeArgs` except that the arguments composition - * is tailored for `_.partialRight`. - * - * @private - * @param {Array} args The provided arguments. - * @param {Array} partials The arguments to append to those provided. - * @param {Array} holders The `partials` placeholder indexes. - * @params {boolean} [isCurried] Specify composing for a curried function. - * @returns {Array} Returns the new array of composed arguments. - */ - function composeArgsRight(args, partials, holders, isCurried) { - var argsIndex = -1, - argsLength = args.length, - holdersIndex = -1, - holdersLength = holders.length, - rightIndex = -1, - rightLength = partials.length, - rangeLength = nativeMax(argsLength - holdersLength, 0), - result = Array(rangeLength + rightLength), - isUncurried = !isCurried; - - while (++argsIndex < rangeLength) { - result[argsIndex] = args[argsIndex]; - } - var offset = argsIndex; - while (++rightIndex < rightLength) { - result[offset + rightIndex] = partials[rightIndex]; - } - while (++holdersIndex < holdersLength) { - if (isUncurried || argsIndex < argsLength) { - result[offset + holders[holdersIndex]] = args[argsIndex++]; - } - } - return result; - } - - /** - * Copies the values of `source` to `array`. - * - * @private - * @param {Array} source The array to copy values from. - * @param {Array} [array=[]] The array to copy values to. - * @returns {Array} Returns `array`. - */ - function copyArray(source, array) { - var index = -1, - length = source.length; - - array || (array = Array(length)); - while (++index < length) { - array[index] = source[index]; - } - return array; - } - - /** - * Copies properties of `source` to `object`. - * - * @private - * @param {Object} source The object to copy properties from. - * @param {Array} props The property identifiers to copy. - * @param {Object} [object={}] The object to copy properties to. - * @param {Function} [customizer] The function to customize copied values. - * @returns {Object} Returns `object`. - */ - function copyObject(source, props, object, customizer) { - var isNew = !object; - object || (object = {}); - - var index = -1, - length = props.length; - - while (++index < length) { - var key = props[index]; - - var newValue = customizer - ? customizer(object[key], source[key], key, object, source) - : undefined; - - if (newValue === undefined) { - newValue = source[key]; - } - if (isNew) { - baseAssignValue(object, key, newValue); - } else { - assignValue(object, key, newValue); - } - } - return object; - } - - /** - * Copies own symbols of `source` to `object`. - * - * @private - * @param {Object} source The object to copy symbols from. - * @param {Object} [object={}] The object to copy symbols to. - * @returns {Object} Returns `object`. - */ - function copySymbols(source, object) { - return copyObject(source, getSymbols(source), object); - } - - /** - * Copies own and inherited symbols of `source` to `object`. - * - * @private - * @param {Object} source The object to copy symbols from. - * @param {Object} [object={}] The object to copy symbols to. - * @returns {Object} Returns `object`. - */ - function copySymbolsIn(source, object) { - return copyObject(source, getSymbolsIn(source), object); - } - - /** - * Creates a function like `_.groupBy`. - * - * @private - * @param {Function} setter The function to set accumulator values. - * @param {Function} [initializer] The accumulator object initializer. - * @returns {Function} Returns the new aggregator function. - */ - function createAggregator(setter, initializer) { - return function(collection, iteratee) { - var func = isArray(collection) ? arrayAggregator : baseAggregator, - accumulator = initializer ? initializer() : {}; - - return func(collection, setter, getIteratee(iteratee, 2), accumulator); - }; - } - - /** - * Creates a function like `_.assign`. - * - * @private - * @param {Function} assigner The function to assign values. - * @returns {Function} Returns the new assigner function. - */ - function createAssigner(assigner) { - return baseRest(function(object, sources) { - var index = -1, - length = sources.length, - customizer = length > 1 ? sources[length - 1] : undefined, - guard = length > 2 ? sources[2] : undefined; - - customizer = (assigner.length > 3 && typeof customizer == 'function') - ? (length--, customizer) - : undefined; - - if (guard && isIterateeCall(sources[0], sources[1], guard)) { - customizer = length < 3 ? undefined : customizer; - length = 1; - } - object = Object(object); - while (++index < length) { - var source = sources[index]; - if (source) { - assigner(object, source, index, customizer); - } - } - return object; - }); - } - - /** - * Creates a `baseEach` or `baseEachRight` function. - * - * @private - * @param {Function} eachFunc The function to iterate over a collection. - * @param {boolean} [fromRight] Specify iterating from right to left. - * @returns {Function} Returns the new base function. - */ - function createBaseEach(eachFunc, fromRight) { - return function(collection, iteratee) { - if (collection == null) { - return collection; - } - if (!isArrayLike(collection)) { - return eachFunc(collection, iteratee); - } - var length = collection.length, - index = fromRight ? length : -1, - iterable = Object(collection); - - while ((fromRight ? index-- : ++index < length)) { - if (iteratee(iterable[index], index, iterable) === false) { - break; - } - } - return collection; - }; - } - - /** - * Creates a base function for methods like `_.forIn` and `_.forOwn`. - * - * @private - * @param {boolean} [fromRight] Specify iterating from right to left. - * @returns {Function} Returns the new base function. - */ - function createBaseFor(fromRight) { - return function(object, iteratee, keysFunc) { - var index = -1, - iterable = Object(object), - props = keysFunc(object), - length = props.length; - - while (length--) { - var key = props[fromRight ? length : ++index]; - if (iteratee(iterable[key], key, iterable) === false) { - break; - } - } - return object; - }; - } - - /** - * Creates a function that wraps `func` to invoke it with the optional `this` - * binding of `thisArg`. - * - * @private - * @param {Function} func The function to wrap. - * @param {number} bitmask The bitmask flags. See `createWrap` for more details. - * @param {*} [thisArg] The `this` binding of `func`. - * @returns {Function} Returns the new wrapped function. - */ - function createBind(func, bitmask, thisArg) { - var isBind = bitmask & WRAP_BIND_FLAG, - Ctor = createCtor(func); - - function wrapper() { - var fn = (this && this !== root && this instanceof wrapper) ? Ctor : func; - return fn.apply(isBind ? thisArg : this, arguments); - } - return wrapper; - } - - /** - * Creates a function like `_.lowerFirst`. - * - * @private - * @param {string} methodName The name of the `String` case method to use. - * @returns {Function} Returns the new case function. - */ - function createCaseFirst(methodName) { - return function(string) { - string = toString(string); - - var strSymbols = hasUnicode(string) - ? stringToArray(string) - : undefined; - - var chr = strSymbols - ? strSymbols[0] - : string.charAt(0); - - var trailing = strSymbols - ? castSlice(strSymbols, 1).join('') - : string.slice(1); - - return chr[methodName]() + trailing; - }; - } - - /** - * Creates a function like `_.camelCase`. - * - * @private - * @param {Function} callback The function to combine each word. - * @returns {Function} Returns the new compounder function. - */ - function createCompounder(callback) { - return function(string) { - return arrayReduce(words(deburr(string).replace(reApos, '')), callback, ''); - }; - } - - /** - * Creates a function that produces an instance of `Ctor` regardless of - * whether it was invoked as part of a `new` expression or by `call` or `apply`. - * - * @private - * @param {Function} Ctor The constructor to wrap. - * @returns {Function} Returns the new wrapped function. - */ - function createCtor(Ctor) { - return function() { - // Use a `switch` statement to work with class constructors. See - // http://ecma-international.org/ecma-262/7.0/#sec-ecmascript-function-objects-call-thisargument-argumentslist - // for more details. - var args = arguments; - switch (args.length) { - case 0: return new Ctor; - case 1: return new Ctor(args[0]); - case 2: return new Ctor(args[0], args[1]); - case 3: return new Ctor(args[0], args[1], args[2]); - case 4: return new Ctor(args[0], args[1], args[2], args[3]); - case 5: return new Ctor(args[0], args[1], args[2], args[3], args[4]); - case 6: return new Ctor(args[0], args[1], args[2], args[3], args[4], args[5]); - case 7: return new Ctor(args[0], args[1], args[2], args[3], args[4], args[5], args[6]); - } - var thisBinding = baseCreate(Ctor.prototype), - result = Ctor.apply(thisBinding, args); - - // Mimic the constructor's `return` behavior. - // See https://es5.github.io/#x13.2.2 for more details. - return isObject(result) ? result : thisBinding; - }; - } - - /** - * Creates a function that wraps `func` to enable currying. - * - * @private - * @param {Function} func The function to wrap. - * @param {number} bitmask The bitmask flags. See `createWrap` for more details. - * @param {number} arity The arity of `func`. - * @returns {Function} Returns the new wrapped function. - */ - function createCurry(func, bitmask, arity) { - var Ctor = createCtor(func); - - function wrapper() { - var length = arguments.length, - args = Array(length), - index = length, - placeholder = getHolder(wrapper); - - while (index--) { - args[index] = arguments[index]; - } - var holders = (length < 3 && args[0] !== placeholder && args[length - 1] !== placeholder) - ? [] - : replaceHolders(args, placeholder); - - length -= holders.length; - if (length < arity) { - return createRecurry( - func, bitmask, createHybrid, wrapper.placeholder, undefined, - args, holders, undefined, undefined, arity - length); - } - var fn = (this && this !== root && this instanceof wrapper) ? Ctor : func; - return apply(fn, this, args); - } - return wrapper; - } - - /** - * Creates a `_.find` or `_.findLast` function. - * - * @private - * @param {Function} findIndexFunc The function to find the collection index. - * @returns {Function} Returns the new find function. - */ - function createFind(findIndexFunc) { - return function(collection, predicate, fromIndex) { - var iterable = Object(collection); - if (!isArrayLike(collection)) { - var iteratee = getIteratee(predicate, 3); - collection = keys(collection); - predicate = function(key) { return iteratee(iterable[key], key, iterable); }; - } - var index = findIndexFunc(collection, predicate, fromIndex); - return index > -1 ? iterable[iteratee ? collection[index] : index] : undefined; - }; - } - - /** - * Creates a `_.flow` or `_.flowRight` function. - * - * @private - * @param {boolean} [fromRight] Specify iterating from right to left. - * @returns {Function} Returns the new flow function. - */ - function createFlow(fromRight) { - return flatRest(function(funcs) { - var length = funcs.length, - index = length, - prereq = LodashWrapper.prototype.thru; - - if (fromRight) { - funcs.reverse(); - } - while (index--) { - var func = funcs[index]; - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - if (prereq && !wrapper && getFuncName(func) == 'wrapper') { - var wrapper = new LodashWrapper([], true); - } - } - index = wrapper ? index : length; - while (++index < length) { - func = funcs[index]; - - var funcName = getFuncName(func), - data = funcName == 'wrapper' ? getData(func) : undefined; - - if (data && isLaziable(data[0]) && - data[1] == (WRAP_ARY_FLAG | WRAP_CURRY_FLAG | WRAP_PARTIAL_FLAG | WRAP_REARG_FLAG) && - !data[4].length && data[9] == 1 - ) { - wrapper = wrapper[getFuncName(data[0])].apply(wrapper, data[3]); - } else { - wrapper = (func.length == 1 && isLaziable(func)) - ? wrapper[funcName]() - : wrapper.thru(func); - } - } - return function() { - var args = arguments, - value = args[0]; - - if (wrapper && args.length == 1 && isArray(value)) { - return wrapper.plant(value).value(); - } - var index = 0, - result = length ? funcs[index].apply(this, args) : value; - - while (++index < length) { - result = funcs[index].call(this, result); - } - return result; - }; - }); - } - - /** - * Creates a function that wraps `func` to invoke it with optional `this` - * binding of `thisArg`, partial application, and currying. - * - * @private - * @param {Function|string} func The function or method name to wrap. - * @param {number} bitmask The bitmask flags. See `createWrap` for more details. - * @param {*} [thisArg] The `this` binding of `func`. - * @param {Array} [partials] The arguments to prepend to those provided to - * the new function. - * @param {Array} [holders] The `partials` placeholder indexes. - * @param {Array} [partialsRight] The arguments to append to those provided - * to the new function. - * @param {Array} [holdersRight] The `partialsRight` placeholder indexes. - * @param {Array} [argPos] The argument positions of the new function. - * @param {number} [ary] The arity cap of `func`. - * @param {number} [arity] The arity of `func`. - * @returns {Function} Returns the new wrapped function. - */ - function createHybrid(func, bitmask, thisArg, partials, holders, partialsRight, holdersRight, argPos, ary, arity) { - var isAry = bitmask & WRAP_ARY_FLAG, - isBind = bitmask & WRAP_BIND_FLAG, - isBindKey = bitmask & WRAP_BIND_KEY_FLAG, - isCurried = bitmask & (WRAP_CURRY_FLAG | WRAP_CURRY_RIGHT_FLAG), - isFlip = bitmask & WRAP_FLIP_FLAG, - Ctor = isBindKey ? undefined : createCtor(func); - - function wrapper() { - var length = arguments.length, - args = Array(length), - index = length; - - while (index--) { - args[index] = arguments[index]; - } - if (isCurried) { - var placeholder = getHolder(wrapper), - holdersCount = countHolders(args, placeholder); - } - if (partials) { - args = composeArgs(args, partials, holders, isCurried); - } - if (partialsRight) { - args = composeArgsRight(args, partialsRight, holdersRight, isCurried); - } - length -= holdersCount; - if (isCurried && length < arity) { - var newHolders = replaceHolders(args, placeholder); - return createRecurry( - func, bitmask, createHybrid, wrapper.placeholder, thisArg, - args, newHolders, argPos, ary, arity - length - ); - } - var thisBinding = isBind ? thisArg : this, - fn = isBindKey ? thisBinding[func] : func; - - length = args.length; - if (argPos) { - args = reorder(args, argPos); - } else if (isFlip && length > 1) { - args.reverse(); - } - if (isAry && ary < length) { - args.length = ary; - } - if (this && this !== root && this instanceof wrapper) { - fn = Ctor || createCtor(fn); - } - return fn.apply(thisBinding, args); - } - return wrapper; - } - - /** - * Creates a function like `_.invertBy`. - * - * @private - * @param {Function} setter The function to set accumulator values. - * @param {Function} toIteratee The function to resolve iteratees. - * @returns {Function} Returns the new inverter function. - */ - function createInverter(setter, toIteratee) { - return function(object, iteratee) { - return baseInverter(object, setter, toIteratee(iteratee), {}); - }; - } - - /** - * Creates a function that performs a mathematical operation on two values. - * - * @private - * @param {Function} operator The function to perform the operation. - * @param {number} [defaultValue] The value used for `undefined` arguments. - * @returns {Function} Returns the new mathematical operation function. - */ - function createMathOperation(operator, defaultValue) { - return function(value, other) { - var result; - if (value === undefined && other === undefined) { - return defaultValue; - } - if (value !== undefined) { - result = value; - } - if (other !== undefined) { - if (result === undefined) { - return other; - } - if (typeof value == 'string' || typeof other == 'string') { - value = baseToString(value); - other = baseToString(other); - } else { - value = baseToNumber(value); - other = baseToNumber(other); - } - result = operator(value, other); - } - return result; - }; - } - - /** - * Creates a function like `_.over`. - * - * @private - * @param {Function} arrayFunc The function to iterate over iteratees. - * @returns {Function} Returns the new over function. - */ - function createOver(arrayFunc) { - return flatRest(function(iteratees) { - iteratees = arrayMap(iteratees, baseUnary(getIteratee())); - return baseRest(function(args) { - var thisArg = this; - return arrayFunc(iteratees, function(iteratee) { - return apply(iteratee, thisArg, args); - }); - }); - }); - } - - /** - * Creates the padding for `string` based on `length`. The `chars` string - * is truncated if the number of characters exceeds `length`. - * - * @private - * @param {number} length The padding length. - * @param {string} [chars=' '] The string used as padding. - * @returns {string} Returns the padding for `string`. - */ - function createPadding(length, chars) { - chars = chars === undefined ? ' ' : baseToString(chars); - - var charsLength = chars.length; - if (charsLength < 2) { - return charsLength ? baseRepeat(chars, length) : chars; - } - var result = baseRepeat(chars, nativeCeil(length / stringSize(chars))); - return hasUnicode(chars) - ? castSlice(stringToArray(result), 0, length).join('') - : result.slice(0, length); - } - - /** - * Creates a function that wraps `func` to invoke it with the `this` binding - * of `thisArg` and `partials` prepended to the arguments it receives. - * - * @private - * @param {Function} func The function to wrap. - * @param {number} bitmask The bitmask flags. See `createWrap` for more details. - * @param {*} thisArg The `this` binding of `func`. - * @param {Array} partials The arguments to prepend to those provided to - * the new function. - * @returns {Function} Returns the new wrapped function. - */ - function createPartial(func, bitmask, thisArg, partials) { - var isBind = bitmask & WRAP_BIND_FLAG, - Ctor = createCtor(func); - - function wrapper() { - var argsIndex = -1, - argsLength = arguments.length, - leftIndex = -1, - leftLength = partials.length, - args = Array(leftLength + argsLength), - fn = (this && this !== root && this instanceof wrapper) ? Ctor : func; - - while (++leftIndex < leftLength) { - args[leftIndex] = partials[leftIndex]; - } - while (argsLength--) { - args[leftIndex++] = arguments[++argsIndex]; - } - return apply(fn, isBind ? thisArg : this, args); - } - return wrapper; - } - - /** - * Creates a `_.range` or `_.rangeRight` function. - * - * @private - * @param {boolean} [fromRight] Specify iterating from right to left. - * @returns {Function} Returns the new range function. - */ - function createRange(fromRight) { - return function(start, end, step) { - if (step && typeof step != 'number' && isIterateeCall(start, end, step)) { - end = step = undefined; - } - // Ensure the sign of `-0` is preserved. - start = toFinite(start); - if (end === undefined) { - end = start; - start = 0; - } else { - end = toFinite(end); - } - step = step === undefined ? (start < end ? 1 : -1) : toFinite(step); - return baseRange(start, end, step, fromRight); - }; - } - - /** - * Creates a function that performs a relational operation on two values. - * - * @private - * @param {Function} operator The function to perform the operation. - * @returns {Function} Returns the new relational operation function. - */ - function createRelationalOperation(operator) { - return function(value, other) { - if (!(typeof value == 'string' && typeof other == 'string')) { - value = toNumber(value); - other = toNumber(other); - } - return operator(value, other); - }; - } - - /** - * Creates a function that wraps `func` to continue currying. - * - * @private - * @param {Function} func The function to wrap. - * @param {number} bitmask The bitmask flags. See `createWrap` for more details. - * @param {Function} wrapFunc The function to create the `func` wrapper. - * @param {*} placeholder The placeholder value. - * @param {*} [thisArg] The `this` binding of `func`. - * @param {Array} [partials] The arguments to prepend to those provided to - * the new function. - * @param {Array} [holders] The `partials` placeholder indexes. - * @param {Array} [argPos] The argument positions of the new function. - * @param {number} [ary] The arity cap of `func`. - * @param {number} [arity] The arity of `func`. - * @returns {Function} Returns the new wrapped function. - */ - function createRecurry(func, bitmask, wrapFunc, placeholder, thisArg, partials, holders, argPos, ary, arity) { - var isCurry = bitmask & WRAP_CURRY_FLAG, - newHolders = isCurry ? holders : undefined, - newHoldersRight = isCurry ? undefined : holders, - newPartials = isCurry ? partials : undefined, - newPartialsRight = isCurry ? undefined : partials; - - bitmask |= (isCurry ? WRAP_PARTIAL_FLAG : WRAP_PARTIAL_RIGHT_FLAG); - bitmask &= ~(isCurry ? WRAP_PARTIAL_RIGHT_FLAG : WRAP_PARTIAL_FLAG); - - if (!(bitmask & WRAP_CURRY_BOUND_FLAG)) { - bitmask &= ~(WRAP_BIND_FLAG | WRAP_BIND_KEY_FLAG); - } - var newData = [ - func, bitmask, thisArg, newPartials, newHolders, newPartialsRight, - newHoldersRight, argPos, ary, arity - ]; - - var result = wrapFunc.apply(undefined, newData); - if (isLaziable(func)) { - setData(result, newData); - } - result.placeholder = placeholder; - return setWrapToString(result, func, bitmask); - } - - /** - * Creates a function like `_.round`. - * - * @private - * @param {string} methodName The name of the `Math` method to use when rounding. - * @returns {Function} Returns the new round function. - */ - function createRound(methodName) { - var func = Math[methodName]; - return function(number, precision) { - number = toNumber(number); - precision = precision == null ? 0 : nativeMin(toInteger(precision), 292); - if (precision) { - // Shift with exponential notation to avoid floating-point issues. - // See [MDN](https://mdn.io/round#Examples) for more details. - var pair = (toString(number) + 'e').split('e'), - value = func(pair[0] + 'e' + (+pair[1] + precision)); - - pair = (toString(value) + 'e').split('e'); - return +(pair[0] + 'e' + (+pair[1] - precision)); - } - return func(number); - }; - } - - /** - * Creates a set object of `values`. - * - * @private - * @param {Array} values The values to add to the set. - * @returns {Object} Returns the new set. - */ - var createSet = !(Set && (1 / setToArray(new Set([,-0]))[1]) == INFINITY) ? noop : function(values) { - return new Set(values); - }; - - /** - * Creates a `_.toPairs` or `_.toPairsIn` function. - * - * @private - * @param {Function} keysFunc The function to get the keys of a given object. - * @returns {Function} Returns the new pairs function. - */ - function createToPairs(keysFunc) { - return function(object) { - var tag = getTag(object); - if (tag == mapTag) { - return mapToArray(object); - } - if (tag == setTag) { - return setToPairs(object); - } - return baseToPairs(object, keysFunc(object)); - }; - } - - /** - * Creates a function that either curries or invokes `func` with optional - * `this` binding and partially applied arguments. - * - * @private - * @param {Function|string} func The function or method name to wrap. - * @param {number} bitmask The bitmask flags. - * 1 - `_.bind` - * 2 - `_.bindKey` - * 4 - `_.curry` or `_.curryRight` of a bound function - * 8 - `_.curry` - * 16 - `_.curryRight` - * 32 - `_.partial` - * 64 - `_.partialRight` - * 128 - `_.rearg` - * 256 - `_.ary` - * 512 - `_.flip` - * @param {*} [thisArg] The `this` binding of `func`. - * @param {Array} [partials] The arguments to be partially applied. - * @param {Array} [holders] The `partials` placeholder indexes. - * @param {Array} [argPos] The argument positions of the new function. - * @param {number} [ary] The arity cap of `func`. - * @param {number} [arity] The arity of `func`. - * @returns {Function} Returns the new wrapped function. - */ - function createWrap(func, bitmask, thisArg, partials, holders, argPos, ary, arity) { - var isBindKey = bitmask & WRAP_BIND_KEY_FLAG; - if (!isBindKey && typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - var length = partials ? partials.length : 0; - if (!length) { - bitmask &= ~(WRAP_PARTIAL_FLAG | WRAP_PARTIAL_RIGHT_FLAG); - partials = holders = undefined; - } - ary = ary === undefined ? ary : nativeMax(toInteger(ary), 0); - arity = arity === undefined ? arity : toInteger(arity); - length -= holders ? holders.length : 0; - - if (bitmask & WRAP_PARTIAL_RIGHT_FLAG) { - var partialsRight = partials, - holdersRight = holders; - - partials = holders = undefined; - } - var data = isBindKey ? undefined : getData(func); - - var newData = [ - func, bitmask, thisArg, partials, holders, partialsRight, holdersRight, - argPos, ary, arity - ]; - - if (data) { - mergeData(newData, data); - } - func = newData[0]; - bitmask = newData[1]; - thisArg = newData[2]; - partials = newData[3]; - holders = newData[4]; - arity = newData[9] = newData[9] === undefined - ? (isBindKey ? 0 : func.length) - : nativeMax(newData[9] - length, 0); - - if (!arity && bitmask & (WRAP_CURRY_FLAG | WRAP_CURRY_RIGHT_FLAG)) { - bitmask &= ~(WRAP_CURRY_FLAG | WRAP_CURRY_RIGHT_FLAG); - } - if (!bitmask || bitmask == WRAP_BIND_FLAG) { - var result = createBind(func, bitmask, thisArg); - } else if (bitmask == WRAP_CURRY_FLAG || bitmask == WRAP_CURRY_RIGHT_FLAG) { - result = createCurry(func, bitmask, arity); - } else if ((bitmask == WRAP_PARTIAL_FLAG || bitmask == (WRAP_BIND_FLAG | WRAP_PARTIAL_FLAG)) && !holders.length) { - result = createPartial(func, bitmask, thisArg, partials); - } else { - result = createHybrid.apply(undefined, newData); - } - var setter = data ? baseSetData : setData; - return setWrapToString(setter(result, newData), func, bitmask); - } - - /** - * Used by `_.defaults` to customize its `_.assignIn` use to assign properties - * of source objects to the destination object for all destination properties - * that resolve to `undefined`. - * - * @private - * @param {*} objValue The destination value. - * @param {*} srcValue The source value. - * @param {string} key The key of the property to assign. - * @param {Object} object The parent object of `objValue`. - * @returns {*} Returns the value to assign. - */ - function customDefaultsAssignIn(objValue, srcValue, key, object) { - if (objValue === undefined || - (eq(objValue, objectProto[key]) && !hasOwnProperty.call(object, key))) { - return srcValue; - } - return objValue; - } - - /** - * Used by `_.defaultsDeep` to customize its `_.merge` use to merge source - * objects into destination objects that are passed thru. - * - * @private - * @param {*} objValue The destination value. - * @param {*} srcValue The source value. - * @param {string} key The key of the property to merge. - * @param {Object} object The parent object of `objValue`. - * @param {Object} source The parent object of `srcValue`. - * @param {Object} [stack] Tracks traversed source values and their merged - * counterparts. - * @returns {*} Returns the value to assign. - */ - function customDefaultsMerge(objValue, srcValue, key, object, source, stack) { - if (isObject(objValue) && isObject(srcValue)) { - // Recursively merge objects and arrays (susceptible to call stack limits). - stack.set(srcValue, objValue); - baseMerge(objValue, srcValue, undefined, customDefaultsMerge, stack); - stack['delete'](srcValue); - } - return objValue; - } - - /** - * Used by `_.omit` to customize its `_.cloneDeep` use to only clone plain - * objects. - * - * @private - * @param {*} value The value to inspect. - * @param {string} key The key of the property to inspect. - * @returns {*} Returns the uncloned value or `undefined` to defer cloning to `_.cloneDeep`. - */ - function customOmitClone(value) { - return isPlainObject(value) ? undefined : value; - } - - /** - * A specialized version of `baseIsEqualDeep` for arrays with support for - * partial deep comparisons. - * - * @private - * @param {Array} array The array to compare. - * @param {Array} other The other array to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} stack Tracks traversed `array` and `other` objects. - * @returns {boolean} Returns `true` if the arrays are equivalent, else `false`. - */ - function equalArrays(array, other, bitmask, customizer, equalFunc, stack) { - var isPartial = bitmask & COMPARE_PARTIAL_FLAG, - arrLength = array.length, - othLength = other.length; - - if (arrLength != othLength && !(isPartial && othLength > arrLength)) { - return false; - } - // Assume cyclic values are equal. - var stacked = stack.get(array); - if (stacked && stack.get(other)) { - return stacked == other; - } - var index = -1, - result = true, - seen = (bitmask & COMPARE_UNORDERED_FLAG) ? new SetCache : undefined; - - stack.set(array, other); - stack.set(other, array); - - // Ignore non-index properties. - while (++index < arrLength) { - var arrValue = array[index], - othValue = other[index]; - - if (customizer) { - var compared = isPartial - ? customizer(othValue, arrValue, index, other, array, stack) - : customizer(arrValue, othValue, index, array, other, stack); - } - if (compared !== undefined) { - if (compared) { - continue; - } - result = false; - break; - } - // Recursively compare arrays (susceptible to call stack limits). - if (seen) { - if (!arraySome(other, function(othValue, othIndex) { - if (!cacheHas(seen, othIndex) && - (arrValue === othValue || equalFunc(arrValue, othValue, bitmask, customizer, stack))) { - return seen.push(othIndex); - } - })) { - result = false; - break; - } - } else if (!( - arrValue === othValue || - equalFunc(arrValue, othValue, bitmask, customizer, stack) - )) { - result = false; - break; - } - } - stack['delete'](array); - stack['delete'](other); - return result; - } - - /** - * A specialized version of `baseIsEqualDeep` for comparing objects of - * the same `toStringTag`. - * - * **Note:** This function only supports comparing values with tags of - * `Boolean`, `Date`, `Error`, `Number`, `RegExp`, or `String`. - * - * @private - * @param {Object} object The object to compare. - * @param {Object} other The other object to compare. - * @param {string} tag The `toStringTag` of the objects to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} stack Tracks traversed `object` and `other` objects. - * @returns {boolean} Returns `true` if the objects are equivalent, else `false`. - */ - function equalByTag(object, other, tag, bitmask, customizer, equalFunc, stack) { - switch (tag) { - case dataViewTag: - if ((object.byteLength != other.byteLength) || - (object.byteOffset != other.byteOffset)) { - return false; - } - object = object.buffer; - other = other.buffer; - - case arrayBufferTag: - if ((object.byteLength != other.byteLength) || - !equalFunc(new Uint8Array(object), new Uint8Array(other))) { - return false; - } - return true; - - case boolTag: - case dateTag: - case numberTag: - // Coerce booleans to `1` or `0` and dates to milliseconds. - // Invalid dates are coerced to `NaN`. - return eq(+object, +other); - - case errorTag: - return object.name == other.name && object.message == other.message; - - case regexpTag: - case stringTag: - // Coerce regexes to strings and treat strings, primitives and objects, - // as equal. See http://www.ecma-international.org/ecma-262/7.0/#sec-regexp.prototype.tostring - // for more details. - return object == (other + ''); - - case mapTag: - var convert = mapToArray; - - case setTag: - var isPartial = bitmask & COMPARE_PARTIAL_FLAG; - convert || (convert = setToArray); - - if (object.size != other.size && !isPartial) { - return false; - } - // Assume cyclic values are equal. - var stacked = stack.get(object); - if (stacked) { - return stacked == other; - } - bitmask |= COMPARE_UNORDERED_FLAG; - - // Recursively compare objects (susceptible to call stack limits). - stack.set(object, other); - var result = equalArrays(convert(object), convert(other), bitmask, customizer, equalFunc, stack); - stack['delete'](object); - return result; - - case symbolTag: - if (symbolValueOf) { - return symbolValueOf.call(object) == symbolValueOf.call(other); - } - } - return false; - } - - /** - * A specialized version of `baseIsEqualDeep` for objects with support for - * partial deep comparisons. - * - * @private - * @param {Object} object The object to compare. - * @param {Object} other The other object to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} stack Tracks traversed `object` and `other` objects. - * @returns {boolean} Returns `true` if the objects are equivalent, else `false`. - */ - function equalObjects(object, other, bitmask, customizer, equalFunc, stack) { - var isPartial = bitmask & COMPARE_PARTIAL_FLAG, - objProps = getAllKeys(object), - objLength = objProps.length, - othProps = getAllKeys(other), - othLength = othProps.length; - - if (objLength != othLength && !isPartial) { - return false; - } - var index = objLength; - while (index--) { - var key = objProps[index]; - if (!(isPartial ? key in other : hasOwnProperty.call(other, key))) { - return false; - } - } - // Assume cyclic values are equal. - var stacked = stack.get(object); - if (stacked && stack.get(other)) { - return stacked == other; - } - var result = true; - stack.set(object, other); - stack.set(other, object); - - var skipCtor = isPartial; - while (++index < objLength) { - key = objProps[index]; - var objValue = object[key], - othValue = other[key]; - - if (customizer) { - var compared = isPartial - ? customizer(othValue, objValue, key, other, object, stack) - : customizer(objValue, othValue, key, object, other, stack); - } - // Recursively compare objects (susceptible to call stack limits). - if (!(compared === undefined - ? (objValue === othValue || equalFunc(objValue, othValue, bitmask, customizer, stack)) - : compared - )) { - result = false; - break; - } - skipCtor || (skipCtor = key == 'constructor'); - } - if (result && !skipCtor) { - var objCtor = object.constructor, - othCtor = other.constructor; - - // Non `Object` object instances with different constructors are not equal. - if (objCtor != othCtor && - ('constructor' in object && 'constructor' in other) && - !(typeof objCtor == 'function' && objCtor instanceof objCtor && - typeof othCtor == 'function' && othCtor instanceof othCtor)) { - result = false; - } - } - stack['delete'](object); - stack['delete'](other); - return result; - } - - /** - * A specialized version of `baseRest` which flattens the rest array. - * - * @private - * @param {Function} func The function to apply a rest parameter to. - * @returns {Function} Returns the new function. - */ - function flatRest(func) { - return setToString(overRest(func, undefined, flatten), func + ''); - } - - /** - * Creates an array of own enumerable property names and symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names and symbols. - */ - function getAllKeys(object) { - return baseGetAllKeys(object, keys, getSymbols); - } - - /** - * Creates an array of own and inherited enumerable property names and - * symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names and symbols. - */ - function getAllKeysIn(object) { - return baseGetAllKeys(object, keysIn, getSymbolsIn); - } - - /** - * Gets metadata for `func`. - * - * @private - * @param {Function} func The function to query. - * @returns {*} Returns the metadata for `func`. - */ - var getData = !metaMap ? noop : function(func) { - return metaMap.get(func); - }; - - /** - * Gets the name of `func`. - * - * @private - * @param {Function} func The function to query. - * @returns {string} Returns the function name. - */ - function getFuncName(func) { - var result = (func.name + ''), - array = realNames[result], - length = hasOwnProperty.call(realNames, result) ? array.length : 0; - - while (length--) { - var data = array[length], - otherFunc = data.func; - if (otherFunc == null || otherFunc == func) { - return data.name; - } - } - return result; - } - - /** - * Gets the argument placeholder value for `func`. - * - * @private - * @param {Function} func The function to inspect. - * @returns {*} Returns the placeholder value. - */ - function getHolder(func) { - var object = hasOwnProperty.call(lodash, 'placeholder') ? lodash : func; - return object.placeholder; - } - - /** - * Gets the appropriate "iteratee" function. If `_.iteratee` is customized, - * this function returns the custom method, otherwise it returns `baseIteratee`. - * If arguments are provided, the chosen function is invoked with them and - * its result is returned. - * - * @private - * @param {*} [value] The value to convert to an iteratee. - * @param {number} [arity] The arity of the created iteratee. - * @returns {Function} Returns the chosen function or its result. - */ - function getIteratee() { - var result = lodash.iteratee || iteratee; - result = result === iteratee ? baseIteratee : result; - return arguments.length ? result(arguments[0], arguments[1]) : result; - } - - /** - * Gets the data for `map`. - * - * @private - * @param {Object} map The map to query. - * @param {string} key The reference key. - * @returns {*} Returns the map data. - */ - function getMapData(map, key) { - var data = map.__data__; - return isKeyable(key) - ? data[typeof key == 'string' ? 'string' : 'hash'] - : data.map; - } - - /** - * Gets the property names, values, and compare flags of `object`. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the match data of `object`. - */ - function getMatchData(object) { - var result = keys(object), - length = result.length; - - while (length--) { - var key = result[length], - value = object[key]; - - result[length] = [key, value, isStrictComparable(value)]; - } - return result; - } - - /** - * Gets the native function at `key` of `object`. - * - * @private - * @param {Object} object The object to query. - * @param {string} key The key of the method to get. - * @returns {*} Returns the function if it's native, else `undefined`. - */ - function getNative(object, key) { - var value = getValue(object, key); - return baseIsNative(value) ? value : undefined; - } - - /** - * A specialized version of `baseGetTag` which ignores `Symbol.toStringTag` values. - * - * @private - * @param {*} value The value to query. - * @returns {string} Returns the raw `toStringTag`. - */ - function getRawTag(value) { - var isOwn = hasOwnProperty.call(value, symToStringTag), - tag = value[symToStringTag]; - - try { - value[symToStringTag] = undefined; - var unmasked = true; - } catch (e) {} - - var result = nativeObjectToString.call(value); - if (unmasked) { - if (isOwn) { - value[symToStringTag] = tag; - } else { - delete value[symToStringTag]; - } - } - return result; - } - - /** - * Creates an array of the own enumerable symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of symbols. - */ - var getSymbols = !nativeGetSymbols ? stubArray : function(object) { - if (object == null) { - return []; - } - object = Object(object); - return arrayFilter(nativeGetSymbols(object), function(symbol) { - return propertyIsEnumerable.call(object, symbol); - }); - }; - - /** - * Creates an array of the own and inherited enumerable symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of symbols. - */ - var getSymbolsIn = !nativeGetSymbols ? stubArray : function(object) { - var result = []; - while (object) { - arrayPush(result, getSymbols(object)); - object = getPrototype(object); - } - return result; - }; - - /** - * Gets the `toStringTag` of `value`. - * - * @private - * @param {*} value The value to query. - * @returns {string} Returns the `toStringTag`. - */ - var getTag = baseGetTag; - - // Fallback for data views, maps, sets, and weak maps in IE 11 and promises in Node.js < 6. - if ((DataView && getTag(new DataView(new ArrayBuffer(1))) != dataViewTag) || - (Map && getTag(new Map) != mapTag) || - (Promise && getTag(Promise.resolve()) != promiseTag) || - (Set && getTag(new Set) != setTag) || - (WeakMap && getTag(new WeakMap) != weakMapTag)) { - getTag = function(value) { - var result = baseGetTag(value), - Ctor = result == objectTag ? value.constructor : undefined, - ctorString = Ctor ? toSource(Ctor) : ''; - - if (ctorString) { - switch (ctorString) { - case dataViewCtorString: return dataViewTag; - case mapCtorString: return mapTag; - case promiseCtorString: return promiseTag; - case setCtorString: return setTag; - case weakMapCtorString: return weakMapTag; - } - } - return result; - }; - } - - /** - * Gets the view, applying any `transforms` to the `start` and `end` positions. - * - * @private - * @param {number} start The start of the view. - * @param {number} end The end of the view. - * @param {Array} transforms The transformations to apply to the view. - * @returns {Object} Returns an object containing the `start` and `end` - * positions of the view. - */ - function getView(start, end, transforms) { - var index = -1, - length = transforms.length; - - while (++index < length) { - var data = transforms[index], - size = data.size; - - switch (data.type) { - case 'drop': start += size; break; - case 'dropRight': end -= size; break; - case 'take': end = nativeMin(end, start + size); break; - case 'takeRight': start = nativeMax(start, end - size); break; - } - } - return { 'start': start, 'end': end }; - } - - /** - * Extracts wrapper details from the `source` body comment. - * - * @private - * @param {string} source The source to inspect. - * @returns {Array} Returns the wrapper details. - */ - function getWrapDetails(source) { - var match = source.match(reWrapDetails); - return match ? match[1].split(reSplitDetails) : []; - } - - /** - * Checks if `path` exists on `object`. - * - * @private - * @param {Object} object The object to query. - * @param {Array|string} path The path to check. - * @param {Function} hasFunc The function to check properties. - * @returns {boolean} Returns `true` if `path` exists, else `false`. - */ - function hasPath(object, path, hasFunc) { - path = castPath(path, object); - - var index = -1, - length = path.length, - result = false; - - while (++index < length) { - var key = toKey(path[index]); - if (!(result = object != null && hasFunc(object, key))) { - break; - } - object = object[key]; - } - if (result || ++index != length) { - return result; - } - length = object == null ? 0 : object.length; - return !!length && isLength(length) && isIndex(key, length) && - (isArray(object) || isArguments(object)); - } - - /** - * Initializes an array clone. - * - * @private - * @param {Array} array The array to clone. - * @returns {Array} Returns the initialized clone. - */ - function initCloneArray(array) { - var length = array.length, - result = new array.constructor(length); - - // Add properties assigned by `RegExp#exec`. - if (length && typeof array[0] == 'string' && hasOwnProperty.call(array, 'index')) { - result.index = array.index; - result.input = array.input; - } - return result; - } - - /** - * Initializes an object clone. - * - * @private - * @param {Object} object The object to clone. - * @returns {Object} Returns the initialized clone. - */ - function initCloneObject(object) { - return (typeof object.constructor == 'function' && !isPrototype(object)) - ? baseCreate(getPrototype(object)) - : {}; - } - - /** - * Initializes an object clone based on its `toStringTag`. - * - * **Note:** This function only supports cloning values with tags of - * `Boolean`, `Date`, `Error`, `Map`, `Number`, `RegExp`, `Set`, or `String`. - * - * @private - * @param {Object} object The object to clone. - * @param {string} tag The `toStringTag` of the object to clone. - * @param {boolean} [isDeep] Specify a deep clone. - * @returns {Object} Returns the initialized clone. - */ - function initCloneByTag(object, tag, isDeep) { - var Ctor = object.constructor; - switch (tag) { - case arrayBufferTag: - return cloneArrayBuffer(object); - - case boolTag: - case dateTag: - return new Ctor(+object); - - case dataViewTag: - return cloneDataView(object, isDeep); - - case float32Tag: case float64Tag: - case int8Tag: case int16Tag: case int32Tag: - case uint8Tag: case uint8ClampedTag: case uint16Tag: case uint32Tag: - return cloneTypedArray(object, isDeep); - - case mapTag: - return new Ctor; - - case numberTag: - case stringTag: - return new Ctor(object); - - case regexpTag: - return cloneRegExp(object); - - case setTag: - return new Ctor; - - case symbolTag: - return cloneSymbol(object); - } - } - - /** - * Inserts wrapper `details` in a comment at the top of the `source` body. - * - * @private - * @param {string} source The source to modify. - * @returns {Array} details The details to insert. - * @returns {string} Returns the modified source. - */ - function insertWrapDetails(source, details) { - var length = details.length; - if (!length) { - return source; - } - var lastIndex = length - 1; - details[lastIndex] = (length > 1 ? '& ' : '') + details[lastIndex]; - details = details.join(length > 2 ? ', ' : ' '); - return source.replace(reWrapComment, '{\n/* [wrapped with ' + details + '] */\n'); - } - - /** - * Checks if `value` is a flattenable `arguments` object or array. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is flattenable, else `false`. - */ - function isFlattenable(value) { - return isArray(value) || isArguments(value) || - !!(spreadableSymbol && value && value[spreadableSymbol]); - } - - /** - * Checks if `value` is a valid array-like index. - * - * @private - * @param {*} value The value to check. - * @param {number} [length=MAX_SAFE_INTEGER] The upper bounds of a valid index. - * @returns {boolean} Returns `true` if `value` is a valid index, else `false`. - */ - function isIndex(value, length) { - var type = typeof value; - length = length == null ? MAX_SAFE_INTEGER : length; - - return !!length && - (type == 'number' || - (type != 'symbol' && reIsUint.test(value))) && - (value > -1 && value % 1 == 0 && value < length); - } - - /** - * Checks if the given arguments are from an iteratee call. - * - * @private - * @param {*} value The potential iteratee value argument. - * @param {*} index The potential iteratee index or key argument. - * @param {*} object The potential iteratee object argument. - * @returns {boolean} Returns `true` if the arguments are from an iteratee call, - * else `false`. - */ - function isIterateeCall(value, index, object) { - if (!isObject(object)) { - return false; - } - var type = typeof index; - if (type == 'number' - ? (isArrayLike(object) && isIndex(index, object.length)) - : (type == 'string' && index in object) - ) { - return eq(object[index], value); - } - return false; - } - - /** - * Checks if `value` is a property name and not a property path. - * - * @private - * @param {*} value The value to check. - * @param {Object} [object] The object to query keys on. - * @returns {boolean} Returns `true` if `value` is a property name, else `false`. - */ - function isKey(value, object) { - if (isArray(value)) { - return false; - } - var type = typeof value; - if (type == 'number' || type == 'symbol' || type == 'boolean' || - value == null || isSymbol(value)) { - return true; - } - return reIsPlainProp.test(value) || !reIsDeepProp.test(value) || - (object != null && value in Object(object)); - } - - /** - * Checks if `value` is suitable for use as unique object key. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is suitable, else `false`. - */ - function isKeyable(value) { - var type = typeof value; - return (type == 'string' || type == 'number' || type == 'symbol' || type == 'boolean') - ? (value !== '__proto__') - : (value === null); - } - - /** - * Checks if `func` has a lazy counterpart. - * - * @private - * @param {Function} func The function to check. - * @returns {boolean} Returns `true` if `func` has a lazy counterpart, - * else `false`. - */ - function isLaziable(func) { - var funcName = getFuncName(func), - other = lodash[funcName]; - - if (typeof other != 'function' || !(funcName in LazyWrapper.prototype)) { - return false; - } - if (func === other) { - return true; - } - var data = getData(other); - return !!data && func === data[0]; - } - - /** - * Checks if `func` has its source masked. - * - * @private - * @param {Function} func The function to check. - * @returns {boolean} Returns `true` if `func` is masked, else `false`. - */ - function isMasked(func) { - return !!maskSrcKey && (maskSrcKey in func); - } - - /** - * Checks if `func` is capable of being masked. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `func` is maskable, else `false`. - */ - var isMaskable = coreJsData ? isFunction : stubFalse; - - /** - * Checks if `value` is likely a prototype object. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a prototype, else `false`. - */ - function isPrototype(value) { - var Ctor = value && value.constructor, - proto = (typeof Ctor == 'function' && Ctor.prototype) || objectProto; - - return value === proto; - } - - /** - * Checks if `value` is suitable for strict equality comparisons, i.e. `===`. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` if suitable for strict - * equality comparisons, else `false`. - */ - function isStrictComparable(value) { - return value === value && !isObject(value); - } - - /** - * A specialized version of `matchesProperty` for source values suitable - * for strict equality comparisons, i.e. `===`. - * - * @private - * @param {string} key The key of the property to get. - * @param {*} srcValue The value to match. - * @returns {Function} Returns the new spec function. - */ - function matchesStrictComparable(key, srcValue) { - return function(object) { - if (object == null) { - return false; - } - return object[key] === srcValue && - (srcValue !== undefined || (key in Object(object))); - }; - } - - /** - * A specialized version of `_.memoize` which clears the memoized function's - * cache when it exceeds `MAX_MEMOIZE_SIZE`. - * - * @private - * @param {Function} func The function to have its output memoized. - * @returns {Function} Returns the new memoized function. - */ - function memoizeCapped(func) { - var result = memoize(func, function(key) { - if (cache.size === MAX_MEMOIZE_SIZE) { - cache.clear(); - } - return key; - }); - - var cache = result.cache; - return result; - } - - /** - * Merges the function metadata of `source` into `data`. - * - * Merging metadata reduces the number of wrappers used to invoke a function. - * This is possible because methods like `_.bind`, `_.curry`, and `_.partial` - * may be applied regardless of execution order. Methods like `_.ary` and - * `_.rearg` modify function arguments, making the order in which they are - * executed important, preventing the merging of metadata. However, we make - * an exception for a safe combined case where curried functions have `_.ary` - * and or `_.rearg` applied. - * - * @private - * @param {Array} data The destination metadata. - * @param {Array} source The source metadata. - * @returns {Array} Returns `data`. - */ - function mergeData(data, source) { - var bitmask = data[1], - srcBitmask = source[1], - newBitmask = bitmask | srcBitmask, - isCommon = newBitmask < (WRAP_BIND_FLAG | WRAP_BIND_KEY_FLAG | WRAP_ARY_FLAG); - - var isCombo = - ((srcBitmask == WRAP_ARY_FLAG) && (bitmask == WRAP_CURRY_FLAG)) || - ((srcBitmask == WRAP_ARY_FLAG) && (bitmask == WRAP_REARG_FLAG) && (data[7].length <= source[8])) || - ((srcBitmask == (WRAP_ARY_FLAG | WRAP_REARG_FLAG)) && (source[7].length <= source[8]) && (bitmask == WRAP_CURRY_FLAG)); - - // Exit early if metadata can't be merged. - if (!(isCommon || isCombo)) { - return data; - } - // Use source `thisArg` if available. - if (srcBitmask & WRAP_BIND_FLAG) { - data[2] = source[2]; - // Set when currying a bound function. - newBitmask |= bitmask & WRAP_BIND_FLAG ? 0 : WRAP_CURRY_BOUND_FLAG; - } - // Compose partial arguments. - var value = source[3]; - if (value) { - var partials = data[3]; - data[3] = partials ? composeArgs(partials, value, source[4]) : value; - data[4] = partials ? replaceHolders(data[3], PLACEHOLDER) : source[4]; - } - // Compose partial right arguments. - value = source[5]; - if (value) { - partials = data[5]; - data[5] = partials ? composeArgsRight(partials, value, source[6]) : value; - data[6] = partials ? replaceHolders(data[5], PLACEHOLDER) : source[6]; - } - // Use source `argPos` if available. - value = source[7]; - if (value) { - data[7] = value; - } - // Use source `ary` if it's smaller. - if (srcBitmask & WRAP_ARY_FLAG) { - data[8] = data[8] == null ? source[8] : nativeMin(data[8], source[8]); - } - // Use source `arity` if one is not provided. - if (data[9] == null) { - data[9] = source[9]; - } - // Use source `func` and merge bitmasks. - data[0] = source[0]; - data[1] = newBitmask; - - return data; - } - - /** - * This function is like - * [`Object.keys`](http://ecma-international.org/ecma-262/7.0/#sec-object.keys) - * except that it includes inherited enumerable properties. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names. - */ - function nativeKeysIn(object) { - var result = []; - if (object != null) { - for (var key in Object(object)) { - result.push(key); - } - } - return result; - } - - /** - * Converts `value` to a string using `Object.prototype.toString`. - * - * @private - * @param {*} value The value to convert. - * @returns {string} Returns the converted string. - */ - function objectToString(value) { - return nativeObjectToString.call(value); - } - - /** - * A specialized version of `baseRest` which transforms the rest array. - * - * @private - * @param {Function} func The function to apply a rest parameter to. - * @param {number} [start=func.length-1] The start position of the rest parameter. - * @param {Function} transform The rest array transform. - * @returns {Function} Returns the new function. - */ - function overRest(func, start, transform) { - start = nativeMax(start === undefined ? (func.length - 1) : start, 0); - return function() { - var args = arguments, - index = -1, - length = nativeMax(args.length - start, 0), - array = Array(length); - - while (++index < length) { - array[index] = args[start + index]; - } - index = -1; - var otherArgs = Array(start + 1); - while (++index < start) { - otherArgs[index] = args[index]; - } - otherArgs[start] = transform(array); - return apply(func, this, otherArgs); - }; - } - - /** - * Gets the parent value at `path` of `object`. - * - * @private - * @param {Object} object The object to query. - * @param {Array} path The path to get the parent value of. - * @returns {*} Returns the parent value. - */ - function parent(object, path) { - return path.length < 2 ? object : baseGet(object, baseSlice(path, 0, -1)); - } - - /** - * Reorder `array` according to the specified indexes where the element at - * the first index is assigned as the first element, the element at - * the second index is assigned as the second element, and so on. - * - * @private - * @param {Array} array The array to reorder. - * @param {Array} indexes The arranged array indexes. - * @returns {Array} Returns `array`. - */ - function reorder(array, indexes) { - var arrLength = array.length, - length = nativeMin(indexes.length, arrLength), - oldArray = copyArray(array); - - while (length--) { - var index = indexes[length]; - array[length] = isIndex(index, arrLength) ? oldArray[index] : undefined; - } - return array; - } - - /** - * Sets metadata for `func`. - * - * **Note:** If this function becomes hot, i.e. is invoked a lot in a short - * period of time, it will trip its breaker and transition to an identity - * function to avoid garbage collection pauses in V8. See - * [V8 issue 2070](https://bugs.chromium.org/p/v8/issues/detail?id=2070) - * for more details. - * - * @private - * @param {Function} func The function to associate metadata with. - * @param {*} data The metadata. - * @returns {Function} Returns `func`. - */ - var setData = shortOut(baseSetData); - - /** - * A simple wrapper around the global [`setTimeout`](https://mdn.io/setTimeout). - * - * @private - * @param {Function} func The function to delay. - * @param {number} wait The number of milliseconds to delay invocation. - * @returns {number|Object} Returns the timer id or timeout object. - */ - var setTimeout = ctxSetTimeout || function(func, wait) { - return root.setTimeout(func, wait); - }; - - /** - * Sets the `toString` method of `func` to return `string`. - * - * @private - * @param {Function} func The function to modify. - * @param {Function} string The `toString` result. - * @returns {Function} Returns `func`. - */ - var setToString = shortOut(baseSetToString); - - /** - * Sets the `toString` method of `wrapper` to mimic the source of `reference` - * with wrapper details in a comment at the top of the source body. - * - * @private - * @param {Function} wrapper The function to modify. - * @param {Function} reference The reference function. - * @param {number} bitmask The bitmask flags. See `createWrap` for more details. - * @returns {Function} Returns `wrapper`. - */ - function setWrapToString(wrapper, reference, bitmask) { - var source = (reference + ''); - return setToString(wrapper, insertWrapDetails(source, updateWrapDetails(getWrapDetails(source), bitmask))); - } - - /** - * Creates a function that'll short out and invoke `identity` instead - * of `func` when it's called `HOT_COUNT` or more times in `HOT_SPAN` - * milliseconds. - * - * @private - * @param {Function} func The function to restrict. - * @returns {Function} Returns the new shortable function. - */ - function shortOut(func) { - var count = 0, - lastCalled = 0; - - return function() { - var stamp = nativeNow(), - remaining = HOT_SPAN - (stamp - lastCalled); - - lastCalled = stamp; - if (remaining > 0) { - if (++count >= HOT_COUNT) { - return arguments[0]; - } - } else { - count = 0; - } - return func.apply(undefined, arguments); - }; - } - - /** - * A specialized version of `_.shuffle` which mutates and sets the size of `array`. - * - * @private - * @param {Array} array The array to shuffle. - * @param {number} [size=array.length] The size of `array`. - * @returns {Array} Returns `array`. - */ - function shuffleSelf(array, size) { - var index = -1, - length = array.length, - lastIndex = length - 1; - - size = size === undefined ? length : size; - while (++index < size) { - var rand = baseRandom(index, lastIndex), - value = array[rand]; - - array[rand] = array[index]; - array[index] = value; - } - array.length = size; - return array; - } - - /** - * Converts `string` to a property path array. - * - * @private - * @param {string} string The string to convert. - * @returns {Array} Returns the property path array. - */ - var stringToPath = memoizeCapped(function(string) { - var result = []; - if (string.charCodeAt(0) === 46 /* . */) { - result.push(''); - } - string.replace(rePropName, function(match, number, quote, subString) { - result.push(quote ? subString.replace(reEscapeChar, '$1') : (number || match)); - }); - return result; - }); - - /** - * Converts `value` to a string key if it's not a string or symbol. - * - * @private - * @param {*} value The value to inspect. - * @returns {string|symbol} Returns the key. - */ - function toKey(value) { - if (typeof value == 'string' || isSymbol(value)) { - return value; - } - var result = (value + ''); - return (result == '0' && (1 / value) == -INFINITY) ? '-0' : result; - } - - /** - * Converts `func` to its source code. - * - * @private - * @param {Function} func The function to convert. - * @returns {string} Returns the source code. - */ - function toSource(func) { - if (func != null) { - try { - return funcToString.call(func); - } catch (e) {} - try { - return (func + ''); - } catch (e) {} - } - return ''; - } - - /** - * Updates wrapper `details` based on `bitmask` flags. - * - * @private - * @returns {Array} details The details to modify. - * @param {number} bitmask The bitmask flags. See `createWrap` for more details. - * @returns {Array} Returns `details`. - */ - function updateWrapDetails(details, bitmask) { - arrayEach(wrapFlags, function(pair) { - var value = '_.' + pair[0]; - if ((bitmask & pair[1]) && !arrayIncludes(details, value)) { - details.push(value); - } - }); - return details.sort(); - } - - /** - * Creates a clone of `wrapper`. - * - * @private - * @param {Object} wrapper The wrapper to clone. - * @returns {Object} Returns the cloned wrapper. - */ - function wrapperClone(wrapper) { - if (wrapper instanceof LazyWrapper) { - return wrapper.clone(); - } - var result = new LodashWrapper(wrapper.__wrapped__, wrapper.__chain__); - result.__actions__ = copyArray(wrapper.__actions__); - result.__index__ = wrapper.__index__; - result.__values__ = wrapper.__values__; - return result; - } - - /*------------------------------------------------------------------------*/ - - /** - * Creates an array of elements split into groups the length of `size`. - * If `array` can't be split evenly, the final chunk will be the remaining - * elements. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to process. - * @param {number} [size=1] The length of each chunk - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Array} Returns the new array of chunks. - * @example - * - * _.chunk(['a', 'b', 'c', 'd'], 2); - * // => [['a', 'b'], ['c', 'd']] - * - * _.chunk(['a', 'b', 'c', 'd'], 3); - * // => [['a', 'b', 'c'], ['d']] - */ - function chunk(array, size, guard) { - if ((guard ? isIterateeCall(array, size, guard) : size === undefined)) { - size = 1; - } else { - size = nativeMax(toInteger(size), 0); - } - var length = array == null ? 0 : array.length; - if (!length || size < 1) { - return []; - } - var index = 0, - resIndex = 0, - result = Array(nativeCeil(length / size)); - - while (index < length) { - result[resIndex++] = baseSlice(array, index, (index += size)); - } - return result; - } - - /** - * Creates an array with all falsey values removed. The values `false`, `null`, - * `0`, `""`, `undefined`, and `NaN` are falsey. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to compact. - * @returns {Array} Returns the new array of filtered values. - * @example - * - * _.compact([0, 1, false, 2, '', 3]); - * // => [1, 2, 3] - */ - function compact(array) { - var index = -1, - length = array == null ? 0 : array.length, - resIndex = 0, - result = []; - - while (++index < length) { - var value = array[index]; - if (value) { - result[resIndex++] = value; - } - } - return result; - } - - /** - * Creates a new array concatenating `array` with any additional arrays - * and/or values. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to concatenate. - * @param {...*} [values] The values to concatenate. - * @returns {Array} Returns the new concatenated array. - * @example - * - * var array = [1]; - * var other = _.concat(array, 2, [3], [[4]]); - * - * console.log(other); - * // => [1, 2, 3, [4]] - * - * console.log(array); - * // => [1] - */ - function concat() { - var length = arguments.length; - if (!length) { - return []; - } - var args = Array(length - 1), - array = arguments[0], - index = length; - - while (index--) { - args[index - 1] = arguments[index]; - } - return arrayPush(isArray(array) ? copyArray(array) : [array], baseFlatten(args, 1)); - } - - /** - * Creates an array of `array` values not included in the other given arrays - * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons. The order and references of result values are - * determined by the first array. - * - * **Note:** Unlike `_.pullAll`, this method returns a new array. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {...Array} [values] The values to exclude. - * @returns {Array} Returns the new array of filtered values. - * @see _.without, _.xor - * @example - * - * _.difference([2, 1], [2, 3]); - * // => [1] - */ - var difference = baseRest(function(array, values) { - return isArrayLikeObject(array) - ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true)) - : []; - }); - - /** - * This method is like `_.difference` except that it accepts `iteratee` which - * is invoked for each element of `array` and `values` to generate the criterion - * by which they're compared. The order and references of result values are - * determined by the first array. The iteratee is invoked with one argument: - * (value). - * - * **Note:** Unlike `_.pullAllBy`, this method returns a new array. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {...Array} [values] The values to exclude. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {Array} Returns the new array of filtered values. - * @example - * - * _.differenceBy([2.1, 1.2], [2.3, 3.4], Math.floor); - * // => [1.2] - * - * // The `_.property` iteratee shorthand. - * _.differenceBy([{ 'x': 2 }, { 'x': 1 }], [{ 'x': 1 }], 'x'); - * // => [{ 'x': 2 }] - */ - var differenceBy = baseRest(function(array, values) { - var iteratee = last(values); - if (isArrayLikeObject(iteratee)) { - iteratee = undefined; - } - return isArrayLikeObject(array) - ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true), getIteratee(iteratee, 2)) - : []; - }); - - /** - * This method is like `_.difference` except that it accepts `comparator` - * which is invoked to compare elements of `array` to `values`. The order and - * references of result values are determined by the first array. The comparator - * is invoked with two arguments: (arrVal, othVal). - * - * **Note:** Unlike `_.pullAllWith`, this method returns a new array. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {...Array} [values] The values to exclude. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new array of filtered values. - * @example - * - * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]; - * - * _.differenceWith(objects, [{ 'x': 1, 'y': 2 }], _.isEqual); - * // => [{ 'x': 2, 'y': 1 }] - */ - var differenceWith = baseRest(function(array, values) { - var comparator = last(values); - if (isArrayLikeObject(comparator)) { - comparator = undefined; - } - return isArrayLikeObject(array) - ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true), undefined, comparator) - : []; - }); - - /** - * Creates a slice of `array` with `n` elements dropped from the beginning. - * - * @static - * @memberOf _ - * @since 0.5.0 - * @category Array - * @param {Array} array The array to query. - * @param {number} [n=1] The number of elements to drop. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Array} Returns the slice of `array`. - * @example - * - * _.drop([1, 2, 3]); - * // => [2, 3] - * - * _.drop([1, 2, 3], 2); - * // => [3] - * - * _.drop([1, 2, 3], 5); - * // => [] - * - * _.drop([1, 2, 3], 0); - * // => [1, 2, 3] - */ - function drop(array, n, guard) { - var length = array == null ? 0 : array.length; - if (!length) { - return []; - } - n = (guard || n === undefined) ? 1 : toInteger(n); - return baseSlice(array, n < 0 ? 0 : n, length); - } - - /** - * Creates a slice of `array` with `n` elements dropped from the end. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to query. - * @param {number} [n=1] The number of elements to drop. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Array} Returns the slice of `array`. - * @example - * - * _.dropRight([1, 2, 3]); - * // => [1, 2] - * - * _.dropRight([1, 2, 3], 2); - * // => [1] - * - * _.dropRight([1, 2, 3], 5); - * // => [] - * - * _.dropRight([1, 2, 3], 0); - * // => [1, 2, 3] - */ - function dropRight(array, n, guard) { - var length = array == null ? 0 : array.length; - if (!length) { - return []; - } - n = (guard || n === undefined) ? 1 : toInteger(n); - n = length - n; - return baseSlice(array, 0, n < 0 ? 0 : n); - } - - /** - * Creates a slice of `array` excluding elements dropped from the end. - * Elements are dropped until `predicate` returns falsey. The predicate is - * invoked with three arguments: (value, index, array). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to query. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the slice of `array`. - * @example - * - * var users = [ - * { 'user': 'barney', 'active': true }, - * { 'user': 'fred', 'active': false }, - * { 'user': 'pebbles', 'active': false } - * ]; - * - * _.dropRightWhile(users, function(o) { return !o.active; }); - * // => objects for ['barney'] - * - * // The `_.matches` iteratee shorthand. - * _.dropRightWhile(users, { 'user': 'pebbles', 'active': false }); - * // => objects for ['barney', 'fred'] - * - * // The `_.matchesProperty` iteratee shorthand. - * _.dropRightWhile(users, ['active', false]); - * // => objects for ['barney'] - * - * // The `_.property` iteratee shorthand. - * _.dropRightWhile(users, 'active'); - * // => objects for ['barney', 'fred', 'pebbles'] - */ - function dropRightWhile(array, predicate) { - return (array && array.length) - ? baseWhile(array, getIteratee(predicate, 3), true, true) - : []; - } - - /** - * Creates a slice of `array` excluding elements dropped from the beginning. - * Elements are dropped until `predicate` returns falsey. The predicate is - * invoked with three arguments: (value, index, array). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to query. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the slice of `array`. - * @example - * - * var users = [ - * { 'user': 'barney', 'active': false }, - * { 'user': 'fred', 'active': false }, - * { 'user': 'pebbles', 'active': true } - * ]; - * - * _.dropWhile(users, function(o) { return !o.active; }); - * // => objects for ['pebbles'] - * - * // The `_.matches` iteratee shorthand. - * _.dropWhile(users, { 'user': 'barney', 'active': false }); - * // => objects for ['fred', 'pebbles'] - * - * // The `_.matchesProperty` iteratee shorthand. - * _.dropWhile(users, ['active', false]); - * // => objects for ['pebbles'] - * - * // The `_.property` iteratee shorthand. - * _.dropWhile(users, 'active'); - * // => objects for ['barney', 'fred', 'pebbles'] - */ - function dropWhile(array, predicate) { - return (array && array.length) - ? baseWhile(array, getIteratee(predicate, 3), true) - : []; - } - - /** - * Fills elements of `array` with `value` from `start` up to, but not - * including, `end`. - * - * **Note:** This method mutates `array`. - * - * @static - * @memberOf _ - * @since 3.2.0 - * @category Array - * @param {Array} array The array to fill. - * @param {*} value The value to fill `array` with. - * @param {number} [start=0] The start position. - * @param {number} [end=array.length] The end position. - * @returns {Array} Returns `array`. - * @example - * - * var array = [1, 2, 3]; - * - * _.fill(array, 'a'); - * console.log(array); - * // => ['a', 'a', 'a'] - * - * _.fill(Array(3), 2); - * // => [2, 2, 2] - * - * _.fill([4, 6, 8, 10], '*', 1, 3); - * // => [4, '*', '*', 10] - */ - function fill(array, value, start, end) { - var length = array == null ? 0 : array.length; - if (!length) { - return []; - } - if (start && typeof start != 'number' && isIterateeCall(array, value, start)) { - start = 0; - end = length; - } - return baseFill(array, value, start, end); - } - - /** - * This method is like `_.find` except that it returns the index of the first - * element `predicate` returns truthy for instead of the element itself. - * - * @static - * @memberOf _ - * @since 1.1.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @param {number} [fromIndex=0] The index to search from. - * @returns {number} Returns the index of the found element, else `-1`. - * @example - * - * var users = [ - * { 'user': 'barney', 'active': false }, - * { 'user': 'fred', 'active': false }, - * { 'user': 'pebbles', 'active': true } - * ]; - * - * _.findIndex(users, function(o) { return o.user == 'barney'; }); - * // => 0 - * - * // The `_.matches` iteratee shorthand. - * _.findIndex(users, { 'user': 'fred', 'active': false }); - * // => 1 - * - * // The `_.matchesProperty` iteratee shorthand. - * _.findIndex(users, ['active', false]); - * // => 0 - * - * // The `_.property` iteratee shorthand. - * _.findIndex(users, 'active'); - * // => 2 - */ - function findIndex(array, predicate, fromIndex) { - var length = array == null ? 0 : array.length; - if (!length) { - return -1; - } - var index = fromIndex == null ? 0 : toInteger(fromIndex); - if (index < 0) { - index = nativeMax(length + index, 0); - } - return baseFindIndex(array, getIteratee(predicate, 3), index); - } - - /** - * This method is like `_.findIndex` except that it iterates over elements - * of `collection` from right to left. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @param {number} [fromIndex=array.length-1] The index to search from. - * @returns {number} Returns the index of the found element, else `-1`. - * @example - * - * var users = [ - * { 'user': 'barney', 'active': true }, - * { 'user': 'fred', 'active': false }, - * { 'user': 'pebbles', 'active': false } - * ]; - * - * _.findLastIndex(users, function(o) { return o.user == 'pebbles'; }); - * // => 2 - * - * // The `_.matches` iteratee shorthand. - * _.findLastIndex(users, { 'user': 'barney', 'active': true }); - * // => 0 - * - * // The `_.matchesProperty` iteratee shorthand. - * _.findLastIndex(users, ['active', false]); - * // => 2 - * - * // The `_.property` iteratee shorthand. - * _.findLastIndex(users, 'active'); - * // => 0 - */ - function findLastIndex(array, predicate, fromIndex) { - var length = array == null ? 0 : array.length; - if (!length) { - return -1; - } - var index = length - 1; - if (fromIndex !== undefined) { - index = toInteger(fromIndex); - index = fromIndex < 0 - ? nativeMax(length + index, 0) - : nativeMin(index, length - 1); - } - return baseFindIndex(array, getIteratee(predicate, 3), index, true); - } - - /** - * Flattens `array` a single level deep. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to flatten. - * @returns {Array} Returns the new flattened array. - * @example - * - * _.flatten([1, [2, [3, [4]], 5]]); - * // => [1, 2, [3, [4]], 5] - */ - function flatten(array) { - var length = array == null ? 0 : array.length; - return length ? baseFlatten(array, 1) : []; - } - - /** - * Recursively flattens `array`. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to flatten. - * @returns {Array} Returns the new flattened array. - * @example - * - * _.flattenDeep([1, [2, [3, [4]], 5]]); - * // => [1, 2, 3, 4, 5] - */ - function flattenDeep(array) { - var length = array == null ? 0 : array.length; - return length ? baseFlatten(array, INFINITY) : []; - } - - /** - * Recursively flatten `array` up to `depth` times. - * - * @static - * @memberOf _ - * @since 4.4.0 - * @category Array - * @param {Array} array The array to flatten. - * @param {number} [depth=1] The maximum recursion depth. - * @returns {Array} Returns the new flattened array. - * @example - * - * var array = [1, [2, [3, [4]], 5]]; - * - * _.flattenDepth(array, 1); - * // => [1, 2, [3, [4]], 5] - * - * _.flattenDepth(array, 2); - * // => [1, 2, 3, [4], 5] - */ - function flattenDepth(array, depth) { - var length = array == null ? 0 : array.length; - if (!length) { - return []; - } - depth = depth === undefined ? 1 : toInteger(depth); - return baseFlatten(array, depth); - } - - /** - * The inverse of `_.toPairs`; this method returns an object composed - * from key-value `pairs`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} pairs The key-value pairs. - * @returns {Object} Returns the new object. - * @example - * - * _.fromPairs([['a', 1], ['b', 2]]); - * // => { 'a': 1, 'b': 2 } - */ - function fromPairs(pairs) { - var index = -1, - length = pairs == null ? 0 : pairs.length, - result = {}; - - while (++index < length) { - var pair = pairs[index]; - result[pair[0]] = pair[1]; - } - return result; - } - - /** - * Gets the first element of `array`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @alias first - * @category Array - * @param {Array} array The array to query. - * @returns {*} Returns the first element of `array`. - * @example - * - * _.head([1, 2, 3]); - * // => 1 - * - * _.head([]); - * // => undefined - */ - function head(array) { - return (array && array.length) ? array[0] : undefined; - } - - /** - * Gets the index at which the first occurrence of `value` is found in `array` - * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons. If `fromIndex` is negative, it's used as the - * offset from the end of `array`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @param {number} [fromIndex=0] The index to search from. - * @returns {number} Returns the index of the matched value, else `-1`. - * @example - * - * _.indexOf([1, 2, 1, 2], 2); - * // => 1 - * - * // Search from the `fromIndex`. - * _.indexOf([1, 2, 1, 2], 2, 2); - * // => 3 - */ - function indexOf(array, value, fromIndex) { - var length = array == null ? 0 : array.length; - if (!length) { - return -1; - } - var index = fromIndex == null ? 0 : toInteger(fromIndex); - if (index < 0) { - index = nativeMax(length + index, 0); - } - return baseIndexOf(array, value, index); - } - - /** - * Gets all but the last element of `array`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to query. - * @returns {Array} Returns the slice of `array`. - * @example - * - * _.initial([1, 2, 3]); - * // => [1, 2] - */ - function initial(array) { - var length = array == null ? 0 : array.length; - return length ? baseSlice(array, 0, -1) : []; - } - - /** - * Creates an array of unique values that are included in all given arrays - * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons. The order and references of result values are - * determined by the first array. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @returns {Array} Returns the new array of intersecting values. - * @example - * - * _.intersection([2, 1], [2, 3]); - * // => [2] - */ - var intersection = baseRest(function(arrays) { - var mapped = arrayMap(arrays, castArrayLikeObject); - return (mapped.length && mapped[0] === arrays[0]) - ? baseIntersection(mapped) - : []; - }); - - /** - * This method is like `_.intersection` except that it accepts `iteratee` - * which is invoked for each element of each `arrays` to generate the criterion - * by which they're compared. The order and references of result values are - * determined by the first array. The iteratee is invoked with one argument: - * (value). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {Array} Returns the new array of intersecting values. - * @example - * - * _.intersectionBy([2.1, 1.2], [2.3, 3.4], Math.floor); - * // => [2.1] - * - * // The `_.property` iteratee shorthand. - * _.intersectionBy([{ 'x': 1 }], [{ 'x': 2 }, { 'x': 1 }], 'x'); - * // => [{ 'x': 1 }] - */ - var intersectionBy = baseRest(function(arrays) { - var iteratee = last(arrays), - mapped = arrayMap(arrays, castArrayLikeObject); - - if (iteratee === last(mapped)) { - iteratee = undefined; - } else { - mapped.pop(); - } - return (mapped.length && mapped[0] === arrays[0]) - ? baseIntersection(mapped, getIteratee(iteratee, 2)) - : []; - }); - - /** - * This method is like `_.intersection` except that it accepts `comparator` - * which is invoked to compare elements of `arrays`. The order and references - * of result values are determined by the first array. The comparator is - * invoked with two arguments: (arrVal, othVal). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new array of intersecting values. - * @example - * - * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]; - * var others = [{ 'x': 1, 'y': 1 }, { 'x': 1, 'y': 2 }]; - * - * _.intersectionWith(objects, others, _.isEqual); - * // => [{ 'x': 1, 'y': 2 }] - */ - var intersectionWith = baseRest(function(arrays) { - var comparator = last(arrays), - mapped = arrayMap(arrays, castArrayLikeObject); - - comparator = typeof comparator == 'function' ? comparator : undefined; - if (comparator) { - mapped.pop(); - } - return (mapped.length && mapped[0] === arrays[0]) - ? baseIntersection(mapped, undefined, comparator) - : []; - }); - - /** - * Converts all elements in `array` into a string separated by `separator`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to convert. - * @param {string} [separator=','] The element separator. - * @returns {string} Returns the joined string. - * @example - * - * _.join(['a', 'b', 'c'], '~'); - * // => 'a~b~c' - */ - function join(array, separator) { - return array == null ? '' : nativeJoin.call(array, separator); - } - - /** - * Gets the last element of `array`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to query. - * @returns {*} Returns the last element of `array`. - * @example - * - * _.last([1, 2, 3]); - * // => 3 - */ - function last(array) { - var length = array == null ? 0 : array.length; - return length ? array[length - 1] : undefined; - } - - /** - * This method is like `_.indexOf` except that it iterates over elements of - * `array` from right to left. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @param {number} [fromIndex=array.length-1] The index to search from. - * @returns {number} Returns the index of the matched value, else `-1`. - * @example - * - * _.lastIndexOf([1, 2, 1, 2], 2); - * // => 3 - * - * // Search from the `fromIndex`. - * _.lastIndexOf([1, 2, 1, 2], 2, 2); - * // => 1 - */ - function lastIndexOf(array, value, fromIndex) { - var length = array == null ? 0 : array.length; - if (!length) { - return -1; - } - var index = length; - if (fromIndex !== undefined) { - index = toInteger(fromIndex); - index = index < 0 ? nativeMax(length + index, 0) : nativeMin(index, length - 1); - } - return value === value - ? strictLastIndexOf(array, value, index) - : baseFindIndex(array, baseIsNaN, index, true); - } - - /** - * Gets the element at index `n` of `array`. If `n` is negative, the nth - * element from the end is returned. - * - * @static - * @memberOf _ - * @since 4.11.0 - * @category Array - * @param {Array} array The array to query. - * @param {number} [n=0] The index of the element to return. - * @returns {*} Returns the nth element of `array`. - * @example - * - * var array = ['a', 'b', 'c', 'd']; - * - * _.nth(array, 1); - * // => 'b' - * - * _.nth(array, -2); - * // => 'c'; - */ - function nth(array, n) { - return (array && array.length) ? baseNth(array, toInteger(n)) : undefined; - } - - /** - * Removes all given values from `array` using - * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons. - * - * **Note:** Unlike `_.without`, this method mutates `array`. Use `_.remove` - * to remove elements from an array by predicate. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Array - * @param {Array} array The array to modify. - * @param {...*} [values] The values to remove. - * @returns {Array} Returns `array`. - * @example - * - * var array = ['a', 'b', 'c', 'a', 'b', 'c']; - * - * _.pull(array, 'a', 'c'); - * console.log(array); - * // => ['b', 'b'] - */ - var pull = baseRest(pullAll); - - /** - * This method is like `_.pull` except that it accepts an array of values to remove. - * - * **Note:** Unlike `_.difference`, this method mutates `array`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to modify. - * @param {Array} values The values to remove. - * @returns {Array} Returns `array`. - * @example - * - * var array = ['a', 'b', 'c', 'a', 'b', 'c']; - * - * _.pullAll(array, ['a', 'c']); - * console.log(array); - * // => ['b', 'b'] - */ - function pullAll(array, values) { - return (array && array.length && values && values.length) - ? basePullAll(array, values) - : array; - } - - /** - * This method is like `_.pullAll` except that it accepts `iteratee` which is - * invoked for each element of `array` and `values` to generate the criterion - * by which they're compared. The iteratee is invoked with one argument: (value). - * - * **Note:** Unlike `_.differenceBy`, this method mutates `array`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to modify. - * @param {Array} values The values to remove. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {Array} Returns `array`. - * @example - * - * var array = [{ 'x': 1 }, { 'x': 2 }, { 'x': 3 }, { 'x': 1 }]; - * - * _.pullAllBy(array, [{ 'x': 1 }, { 'x': 3 }], 'x'); - * console.log(array); - * // => [{ 'x': 2 }] - */ - function pullAllBy(array, values, iteratee) { - return (array && array.length && values && values.length) - ? basePullAll(array, values, getIteratee(iteratee, 2)) - : array; - } - - /** - * This method is like `_.pullAll` except that it accepts `comparator` which - * is invoked to compare elements of `array` to `values`. The comparator is - * invoked with two arguments: (arrVal, othVal). - * - * **Note:** Unlike `_.differenceWith`, this method mutates `array`. - * - * @static - * @memberOf _ - * @since 4.6.0 - * @category Array - * @param {Array} array The array to modify. - * @param {Array} values The values to remove. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns `array`. - * @example - * - * var array = [{ 'x': 1, 'y': 2 }, { 'x': 3, 'y': 4 }, { 'x': 5, 'y': 6 }]; - * - * _.pullAllWith(array, [{ 'x': 3, 'y': 4 }], _.isEqual); - * console.log(array); - * // => [{ 'x': 1, 'y': 2 }, { 'x': 5, 'y': 6 }] - */ - function pullAllWith(array, values, comparator) { - return (array && array.length && values && values.length) - ? basePullAll(array, values, undefined, comparator) - : array; - } - - /** - * Removes elements from `array` corresponding to `indexes` and returns an - * array of removed elements. - * - * **Note:** Unlike `_.at`, this method mutates `array`. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to modify. - * @param {...(number|number[])} [indexes] The indexes of elements to remove. - * @returns {Array} Returns the new array of removed elements. - * @example - * - * var array = ['a', 'b', 'c', 'd']; - * var pulled = _.pullAt(array, [1, 3]); - * - * console.log(array); - * // => ['a', 'c'] - * - * console.log(pulled); - * // => ['b', 'd'] - */ - var pullAt = flatRest(function(array, indexes) { - var length = array == null ? 0 : array.length, - result = baseAt(array, indexes); - - basePullAt(array, arrayMap(indexes, function(index) { - return isIndex(index, length) ? +index : index; - }).sort(compareAscending)); - - return result; - }); - - /** - * Removes all elements from `array` that `predicate` returns truthy for - * and returns an array of the removed elements. The predicate is invoked - * with three arguments: (value, index, array). - * - * **Note:** Unlike `_.filter`, this method mutates `array`. Use `_.pull` - * to pull elements from an array by value. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Array - * @param {Array} array The array to modify. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the new array of removed elements. - * @example - * - * var array = [1, 2, 3, 4]; - * var evens = _.remove(array, function(n) { - * return n % 2 == 0; - * }); - * - * console.log(array); - * // => [1, 3] - * - * console.log(evens); - * // => [2, 4] - */ - function remove(array, predicate) { - var result = []; - if (!(array && array.length)) { - return result; - } - var index = -1, - indexes = [], - length = array.length; - - predicate = getIteratee(predicate, 3); - while (++index < length) { - var value = array[index]; - if (predicate(value, index, array)) { - result.push(value); - indexes.push(index); - } - } - basePullAt(array, indexes); - return result; - } - - /** - * Reverses `array` so that the first element becomes the last, the second - * element becomes the second to last, and so on. - * - * **Note:** This method mutates `array` and is based on - * [`Array#reverse`](https://mdn.io/Array/reverse). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to modify. - * @returns {Array} Returns `array`. - * @example - * - * var array = [1, 2, 3]; - * - * _.reverse(array); - * // => [3, 2, 1] - * - * console.log(array); - * // => [3, 2, 1] - */ - function reverse(array) { - return array == null ? array : nativeReverse.call(array); - } - - /** - * Creates a slice of `array` from `start` up to, but not including, `end`. - * - * **Note:** This method is used instead of - * [`Array#slice`](https://mdn.io/Array/slice) to ensure dense arrays are - * returned. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to slice. - * @param {number} [start=0] The start position. - * @param {number} [end=array.length] The end position. - * @returns {Array} Returns the slice of `array`. - */ - function slice(array, start, end) { - var length = array == null ? 0 : array.length; - if (!length) { - return []; - } - if (end && typeof end != 'number' && isIterateeCall(array, start, end)) { - start = 0; - end = length; - } - else { - start = start == null ? 0 : toInteger(start); - end = end === undefined ? length : toInteger(end); - } - return baseSlice(array, start, end); - } - - /** - * Uses a binary search to determine the lowest index at which `value` - * should be inserted into `array` in order to maintain its sort order. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The sorted array to inspect. - * @param {*} value The value to evaluate. - * @returns {number} Returns the index at which `value` should be inserted - * into `array`. - * @example - * - * _.sortedIndex([30, 50], 40); - * // => 1 - */ - function sortedIndex(array, value) { - return baseSortedIndex(array, value); - } - - /** - * This method is like `_.sortedIndex` except that it accepts `iteratee` - * which is invoked for `value` and each element of `array` to compute their - * sort ranking. The iteratee is invoked with one argument: (value). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The sorted array to inspect. - * @param {*} value The value to evaluate. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {number} Returns the index at which `value` should be inserted - * into `array`. - * @example - * - * var objects = [{ 'x': 4 }, { 'x': 5 }]; - * - * _.sortedIndexBy(objects, { 'x': 4 }, function(o) { return o.x; }); - * // => 0 - * - * // The `_.property` iteratee shorthand. - * _.sortedIndexBy(objects, { 'x': 4 }, 'x'); - * // => 0 - */ - function sortedIndexBy(array, value, iteratee) { - return baseSortedIndexBy(array, value, getIteratee(iteratee, 2)); - } - - /** - * This method is like `_.indexOf` except that it performs a binary - * search on a sorted `array`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @returns {number} Returns the index of the matched value, else `-1`. - * @example - * - * _.sortedIndexOf([4, 5, 5, 5, 6], 5); - * // => 1 - */ - function sortedIndexOf(array, value) { - var length = array == null ? 0 : array.length; - if (length) { - var index = baseSortedIndex(array, value); - if (index < length && eq(array[index], value)) { - return index; - } - } - return -1; - } - - /** - * This method is like `_.sortedIndex` except that it returns the highest - * index at which `value` should be inserted into `array` in order to - * maintain its sort order. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The sorted array to inspect. - * @param {*} value The value to evaluate. - * @returns {number} Returns the index at which `value` should be inserted - * into `array`. - * @example - * - * _.sortedLastIndex([4, 5, 5, 5, 6], 5); - * // => 4 - */ - function sortedLastIndex(array, value) { - return baseSortedIndex(array, value, true); - } - - /** - * This method is like `_.sortedLastIndex` except that it accepts `iteratee` - * which is invoked for `value` and each element of `array` to compute their - * sort ranking. The iteratee is invoked with one argument: (value). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The sorted array to inspect. - * @param {*} value The value to evaluate. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {number} Returns the index at which `value` should be inserted - * into `array`. - * @example - * - * var objects = [{ 'x': 4 }, { 'x': 5 }]; - * - * _.sortedLastIndexBy(objects, { 'x': 4 }, function(o) { return o.x; }); - * // => 1 - * - * // The `_.property` iteratee shorthand. - * _.sortedLastIndexBy(objects, { 'x': 4 }, 'x'); - * // => 1 - */ - function sortedLastIndexBy(array, value, iteratee) { - return baseSortedIndexBy(array, value, getIteratee(iteratee, 2), true); - } - - /** - * This method is like `_.lastIndexOf` except that it performs a binary - * search on a sorted `array`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {*} value The value to search for. - * @returns {number} Returns the index of the matched value, else `-1`. - * @example - * - * _.sortedLastIndexOf([4, 5, 5, 5, 6], 5); - * // => 3 - */ - function sortedLastIndexOf(array, value) { - var length = array == null ? 0 : array.length; - if (length) { - var index = baseSortedIndex(array, value, true) - 1; - if (eq(array[index], value)) { - return index; - } - } - return -1; - } - - /** - * This method is like `_.uniq` except that it's designed and optimized - * for sorted arrays. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @returns {Array} Returns the new duplicate free array. - * @example - * - * _.sortedUniq([1, 1, 2]); - * // => [1, 2] - */ - function sortedUniq(array) { - return (array && array.length) - ? baseSortedUniq(array) - : []; - } - - /** - * This method is like `_.uniqBy` except that it's designed and optimized - * for sorted arrays. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {Function} [iteratee] The iteratee invoked per element. - * @returns {Array} Returns the new duplicate free array. - * @example - * - * _.sortedUniqBy([1.1, 1.2, 2.3, 2.4], Math.floor); - * // => [1.1, 2.3] - */ - function sortedUniqBy(array, iteratee) { - return (array && array.length) - ? baseSortedUniq(array, getIteratee(iteratee, 2)) - : []; - } - - /** - * Gets all but the first element of `array`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to query. - * @returns {Array} Returns the slice of `array`. - * @example - * - * _.tail([1, 2, 3]); - * // => [2, 3] - */ - function tail(array) { - var length = array == null ? 0 : array.length; - return length ? baseSlice(array, 1, length) : []; - } - - /** - * Creates a slice of `array` with `n` elements taken from the beginning. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to query. - * @param {number} [n=1] The number of elements to take. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Array} Returns the slice of `array`. - * @example - * - * _.take([1, 2, 3]); - * // => [1] - * - * _.take([1, 2, 3], 2); - * // => [1, 2] - * - * _.take([1, 2, 3], 5); - * // => [1, 2, 3] - * - * _.take([1, 2, 3], 0); - * // => [] - */ - function take(array, n, guard) { - if (!(array && array.length)) { - return []; - } - n = (guard || n === undefined) ? 1 : toInteger(n); - return baseSlice(array, 0, n < 0 ? 0 : n); - } - - /** - * Creates a slice of `array` with `n` elements taken from the end. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to query. - * @param {number} [n=1] The number of elements to take. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Array} Returns the slice of `array`. - * @example - * - * _.takeRight([1, 2, 3]); - * // => [3] - * - * _.takeRight([1, 2, 3], 2); - * // => [2, 3] - * - * _.takeRight([1, 2, 3], 5); - * // => [1, 2, 3] - * - * _.takeRight([1, 2, 3], 0); - * // => [] - */ - function takeRight(array, n, guard) { - var length = array == null ? 0 : array.length; - if (!length) { - return []; - } - n = (guard || n === undefined) ? 1 : toInteger(n); - n = length - n; - return baseSlice(array, n < 0 ? 0 : n, length); - } - - /** - * Creates a slice of `array` with elements taken from the end. Elements are - * taken until `predicate` returns falsey. The predicate is invoked with - * three arguments: (value, index, array). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to query. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the slice of `array`. - * @example - * - * var users = [ - * { 'user': 'barney', 'active': true }, - * { 'user': 'fred', 'active': false }, - * { 'user': 'pebbles', 'active': false } - * ]; - * - * _.takeRightWhile(users, function(o) { return !o.active; }); - * // => objects for ['fred', 'pebbles'] - * - * // The `_.matches` iteratee shorthand. - * _.takeRightWhile(users, { 'user': 'pebbles', 'active': false }); - * // => objects for ['pebbles'] - * - * // The `_.matchesProperty` iteratee shorthand. - * _.takeRightWhile(users, ['active', false]); - * // => objects for ['fred', 'pebbles'] - * - * // The `_.property` iteratee shorthand. - * _.takeRightWhile(users, 'active'); - * // => [] - */ - function takeRightWhile(array, predicate) { - return (array && array.length) - ? baseWhile(array, getIteratee(predicate, 3), false, true) - : []; - } - - /** - * Creates a slice of `array` with elements taken from the beginning. Elements - * are taken until `predicate` returns falsey. The predicate is invoked with - * three arguments: (value, index, array). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Array - * @param {Array} array The array to query. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the slice of `array`. - * @example - * - * var users = [ - * { 'user': 'barney', 'active': false }, - * { 'user': 'fred', 'active': false }, - * { 'user': 'pebbles', 'active': true } - * ]; - * - * _.takeWhile(users, function(o) { return !o.active; }); - * // => objects for ['barney', 'fred'] - * - * // The `_.matches` iteratee shorthand. - * _.takeWhile(users, { 'user': 'barney', 'active': false }); - * // => objects for ['barney'] - * - * // The `_.matchesProperty` iteratee shorthand. - * _.takeWhile(users, ['active', false]); - * // => objects for ['barney', 'fred'] - * - * // The `_.property` iteratee shorthand. - * _.takeWhile(users, 'active'); - * // => [] - */ - function takeWhile(array, predicate) { - return (array && array.length) - ? baseWhile(array, getIteratee(predicate, 3)) - : []; - } - - /** - * Creates an array of unique values, in order, from all given arrays using - * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @returns {Array} Returns the new array of combined values. - * @example - * - * _.union([2], [1, 2]); - * // => [2, 1] - */ - var union = baseRest(function(arrays) { - return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject, true)); - }); - - /** - * This method is like `_.union` except that it accepts `iteratee` which is - * invoked for each element of each `arrays` to generate the criterion by - * which uniqueness is computed. Result values are chosen from the first - * array in which the value occurs. The iteratee is invoked with one argument: - * (value). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {Array} Returns the new array of combined values. - * @example - * - * _.unionBy([2.1], [1.2, 2.3], Math.floor); - * // => [2.1, 1.2] - * - * // The `_.property` iteratee shorthand. - * _.unionBy([{ 'x': 1 }], [{ 'x': 2 }, { 'x': 1 }], 'x'); - * // => [{ 'x': 1 }, { 'x': 2 }] - */ - var unionBy = baseRest(function(arrays) { - var iteratee = last(arrays); - if (isArrayLikeObject(iteratee)) { - iteratee = undefined; - } - return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject, true), getIteratee(iteratee, 2)); - }); - - /** - * This method is like `_.union` except that it accepts `comparator` which - * is invoked to compare elements of `arrays`. Result values are chosen from - * the first array in which the value occurs. The comparator is invoked - * with two arguments: (arrVal, othVal). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new array of combined values. - * @example - * - * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]; - * var others = [{ 'x': 1, 'y': 1 }, { 'x': 1, 'y': 2 }]; - * - * _.unionWith(objects, others, _.isEqual); - * // => [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }, { 'x': 1, 'y': 1 }] - */ - var unionWith = baseRest(function(arrays) { - var comparator = last(arrays); - comparator = typeof comparator == 'function' ? comparator : undefined; - return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject, true), undefined, comparator); - }); - - /** - * Creates a duplicate-free version of an array, using - * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons, in which only the first occurrence of each element - * is kept. The order of result values is determined by the order they occur - * in the array. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to inspect. - * @returns {Array} Returns the new duplicate free array. - * @example - * - * _.uniq([2, 1, 2]); - * // => [2, 1] - */ - function uniq(array) { - return (array && array.length) ? baseUniq(array) : []; - } - - /** - * This method is like `_.uniq` except that it accepts `iteratee` which is - * invoked for each element in `array` to generate the criterion by which - * uniqueness is computed. The order of result values is determined by the - * order they occur in the array. The iteratee is invoked with one argument: - * (value). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {Array} Returns the new duplicate free array. - * @example - * - * _.uniqBy([2.1, 1.2, 2.3], Math.floor); - * // => [2.1, 1.2] - * - * // The `_.property` iteratee shorthand. - * _.uniqBy([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x'); - * // => [{ 'x': 1 }, { 'x': 2 }] - */ - function uniqBy(array, iteratee) { - return (array && array.length) ? baseUniq(array, getIteratee(iteratee, 2)) : []; - } - - /** - * This method is like `_.uniq` except that it accepts `comparator` which - * is invoked to compare elements of `array`. The order of result values is - * determined by the order they occur in the array.The comparator is invoked - * with two arguments: (arrVal, othVal). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new duplicate free array. - * @example - * - * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }, { 'x': 1, 'y': 2 }]; - * - * _.uniqWith(objects, _.isEqual); - * // => [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }] - */ - function uniqWith(array, comparator) { - comparator = typeof comparator == 'function' ? comparator : undefined; - return (array && array.length) ? baseUniq(array, undefined, comparator) : []; - } - - /** - * This method is like `_.zip` except that it accepts an array of grouped - * elements and creates an array regrouping the elements to their pre-zip - * configuration. - * - * @static - * @memberOf _ - * @since 1.2.0 - * @category Array - * @param {Array} array The array of grouped elements to process. - * @returns {Array} Returns the new array of regrouped elements. - * @example - * - * var zipped = _.zip(['a', 'b'], [1, 2], [true, false]); - * // => [['a', 1, true], ['b', 2, false]] - * - * _.unzip(zipped); - * // => [['a', 'b'], [1, 2], [true, false]] - */ - function unzip(array) { - if (!(array && array.length)) { - return []; - } - var length = 0; - array = arrayFilter(array, function(group) { - if (isArrayLikeObject(group)) { - length = nativeMax(group.length, length); - return true; - } - }); - return baseTimes(length, function(index) { - return arrayMap(array, baseProperty(index)); - }); - } - - /** - * This method is like `_.unzip` except that it accepts `iteratee` to specify - * how regrouped values should be combined. The iteratee is invoked with the - * elements of each group: (...group). - * - * @static - * @memberOf _ - * @since 3.8.0 - * @category Array - * @param {Array} array The array of grouped elements to process. - * @param {Function} [iteratee=_.identity] The function to combine - * regrouped values. - * @returns {Array} Returns the new array of regrouped elements. - * @example - * - * var zipped = _.zip([1, 2], [10, 20], [100, 200]); - * // => [[1, 10, 100], [2, 20, 200]] - * - * _.unzipWith(zipped, _.add); - * // => [3, 30, 300] - */ - function unzipWith(array, iteratee) { - if (!(array && array.length)) { - return []; - } - var result = unzip(array); - if (iteratee == null) { - return result; - } - return arrayMap(result, function(group) { - return apply(iteratee, undefined, group); - }); - } - - /** - * Creates an array excluding all given values using - * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * for equality comparisons. - * - * **Note:** Unlike `_.pull`, this method returns a new array. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {Array} array The array to inspect. - * @param {...*} [values] The values to exclude. - * @returns {Array} Returns the new array of filtered values. - * @see _.difference, _.xor - * @example - * - * _.without([2, 1, 2, 3], 1, 2); - * // => [3] - */ - var without = baseRest(function(array, values) { - return isArrayLikeObject(array) - ? baseDifference(array, values) - : []; - }); - - /** - * Creates an array of unique values that is the - * [symmetric difference](https://en.wikipedia.org/wiki/Symmetric_difference) - * of the given arrays. The order of result values is determined by the order - * they occur in the arrays. - * - * @static - * @memberOf _ - * @since 2.4.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @returns {Array} Returns the new array of filtered values. - * @see _.difference, _.without - * @example - * - * _.xor([2, 1], [2, 3]); - * // => [1, 3] - */ - var xor = baseRest(function(arrays) { - return baseXor(arrayFilter(arrays, isArrayLikeObject)); - }); - - /** - * This method is like `_.xor` except that it accepts `iteratee` which is - * invoked for each element of each `arrays` to generate the criterion by - * which by which they're compared. The order of result values is determined - * by the order they occur in the arrays. The iteratee is invoked with one - * argument: (value). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {Array} Returns the new array of filtered values. - * @example - * - * _.xorBy([2.1, 1.2], [2.3, 3.4], Math.floor); - * // => [1.2, 3.4] - * - * // The `_.property` iteratee shorthand. - * _.xorBy([{ 'x': 1 }], [{ 'x': 2 }, { 'x': 1 }], 'x'); - * // => [{ 'x': 2 }] - */ - var xorBy = baseRest(function(arrays) { - var iteratee = last(arrays); - if (isArrayLikeObject(iteratee)) { - iteratee = undefined; - } - return baseXor(arrayFilter(arrays, isArrayLikeObject), getIteratee(iteratee, 2)); - }); - - /** - * This method is like `_.xor` except that it accepts `comparator` which is - * invoked to compare elements of `arrays`. The order of result values is - * determined by the order they occur in the arrays. The comparator is invoked - * with two arguments: (arrVal, othVal). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Array - * @param {...Array} [arrays] The arrays to inspect. - * @param {Function} [comparator] The comparator invoked per element. - * @returns {Array} Returns the new array of filtered values. - * @example - * - * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]; - * var others = [{ 'x': 1, 'y': 1 }, { 'x': 1, 'y': 2 }]; - * - * _.xorWith(objects, others, _.isEqual); - * // => [{ 'x': 2, 'y': 1 }, { 'x': 1, 'y': 1 }] - */ - var xorWith = baseRest(function(arrays) { - var comparator = last(arrays); - comparator = typeof comparator == 'function' ? comparator : undefined; - return baseXor(arrayFilter(arrays, isArrayLikeObject), undefined, comparator); - }); - - /** - * Creates an array of grouped elements, the first of which contains the - * first elements of the given arrays, the second of which contains the - * second elements of the given arrays, and so on. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Array - * @param {...Array} [arrays] The arrays to process. - * @returns {Array} Returns the new array of grouped elements. - * @example - * - * _.zip(['a', 'b'], [1, 2], [true, false]); - * // => [['a', 1, true], ['b', 2, false]] - */ - var zip = baseRest(unzip); - - /** - * This method is like `_.fromPairs` except that it accepts two arrays, - * one of property identifiers and one of corresponding values. - * - * @static - * @memberOf _ - * @since 0.4.0 - * @category Array - * @param {Array} [props=[]] The property identifiers. - * @param {Array} [values=[]] The property values. - * @returns {Object} Returns the new object. - * @example - * - * _.zipObject(['a', 'b'], [1, 2]); - * // => { 'a': 1, 'b': 2 } - */ - function zipObject(props, values) { - return baseZipObject(props || [], values || [], assignValue); - } - - /** - * This method is like `_.zipObject` except that it supports property paths. - * - * @static - * @memberOf _ - * @since 4.1.0 - * @category Array - * @param {Array} [props=[]] The property identifiers. - * @param {Array} [values=[]] The property values. - * @returns {Object} Returns the new object. - * @example - * - * _.zipObjectDeep(['a.b[0].c', 'a.b[1].d'], [1, 2]); - * // => { 'a': { 'b': [{ 'c': 1 }, { 'd': 2 }] } } - */ - function zipObjectDeep(props, values) { - return baseZipObject(props || [], values || [], baseSet); - } - - /** - * This method is like `_.zip` except that it accepts `iteratee` to specify - * how grouped values should be combined. The iteratee is invoked with the - * elements of each group: (...group). - * - * @static - * @memberOf _ - * @since 3.8.0 - * @category Array - * @param {...Array} [arrays] The arrays to process. - * @param {Function} [iteratee=_.identity] The function to combine - * grouped values. - * @returns {Array} Returns the new array of grouped elements. - * @example - * - * _.zipWith([1, 2], [10, 20], [100, 200], function(a, b, c) { - * return a + b + c; - * }); - * // => [111, 222] - */ - var zipWith = baseRest(function(arrays) { - var length = arrays.length, - iteratee = length > 1 ? arrays[length - 1] : undefined; - - iteratee = typeof iteratee == 'function' ? (arrays.pop(), iteratee) : undefined; - return unzipWith(arrays, iteratee); - }); - - /*------------------------------------------------------------------------*/ - - /** - * Creates a `lodash` wrapper instance that wraps `value` with explicit method - * chain sequences enabled. The result of such sequences must be unwrapped - * with `_#value`. - * - * @static - * @memberOf _ - * @since 1.3.0 - * @category Seq - * @param {*} value The value to wrap. - * @returns {Object} Returns the new `lodash` wrapper instance. - * @example - * - * var users = [ - * { 'user': 'barney', 'age': 36 }, - * { 'user': 'fred', 'age': 40 }, - * { 'user': 'pebbles', 'age': 1 } - * ]; - * - * var youngest = _ - * .chain(users) - * .sortBy('age') - * .map(function(o) { - * return o.user + ' is ' + o.age; - * }) - * .head() - * .value(); - * // => 'pebbles is 1' - */ - function chain(value) { - var result = lodash(value); - result.__chain__ = true; - return result; - } - - /** - * This method invokes `interceptor` and returns `value`. The interceptor - * is invoked with one argument; (value). The purpose of this method is to - * "tap into" a method chain sequence in order to modify intermediate results. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Seq - * @param {*} value The value to provide to `interceptor`. - * @param {Function} interceptor The function to invoke. - * @returns {*} Returns `value`. - * @example - * - * _([1, 2, 3]) - * .tap(function(array) { - * // Mutate input array. - * array.pop(); - * }) - * .reverse() - * .value(); - * // => [2, 1] - */ - function tap(value, interceptor) { - interceptor(value); - return value; - } - - /** - * This method is like `_.tap` except that it returns the result of `interceptor`. - * The purpose of this method is to "pass thru" values replacing intermediate - * results in a method chain sequence. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Seq - * @param {*} value The value to provide to `interceptor`. - * @param {Function} interceptor The function to invoke. - * @returns {*} Returns the result of `interceptor`. - * @example - * - * _(' abc ') - * .chain() - * .trim() - * .thru(function(value) { - * return [value]; - * }) - * .value(); - * // => ['abc'] - */ - function thru(value, interceptor) { - return interceptor(value); - } - - /** - * This method is the wrapper version of `_.at`. - * - * @name at - * @memberOf _ - * @since 1.0.0 - * @category Seq - * @param {...(string|string[])} [paths] The property paths to pick. - * @returns {Object} Returns the new `lodash` wrapper instance. - * @example - * - * var object = { 'a': [{ 'b': { 'c': 3 } }, 4] }; - * - * _(object).at(['a[0].b.c', 'a[1]']).value(); - * // => [3, 4] - */ - var wrapperAt = flatRest(function(paths) { - var length = paths.length, - start = length ? paths[0] : 0, - value = this.__wrapped__, - interceptor = function(object) { return baseAt(object, paths); }; - - if (length > 1 || this.__actions__.length || - !(value instanceof LazyWrapper) || !isIndex(start)) { - return this.thru(interceptor); - } - value = value.slice(start, +start + (length ? 1 : 0)); - value.__actions__.push({ - 'func': thru, - 'args': [interceptor], - 'thisArg': undefined - }); - return new LodashWrapper(value, this.__chain__).thru(function(array) { - if (length && !array.length) { - array.push(undefined); - } - return array; - }); - }); - - /** - * Creates a `lodash` wrapper instance with explicit method chain sequences enabled. - * - * @name chain - * @memberOf _ - * @since 0.1.0 - * @category Seq - * @returns {Object} Returns the new `lodash` wrapper instance. - * @example - * - * var users = [ - * { 'user': 'barney', 'age': 36 }, - * { 'user': 'fred', 'age': 40 } - * ]; - * - * // A sequence without explicit chaining. - * _(users).head(); - * // => { 'user': 'barney', 'age': 36 } - * - * // A sequence with explicit chaining. - * _(users) - * .chain() - * .head() - * .pick('user') - * .value(); - * // => { 'user': 'barney' } - */ - function wrapperChain() { - return chain(this); - } - - /** - * Executes the chain sequence and returns the wrapped result. - * - * @name commit - * @memberOf _ - * @since 3.2.0 - * @category Seq - * @returns {Object} Returns the new `lodash` wrapper instance. - * @example - * - * var array = [1, 2]; - * var wrapped = _(array).push(3); - * - * console.log(array); - * // => [1, 2] - * - * wrapped = wrapped.commit(); - * console.log(array); - * // => [1, 2, 3] - * - * wrapped.last(); - * // => 3 - * - * console.log(array); - * // => [1, 2, 3] - */ - function wrapperCommit() { - return new LodashWrapper(this.value(), this.__chain__); - } - - /** - * Gets the next value on a wrapped object following the - * [iterator protocol](https://mdn.io/iteration_protocols#iterator). - * - * @name next - * @memberOf _ - * @since 4.0.0 - * @category Seq - * @returns {Object} Returns the next iterator value. - * @example - * - * var wrapped = _([1, 2]); - * - * wrapped.next(); - * // => { 'done': false, 'value': 1 } - * - * wrapped.next(); - * // => { 'done': false, 'value': 2 } - * - * wrapped.next(); - * // => { 'done': true, 'value': undefined } - */ - function wrapperNext() { - if (this.__values__ === undefined) { - this.__values__ = toArray(this.value()); - } - var done = this.__index__ >= this.__values__.length, - value = done ? undefined : this.__values__[this.__index__++]; - - return { 'done': done, 'value': value }; - } - - /** - * Enables the wrapper to be iterable. - * - * @name Symbol.iterator - * @memberOf _ - * @since 4.0.0 - * @category Seq - * @returns {Object} Returns the wrapper object. - * @example - * - * var wrapped = _([1, 2]); - * - * wrapped[Symbol.iterator]() === wrapped; - * // => true - * - * Array.from(wrapped); - * // => [1, 2] - */ - function wrapperToIterator() { - return this; - } - - /** - * Creates a clone of the chain sequence planting `value` as the wrapped value. - * - * @name plant - * @memberOf _ - * @since 3.2.0 - * @category Seq - * @param {*} value The value to plant. - * @returns {Object} Returns the new `lodash` wrapper instance. - * @example - * - * function square(n) { - * return n * n; - * } - * - * var wrapped = _([1, 2]).map(square); - * var other = wrapped.plant([3, 4]); - * - * other.value(); - * // => [9, 16] - * - * wrapped.value(); - * // => [1, 4] - */ - function wrapperPlant(value) { - var result, - parent = this; - - while (parent instanceof baseLodash) { - var clone = wrapperClone(parent); - clone.__index__ = 0; - clone.__values__ = undefined; - if (result) { - previous.__wrapped__ = clone; - } else { - result = clone; - } - var previous = clone; - parent = parent.__wrapped__; - } - previous.__wrapped__ = value; - return result; - } - - /** - * This method is the wrapper version of `_.reverse`. - * - * **Note:** This method mutates the wrapped array. - * - * @name reverse - * @memberOf _ - * @since 0.1.0 - * @category Seq - * @returns {Object} Returns the new `lodash` wrapper instance. - * @example - * - * var array = [1, 2, 3]; - * - * _(array).reverse().value() - * // => [3, 2, 1] - * - * console.log(array); - * // => [3, 2, 1] - */ - function wrapperReverse() { - var value = this.__wrapped__; - if (value instanceof LazyWrapper) { - var wrapped = value; - if (this.__actions__.length) { - wrapped = new LazyWrapper(this); - } - wrapped = wrapped.reverse(); - wrapped.__actions__.push({ - 'func': thru, - 'args': [reverse], - 'thisArg': undefined - }); - return new LodashWrapper(wrapped, this.__chain__); - } - return this.thru(reverse); - } - - /** - * Executes the chain sequence to resolve the unwrapped value. - * - * @name value - * @memberOf _ - * @since 0.1.0 - * @alias toJSON, valueOf - * @category Seq - * @returns {*} Returns the resolved unwrapped value. - * @example - * - * _([1, 2, 3]).value(); - * // => [1, 2, 3] - */ - function wrapperValue() { - return baseWrapperValue(this.__wrapped__, this.__actions__); - } - - /*------------------------------------------------------------------------*/ - - /** - * Creates an object composed of keys generated from the results of running - * each element of `collection` thru `iteratee`. The corresponding value of - * each key is the number of times the key was returned by `iteratee`. The - * iteratee is invoked with one argument: (value). - * - * @static - * @memberOf _ - * @since 0.5.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The iteratee to transform keys. - * @returns {Object} Returns the composed aggregate object. - * @example - * - * _.countBy([6.1, 4.2, 6.3], Math.floor); - * // => { '4': 1, '6': 2 } - * - * // The `_.property` iteratee shorthand. - * _.countBy(['one', 'two', 'three'], 'length'); - * // => { '3': 2, '5': 1 } - */ - var countBy = createAggregator(function(result, value, key) { - if (hasOwnProperty.call(result, key)) { - ++result[key]; - } else { - baseAssignValue(result, key, 1); - } - }); - - /** - * Checks if `predicate` returns truthy for **all** elements of `collection`. - * Iteration is stopped once `predicate` returns falsey. The predicate is - * invoked with three arguments: (value, index|key, collection). - * - * **Note:** This method returns `true` for - * [empty collections](https://en.wikipedia.org/wiki/Empty_set) because - * [everything is true](https://en.wikipedia.org/wiki/Vacuous_truth) of - * elements of empty collections. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {boolean} Returns `true` if all elements pass the predicate check, - * else `false`. - * @example - * - * _.every([true, 1, null, 'yes'], Boolean); - * // => false - * - * var users = [ - * { 'user': 'barney', 'age': 36, 'active': false }, - * { 'user': 'fred', 'age': 40, 'active': false } - * ]; - * - * // The `_.matches` iteratee shorthand. - * _.every(users, { 'user': 'barney', 'active': false }); - * // => false - * - * // The `_.matchesProperty` iteratee shorthand. - * _.every(users, ['active', false]); - * // => true - * - * // The `_.property` iteratee shorthand. - * _.every(users, 'active'); - * // => false - */ - function every(collection, predicate, guard) { - var func = isArray(collection) ? arrayEvery : baseEvery; - if (guard && isIterateeCall(collection, predicate, guard)) { - predicate = undefined; - } - return func(collection, getIteratee(predicate, 3)); - } - - /** - * Iterates over elements of `collection`, returning an array of all elements - * `predicate` returns truthy for. The predicate is invoked with three - * arguments: (value, index|key, collection). - * - * **Note:** Unlike `_.remove`, this method returns a new array. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the new filtered array. - * @see _.reject - * @example - * - * var users = [ - * { 'user': 'barney', 'age': 36, 'active': true }, - * { 'user': 'fred', 'age': 40, 'active': false } - * ]; - * - * _.filter(users, function(o) { return !o.active; }); - * // => objects for ['fred'] - * - * // The `_.matches` iteratee shorthand. - * _.filter(users, { 'age': 36, 'active': true }); - * // => objects for ['barney'] - * - * // The `_.matchesProperty` iteratee shorthand. - * _.filter(users, ['active', false]); - * // => objects for ['fred'] - * - * // The `_.property` iteratee shorthand. - * _.filter(users, 'active'); - * // => objects for ['barney'] - */ - function filter(collection, predicate) { - var func = isArray(collection) ? arrayFilter : baseFilter; - return func(collection, getIteratee(predicate, 3)); - } - - /** - * Iterates over elements of `collection`, returning the first element - * `predicate` returns truthy for. The predicate is invoked with three - * arguments: (value, index|key, collection). - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to inspect. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @param {number} [fromIndex=0] The index to search from. - * @returns {*} Returns the matched element, else `undefined`. - * @example - * - * var users = [ - * { 'user': 'barney', 'age': 36, 'active': true }, - * { 'user': 'fred', 'age': 40, 'active': false }, - * { 'user': 'pebbles', 'age': 1, 'active': true } - * ]; - * - * _.find(users, function(o) { return o.age < 40; }); - * // => object for 'barney' - * - * // The `_.matches` iteratee shorthand. - * _.find(users, { 'age': 1, 'active': true }); - * // => object for 'pebbles' - * - * // The `_.matchesProperty` iteratee shorthand. - * _.find(users, ['active', false]); - * // => object for 'fred' - * - * // The `_.property` iteratee shorthand. - * _.find(users, 'active'); - * // => object for 'barney' - */ - var find = createFind(findIndex); - - /** - * This method is like `_.find` except that it iterates over elements of - * `collection` from right to left. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Collection - * @param {Array|Object} collection The collection to inspect. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @param {number} [fromIndex=collection.length-1] The index to search from. - * @returns {*} Returns the matched element, else `undefined`. - * @example - * - * _.findLast([1, 2, 3, 4], function(n) { - * return n % 2 == 1; - * }); - * // => 3 - */ - var findLast = createFind(findLastIndex); - - /** - * Creates a flattened array of values by running each element in `collection` - * thru `iteratee` and flattening the mapped results. The iteratee is invoked - * with three arguments: (value, index|key, collection). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Array} Returns the new flattened array. - * @example - * - * function duplicate(n) { - * return [n, n]; - * } - * - * _.flatMap([1, 2], duplicate); - * // => [1, 1, 2, 2] - */ - function flatMap(collection, iteratee) { - return baseFlatten(map(collection, iteratee), 1); - } - - /** - * This method is like `_.flatMap` except that it recursively flattens the - * mapped results. - * - * @static - * @memberOf _ - * @since 4.7.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Array} Returns the new flattened array. - * @example - * - * function duplicate(n) { - * return [[[n, n]]]; - * } - * - * _.flatMapDeep([1, 2], duplicate); - * // => [1, 1, 2, 2] - */ - function flatMapDeep(collection, iteratee) { - return baseFlatten(map(collection, iteratee), INFINITY); - } - - /** - * This method is like `_.flatMap` except that it recursively flattens the - * mapped results up to `depth` times. - * - * @static - * @memberOf _ - * @since 4.7.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @param {number} [depth=1] The maximum recursion depth. - * @returns {Array} Returns the new flattened array. - * @example - * - * function duplicate(n) { - * return [[[n, n]]]; - * } - * - * _.flatMapDepth([1, 2], duplicate, 2); - * // => [[1, 1], [2, 2]] - */ - function flatMapDepth(collection, iteratee, depth) { - depth = depth === undefined ? 1 : toInteger(depth); - return baseFlatten(map(collection, iteratee), depth); - } - - /** - * Iterates over elements of `collection` and invokes `iteratee` for each element. - * The iteratee is invoked with three arguments: (value, index|key, collection). - * Iteratee functions may exit iteration early by explicitly returning `false`. - * - * **Note:** As with other "Collections" methods, objects with a "length" - * property are iterated like arrays. To avoid this behavior use `_.forIn` - * or `_.forOwn` for object iteration. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @alias each - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Array|Object} Returns `collection`. - * @see _.forEachRight - * @example - * - * _.forEach([1, 2], function(value) { - * console.log(value); - * }); - * // => Logs `1` then `2`. - * - * _.forEach({ 'a': 1, 'b': 2 }, function(value, key) { - * console.log(key); - * }); - * // => Logs 'a' then 'b' (iteration order is not guaranteed). - */ - function forEach(collection, iteratee) { - var func = isArray(collection) ? arrayEach : baseEach; - return func(collection, getIteratee(iteratee, 3)); - } - - /** - * This method is like `_.forEach` except that it iterates over elements of - * `collection` from right to left. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @alias eachRight - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Array|Object} Returns `collection`. - * @see _.forEach - * @example - * - * _.forEachRight([1, 2], function(value) { - * console.log(value); - * }); - * // => Logs `2` then `1`. - */ - function forEachRight(collection, iteratee) { - var func = isArray(collection) ? arrayEachRight : baseEachRight; - return func(collection, getIteratee(iteratee, 3)); - } - - /** - * Creates an object composed of keys generated from the results of running - * each element of `collection` thru `iteratee`. The order of grouped values - * is determined by the order they occur in `collection`. The corresponding - * value of each key is an array of elements responsible for generating the - * key. The iteratee is invoked with one argument: (value). - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The iteratee to transform keys. - * @returns {Object} Returns the composed aggregate object. - * @example - * - * _.groupBy([6.1, 4.2, 6.3], Math.floor); - * // => { '4': [4.2], '6': [6.1, 6.3] } - * - * // The `_.property` iteratee shorthand. - * _.groupBy(['one', 'two', 'three'], 'length'); - * // => { '3': ['one', 'two'], '5': ['three'] } - */ - var groupBy = createAggregator(function(result, value, key) { - if (hasOwnProperty.call(result, key)) { - result[key].push(value); - } else { - baseAssignValue(result, key, [value]); - } - }); - - /** - * Checks if `value` is in `collection`. If `collection` is a string, it's - * checked for a substring of `value`, otherwise - * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * is used for equality comparisons. If `fromIndex` is negative, it's used as - * the offset from the end of `collection`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object|string} collection The collection to inspect. - * @param {*} value The value to search for. - * @param {number} [fromIndex=0] The index to search from. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.reduce`. - * @returns {boolean} Returns `true` if `value` is found, else `false`. - * @example - * - * _.includes([1, 2, 3], 1); - * // => true - * - * _.includes([1, 2, 3], 1, 2); - * // => false - * - * _.includes({ 'a': 1, 'b': 2 }, 1); - * // => true - * - * _.includes('abcd', 'bc'); - * // => true - */ - function includes(collection, value, fromIndex, guard) { - collection = isArrayLike(collection) ? collection : values(collection); - fromIndex = (fromIndex && !guard) ? toInteger(fromIndex) : 0; - - var length = collection.length; - if (fromIndex < 0) { - fromIndex = nativeMax(length + fromIndex, 0); - } - return isString(collection) - ? (fromIndex <= length && collection.indexOf(value, fromIndex) > -1) - : (!!length && baseIndexOf(collection, value, fromIndex) > -1); - } - - /** - * Invokes the method at `path` of each element in `collection`, returning - * an array of the results of each invoked method. Any additional arguments - * are provided to each invoked method. If `path` is a function, it's invoked - * for, and `this` bound to, each element in `collection`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Array|Function|string} path The path of the method to invoke or - * the function invoked per iteration. - * @param {...*} [args] The arguments to invoke each method with. - * @returns {Array} Returns the array of results. - * @example - * - * _.invokeMap([[5, 1, 7], [3, 2, 1]], 'sort'); - * // => [[1, 5, 7], [1, 2, 3]] - * - * _.invokeMap([123, 456], String.prototype.split, ''); - * // => [['1', '2', '3'], ['4', '5', '6']] - */ - var invokeMap = baseRest(function(collection, path, args) { - var index = -1, - isFunc = typeof path == 'function', - result = isArrayLike(collection) ? Array(collection.length) : []; - - baseEach(collection, function(value) { - result[++index] = isFunc ? apply(path, value, args) : baseInvoke(value, path, args); - }); - return result; - }); - - /** - * Creates an object composed of keys generated from the results of running - * each element of `collection` thru `iteratee`. The corresponding value of - * each key is the last element responsible for generating the key. The - * iteratee is invoked with one argument: (value). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The iteratee to transform keys. - * @returns {Object} Returns the composed aggregate object. - * @example - * - * var array = [ - * { 'dir': 'left', 'code': 97 }, - * { 'dir': 'right', 'code': 100 } - * ]; - * - * _.keyBy(array, function(o) { - * return String.fromCharCode(o.code); - * }); - * // => { 'a': { 'dir': 'left', 'code': 97 }, 'd': { 'dir': 'right', 'code': 100 } } - * - * _.keyBy(array, 'dir'); - * // => { 'left': { 'dir': 'left', 'code': 97 }, 'right': { 'dir': 'right', 'code': 100 } } - */ - var keyBy = createAggregator(function(result, value, key) { - baseAssignValue(result, key, value); - }); - - /** - * Creates an array of values by running each element in `collection` thru - * `iteratee`. The iteratee is invoked with three arguments: - * (value, index|key, collection). - * - * Many lodash methods are guarded to work as iteratees for methods like - * `_.every`, `_.filter`, `_.map`, `_.mapValues`, `_.reject`, and `_.some`. - * - * The guarded methods are: - * `ary`, `chunk`, `curry`, `curryRight`, `drop`, `dropRight`, `every`, - * `fill`, `invert`, `parseInt`, `random`, `range`, `rangeRight`, `repeat`, - * `sampleSize`, `slice`, `some`, `sortBy`, `split`, `take`, `takeRight`, - * `template`, `trim`, `trimEnd`, `trimStart`, and `words` - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Array} Returns the new mapped array. - * @example - * - * function square(n) { - * return n * n; - * } - * - * _.map([4, 8], square); - * // => [16, 64] - * - * _.map({ 'a': 4, 'b': 8 }, square); - * // => [16, 64] (iteration order is not guaranteed) - * - * var users = [ - * { 'user': 'barney' }, - * { 'user': 'fred' } - * ]; - * - * // The `_.property` iteratee shorthand. - * _.map(users, 'user'); - * // => ['barney', 'fred'] - */ - function map(collection, iteratee) { - var func = isArray(collection) ? arrayMap : baseMap; - return func(collection, getIteratee(iteratee, 3)); - } - - /** - * This method is like `_.sortBy` except that it allows specifying the sort - * orders of the iteratees to sort by. If `orders` is unspecified, all values - * are sorted in ascending order. Otherwise, specify an order of "desc" for - * descending or "asc" for ascending sort order of corresponding values. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Array[]|Function[]|Object[]|string[]} [iteratees=[_.identity]] - * The iteratees to sort by. - * @param {string[]} [orders] The sort orders of `iteratees`. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.reduce`. - * @returns {Array} Returns the new sorted array. - * @example - * - * var users = [ - * { 'user': 'fred', 'age': 48 }, - * { 'user': 'barney', 'age': 34 }, - * { 'user': 'fred', 'age': 40 }, - * { 'user': 'barney', 'age': 36 } - * ]; - * - * // Sort by `user` in ascending order and by `age` in descending order. - * _.orderBy(users, ['user', 'age'], ['asc', 'desc']); - * // => objects for [['barney', 36], ['barney', 34], ['fred', 48], ['fred', 40]] - */ - function orderBy(collection, iteratees, orders, guard) { - if (collection == null) { - return []; - } - if (!isArray(iteratees)) { - iteratees = iteratees == null ? [] : [iteratees]; - } - orders = guard ? undefined : orders; - if (!isArray(orders)) { - orders = orders == null ? [] : [orders]; - } - return baseOrderBy(collection, iteratees, orders); - } - - /** - * Creates an array of elements split into two groups, the first of which - * contains elements `predicate` returns truthy for, the second of which - * contains elements `predicate` returns falsey for. The predicate is - * invoked with one argument: (value). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the array of grouped elements. - * @example - * - * var users = [ - * { 'user': 'barney', 'age': 36, 'active': false }, - * { 'user': 'fred', 'age': 40, 'active': true }, - * { 'user': 'pebbles', 'age': 1, 'active': false } - * ]; - * - * _.partition(users, function(o) { return o.active; }); - * // => objects for [['fred'], ['barney', 'pebbles']] - * - * // The `_.matches` iteratee shorthand. - * _.partition(users, { 'age': 1, 'active': false }); - * // => objects for [['pebbles'], ['barney', 'fred']] - * - * // The `_.matchesProperty` iteratee shorthand. - * _.partition(users, ['active', false]); - * // => objects for [['barney', 'pebbles'], ['fred']] - * - * // The `_.property` iteratee shorthand. - * _.partition(users, 'active'); - * // => objects for [['fred'], ['barney', 'pebbles']] - */ - var partition = createAggregator(function(result, value, key) { - result[key ? 0 : 1].push(value); - }, function() { return [[], []]; }); - - /** - * Reduces `collection` to a value which is the accumulated result of running - * each element in `collection` thru `iteratee`, where each successive - * invocation is supplied the return value of the previous. If `accumulator` - * is not given, the first element of `collection` is used as the initial - * value. The iteratee is invoked with four arguments: - * (accumulator, value, index|key, collection). - * - * Many lodash methods are guarded to work as iteratees for methods like - * `_.reduce`, `_.reduceRight`, and `_.transform`. - * - * The guarded methods are: - * `assign`, `defaults`, `defaultsDeep`, `includes`, `merge`, `orderBy`, - * and `sortBy` - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @param {*} [accumulator] The initial value. - * @returns {*} Returns the accumulated value. - * @see _.reduceRight - * @example - * - * _.reduce([1, 2], function(sum, n) { - * return sum + n; - * }, 0); - * // => 3 - * - * _.reduce({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) { - * (result[value] || (result[value] = [])).push(key); - * return result; - * }, {}); - * // => { '1': ['a', 'c'], '2': ['b'] } (iteration order is not guaranteed) - */ - function reduce(collection, iteratee, accumulator) { - var func = isArray(collection) ? arrayReduce : baseReduce, - initAccum = arguments.length < 3; - - return func(collection, getIteratee(iteratee, 4), accumulator, initAccum, baseEach); - } - - /** - * This method is like `_.reduce` except that it iterates over elements of - * `collection` from right to left. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @param {*} [accumulator] The initial value. - * @returns {*} Returns the accumulated value. - * @see _.reduce - * @example - * - * var array = [[0, 1], [2, 3], [4, 5]]; - * - * _.reduceRight(array, function(flattened, other) { - * return flattened.concat(other); - * }, []); - * // => [4, 5, 2, 3, 0, 1] - */ - function reduceRight(collection, iteratee, accumulator) { - var func = isArray(collection) ? arrayReduceRight : baseReduce, - initAccum = arguments.length < 3; - - return func(collection, getIteratee(iteratee, 4), accumulator, initAccum, baseEachRight); - } - - /** - * The opposite of `_.filter`; this method returns the elements of `collection` - * that `predicate` does **not** return truthy for. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {Array} Returns the new filtered array. - * @see _.filter - * @example - * - * var users = [ - * { 'user': 'barney', 'age': 36, 'active': false }, - * { 'user': 'fred', 'age': 40, 'active': true } - * ]; - * - * _.reject(users, function(o) { return !o.active; }); - * // => objects for ['fred'] - * - * // The `_.matches` iteratee shorthand. - * _.reject(users, { 'age': 40, 'active': true }); - * // => objects for ['barney'] - * - * // The `_.matchesProperty` iteratee shorthand. - * _.reject(users, ['active', false]); - * // => objects for ['fred'] - * - * // The `_.property` iteratee shorthand. - * _.reject(users, 'active'); - * // => objects for ['barney'] - */ - function reject(collection, predicate) { - var func = isArray(collection) ? arrayFilter : baseFilter; - return func(collection, negate(getIteratee(predicate, 3))); - } - - /** - * Gets a random element from `collection`. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Collection - * @param {Array|Object} collection The collection to sample. - * @returns {*} Returns the random element. - * @example - * - * _.sample([1, 2, 3, 4]); - * // => 2 - */ - function sample(collection) { - var func = isArray(collection) ? arraySample : baseSample; - return func(collection); - } - - /** - * Gets `n` random elements at unique keys from `collection` up to the - * size of `collection`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Collection - * @param {Array|Object} collection The collection to sample. - * @param {number} [n=1] The number of elements to sample. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Array} Returns the random elements. - * @example - * - * _.sampleSize([1, 2, 3], 2); - * // => [3, 1] - * - * _.sampleSize([1, 2, 3], 4); - * // => [2, 3, 1] - */ - function sampleSize(collection, n, guard) { - if ((guard ? isIterateeCall(collection, n, guard) : n === undefined)) { - n = 1; - } else { - n = toInteger(n); - } - var func = isArray(collection) ? arraySampleSize : baseSampleSize; - return func(collection, n); - } - - /** - * Creates an array of shuffled values, using a version of the - * [Fisher-Yates shuffle](https://en.wikipedia.org/wiki/Fisher-Yates_shuffle). - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to shuffle. - * @returns {Array} Returns the new shuffled array. - * @example - * - * _.shuffle([1, 2, 3, 4]); - * // => [4, 1, 3, 2] - */ - function shuffle(collection) { - var func = isArray(collection) ? arrayShuffle : baseShuffle; - return func(collection); - } - - /** - * Gets the size of `collection` by returning its length for array-like - * values or the number of own enumerable string keyed properties for objects. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object|string} collection The collection to inspect. - * @returns {number} Returns the collection size. - * @example - * - * _.size([1, 2, 3]); - * // => 3 - * - * _.size({ 'a': 1, 'b': 2 }); - * // => 2 - * - * _.size('pebbles'); - * // => 7 - */ - function size(collection) { - if (collection == null) { - return 0; - } - if (isArrayLike(collection)) { - return isString(collection) ? stringSize(collection) : collection.length; - } - var tag = getTag(collection); - if (tag == mapTag || tag == setTag) { - return collection.size; - } - return baseKeys(collection).length; - } - - /** - * Checks if `predicate` returns truthy for **any** element of `collection`. - * Iteration is stopped once `predicate` returns truthy. The predicate is - * invoked with three arguments: (value, index|key, collection). - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {boolean} Returns `true` if any element passes the predicate check, - * else `false`. - * @example - * - * _.some([null, 0, 'yes', false], Boolean); - * // => true - * - * var users = [ - * { 'user': 'barney', 'active': true }, - * { 'user': 'fred', 'active': false } - * ]; - * - * // The `_.matches` iteratee shorthand. - * _.some(users, { 'user': 'barney', 'active': false }); - * // => false - * - * // The `_.matchesProperty` iteratee shorthand. - * _.some(users, ['active', false]); - * // => true - * - * // The `_.property` iteratee shorthand. - * _.some(users, 'active'); - * // => true - */ - function some(collection, predicate, guard) { - var func = isArray(collection) ? arraySome : baseSome; - if (guard && isIterateeCall(collection, predicate, guard)) { - predicate = undefined; - } - return func(collection, getIteratee(predicate, 3)); - } - - /** - * Creates an array of elements, sorted in ascending order by the results of - * running each element in a collection thru each iteratee. This method - * performs a stable sort, that is, it preserves the original sort order of - * equal elements. The iteratees are invoked with one argument: (value). - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Collection - * @param {Array|Object} collection The collection to iterate over. - * @param {...(Function|Function[])} [iteratees=[_.identity]] - * The iteratees to sort by. - * @returns {Array} Returns the new sorted array. - * @example - * - * var users = [ - * { 'user': 'fred', 'age': 48 }, - * { 'user': 'barney', 'age': 36 }, - * { 'user': 'fred', 'age': 40 }, - * { 'user': 'barney', 'age': 34 } - * ]; - * - * _.sortBy(users, [function(o) { return o.user; }]); - * // => objects for [['barney', 36], ['barney', 34], ['fred', 48], ['fred', 40]] - * - * _.sortBy(users, ['user', 'age']); - * // => objects for [['barney', 34], ['barney', 36], ['fred', 40], ['fred', 48]] - */ - var sortBy = baseRest(function(collection, iteratees) { - if (collection == null) { - return []; - } - var length = iteratees.length; - if (length > 1 && isIterateeCall(collection, iteratees[0], iteratees[1])) { - iteratees = []; - } else if (length > 2 && isIterateeCall(iteratees[0], iteratees[1], iteratees[2])) { - iteratees = [iteratees[0]]; - } - return baseOrderBy(collection, baseFlatten(iteratees, 1), []); - }); - - /*------------------------------------------------------------------------*/ - - /** - * Gets the timestamp of the number of milliseconds that have elapsed since - * the Unix epoch (1 January 1970 00:00:00 UTC). - * - * @static - * @memberOf _ - * @since 2.4.0 - * @category Date - * @returns {number} Returns the timestamp. - * @example - * - * _.defer(function(stamp) { - * console.log(_.now() - stamp); - * }, _.now()); - * // => Logs the number of milliseconds it took for the deferred invocation. - */ - var now = ctxNow || function() { - return root.Date.now(); - }; - - /*------------------------------------------------------------------------*/ - - /** - * The opposite of `_.before`; this method creates a function that invokes - * `func` once it's called `n` or more times. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {number} n The number of calls before `func` is invoked. - * @param {Function} func The function to restrict. - * @returns {Function} Returns the new restricted function. - * @example - * - * var saves = ['profile', 'settings']; - * - * var done = _.after(saves.length, function() { - * console.log('done saving!'); - * }); - * - * _.forEach(saves, function(type) { - * asyncSave({ 'type': type, 'complete': done }); - * }); - * // => Logs 'done saving!' after the two async saves have completed. - */ - function after(n, func) { - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - n = toInteger(n); - return function() { - if (--n < 1) { - return func.apply(this, arguments); - } - }; - } - - /** - * Creates a function that invokes `func`, with up to `n` arguments, - * ignoring any additional arguments. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Function - * @param {Function} func The function to cap arguments for. - * @param {number} [n=func.length] The arity cap. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Function} Returns the new capped function. - * @example - * - * _.map(['6', '8', '10'], _.ary(parseInt, 1)); - * // => [6, 8, 10] - */ - function ary(func, n, guard) { - n = guard ? undefined : n; - n = (func && n == null) ? func.length : n; - return createWrap(func, WRAP_ARY_FLAG, undefined, undefined, undefined, undefined, n); - } - - /** - * Creates a function that invokes `func`, with the `this` binding and arguments - * of the created function, while it's called less than `n` times. Subsequent - * calls to the created function return the result of the last `func` invocation. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Function - * @param {number} n The number of calls at which `func` is no longer invoked. - * @param {Function} func The function to restrict. - * @returns {Function} Returns the new restricted function. - * @example - * - * jQuery(element).on('click', _.before(5, addContactToList)); - * // => Allows adding up to 4 contacts to the list. - */ - function before(n, func) { - var result; - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - n = toInteger(n); - return function() { - if (--n > 0) { - result = func.apply(this, arguments); - } - if (n <= 1) { - func = undefined; - } - return result; - }; - } - - /** - * Creates a function that invokes `func` with the `this` binding of `thisArg` - * and `partials` prepended to the arguments it receives. - * - * The `_.bind.placeholder` value, which defaults to `_` in monolithic builds, - * may be used as a placeholder for partially applied arguments. - * - * **Note:** Unlike native `Function#bind`, this method doesn't set the "length" - * property of bound functions. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {Function} func The function to bind. - * @param {*} thisArg The `this` binding of `func`. - * @param {...*} [partials] The arguments to be partially applied. - * @returns {Function} Returns the new bound function. - * @example - * - * function greet(greeting, punctuation) { - * return greeting + ' ' + this.user + punctuation; - * } - * - * var object = { 'user': 'fred' }; - * - * var bound = _.bind(greet, object, 'hi'); - * bound('!'); - * // => 'hi fred!' - * - * // Bound with placeholders. - * var bound = _.bind(greet, object, _, '!'); - * bound('hi'); - * // => 'hi fred!' - */ - var bind = baseRest(function(func, thisArg, partials) { - var bitmask = WRAP_BIND_FLAG; - if (partials.length) { - var holders = replaceHolders(partials, getHolder(bind)); - bitmask |= WRAP_PARTIAL_FLAG; - } - return createWrap(func, bitmask, thisArg, partials, holders); - }); - - /** - * Creates a function that invokes the method at `object[key]` with `partials` - * prepended to the arguments it receives. - * - * This method differs from `_.bind` by allowing bound functions to reference - * methods that may be redefined or don't yet exist. See - * [Peter Michaux's article](http://peter.michaux.ca/articles/lazy-function-definition-pattern) - * for more details. - * - * The `_.bindKey.placeholder` value, which defaults to `_` in monolithic - * builds, may be used as a placeholder for partially applied arguments. - * - * @static - * @memberOf _ - * @since 0.10.0 - * @category Function - * @param {Object} object The object to invoke the method on. - * @param {string} key The key of the method. - * @param {...*} [partials] The arguments to be partially applied. - * @returns {Function} Returns the new bound function. - * @example - * - * var object = { - * 'user': 'fred', - * 'greet': function(greeting, punctuation) { - * return greeting + ' ' + this.user + punctuation; - * } - * }; - * - * var bound = _.bindKey(object, 'greet', 'hi'); - * bound('!'); - * // => 'hi fred!' - * - * object.greet = function(greeting, punctuation) { - * return greeting + 'ya ' + this.user + punctuation; - * }; - * - * bound('!'); - * // => 'hiya fred!' - * - * // Bound with placeholders. - * var bound = _.bindKey(object, 'greet', _, '!'); - * bound('hi'); - * // => 'hiya fred!' - */ - var bindKey = baseRest(function(object, key, partials) { - var bitmask = WRAP_BIND_FLAG | WRAP_BIND_KEY_FLAG; - if (partials.length) { - var holders = replaceHolders(partials, getHolder(bindKey)); - bitmask |= WRAP_PARTIAL_FLAG; - } - return createWrap(key, bitmask, object, partials, holders); - }); - - /** - * Creates a function that accepts arguments of `func` and either invokes - * `func` returning its result, if at least `arity` number of arguments have - * been provided, or returns a function that accepts the remaining `func` - * arguments, and so on. The arity of `func` may be specified if `func.length` - * is not sufficient. - * - * The `_.curry.placeholder` value, which defaults to `_` in monolithic builds, - * may be used as a placeholder for provided arguments. - * - * **Note:** This method doesn't set the "length" property of curried functions. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Function - * @param {Function} func The function to curry. - * @param {number} [arity=func.length] The arity of `func`. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Function} Returns the new curried function. - * @example - * - * var abc = function(a, b, c) { - * return [a, b, c]; - * }; - * - * var curried = _.curry(abc); - * - * curried(1)(2)(3); - * // => [1, 2, 3] - * - * curried(1, 2)(3); - * // => [1, 2, 3] - * - * curried(1, 2, 3); - * // => [1, 2, 3] - * - * // Curried with placeholders. - * curried(1)(_, 3)(2); - * // => [1, 2, 3] - */ - function curry(func, arity, guard) { - arity = guard ? undefined : arity; - var result = createWrap(func, WRAP_CURRY_FLAG, undefined, undefined, undefined, undefined, undefined, arity); - result.placeholder = curry.placeholder; - return result; - } - - /** - * This method is like `_.curry` except that arguments are applied to `func` - * in the manner of `_.partialRight` instead of `_.partial`. - * - * The `_.curryRight.placeholder` value, which defaults to `_` in monolithic - * builds, may be used as a placeholder for provided arguments. - * - * **Note:** This method doesn't set the "length" property of curried functions. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Function - * @param {Function} func The function to curry. - * @param {number} [arity=func.length] The arity of `func`. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Function} Returns the new curried function. - * @example - * - * var abc = function(a, b, c) { - * return [a, b, c]; - * }; - * - * var curried = _.curryRight(abc); - * - * curried(3)(2)(1); - * // => [1, 2, 3] - * - * curried(2, 3)(1); - * // => [1, 2, 3] - * - * curried(1, 2, 3); - * // => [1, 2, 3] - * - * // Curried with placeholders. - * curried(3)(1, _)(2); - * // => [1, 2, 3] - */ - function curryRight(func, arity, guard) { - arity = guard ? undefined : arity; - var result = createWrap(func, WRAP_CURRY_RIGHT_FLAG, undefined, undefined, undefined, undefined, undefined, arity); - result.placeholder = curryRight.placeholder; - return result; - } - - /** - * Creates a debounced function that delays invoking `func` until after `wait` - * milliseconds have elapsed since the last time the debounced function was - * invoked. The debounced function comes with a `cancel` method to cancel - * delayed `func` invocations and a `flush` method to immediately invoke them. - * Provide `options` to indicate whether `func` should be invoked on the - * leading and/or trailing edge of the `wait` timeout. The `func` is invoked - * with the last arguments provided to the debounced function. Subsequent - * calls to the debounced function return the result of the last `func` - * invocation. - * - * **Note:** If `leading` and `trailing` options are `true`, `func` is - * invoked on the trailing edge of the timeout only if the debounced function - * is invoked more than once during the `wait` timeout. - * - * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred - * until to the next tick, similar to `setTimeout` with a timeout of `0`. - * - * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) - * for details over the differences between `_.debounce` and `_.throttle`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {Function} func The function to debounce. - * @param {number} [wait=0] The number of milliseconds to delay. - * @param {Object} [options={}] The options object. - * @param {boolean} [options.leading=false] - * Specify invoking on the leading edge of the timeout. - * @param {number} [options.maxWait] - * The maximum time `func` is allowed to be delayed before it's invoked. - * @param {boolean} [options.trailing=true] - * Specify invoking on the trailing edge of the timeout. - * @returns {Function} Returns the new debounced function. - * @example - * - * // Avoid costly calculations while the window size is in flux. - * jQuery(window).on('resize', _.debounce(calculateLayout, 150)); - * - * // Invoke `sendMail` when clicked, debouncing subsequent calls. - * jQuery(element).on('click', _.debounce(sendMail, 300, { - * 'leading': true, - * 'trailing': false - * })); - * - * // Ensure `batchLog` is invoked once after 1 second of debounced calls. - * var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 }); - * var source = new EventSource('/stream'); - * jQuery(source).on('message', debounced); - * - * // Cancel the trailing debounced invocation. - * jQuery(window).on('popstate', debounced.cancel); - */ - function debounce(func, wait, options) { - var lastArgs, - lastThis, - maxWait, - result, - timerId, - lastCallTime, - lastInvokeTime = 0, - leading = false, - maxing = false, - trailing = true; - - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - wait = toNumber(wait) || 0; - if (isObject(options)) { - leading = !!options.leading; - maxing = 'maxWait' in options; - maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait; - trailing = 'trailing' in options ? !!options.trailing : trailing; - } - - function invokeFunc(time) { - var args = lastArgs, - thisArg = lastThis; - - lastArgs = lastThis = undefined; - lastInvokeTime = time; - result = func.apply(thisArg, args); - return result; - } - - function leadingEdge(time) { - // Reset any `maxWait` timer. - lastInvokeTime = time; - // Start the timer for the trailing edge. - timerId = setTimeout(timerExpired, wait); - // Invoke the leading edge. - return leading ? invokeFunc(time) : result; - } - - function remainingWait(time) { - var timeSinceLastCall = time - lastCallTime, - timeSinceLastInvoke = time - lastInvokeTime, - timeWaiting = wait - timeSinceLastCall; - - return maxing - ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke) - : timeWaiting; - } - - function shouldInvoke(time) { - var timeSinceLastCall = time - lastCallTime, - timeSinceLastInvoke = time - lastInvokeTime; - - // Either this is the first call, activity has stopped and we're at the - // trailing edge, the system time has gone backwards and we're treating - // it as the trailing edge, or we've hit the `maxWait` limit. - return (lastCallTime === undefined || (timeSinceLastCall >= wait) || - (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait)); - } - - function timerExpired() { - var time = now(); - if (shouldInvoke(time)) { - return trailingEdge(time); - } - // Restart the timer. - timerId = setTimeout(timerExpired, remainingWait(time)); - } - - function trailingEdge(time) { - timerId = undefined; - - // Only invoke if we have `lastArgs` which means `func` has been - // debounced at least once. - if (trailing && lastArgs) { - return invokeFunc(time); - } - lastArgs = lastThis = undefined; - return result; - } - - function cancel() { - if (timerId !== undefined) { - clearTimeout(timerId); - } - lastInvokeTime = 0; - lastArgs = lastCallTime = lastThis = timerId = undefined; - } - - function flush() { - return timerId === undefined ? result : trailingEdge(now()); - } - - function debounced() { - var time = now(), - isInvoking = shouldInvoke(time); - - lastArgs = arguments; - lastThis = this; - lastCallTime = time; - - if (isInvoking) { - if (timerId === undefined) { - return leadingEdge(lastCallTime); - } - if (maxing) { - // Handle invocations in a tight loop. - timerId = setTimeout(timerExpired, wait); - return invokeFunc(lastCallTime); - } - } - if (timerId === undefined) { - timerId = setTimeout(timerExpired, wait); - } - return result; - } - debounced.cancel = cancel; - debounced.flush = flush; - return debounced; - } - - /** - * Defers invoking the `func` until the current call stack has cleared. Any - * additional arguments are provided to `func` when it's invoked. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {Function} func The function to defer. - * @param {...*} [args] The arguments to invoke `func` with. - * @returns {number} Returns the timer id. - * @example - * - * _.defer(function(text) { - * console.log(text); - * }, 'deferred'); - * // => Logs 'deferred' after one millisecond. - */ - var defer = baseRest(function(func, args) { - return baseDelay(func, 1, args); - }); - - /** - * Invokes `func` after `wait` milliseconds. Any additional arguments are - * provided to `func` when it's invoked. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {Function} func The function to delay. - * @param {number} wait The number of milliseconds to delay invocation. - * @param {...*} [args] The arguments to invoke `func` with. - * @returns {number} Returns the timer id. - * @example - * - * _.delay(function(text) { - * console.log(text); - * }, 1000, 'later'); - * // => Logs 'later' after one second. - */ - var delay = baseRest(function(func, wait, args) { - return baseDelay(func, toNumber(wait) || 0, args); - }); - - /** - * Creates a function that invokes `func` with arguments reversed. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Function - * @param {Function} func The function to flip arguments for. - * @returns {Function} Returns the new flipped function. - * @example - * - * var flipped = _.flip(function() { - * return _.toArray(arguments); - * }); - * - * flipped('a', 'b', 'c', 'd'); - * // => ['d', 'c', 'b', 'a'] - */ - function flip(func) { - return createWrap(func, WRAP_FLIP_FLAG); - } - - /** - * Creates a function that memoizes the result of `func`. If `resolver` is - * provided, it determines the cache key for storing the result based on the - * arguments provided to the memoized function. By default, the first argument - * provided to the memoized function is used as the map cache key. The `func` - * is invoked with the `this` binding of the memoized function. - * - * **Note:** The cache is exposed as the `cache` property on the memoized - * function. Its creation may be customized by replacing the `_.memoize.Cache` - * constructor with one whose instances implement the - * [`Map`](http://ecma-international.org/ecma-262/7.0/#sec-properties-of-the-map-prototype-object) - * method interface of `clear`, `delete`, `get`, `has`, and `set`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {Function} func The function to have its output memoized. - * @param {Function} [resolver] The function to resolve the cache key. - * @returns {Function} Returns the new memoized function. - * @example - * - * var object = { 'a': 1, 'b': 2 }; - * var other = { 'c': 3, 'd': 4 }; - * - * var values = _.memoize(_.values); - * values(object); - * // => [1, 2] - * - * values(other); - * // => [3, 4] - * - * object.a = 2; - * values(object); - * // => [1, 2] - * - * // Modify the result cache. - * values.cache.set(object, ['a', 'b']); - * values(object); - * // => ['a', 'b'] - * - * // Replace `_.memoize.Cache`. - * _.memoize.Cache = WeakMap; - */ - function memoize(func, resolver) { - if (typeof func != 'function' || (resolver != null && typeof resolver != 'function')) { - throw new TypeError(FUNC_ERROR_TEXT); - } - var memoized = function() { - var args = arguments, - key = resolver ? resolver.apply(this, args) : args[0], - cache = memoized.cache; - - if (cache.has(key)) { - return cache.get(key); - } - var result = func.apply(this, args); - memoized.cache = cache.set(key, result) || cache; - return result; - }; - memoized.cache = new (memoize.Cache || MapCache); - return memoized; - } - - // Expose `MapCache`. - memoize.Cache = MapCache; - - /** - * Creates a function that negates the result of the predicate `func`. The - * `func` predicate is invoked with the `this` binding and arguments of the - * created function. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Function - * @param {Function} predicate The predicate to negate. - * @returns {Function} Returns the new negated function. - * @example - * - * function isEven(n) { - * return n % 2 == 0; - * } - * - * _.filter([1, 2, 3, 4, 5, 6], _.negate(isEven)); - * // => [1, 3, 5] - */ - function negate(predicate) { - if (typeof predicate != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - return function() { - var args = arguments; - switch (args.length) { - case 0: return !predicate.call(this); - case 1: return !predicate.call(this, args[0]); - case 2: return !predicate.call(this, args[0], args[1]); - case 3: return !predicate.call(this, args[0], args[1], args[2]); - } - return !predicate.apply(this, args); - }; - } - - /** - * Creates a function that is restricted to invoking `func` once. Repeat calls - * to the function return the value of the first invocation. The `func` is - * invoked with the `this` binding and arguments of the created function. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {Function} func The function to restrict. - * @returns {Function} Returns the new restricted function. - * @example - * - * var initialize = _.once(createApplication); - * initialize(); - * initialize(); - * // => `createApplication` is invoked once - */ - function once(func) { - return before(2, func); - } - - /** - * Creates a function that invokes `func` with its arguments transformed. - * - * @static - * @since 4.0.0 - * @memberOf _ - * @category Function - * @param {Function} func The function to wrap. - * @param {...(Function|Function[])} [transforms=[_.identity]] - * The argument transforms. - * @returns {Function} Returns the new function. - * @example - * - * function doubled(n) { - * return n * 2; - * } - * - * function square(n) { - * return n * n; - * } - * - * var func = _.overArgs(function(x, y) { - * return [x, y]; - * }, [square, doubled]); - * - * func(9, 3); - * // => [81, 6] - * - * func(10, 5); - * // => [100, 10] - */ - var overArgs = castRest(function(func, transforms) { - transforms = (transforms.length == 1 && isArray(transforms[0])) - ? arrayMap(transforms[0], baseUnary(getIteratee())) - : arrayMap(baseFlatten(transforms, 1), baseUnary(getIteratee())); - - var funcsLength = transforms.length; - return baseRest(function(args) { - var index = -1, - length = nativeMin(args.length, funcsLength); - - while (++index < length) { - args[index] = transforms[index].call(this, args[index]); - } - return apply(func, this, args); - }); - }); - - /** - * Creates a function that invokes `func` with `partials` prepended to the - * arguments it receives. This method is like `_.bind` except it does **not** - * alter the `this` binding. - * - * The `_.partial.placeholder` value, which defaults to `_` in monolithic - * builds, may be used as a placeholder for partially applied arguments. - * - * **Note:** This method doesn't set the "length" property of partially - * applied functions. - * - * @static - * @memberOf _ - * @since 0.2.0 - * @category Function - * @param {Function} func The function to partially apply arguments to. - * @param {...*} [partials] The arguments to be partially applied. - * @returns {Function} Returns the new partially applied function. - * @example - * - * function greet(greeting, name) { - * return greeting + ' ' + name; - * } - * - * var sayHelloTo = _.partial(greet, 'hello'); - * sayHelloTo('fred'); - * // => 'hello fred' - * - * // Partially applied with placeholders. - * var greetFred = _.partial(greet, _, 'fred'); - * greetFred('hi'); - * // => 'hi fred' - */ - var partial = baseRest(function(func, partials) { - var holders = replaceHolders(partials, getHolder(partial)); - return createWrap(func, WRAP_PARTIAL_FLAG, undefined, partials, holders); - }); - - /** - * This method is like `_.partial` except that partially applied arguments - * are appended to the arguments it receives. - * - * The `_.partialRight.placeholder` value, which defaults to `_` in monolithic - * builds, may be used as a placeholder for partially applied arguments. - * - * **Note:** This method doesn't set the "length" property of partially - * applied functions. - * - * @static - * @memberOf _ - * @since 1.0.0 - * @category Function - * @param {Function} func The function to partially apply arguments to. - * @param {...*} [partials] The arguments to be partially applied. - * @returns {Function} Returns the new partially applied function. - * @example - * - * function greet(greeting, name) { - * return greeting + ' ' + name; - * } - * - * var greetFred = _.partialRight(greet, 'fred'); - * greetFred('hi'); - * // => 'hi fred' - * - * // Partially applied with placeholders. - * var sayHelloTo = _.partialRight(greet, 'hello', _); - * sayHelloTo('fred'); - * // => 'hello fred' - */ - var partialRight = baseRest(function(func, partials) { - var holders = replaceHolders(partials, getHolder(partialRight)); - return createWrap(func, WRAP_PARTIAL_RIGHT_FLAG, undefined, partials, holders); - }); - - /** - * Creates a function that invokes `func` with arguments arranged according - * to the specified `indexes` where the argument value at the first index is - * provided as the first argument, the argument value at the second index is - * provided as the second argument, and so on. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Function - * @param {Function} func The function to rearrange arguments for. - * @param {...(number|number[])} indexes The arranged argument indexes. - * @returns {Function} Returns the new function. - * @example - * - * var rearged = _.rearg(function(a, b, c) { - * return [a, b, c]; - * }, [2, 0, 1]); - * - * rearged('b', 'c', 'a') - * // => ['a', 'b', 'c'] - */ - var rearg = flatRest(function(func, indexes) { - return createWrap(func, WRAP_REARG_FLAG, undefined, undefined, undefined, indexes); - }); - - /** - * Creates a function that invokes `func` with the `this` binding of the - * created function and arguments from `start` and beyond provided as - * an array. - * - * **Note:** This method is based on the - * [rest parameter](https://mdn.io/rest_parameters). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Function - * @param {Function} func The function to apply a rest parameter to. - * @param {number} [start=func.length-1] The start position of the rest parameter. - * @returns {Function} Returns the new function. - * @example - * - * var say = _.rest(function(what, names) { - * return what + ' ' + _.initial(names).join(', ') + - * (_.size(names) > 1 ? ', & ' : '') + _.last(names); - * }); - * - * say('hello', 'fred', 'barney', 'pebbles'); - * // => 'hello fred, barney, & pebbles' - */ - function rest(func, start) { - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - start = start === undefined ? start : toInteger(start); - return baseRest(func, start); - } - - /** - * Creates a function that invokes `func` with the `this` binding of the - * create function and an array of arguments much like - * [`Function#apply`](http://www.ecma-international.org/ecma-262/7.0/#sec-function.prototype.apply). - * - * **Note:** This method is based on the - * [spread operator](https://mdn.io/spread_operator). - * - * @static - * @memberOf _ - * @since 3.2.0 - * @category Function - * @param {Function} func The function to spread arguments over. - * @param {number} [start=0] The start position of the spread. - * @returns {Function} Returns the new function. - * @example - * - * var say = _.spread(function(who, what) { - * return who + ' says ' + what; - * }); - * - * say(['fred', 'hello']); - * // => 'fred says hello' - * - * var numbers = Promise.all([ - * Promise.resolve(40), - * Promise.resolve(36) - * ]); - * - * numbers.then(_.spread(function(x, y) { - * return x + y; - * })); - * // => a Promise of 76 - */ - function spread(func, start) { - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - start = start == null ? 0 : nativeMax(toInteger(start), 0); - return baseRest(function(args) { - var array = args[start], - otherArgs = castSlice(args, 0, start); - - if (array) { - arrayPush(otherArgs, array); - } - return apply(func, this, otherArgs); - }); - } - - /** - * Creates a throttled function that only invokes `func` at most once per - * every `wait` milliseconds. The throttled function comes with a `cancel` - * method to cancel delayed `func` invocations and a `flush` method to - * immediately invoke them. Provide `options` to indicate whether `func` - * should be invoked on the leading and/or trailing edge of the `wait` - * timeout. The `func` is invoked with the last arguments provided to the - * throttled function. Subsequent calls to the throttled function return the - * result of the last `func` invocation. - * - * **Note:** If `leading` and `trailing` options are `true`, `func` is - * invoked on the trailing edge of the timeout only if the throttled function - * is invoked more than once during the `wait` timeout. - * - * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred - * until to the next tick, similar to `setTimeout` with a timeout of `0`. - * - * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) - * for details over the differences between `_.throttle` and `_.debounce`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {Function} func The function to throttle. - * @param {number} [wait=0] The number of milliseconds to throttle invocations to. - * @param {Object} [options={}] The options object. - * @param {boolean} [options.leading=true] - * Specify invoking on the leading edge of the timeout. - * @param {boolean} [options.trailing=true] - * Specify invoking on the trailing edge of the timeout. - * @returns {Function} Returns the new throttled function. - * @example - * - * // Avoid excessively updating the position while scrolling. - * jQuery(window).on('scroll', _.throttle(updatePosition, 100)); - * - * // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes. - * var throttled = _.throttle(renewToken, 300000, { 'trailing': false }); - * jQuery(element).on('click', throttled); - * - * // Cancel the trailing throttled invocation. - * jQuery(window).on('popstate', throttled.cancel); - */ - function throttle(func, wait, options) { - var leading = true, - trailing = true; - - if (typeof func != 'function') { - throw new TypeError(FUNC_ERROR_TEXT); - } - if (isObject(options)) { - leading = 'leading' in options ? !!options.leading : leading; - trailing = 'trailing' in options ? !!options.trailing : trailing; - } - return debounce(func, wait, { - 'leading': leading, - 'maxWait': wait, - 'trailing': trailing - }); - } - - /** - * Creates a function that accepts up to one argument, ignoring any - * additional arguments. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Function - * @param {Function} func The function to cap arguments for. - * @returns {Function} Returns the new capped function. - * @example - * - * _.map(['6', '8', '10'], _.unary(parseInt)); - * // => [6, 8, 10] - */ - function unary(func) { - return ary(func, 1); - } - - /** - * Creates a function that provides `value` to `wrapper` as its first - * argument. Any additional arguments provided to the function are appended - * to those provided to the `wrapper`. The wrapper is invoked with the `this` - * binding of the created function. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Function - * @param {*} value The value to wrap. - * @param {Function} [wrapper=identity] The wrapper function. - * @returns {Function} Returns the new function. - * @example - * - * var p = _.wrap(_.escape, function(func, text) { - * return '

' + func(text) + '

'; - * }); - * - * p('fred, barney, & pebbles'); - * // => '

fred, barney, & pebbles

' - */ - function wrap(value, wrapper) { - return partial(castFunction(wrapper), value); - } - - /*------------------------------------------------------------------------*/ - - /** - * Casts `value` as an array if it's not one. - * - * @static - * @memberOf _ - * @since 4.4.0 - * @category Lang - * @param {*} value The value to inspect. - * @returns {Array} Returns the cast array. - * @example - * - * _.castArray(1); - * // => [1] - * - * _.castArray({ 'a': 1 }); - * // => [{ 'a': 1 }] - * - * _.castArray('abc'); - * // => ['abc'] - * - * _.castArray(null); - * // => [null] - * - * _.castArray(undefined); - * // => [undefined] - * - * _.castArray(); - * // => [] - * - * var array = [1, 2, 3]; - * console.log(_.castArray(array) === array); - * // => true - */ - function castArray() { - if (!arguments.length) { - return []; - } - var value = arguments[0]; - return isArray(value) ? value : [value]; - } - - /** - * Creates a shallow clone of `value`. - * - * **Note:** This method is loosely based on the - * [structured clone algorithm](https://mdn.io/Structured_clone_algorithm) - * and supports cloning arrays, array buffers, booleans, date objects, maps, - * numbers, `Object` objects, regexes, sets, strings, symbols, and typed - * arrays. The own enumerable properties of `arguments` objects are cloned - * as plain objects. An empty object is returned for uncloneable values such - * as error objects, functions, DOM nodes, and WeakMaps. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to clone. - * @returns {*} Returns the cloned value. - * @see _.cloneDeep - * @example - * - * var objects = [{ 'a': 1 }, { 'b': 2 }]; - * - * var shallow = _.clone(objects); - * console.log(shallow[0] === objects[0]); - * // => true - */ - function clone(value) { - return baseClone(value, CLONE_SYMBOLS_FLAG); - } - - /** - * This method is like `_.clone` except that it accepts `customizer` which - * is invoked to produce the cloned value. If `customizer` returns `undefined`, - * cloning is handled by the method instead. The `customizer` is invoked with - * up to four arguments; (value [, index|key, object, stack]). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to clone. - * @param {Function} [customizer] The function to customize cloning. - * @returns {*} Returns the cloned value. - * @see _.cloneDeepWith - * @example - * - * function customizer(value) { - * if (_.isElement(value)) { - * return value.cloneNode(false); - * } - * } - * - * var el = _.cloneWith(document.body, customizer); - * - * console.log(el === document.body); - * // => false - * console.log(el.nodeName); - * // => 'BODY' - * console.log(el.childNodes.length); - * // => 0 - */ - function cloneWith(value, customizer) { - customizer = typeof customizer == 'function' ? customizer : undefined; - return baseClone(value, CLONE_SYMBOLS_FLAG, customizer); - } - - /** - * This method is like `_.clone` except that it recursively clones `value`. - * - * @static - * @memberOf _ - * @since 1.0.0 - * @category Lang - * @param {*} value The value to recursively clone. - * @returns {*} Returns the deep cloned value. - * @see _.clone - * @example - * - * var objects = [{ 'a': 1 }, { 'b': 2 }]; - * - * var deep = _.cloneDeep(objects); - * console.log(deep[0] === objects[0]); - * // => false - */ - function cloneDeep(value) { - return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG); - } - - /** - * This method is like `_.cloneWith` except that it recursively clones `value`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to recursively clone. - * @param {Function} [customizer] The function to customize cloning. - * @returns {*} Returns the deep cloned value. - * @see _.cloneWith - * @example - * - * function customizer(value) { - * if (_.isElement(value)) { - * return value.cloneNode(true); - * } - * } - * - * var el = _.cloneDeepWith(document.body, customizer); - * - * console.log(el === document.body); - * // => false - * console.log(el.nodeName); - * // => 'BODY' - * console.log(el.childNodes.length); - * // => 20 - */ - function cloneDeepWith(value, customizer) { - customizer = typeof customizer == 'function' ? customizer : undefined; - return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG, customizer); - } - - /** - * Checks if `object` conforms to `source` by invoking the predicate - * properties of `source` with the corresponding property values of `object`. - * - * **Note:** This method is equivalent to `_.conforms` when `source` is - * partially applied. - * - * @static - * @memberOf _ - * @since 4.14.0 - * @category Lang - * @param {Object} object The object to inspect. - * @param {Object} source The object of property predicates to conform to. - * @returns {boolean} Returns `true` if `object` conforms, else `false`. - * @example - * - * var object = { 'a': 1, 'b': 2 }; - * - * _.conformsTo(object, { 'b': function(n) { return n > 1; } }); - * // => true - * - * _.conformsTo(object, { 'b': function(n) { return n > 2; } }); - * // => false - */ - function conformsTo(object, source) { - return source == null || baseConformsTo(object, source, keys(source)); - } - - /** - * Performs a - * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * comparison between two values to determine if they are equivalent. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if the values are equivalent, else `false`. - * @example - * - * var object = { 'a': 1 }; - * var other = { 'a': 1 }; - * - * _.eq(object, object); - * // => true - * - * _.eq(object, other); - * // => false - * - * _.eq('a', 'a'); - * // => true - * - * _.eq('a', Object('a')); - * // => false - * - * _.eq(NaN, NaN); - * // => true - */ - function eq(value, other) { - return value === other || (value !== value && other !== other); - } - - /** - * Checks if `value` is greater than `other`. - * - * @static - * @memberOf _ - * @since 3.9.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if `value` is greater than `other`, - * else `false`. - * @see _.lt - * @example - * - * _.gt(3, 1); - * // => true - * - * _.gt(3, 3); - * // => false - * - * _.gt(1, 3); - * // => false - */ - var gt = createRelationalOperation(baseGt); - - /** - * Checks if `value` is greater than or equal to `other`. - * - * @static - * @memberOf _ - * @since 3.9.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if `value` is greater than or equal to - * `other`, else `false`. - * @see _.lte - * @example - * - * _.gte(3, 1); - * // => true - * - * _.gte(3, 3); - * // => true - * - * _.gte(1, 3); - * // => false - */ - var gte = createRelationalOperation(function(value, other) { - return value >= other; - }); - - /** - * Checks if `value` is likely an `arguments` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an `arguments` object, - * else `false`. - * @example - * - * _.isArguments(function() { return arguments; }()); - * // => true - * - * _.isArguments([1, 2, 3]); - * // => false - */ - var isArguments = baseIsArguments(function() { return arguments; }()) ? baseIsArguments : function(value) { - return isObjectLike(value) && hasOwnProperty.call(value, 'callee') && - !propertyIsEnumerable.call(value, 'callee'); - }; - - /** - * Checks if `value` is classified as an `Array` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an array, else `false`. - * @example - * - * _.isArray([1, 2, 3]); - * // => true - * - * _.isArray(document.body.children); - * // => false - * - * _.isArray('abc'); - * // => false - * - * _.isArray(_.noop); - * // => false - */ - var isArray = Array.isArray; - - /** - * Checks if `value` is classified as an `ArrayBuffer` object. - * - * @static - * @memberOf _ - * @since 4.3.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an array buffer, else `false`. - * @example - * - * _.isArrayBuffer(new ArrayBuffer(2)); - * // => true - * - * _.isArrayBuffer(new Array(2)); - * // => false - */ - var isArrayBuffer = nodeIsArrayBuffer ? baseUnary(nodeIsArrayBuffer) : baseIsArrayBuffer; - - /** - * Checks if `value` is array-like. A value is considered array-like if it's - * not a function and has a `value.length` that's an integer greater than or - * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is array-like, else `false`. - * @example - * - * _.isArrayLike([1, 2, 3]); - * // => true - * - * _.isArrayLike(document.body.children); - * // => true - * - * _.isArrayLike('abc'); - * // => true - * - * _.isArrayLike(_.noop); - * // => false - */ - function isArrayLike(value) { - return value != null && isLength(value.length) && !isFunction(value); - } - - /** - * This method is like `_.isArrayLike` except that it also checks if `value` - * is an object. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an array-like object, - * else `false`. - * @example - * - * _.isArrayLikeObject([1, 2, 3]); - * // => true - * - * _.isArrayLikeObject(document.body.children); - * // => true - * - * _.isArrayLikeObject('abc'); - * // => false - * - * _.isArrayLikeObject(_.noop); - * // => false - */ - function isArrayLikeObject(value) { - return isObjectLike(value) && isArrayLike(value); - } - - /** - * Checks if `value` is classified as a boolean primitive or object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a boolean, else `false`. - * @example - * - * _.isBoolean(false); - * // => true - * - * _.isBoolean(null); - * // => false - */ - function isBoolean(value) { - return value === true || value === false || - (isObjectLike(value) && baseGetTag(value) == boolTag); - } - - /** - * Checks if `value` is a buffer. - * - * @static - * @memberOf _ - * @since 4.3.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a buffer, else `false`. - * @example - * - * _.isBuffer(new Buffer(2)); - * // => true - * - * _.isBuffer(new Uint8Array(2)); - * // => false - */ - var isBuffer = nativeIsBuffer || stubFalse; - - /** - * Checks if `value` is classified as a `Date` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a date object, else `false`. - * @example - * - * _.isDate(new Date); - * // => true - * - * _.isDate('Mon April 23 2012'); - * // => false - */ - var isDate = nodeIsDate ? baseUnary(nodeIsDate) : baseIsDate; - - /** - * Checks if `value` is likely a DOM element. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a DOM element, else `false`. - * @example - * - * _.isElement(document.body); - * // => true - * - * _.isElement(''); - * // => false - */ - function isElement(value) { - return isObjectLike(value) && value.nodeType === 1 && !isPlainObject(value); - } - - /** - * Checks if `value` is an empty object, collection, map, or set. - * - * Objects are considered empty if they have no own enumerable string keyed - * properties. - * - * Array-like values such as `arguments` objects, arrays, buffers, strings, or - * jQuery-like collections are considered empty if they have a `length` of `0`. - * Similarly, maps and sets are considered empty if they have a `size` of `0`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is empty, else `false`. - * @example - * - * _.isEmpty(null); - * // => true - * - * _.isEmpty(true); - * // => true - * - * _.isEmpty(1); - * // => true - * - * _.isEmpty([1, 2, 3]); - * // => false - * - * _.isEmpty({ 'a': 1 }); - * // => false - */ - function isEmpty(value) { - if (value == null) { - return true; - } - if (isArrayLike(value) && - (isArray(value) || typeof value == 'string' || typeof value.splice == 'function' || - isBuffer(value) || isTypedArray(value) || isArguments(value))) { - return !value.length; - } - var tag = getTag(value); - if (tag == mapTag || tag == setTag) { - return !value.size; - } - if (isPrototype(value)) { - return !baseKeys(value).length; - } - for (var key in value) { - if (hasOwnProperty.call(value, key)) { - return false; - } - } - return true; - } - - /** - * Performs a deep comparison between two values to determine if they are - * equivalent. - * - * **Note:** This method supports comparing arrays, array buffers, booleans, - * date objects, error objects, maps, numbers, `Object` objects, regexes, - * sets, strings, symbols, and typed arrays. `Object` objects are compared - * by their own, not inherited, enumerable properties. Functions and DOM - * nodes are compared by strict equality, i.e. `===`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if the values are equivalent, else `false`. - * @example - * - * var object = { 'a': 1 }; - * var other = { 'a': 1 }; - * - * _.isEqual(object, other); - * // => true - * - * object === other; - * // => false - */ - function isEqual(value, other) { - return baseIsEqual(value, other); - } - - /** - * This method is like `_.isEqual` except that it accepts `customizer` which - * is invoked to compare values. If `customizer` returns `undefined`, comparisons - * are handled by the method instead. The `customizer` is invoked with up to - * six arguments: (objValue, othValue [, index|key, object, other, stack]). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @param {Function} [customizer] The function to customize comparisons. - * @returns {boolean} Returns `true` if the values are equivalent, else `false`. - * @example - * - * function isGreeting(value) { - * return /^h(?:i|ello)$/.test(value); - * } - * - * function customizer(objValue, othValue) { - * if (isGreeting(objValue) && isGreeting(othValue)) { - * return true; - * } - * } - * - * var array = ['hello', 'goodbye']; - * var other = ['hi', 'goodbye']; - * - * _.isEqualWith(array, other, customizer); - * // => true - */ - function isEqualWith(value, other, customizer) { - customizer = typeof customizer == 'function' ? customizer : undefined; - var result = customizer ? customizer(value, other) : undefined; - return result === undefined ? baseIsEqual(value, other, undefined, customizer) : !!result; - } - - /** - * Checks if `value` is an `Error`, `EvalError`, `RangeError`, `ReferenceError`, - * `SyntaxError`, `TypeError`, or `URIError` object. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an error object, else `false`. - * @example - * - * _.isError(new Error); - * // => true - * - * _.isError(Error); - * // => false - */ - function isError(value) { - if (!isObjectLike(value)) { - return false; - } - var tag = baseGetTag(value); - return tag == errorTag || tag == domExcTag || - (typeof value.message == 'string' && typeof value.name == 'string' && !isPlainObject(value)); - } - - /** - * Checks if `value` is a finite primitive number. - * - * **Note:** This method is based on - * [`Number.isFinite`](https://mdn.io/Number/isFinite). - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a finite number, else `false`. - * @example - * - * _.isFinite(3); - * // => true - * - * _.isFinite(Number.MIN_VALUE); - * // => true - * - * _.isFinite(Infinity); - * // => false - * - * _.isFinite('3'); - * // => false - */ - function isFinite(value) { - return typeof value == 'number' && nativeIsFinite(value); - } - - /** - * Checks if `value` is classified as a `Function` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a function, else `false`. - * @example - * - * _.isFunction(_); - * // => true - * - * _.isFunction(/abc/); - * // => false - */ - function isFunction(value) { - if (!isObject(value)) { - return false; - } - // The use of `Object#toString` avoids issues with the `typeof` operator - // in Safari 9 which returns 'object' for typed arrays and other constructors. - var tag = baseGetTag(value); - return tag == funcTag || tag == genTag || tag == asyncTag || tag == proxyTag; - } - - /** - * Checks if `value` is an integer. - * - * **Note:** This method is based on - * [`Number.isInteger`](https://mdn.io/Number/isInteger). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an integer, else `false`. - * @example - * - * _.isInteger(3); - * // => true - * - * _.isInteger(Number.MIN_VALUE); - * // => false - * - * _.isInteger(Infinity); - * // => false - * - * _.isInteger('3'); - * // => false - */ - function isInteger(value) { - return typeof value == 'number' && value == toInteger(value); - } - - /** - * Checks if `value` is a valid array-like length. - * - * **Note:** This method is loosely based on - * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a valid length, else `false`. - * @example - * - * _.isLength(3); - * // => true - * - * _.isLength(Number.MIN_VALUE); - * // => false - * - * _.isLength(Infinity); - * // => false - * - * _.isLength('3'); - * // => false - */ - function isLength(value) { - return typeof value == 'number' && - value > -1 && value % 1 == 0 && value <= MAX_SAFE_INTEGER; - } - - /** - * Checks if `value` is the - * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) - * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an object, else `false`. - * @example - * - * _.isObject({}); - * // => true - * - * _.isObject([1, 2, 3]); - * // => true - * - * _.isObject(_.noop); - * // => true - * - * _.isObject(null); - * // => false - */ - function isObject(value) { - var type = typeof value; - return value != null && (type == 'object' || type == 'function'); - } - - /** - * Checks if `value` is object-like. A value is object-like if it's not `null` - * and has a `typeof` result of "object". - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is object-like, else `false`. - * @example - * - * _.isObjectLike({}); - * // => true - * - * _.isObjectLike([1, 2, 3]); - * // => true - * - * _.isObjectLike(_.noop); - * // => false - * - * _.isObjectLike(null); - * // => false - */ - function isObjectLike(value) { - return value != null && typeof value == 'object'; - } - - /** - * Checks if `value` is classified as a `Map` object. - * - * @static - * @memberOf _ - * @since 4.3.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a map, else `false`. - * @example - * - * _.isMap(new Map); - * // => true - * - * _.isMap(new WeakMap); - * // => false - */ - var isMap = nodeIsMap ? baseUnary(nodeIsMap) : baseIsMap; - - /** - * Performs a partial deep comparison between `object` and `source` to - * determine if `object` contains equivalent property values. - * - * **Note:** This method is equivalent to `_.matches` when `source` is - * partially applied. - * - * Partial comparisons will match empty array and empty object `source` - * values against any array or object value, respectively. See `_.isEqual` - * for a list of supported value comparisons. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Lang - * @param {Object} object The object to inspect. - * @param {Object} source The object of property values to match. - * @returns {boolean} Returns `true` if `object` is a match, else `false`. - * @example - * - * var object = { 'a': 1, 'b': 2 }; - * - * _.isMatch(object, { 'b': 2 }); - * // => true - * - * _.isMatch(object, { 'b': 1 }); - * // => false - */ - function isMatch(object, source) { - return object === source || baseIsMatch(object, source, getMatchData(source)); - } - - /** - * This method is like `_.isMatch` except that it accepts `customizer` which - * is invoked to compare values. If `customizer` returns `undefined`, comparisons - * are handled by the method instead. The `customizer` is invoked with five - * arguments: (objValue, srcValue, index|key, object, source). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {Object} object The object to inspect. - * @param {Object} source The object of property values to match. - * @param {Function} [customizer] The function to customize comparisons. - * @returns {boolean} Returns `true` if `object` is a match, else `false`. - * @example - * - * function isGreeting(value) { - * return /^h(?:i|ello)$/.test(value); - * } - * - * function customizer(objValue, srcValue) { - * if (isGreeting(objValue) && isGreeting(srcValue)) { - * return true; - * } - * } - * - * var object = { 'greeting': 'hello' }; - * var source = { 'greeting': 'hi' }; - * - * _.isMatchWith(object, source, customizer); - * // => true - */ - function isMatchWith(object, source, customizer) { - customizer = typeof customizer == 'function' ? customizer : undefined; - return baseIsMatch(object, source, getMatchData(source), customizer); - } - - /** - * Checks if `value` is `NaN`. - * - * **Note:** This method is based on - * [`Number.isNaN`](https://mdn.io/Number/isNaN) and is not the same as - * global [`isNaN`](https://mdn.io/isNaN) which returns `true` for - * `undefined` and other non-number values. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is `NaN`, else `false`. - * @example - * - * _.isNaN(NaN); - * // => true - * - * _.isNaN(new Number(NaN)); - * // => true - * - * isNaN(undefined); - * // => true - * - * _.isNaN(undefined); - * // => false - */ - function isNaN(value) { - // An `NaN` primitive is the only value that is not equal to itself. - // Perform the `toStringTag` check first to avoid errors with some - // ActiveX objects in IE. - return isNumber(value) && value != +value; - } - - /** - * Checks if `value` is a pristine native function. - * - * **Note:** This method can't reliably detect native functions in the presence - * of the core-js package because core-js circumvents this kind of detection. - * Despite multiple requests, the core-js maintainer has made it clear: any - * attempt to fix the detection will be obstructed. As a result, we're left - * with little choice but to throw an error. Unfortunately, this also affects - * packages, like [babel-polyfill](https://www.npmjs.com/package/babel-polyfill), - * which rely on core-js. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a native function, - * else `false`. - * @example - * - * _.isNative(Array.prototype.push); - * // => true - * - * _.isNative(_); - * // => false - */ - function isNative(value) { - if (isMaskable(value)) { - throw new Error(CORE_ERROR_TEXT); - } - return baseIsNative(value); - } - - /** - * Checks if `value` is `null`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is `null`, else `false`. - * @example - * - * _.isNull(null); - * // => true - * - * _.isNull(void 0); - * // => false - */ - function isNull(value) { - return value === null; - } - - /** - * Checks if `value` is `null` or `undefined`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is nullish, else `false`. - * @example - * - * _.isNil(null); - * // => true - * - * _.isNil(void 0); - * // => true - * - * _.isNil(NaN); - * // => false - */ - function isNil(value) { - return value == null; - } - - /** - * Checks if `value` is classified as a `Number` primitive or object. - * - * **Note:** To exclude `Infinity`, `-Infinity`, and `NaN`, which are - * classified as numbers, use the `_.isFinite` method. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a number, else `false`. - * @example - * - * _.isNumber(3); - * // => true - * - * _.isNumber(Number.MIN_VALUE); - * // => true - * - * _.isNumber(Infinity); - * // => true - * - * _.isNumber('3'); - * // => false - */ - function isNumber(value) { - return typeof value == 'number' || - (isObjectLike(value) && baseGetTag(value) == numberTag); - } - - /** - * Checks if `value` is a plain object, that is, an object created by the - * `Object` constructor or one with a `[[Prototype]]` of `null`. - * - * @static - * @memberOf _ - * @since 0.8.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. - * @example - * - * function Foo() { - * this.a = 1; - * } - * - * _.isPlainObject(new Foo); - * // => false - * - * _.isPlainObject([1, 2, 3]); - * // => false - * - * _.isPlainObject({ 'x': 0, 'y': 0 }); - * // => true - * - * _.isPlainObject(Object.create(null)); - * // => true - */ - function isPlainObject(value) { - if (!isObjectLike(value) || baseGetTag(value) != objectTag) { - return false; - } - var proto = getPrototype(value); - if (proto === null) { - return true; - } - var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor; - return typeof Ctor == 'function' && Ctor instanceof Ctor && - funcToString.call(Ctor) == objectCtorString; - } - - /** - * Checks if `value` is classified as a `RegExp` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a regexp, else `false`. - * @example - * - * _.isRegExp(/abc/); - * // => true - * - * _.isRegExp('/abc/'); - * // => false - */ - var isRegExp = nodeIsRegExp ? baseUnary(nodeIsRegExp) : baseIsRegExp; - - /** - * Checks if `value` is a safe integer. An integer is safe if it's an IEEE-754 - * double precision number which isn't the result of a rounded unsafe integer. - * - * **Note:** This method is based on - * [`Number.isSafeInteger`](https://mdn.io/Number/isSafeInteger). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a safe integer, else `false`. - * @example - * - * _.isSafeInteger(3); - * // => true - * - * _.isSafeInteger(Number.MIN_VALUE); - * // => false - * - * _.isSafeInteger(Infinity); - * // => false - * - * _.isSafeInteger('3'); - * // => false - */ - function isSafeInteger(value) { - return isInteger(value) && value >= -MAX_SAFE_INTEGER && value <= MAX_SAFE_INTEGER; - } - - /** - * Checks if `value` is classified as a `Set` object. - * - * @static - * @memberOf _ - * @since 4.3.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a set, else `false`. - * @example - * - * _.isSet(new Set); - * // => true - * - * _.isSet(new WeakSet); - * // => false - */ - var isSet = nodeIsSet ? baseUnary(nodeIsSet) : baseIsSet; - - /** - * Checks if `value` is classified as a `String` primitive or object. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a string, else `false`. - * @example - * - * _.isString('abc'); - * // => true - * - * _.isString(1); - * // => false - */ - function isString(value) { - return typeof value == 'string' || - (!isArray(value) && isObjectLike(value) && baseGetTag(value) == stringTag); - } - - /** - * Checks if `value` is classified as a `Symbol` primitive or object. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a symbol, else `false`. - * @example - * - * _.isSymbol(Symbol.iterator); - * // => true - * - * _.isSymbol('abc'); - * // => false - */ - function isSymbol(value) { - return typeof value == 'symbol' || - (isObjectLike(value) && baseGetTag(value) == symbolTag); - } - - /** - * Checks if `value` is classified as a typed array. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a typed array, else `false`. - * @example - * - * _.isTypedArray(new Uint8Array); - * // => true - * - * _.isTypedArray([]); - * // => false - */ - var isTypedArray = nodeIsTypedArray ? baseUnary(nodeIsTypedArray) : baseIsTypedArray; - - /** - * Checks if `value` is `undefined`. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is `undefined`, else `false`. - * @example - * - * _.isUndefined(void 0); - * // => true - * - * _.isUndefined(null); - * // => false - */ - function isUndefined(value) { - return value === undefined; - } - - /** - * Checks if `value` is classified as a `WeakMap` object. - * - * @static - * @memberOf _ - * @since 4.3.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a weak map, else `false`. - * @example - * - * _.isWeakMap(new WeakMap); - * // => true - * - * _.isWeakMap(new Map); - * // => false - */ - function isWeakMap(value) { - return isObjectLike(value) && getTag(value) == weakMapTag; - } - - /** - * Checks if `value` is classified as a `WeakSet` object. - * - * @static - * @memberOf _ - * @since 4.3.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a weak set, else `false`. - * @example - * - * _.isWeakSet(new WeakSet); - * // => true - * - * _.isWeakSet(new Set); - * // => false - */ - function isWeakSet(value) { - return isObjectLike(value) && baseGetTag(value) == weakSetTag; - } - - /** - * Checks if `value` is less than `other`. - * - * @static - * @memberOf _ - * @since 3.9.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if `value` is less than `other`, - * else `false`. - * @see _.gt - * @example - * - * _.lt(1, 3); - * // => true - * - * _.lt(3, 3); - * // => false - * - * _.lt(3, 1); - * // => false - */ - var lt = createRelationalOperation(baseLt); - - /** - * Checks if `value` is less than or equal to `other`. - * - * @static - * @memberOf _ - * @since 3.9.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if `value` is less than or equal to - * `other`, else `false`. - * @see _.gte - * @example - * - * _.lte(1, 3); - * // => true - * - * _.lte(3, 3); - * // => true - * - * _.lte(3, 1); - * // => false - */ - var lte = createRelationalOperation(function(value, other) { - return value <= other; - }); - - /** - * Converts `value` to an array. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Lang - * @param {*} value The value to convert. - * @returns {Array} Returns the converted array. - * @example - * - * _.toArray({ 'a': 1, 'b': 2 }); - * // => [1, 2] - * - * _.toArray('abc'); - * // => ['a', 'b', 'c'] - * - * _.toArray(1); - * // => [] - * - * _.toArray(null); - * // => [] - */ - function toArray(value) { - if (!value) { - return []; - } - if (isArrayLike(value)) { - return isString(value) ? stringToArray(value) : copyArray(value); - } - if (symIterator && value[symIterator]) { - return iteratorToArray(value[symIterator]()); - } - var tag = getTag(value), - func = tag == mapTag ? mapToArray : (tag == setTag ? setToArray : values); - - return func(value); - } - - /** - * Converts `value` to a finite number. - * - * @static - * @memberOf _ - * @since 4.12.0 - * @category Lang - * @param {*} value The value to convert. - * @returns {number} Returns the converted number. - * @example - * - * _.toFinite(3.2); - * // => 3.2 - * - * _.toFinite(Number.MIN_VALUE); - * // => 5e-324 - * - * _.toFinite(Infinity); - * // => 1.7976931348623157e+308 - * - * _.toFinite('3.2'); - * // => 3.2 - */ - function toFinite(value) { - if (!value) { - return value === 0 ? value : 0; - } - value = toNumber(value); - if (value === INFINITY || value === -INFINITY) { - var sign = (value < 0 ? -1 : 1); - return sign * MAX_INTEGER; - } - return value === value ? value : 0; - } - - /** - * Converts `value` to an integer. - * - * **Note:** This method is loosely based on - * [`ToInteger`](http://www.ecma-international.org/ecma-262/7.0/#sec-tointeger). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to convert. - * @returns {number} Returns the converted integer. - * @example - * - * _.toInteger(3.2); - * // => 3 - * - * _.toInteger(Number.MIN_VALUE); - * // => 0 - * - * _.toInteger(Infinity); - * // => 1.7976931348623157e+308 - * - * _.toInteger('3.2'); - * // => 3 - */ - function toInteger(value) { - var result = toFinite(value), - remainder = result % 1; - - return result === result ? (remainder ? result - remainder : result) : 0; - } - - /** - * Converts `value` to an integer suitable for use as the length of an - * array-like object. - * - * **Note:** This method is based on - * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to convert. - * @returns {number} Returns the converted integer. - * @example - * - * _.toLength(3.2); - * // => 3 - * - * _.toLength(Number.MIN_VALUE); - * // => 0 - * - * _.toLength(Infinity); - * // => 4294967295 - * - * _.toLength('3.2'); - * // => 3 - */ - function toLength(value) { - return value ? baseClamp(toInteger(value), 0, MAX_ARRAY_LENGTH) : 0; - } - - /** - * Converts `value` to a number. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to process. - * @returns {number} Returns the number. - * @example - * - * _.toNumber(3.2); - * // => 3.2 - * - * _.toNumber(Number.MIN_VALUE); - * // => 5e-324 - * - * _.toNumber(Infinity); - * // => Infinity - * - * _.toNumber('3.2'); - * // => 3.2 - */ - function toNumber(value) { - if (typeof value == 'number') { - return value; - } - if (isSymbol(value)) { - return NAN; - } - if (isObject(value)) { - var other = typeof value.valueOf == 'function' ? value.valueOf() : value; - value = isObject(other) ? (other + '') : other; - } - if (typeof value != 'string') { - return value === 0 ? value : +value; - } - value = value.replace(reTrim, ''); - var isBinary = reIsBinary.test(value); - return (isBinary || reIsOctal.test(value)) - ? freeParseInt(value.slice(2), isBinary ? 2 : 8) - : (reIsBadHex.test(value) ? NAN : +value); - } - - /** - * Converts `value` to a plain object flattening inherited enumerable string - * keyed properties of `value` to own properties of the plain object. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Lang - * @param {*} value The value to convert. - * @returns {Object} Returns the converted plain object. - * @example - * - * function Foo() { - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.assign({ 'a': 1 }, new Foo); - * // => { 'a': 1, 'b': 2 } - * - * _.assign({ 'a': 1 }, _.toPlainObject(new Foo)); - * // => { 'a': 1, 'b': 2, 'c': 3 } - */ - function toPlainObject(value) { - return copyObject(value, keysIn(value)); - } - - /** - * Converts `value` to a safe integer. A safe integer can be compared and - * represented correctly. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to convert. - * @returns {number} Returns the converted integer. - * @example - * - * _.toSafeInteger(3.2); - * // => 3 - * - * _.toSafeInteger(Number.MIN_VALUE); - * // => 0 - * - * _.toSafeInteger(Infinity); - * // => 9007199254740991 - * - * _.toSafeInteger('3.2'); - * // => 3 - */ - function toSafeInteger(value) { - return value - ? baseClamp(toInteger(value), -MAX_SAFE_INTEGER, MAX_SAFE_INTEGER) - : (value === 0 ? value : 0); - } - - /** - * Converts `value` to a string. An empty string is returned for `null` - * and `undefined` values. The sign of `-0` is preserved. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to convert. - * @returns {string} Returns the converted string. - * @example - * - * _.toString(null); - * // => '' - * - * _.toString(-0); - * // => '-0' - * - * _.toString([1, 2, 3]); - * // => '1,2,3' - */ - function toString(value) { - return value == null ? '' : baseToString(value); - } - - /*------------------------------------------------------------------------*/ - - /** - * Assigns own enumerable string keyed properties of source objects to the - * destination object. Source objects are applied from left to right. - * Subsequent sources overwrite property assignments of previous sources. - * - * **Note:** This method mutates `object` and is loosely based on - * [`Object.assign`](https://mdn.io/Object/assign). - * - * @static - * @memberOf _ - * @since 0.10.0 - * @category Object - * @param {Object} object The destination object. - * @param {...Object} [sources] The source objects. - * @returns {Object} Returns `object`. - * @see _.assignIn - * @example - * - * function Foo() { - * this.a = 1; - * } - * - * function Bar() { - * this.c = 3; - * } - * - * Foo.prototype.b = 2; - * Bar.prototype.d = 4; - * - * _.assign({ 'a': 0 }, new Foo, new Bar); - * // => { 'a': 1, 'c': 3 } - */ - var assign = createAssigner(function(object, source) { - if (isPrototype(source) || isArrayLike(source)) { - copyObject(source, keys(source), object); - return; - } - for (var key in source) { - if (hasOwnProperty.call(source, key)) { - assignValue(object, key, source[key]); - } - } - }); - - /** - * This method is like `_.assign` except that it iterates over own and - * inherited source properties. - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @alias extend - * @category Object - * @param {Object} object The destination object. - * @param {...Object} [sources] The source objects. - * @returns {Object} Returns `object`. - * @see _.assign - * @example - * - * function Foo() { - * this.a = 1; - * } - * - * function Bar() { - * this.c = 3; - * } - * - * Foo.prototype.b = 2; - * Bar.prototype.d = 4; - * - * _.assignIn({ 'a': 0 }, new Foo, new Bar); - * // => { 'a': 1, 'b': 2, 'c': 3, 'd': 4 } - */ - var assignIn = createAssigner(function(object, source) { - copyObject(source, keysIn(source), object); - }); - - /** - * This method is like `_.assignIn` except that it accepts `customizer` - * which is invoked to produce the assigned values. If `customizer` returns - * `undefined`, assignment is handled by the method instead. The `customizer` - * is invoked with five arguments: (objValue, srcValue, key, object, source). - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @alias extendWith - * @category Object - * @param {Object} object The destination object. - * @param {...Object} sources The source objects. - * @param {Function} [customizer] The function to customize assigned values. - * @returns {Object} Returns `object`. - * @see _.assignWith - * @example - * - * function customizer(objValue, srcValue) { - * return _.isUndefined(objValue) ? srcValue : objValue; - * } - * - * var defaults = _.partialRight(_.assignInWith, customizer); - * - * defaults({ 'a': 1 }, { 'b': 2 }, { 'a': 3 }); - * // => { 'a': 1, 'b': 2 } - */ - var assignInWith = createAssigner(function(object, source, srcIndex, customizer) { - copyObject(source, keysIn(source), object, customizer); - }); - - /** - * This method is like `_.assign` except that it accepts `customizer` - * which is invoked to produce the assigned values. If `customizer` returns - * `undefined`, assignment is handled by the method instead. The `customizer` - * is invoked with five arguments: (objValue, srcValue, key, object, source). - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The destination object. - * @param {...Object} sources The source objects. - * @param {Function} [customizer] The function to customize assigned values. - * @returns {Object} Returns `object`. - * @see _.assignInWith - * @example - * - * function customizer(objValue, srcValue) { - * return _.isUndefined(objValue) ? srcValue : objValue; - * } - * - * var defaults = _.partialRight(_.assignWith, customizer); - * - * defaults({ 'a': 1 }, { 'b': 2 }, { 'a': 3 }); - * // => { 'a': 1, 'b': 2 } - */ - var assignWith = createAssigner(function(object, source, srcIndex, customizer) { - copyObject(source, keys(source), object, customizer); - }); - - /** - * Creates an array of values corresponding to `paths` of `object`. - * - * @static - * @memberOf _ - * @since 1.0.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {...(string|string[])} [paths] The property paths to pick. - * @returns {Array} Returns the picked values. - * @example - * - * var object = { 'a': [{ 'b': { 'c': 3 } }, 4] }; - * - * _.at(object, ['a[0].b.c', 'a[1]']); - * // => [3, 4] - */ - var at = flatRest(baseAt); - - /** - * Creates an object that inherits from the `prototype` object. If a - * `properties` object is given, its own enumerable string keyed properties - * are assigned to the created object. - * - * @static - * @memberOf _ - * @since 2.3.0 - * @category Object - * @param {Object} prototype The object to inherit from. - * @param {Object} [properties] The properties to assign to the object. - * @returns {Object} Returns the new object. - * @example - * - * function Shape() { - * this.x = 0; - * this.y = 0; - * } - * - * function Circle() { - * Shape.call(this); - * } - * - * Circle.prototype = _.create(Shape.prototype, { - * 'constructor': Circle - * }); - * - * var circle = new Circle; - * circle instanceof Circle; - * // => true - * - * circle instanceof Shape; - * // => true - */ - function create(prototype, properties) { - var result = baseCreate(prototype); - return properties == null ? result : baseAssign(result, properties); - } - - /** - * Assigns own and inherited enumerable string keyed properties of source - * objects to the destination object for all destination properties that - * resolve to `undefined`. Source objects are applied from left to right. - * Once a property is set, additional values of the same property are ignored. - * - * **Note:** This method mutates `object`. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The destination object. - * @param {...Object} [sources] The source objects. - * @returns {Object} Returns `object`. - * @see _.defaultsDeep - * @example - * - * _.defaults({ 'a': 1 }, { 'b': 2 }, { 'a': 3 }); - * // => { 'a': 1, 'b': 2 } - */ - var defaults = baseRest(function(object, sources) { - object = Object(object); - - var index = -1; - var length = sources.length; - var guard = length > 2 ? sources[2] : undefined; - - if (guard && isIterateeCall(sources[0], sources[1], guard)) { - length = 1; - } - - while (++index < length) { - var source = sources[index]; - var props = keysIn(source); - var propsIndex = -1; - var propsLength = props.length; - - while (++propsIndex < propsLength) { - var key = props[propsIndex]; - var value = object[key]; - - if (value === undefined || - (eq(value, objectProto[key]) && !hasOwnProperty.call(object, key))) { - object[key] = source[key]; - } - } - } - - return object; - }); - - /** - * This method is like `_.defaults` except that it recursively assigns - * default properties. - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 3.10.0 - * @category Object - * @param {Object} object The destination object. - * @param {...Object} [sources] The source objects. - * @returns {Object} Returns `object`. - * @see _.defaults - * @example - * - * _.defaultsDeep({ 'a': { 'b': 2 } }, { 'a': { 'b': 1, 'c': 3 } }); - * // => { 'a': { 'b': 2, 'c': 3 } } - */ - var defaultsDeep = baseRest(function(args) { - args.push(undefined, customDefaultsMerge); - return apply(mergeWith, undefined, args); - }); - - /** - * This method is like `_.find` except that it returns the key of the first - * element `predicate` returns truthy for instead of the element itself. - * - * @static - * @memberOf _ - * @since 1.1.0 - * @category Object - * @param {Object} object The object to inspect. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {string|undefined} Returns the key of the matched element, - * else `undefined`. - * @example - * - * var users = { - * 'barney': { 'age': 36, 'active': true }, - * 'fred': { 'age': 40, 'active': false }, - * 'pebbles': { 'age': 1, 'active': true } - * }; - * - * _.findKey(users, function(o) { return o.age < 40; }); - * // => 'barney' (iteration order is not guaranteed) - * - * // The `_.matches` iteratee shorthand. - * _.findKey(users, { 'age': 1, 'active': true }); - * // => 'pebbles' - * - * // The `_.matchesProperty` iteratee shorthand. - * _.findKey(users, ['active', false]); - * // => 'fred' - * - * // The `_.property` iteratee shorthand. - * _.findKey(users, 'active'); - * // => 'barney' - */ - function findKey(object, predicate) { - return baseFindKey(object, getIteratee(predicate, 3), baseForOwn); - } - - /** - * This method is like `_.findKey` except that it iterates over elements of - * a collection in the opposite order. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Object - * @param {Object} object The object to inspect. - * @param {Function} [predicate=_.identity] The function invoked per iteration. - * @returns {string|undefined} Returns the key of the matched element, - * else `undefined`. - * @example - * - * var users = { - * 'barney': { 'age': 36, 'active': true }, - * 'fred': { 'age': 40, 'active': false }, - * 'pebbles': { 'age': 1, 'active': true } - * }; - * - * _.findLastKey(users, function(o) { return o.age < 40; }); - * // => returns 'pebbles' assuming `_.findKey` returns 'barney' - * - * // The `_.matches` iteratee shorthand. - * _.findLastKey(users, { 'age': 36, 'active': true }); - * // => 'barney' - * - * // The `_.matchesProperty` iteratee shorthand. - * _.findLastKey(users, ['active', false]); - * // => 'fred' - * - * // The `_.property` iteratee shorthand. - * _.findLastKey(users, 'active'); - * // => 'pebbles' - */ - function findLastKey(object, predicate) { - return baseFindKey(object, getIteratee(predicate, 3), baseForOwnRight); - } - - /** - * Iterates over own and inherited enumerable string keyed properties of an - * object and invokes `iteratee` for each property. The iteratee is invoked - * with three arguments: (value, key, object). Iteratee functions may exit - * iteration early by explicitly returning `false`. - * - * @static - * @memberOf _ - * @since 0.3.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Object} Returns `object`. - * @see _.forInRight - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.forIn(new Foo, function(value, key) { - * console.log(key); - * }); - * // => Logs 'a', 'b', then 'c' (iteration order is not guaranteed). - */ - function forIn(object, iteratee) { - return object == null - ? object - : baseFor(object, getIteratee(iteratee, 3), keysIn); - } - - /** - * This method is like `_.forIn` except that it iterates over properties of - * `object` in the opposite order. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Object} Returns `object`. - * @see _.forIn - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.forInRight(new Foo, function(value, key) { - * console.log(key); - * }); - * // => Logs 'c', 'b', then 'a' assuming `_.forIn` logs 'a', 'b', then 'c'. - */ - function forInRight(object, iteratee) { - return object == null - ? object - : baseForRight(object, getIteratee(iteratee, 3), keysIn); - } - - /** - * Iterates over own enumerable string keyed properties of an object and - * invokes `iteratee` for each property. The iteratee is invoked with three - * arguments: (value, key, object). Iteratee functions may exit iteration - * early by explicitly returning `false`. - * - * @static - * @memberOf _ - * @since 0.3.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Object} Returns `object`. - * @see _.forOwnRight - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.forOwn(new Foo, function(value, key) { - * console.log(key); - * }); - * // => Logs 'a' then 'b' (iteration order is not guaranteed). - */ - function forOwn(object, iteratee) { - return object && baseForOwn(object, getIteratee(iteratee, 3)); - } - - /** - * This method is like `_.forOwn` except that it iterates over properties of - * `object` in the opposite order. - * - * @static - * @memberOf _ - * @since 2.0.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Object} Returns `object`. - * @see _.forOwn - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.forOwnRight(new Foo, function(value, key) { - * console.log(key); - * }); - * // => Logs 'b' then 'a' assuming `_.forOwn` logs 'a' then 'b'. - */ - function forOwnRight(object, iteratee) { - return object && baseForOwnRight(object, getIteratee(iteratee, 3)); - } - - /** - * Creates an array of function property names from own enumerable properties - * of `object`. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The object to inspect. - * @returns {Array} Returns the function names. - * @see _.functionsIn - * @example - * - * function Foo() { - * this.a = _.constant('a'); - * this.b = _.constant('b'); - * } - * - * Foo.prototype.c = _.constant('c'); - * - * _.functions(new Foo); - * // => ['a', 'b'] - */ - function functions(object) { - return object == null ? [] : baseFunctions(object, keys(object)); - } - - /** - * Creates an array of function property names from own and inherited - * enumerable properties of `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The object to inspect. - * @returns {Array} Returns the function names. - * @see _.functions - * @example - * - * function Foo() { - * this.a = _.constant('a'); - * this.b = _.constant('b'); - * } - * - * Foo.prototype.c = _.constant('c'); - * - * _.functionsIn(new Foo); - * // => ['a', 'b', 'c'] - */ - function functionsIn(object) { - return object == null ? [] : baseFunctions(object, keysIn(object)); - } - - /** - * Gets the value at `path` of `object`. If the resolved value is - * `undefined`, the `defaultValue` is returned in its place. - * - * @static - * @memberOf _ - * @since 3.7.0 - * @category Object - * @param {Object} object The object to query. - * @param {Array|string} path The path of the property to get. - * @param {*} [defaultValue] The value returned for `undefined` resolved values. - * @returns {*} Returns the resolved value. - * @example - * - * var object = { 'a': [{ 'b': { 'c': 3 } }] }; - * - * _.get(object, 'a[0].b.c'); - * // => 3 - * - * _.get(object, ['a', '0', 'b', 'c']); - * // => 3 - * - * _.get(object, 'a.b.c', 'default'); - * // => 'default' - */ - function get(object, path, defaultValue) { - var result = object == null ? undefined : baseGet(object, path); - return result === undefined ? defaultValue : result; - } - - /** - * Checks if `path` is a direct property of `object`. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The object to query. - * @param {Array|string} path The path to check. - * @returns {boolean} Returns `true` if `path` exists, else `false`. - * @example - * - * var object = { 'a': { 'b': 2 } }; - * var other = _.create({ 'a': _.create({ 'b': 2 }) }); - * - * _.has(object, 'a'); - * // => true - * - * _.has(object, 'a.b'); - * // => true - * - * _.has(object, ['a', 'b']); - * // => true - * - * _.has(other, 'a'); - * // => false - */ - function has(object, path) { - return object != null && hasPath(object, path, baseHas); - } - - /** - * Checks if `path` is a direct or inherited property of `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The object to query. - * @param {Array|string} path The path to check. - * @returns {boolean} Returns `true` if `path` exists, else `false`. - * @example - * - * var object = _.create({ 'a': _.create({ 'b': 2 }) }); - * - * _.hasIn(object, 'a'); - * // => true - * - * _.hasIn(object, 'a.b'); - * // => true - * - * _.hasIn(object, ['a', 'b']); - * // => true - * - * _.hasIn(object, 'b'); - * // => false - */ - function hasIn(object, path) { - return object != null && hasPath(object, path, baseHasIn); - } - - /** - * Creates an object composed of the inverted keys and values of `object`. - * If `object` contains duplicate values, subsequent values overwrite - * property assignments of previous values. - * - * @static - * @memberOf _ - * @since 0.7.0 - * @category Object - * @param {Object} object The object to invert. - * @returns {Object} Returns the new inverted object. - * @example - * - * var object = { 'a': 1, 'b': 2, 'c': 1 }; - * - * _.invert(object); - * // => { '1': 'c', '2': 'b' } - */ - var invert = createInverter(function(result, value, key) { - if (value != null && - typeof value.toString != 'function') { - value = nativeObjectToString.call(value); - } - - result[value] = key; - }, constant(identity)); - - /** - * This method is like `_.invert` except that the inverted object is generated - * from the results of running each element of `object` thru `iteratee`. The - * corresponding inverted value of each inverted key is an array of keys - * responsible for generating the inverted value. The iteratee is invoked - * with one argument: (value). - * - * @static - * @memberOf _ - * @since 4.1.0 - * @category Object - * @param {Object} object The object to invert. - * @param {Function} [iteratee=_.identity] The iteratee invoked per element. - * @returns {Object} Returns the new inverted object. - * @example - * - * var object = { 'a': 1, 'b': 2, 'c': 1 }; - * - * _.invertBy(object); - * // => { '1': ['a', 'c'], '2': ['b'] } - * - * _.invertBy(object, function(value) { - * return 'group' + value; - * }); - * // => { 'group1': ['a', 'c'], 'group2': ['b'] } - */ - var invertBy = createInverter(function(result, value, key) { - if (value != null && - typeof value.toString != 'function') { - value = nativeObjectToString.call(value); - } - - if (hasOwnProperty.call(result, value)) { - result[value].push(key); - } else { - result[value] = [key]; - } - }, getIteratee); - - /** - * Invokes the method at `path` of `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The object to query. - * @param {Array|string} path The path of the method to invoke. - * @param {...*} [args] The arguments to invoke the method with. - * @returns {*} Returns the result of the invoked method. - * @example - * - * var object = { 'a': [{ 'b': { 'c': [1, 2, 3, 4] } }] }; - * - * _.invoke(object, 'a[0].b.c.slice', 1, 3); - * // => [2, 3] - */ - var invoke = baseRest(baseInvoke); - - /** - * Creates an array of the own enumerable property names of `object`. - * - * **Note:** Non-object values are coerced to objects. See the - * [ES spec](http://ecma-international.org/ecma-262/7.0/#sec-object.keys) - * for more details. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names. - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.keys(new Foo); - * // => ['a', 'b'] (iteration order is not guaranteed) - * - * _.keys('hi'); - * // => ['0', '1'] - */ - function keys(object) { - return isArrayLike(object) ? arrayLikeKeys(object) : baseKeys(object); - } - - /** - * Creates an array of the own and inherited enumerable property names of `object`. - * - * **Note:** Non-object values are coerced to objects. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Object - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names. - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.keysIn(new Foo); - * // => ['a', 'b', 'c'] (iteration order is not guaranteed) - */ - function keysIn(object) { - return isArrayLike(object) ? arrayLikeKeys(object, true) : baseKeysIn(object); - } - - /** - * The opposite of `_.mapValues`; this method creates an object with the - * same values as `object` and keys generated by running each own enumerable - * string keyed property of `object` thru `iteratee`. The iteratee is invoked - * with three arguments: (value, key, object). - * - * @static - * @memberOf _ - * @since 3.8.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Object} Returns the new mapped object. - * @see _.mapValues - * @example - * - * _.mapKeys({ 'a': 1, 'b': 2 }, function(value, key) { - * return key + value; - * }); - * // => { 'a1': 1, 'b2': 2 } - */ - function mapKeys(object, iteratee) { - var result = {}; - iteratee = getIteratee(iteratee, 3); - - baseForOwn(object, function(value, key, object) { - baseAssignValue(result, iteratee(value, key, object), value); - }); - return result; - } - - /** - * Creates an object with the same keys as `object` and values generated - * by running each own enumerable string keyed property of `object` thru - * `iteratee`. The iteratee is invoked with three arguments: - * (value, key, object). - * - * @static - * @memberOf _ - * @since 2.4.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @returns {Object} Returns the new mapped object. - * @see _.mapKeys - * @example - * - * var users = { - * 'fred': { 'user': 'fred', 'age': 40 }, - * 'pebbles': { 'user': 'pebbles', 'age': 1 } - * }; - * - * _.mapValues(users, function(o) { return o.age; }); - * // => { 'fred': 40, 'pebbles': 1 } (iteration order is not guaranteed) - * - * // The `_.property` iteratee shorthand. - * _.mapValues(users, 'age'); - * // => { 'fred': 40, 'pebbles': 1 } (iteration order is not guaranteed) - */ - function mapValues(object, iteratee) { - var result = {}; - iteratee = getIteratee(iteratee, 3); - - baseForOwn(object, function(value, key, object) { - baseAssignValue(result, key, iteratee(value, key, object)); - }); - return result; - } - - /** - * This method is like `_.assign` except that it recursively merges own and - * inherited enumerable string keyed properties of source objects into the - * destination object. Source properties that resolve to `undefined` are - * skipped if a destination value exists. Array and plain object properties - * are merged recursively. Other objects and value types are overridden by - * assignment. Source objects are applied from left to right. Subsequent - * sources overwrite property assignments of previous sources. - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 0.5.0 - * @category Object - * @param {Object} object The destination object. - * @param {...Object} [sources] The source objects. - * @returns {Object} Returns `object`. - * @example - * - * var object = { - * 'a': [{ 'b': 2 }, { 'd': 4 }] - * }; - * - * var other = { - * 'a': [{ 'c': 3 }, { 'e': 5 }] - * }; - * - * _.merge(object, other); - * // => { 'a': [{ 'b': 2, 'c': 3 }, { 'd': 4, 'e': 5 }] } - */ - var merge = createAssigner(function(object, source, srcIndex) { - baseMerge(object, source, srcIndex); - }); - - /** - * This method is like `_.merge` except that it accepts `customizer` which - * is invoked to produce the merged values of the destination and source - * properties. If `customizer` returns `undefined`, merging is handled by the - * method instead. The `customizer` is invoked with six arguments: - * (objValue, srcValue, key, object, source, stack). - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The destination object. - * @param {...Object} sources The source objects. - * @param {Function} customizer The function to customize assigned values. - * @returns {Object} Returns `object`. - * @example - * - * function customizer(objValue, srcValue) { - * if (_.isArray(objValue)) { - * return objValue.concat(srcValue); - * } - * } - * - * var object = { 'a': [1], 'b': [2] }; - * var other = { 'a': [3], 'b': [4] }; - * - * _.mergeWith(object, other, customizer); - * // => { 'a': [1, 3], 'b': [2, 4] } - */ - var mergeWith = createAssigner(function(object, source, srcIndex, customizer) { - baseMerge(object, source, srcIndex, customizer); - }); - - /** - * The opposite of `_.pick`; this method creates an object composed of the - * own and inherited enumerable property paths of `object` that are not omitted. - * - * **Note:** This method is considerably slower than `_.pick`. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The source object. - * @param {...(string|string[])} [paths] The property paths to omit. - * @returns {Object} Returns the new object. - * @example - * - * var object = { 'a': 1, 'b': '2', 'c': 3 }; - * - * _.omit(object, ['a', 'c']); - * // => { 'b': '2' } - */ - var omit = flatRest(function(object, paths) { - var result = {}; - if (object == null) { - return result; - } - var isDeep = false; - paths = arrayMap(paths, function(path) { - path = castPath(path, object); - isDeep || (isDeep = path.length > 1); - return path; - }); - copyObject(object, getAllKeysIn(object), result); - if (isDeep) { - result = baseClone(result, CLONE_DEEP_FLAG | CLONE_FLAT_FLAG | CLONE_SYMBOLS_FLAG, customOmitClone); - } - var length = paths.length; - while (length--) { - baseUnset(result, paths[length]); - } - return result; - }); - - /** - * The opposite of `_.pickBy`; this method creates an object composed of - * the own and inherited enumerable string keyed properties of `object` that - * `predicate` doesn't return truthy for. The predicate is invoked with two - * arguments: (value, key). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The source object. - * @param {Function} [predicate=_.identity] The function invoked per property. - * @returns {Object} Returns the new object. - * @example - * - * var object = { 'a': 1, 'b': '2', 'c': 3 }; - * - * _.omitBy(object, _.isNumber); - * // => { 'b': '2' } - */ - function omitBy(object, predicate) { - return pickBy(object, negate(getIteratee(predicate))); - } - - /** - * Creates an object composed of the picked `object` properties. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The source object. - * @param {...(string|string[])} [paths] The property paths to pick. - * @returns {Object} Returns the new object. - * @example - * - * var object = { 'a': 1, 'b': '2', 'c': 3 }; - * - * _.pick(object, ['a', 'c']); - * // => { 'a': 1, 'c': 3 } - */ - var pick = flatRest(function(object, paths) { - return object == null ? {} : basePick(object, paths); - }); - - /** - * Creates an object composed of the `object` properties `predicate` returns - * truthy for. The predicate is invoked with two arguments: (value, key). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The source object. - * @param {Function} [predicate=_.identity] The function invoked per property. - * @returns {Object} Returns the new object. - * @example - * - * var object = { 'a': 1, 'b': '2', 'c': 3 }; - * - * _.pickBy(object, _.isNumber); - * // => { 'a': 1, 'c': 3 } - */ - function pickBy(object, predicate) { - if (object == null) { - return {}; - } - var props = arrayMap(getAllKeysIn(object), function(prop) { - return [prop]; - }); - predicate = getIteratee(predicate); - return basePickBy(object, props, function(value, path) { - return predicate(value, path[0]); - }); - } - - /** - * This method is like `_.get` except that if the resolved value is a - * function it's invoked with the `this` binding of its parent object and - * its result is returned. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The object to query. - * @param {Array|string} path The path of the property to resolve. - * @param {*} [defaultValue] The value returned for `undefined` resolved values. - * @returns {*} Returns the resolved value. - * @example - * - * var object = { 'a': [{ 'b': { 'c1': 3, 'c2': _.constant(4) } }] }; - * - * _.result(object, 'a[0].b.c1'); - * // => 3 - * - * _.result(object, 'a[0].b.c2'); - * // => 4 - * - * _.result(object, 'a[0].b.c3', 'default'); - * // => 'default' - * - * _.result(object, 'a[0].b.c3', _.constant('default')); - * // => 'default' - */ - function result(object, path, defaultValue) { - path = castPath(path, object); - - var index = -1, - length = path.length; - - // Ensure the loop is entered when path is empty. - if (!length) { - length = 1; - object = undefined; - } - while (++index < length) { - var value = object == null ? undefined : object[toKey(path[index])]; - if (value === undefined) { - index = length; - value = defaultValue; - } - object = isFunction(value) ? value.call(object) : value; - } - return object; - } - - /** - * Sets the value at `path` of `object`. If a portion of `path` doesn't exist, - * it's created. Arrays are created for missing index properties while objects - * are created for all other missing properties. Use `_.setWith` to customize - * `path` creation. - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 3.7.0 - * @category Object - * @param {Object} object The object to modify. - * @param {Array|string} path The path of the property to set. - * @param {*} value The value to set. - * @returns {Object} Returns `object`. - * @example - * - * var object = { 'a': [{ 'b': { 'c': 3 } }] }; - * - * _.set(object, 'a[0].b.c', 4); - * console.log(object.a[0].b.c); - * // => 4 - * - * _.set(object, ['x', '0', 'y', 'z'], 5); - * console.log(object.x[0].y.z); - * // => 5 - */ - function set(object, path, value) { - return object == null ? object : baseSet(object, path, value); - } - - /** - * This method is like `_.set` except that it accepts `customizer` which is - * invoked to produce the objects of `path`. If `customizer` returns `undefined` - * path creation is handled by the method instead. The `customizer` is invoked - * with three arguments: (nsValue, key, nsObject). - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The object to modify. - * @param {Array|string} path The path of the property to set. - * @param {*} value The value to set. - * @param {Function} [customizer] The function to customize assigned values. - * @returns {Object} Returns `object`. - * @example - * - * var object = {}; - * - * _.setWith(object, '[0][1]', 'a', Object); - * // => { '0': { '1': 'a' } } - */ - function setWith(object, path, value, customizer) { - customizer = typeof customizer == 'function' ? customizer : undefined; - return object == null ? object : baseSet(object, path, value, customizer); - } - - /** - * Creates an array of own enumerable string keyed-value pairs for `object` - * which can be consumed by `_.fromPairs`. If `object` is a map or set, its - * entries are returned. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @alias entries - * @category Object - * @param {Object} object The object to query. - * @returns {Array} Returns the key-value pairs. - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.toPairs(new Foo); - * // => [['a', 1], ['b', 2]] (iteration order is not guaranteed) - */ - var toPairs = createToPairs(keys); - - /** - * Creates an array of own and inherited enumerable string keyed-value pairs - * for `object` which can be consumed by `_.fromPairs`. If `object` is a map - * or set, its entries are returned. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @alias entriesIn - * @category Object - * @param {Object} object The object to query. - * @returns {Array} Returns the key-value pairs. - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.toPairsIn(new Foo); - * // => [['a', 1], ['b', 2], ['c', 3]] (iteration order is not guaranteed) - */ - var toPairsIn = createToPairs(keysIn); - - /** - * An alternative to `_.reduce`; this method transforms `object` to a new - * `accumulator` object which is the result of running each of its own - * enumerable string keyed properties thru `iteratee`, with each invocation - * potentially mutating the `accumulator` object. If `accumulator` is not - * provided, a new object with the same `[[Prototype]]` will be used. The - * iteratee is invoked with four arguments: (accumulator, value, key, object). - * Iteratee functions may exit iteration early by explicitly returning `false`. - * - * @static - * @memberOf _ - * @since 1.3.0 - * @category Object - * @param {Object} object The object to iterate over. - * @param {Function} [iteratee=_.identity] The function invoked per iteration. - * @param {*} [accumulator] The custom accumulator value. - * @returns {*} Returns the accumulated value. - * @example - * - * _.transform([2, 3, 4], function(result, n) { - * result.push(n *= n); - * return n % 2 == 0; - * }, []); - * // => [4, 9] - * - * _.transform({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) { - * (result[value] || (result[value] = [])).push(key); - * }, {}); - * // => { '1': ['a', 'c'], '2': ['b'] } - */ - function transform(object, iteratee, accumulator) { - var isArr = isArray(object), - isArrLike = isArr || isBuffer(object) || isTypedArray(object); - - iteratee = getIteratee(iteratee, 4); - if (accumulator == null) { - var Ctor = object && object.constructor; - if (isArrLike) { - accumulator = isArr ? new Ctor : []; - } - else if (isObject(object)) { - accumulator = isFunction(Ctor) ? baseCreate(getPrototype(object)) : {}; - } - else { - accumulator = {}; - } - } - (isArrLike ? arrayEach : baseForOwn)(object, function(value, index, object) { - return iteratee(accumulator, value, index, object); - }); - return accumulator; - } - - /** - * Removes the property at `path` of `object`. - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Object - * @param {Object} object The object to modify. - * @param {Array|string} path The path of the property to unset. - * @returns {boolean} Returns `true` if the property is deleted, else `false`. - * @example - * - * var object = { 'a': [{ 'b': { 'c': 7 } }] }; - * _.unset(object, 'a[0].b.c'); - * // => true - * - * console.log(object); - * // => { 'a': [{ 'b': {} }] }; - * - * _.unset(object, ['a', '0', 'b', 'c']); - * // => true - * - * console.log(object); - * // => { 'a': [{ 'b': {} }] }; - */ - function unset(object, path) { - return object == null ? true : baseUnset(object, path); - } - - /** - * This method is like `_.set` except that accepts `updater` to produce the - * value to set. Use `_.updateWith` to customize `path` creation. The `updater` - * is invoked with one argument: (value). - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.6.0 - * @category Object - * @param {Object} object The object to modify. - * @param {Array|string} path The path of the property to set. - * @param {Function} updater The function to produce the updated value. - * @returns {Object} Returns `object`. - * @example - * - * var object = { 'a': [{ 'b': { 'c': 3 } }] }; - * - * _.update(object, 'a[0].b.c', function(n) { return n * n; }); - * console.log(object.a[0].b.c); - * // => 9 - * - * _.update(object, 'x[0].y.z', function(n) { return n ? n + 1 : 0; }); - * console.log(object.x[0].y.z); - * // => 0 - */ - function update(object, path, updater) { - return object == null ? object : baseUpdate(object, path, castFunction(updater)); - } - - /** - * This method is like `_.update` except that it accepts `customizer` which is - * invoked to produce the objects of `path`. If `customizer` returns `undefined` - * path creation is handled by the method instead. The `customizer` is invoked - * with three arguments: (nsValue, key, nsObject). - * - * **Note:** This method mutates `object`. - * - * @static - * @memberOf _ - * @since 4.6.0 - * @category Object - * @param {Object} object The object to modify. - * @param {Array|string} path The path of the property to set. - * @param {Function} updater The function to produce the updated value. - * @param {Function} [customizer] The function to customize assigned values. - * @returns {Object} Returns `object`. - * @example - * - * var object = {}; - * - * _.updateWith(object, '[0][1]', _.constant('a'), Object); - * // => { '0': { '1': 'a' } } - */ - function updateWith(object, path, updater, customizer) { - customizer = typeof customizer == 'function' ? customizer : undefined; - return object == null ? object : baseUpdate(object, path, castFunction(updater), customizer); - } - - /** - * Creates an array of the own enumerable string keyed property values of `object`. - * - * **Note:** Non-object values are coerced to objects. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property values. - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.values(new Foo); - * // => [1, 2] (iteration order is not guaranteed) - * - * _.values('hi'); - * // => ['h', 'i'] - */ - function values(object) { - return object == null ? [] : baseValues(object, keys(object)); - } - - /** - * Creates an array of the own and inherited enumerable string keyed property - * values of `object`. - * - * **Note:** Non-object values are coerced to objects. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Object - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property values. - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.valuesIn(new Foo); - * // => [1, 2, 3] (iteration order is not guaranteed) - */ - function valuesIn(object) { - return object == null ? [] : baseValues(object, keysIn(object)); - } - - /*------------------------------------------------------------------------*/ - - /** - * Clamps `number` within the inclusive `lower` and `upper` bounds. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Number - * @param {number} number The number to clamp. - * @param {number} [lower] The lower bound. - * @param {number} upper The upper bound. - * @returns {number} Returns the clamped number. - * @example - * - * _.clamp(-10, -5, 5); - * // => -5 - * - * _.clamp(10, -5, 5); - * // => 5 - */ - function clamp(number, lower, upper) { - if (upper === undefined) { - upper = lower; - lower = undefined; - } - if (upper !== undefined) { - upper = toNumber(upper); - upper = upper === upper ? upper : 0; - } - if (lower !== undefined) { - lower = toNumber(lower); - lower = lower === lower ? lower : 0; - } - return baseClamp(toNumber(number), lower, upper); - } - - /** - * Checks if `n` is between `start` and up to, but not including, `end`. If - * `end` is not specified, it's set to `start` with `start` then set to `0`. - * If `start` is greater than `end` the params are swapped to support - * negative ranges. - * - * @static - * @memberOf _ - * @since 3.3.0 - * @category Number - * @param {number} number The number to check. - * @param {number} [start=0] The start of the range. - * @param {number} end The end of the range. - * @returns {boolean} Returns `true` if `number` is in the range, else `false`. - * @see _.range, _.rangeRight - * @example - * - * _.inRange(3, 2, 4); - * // => true - * - * _.inRange(4, 8); - * // => true - * - * _.inRange(4, 2); - * // => false - * - * _.inRange(2, 2); - * // => false - * - * _.inRange(1.2, 2); - * // => true - * - * _.inRange(5.2, 4); - * // => false - * - * _.inRange(-3, -2, -6); - * // => true - */ - function inRange(number, start, end) { - start = toFinite(start); - if (end === undefined) { - end = start; - start = 0; - } else { - end = toFinite(end); - } - number = toNumber(number); - return baseInRange(number, start, end); - } - - /** - * Produces a random number between the inclusive `lower` and `upper` bounds. - * If only one argument is provided a number between `0` and the given number - * is returned. If `floating` is `true`, or either `lower` or `upper` are - * floats, a floating-point number is returned instead of an integer. - * - * **Note:** JavaScript follows the IEEE-754 standard for resolving - * floating-point values which can produce unexpected results. - * - * @static - * @memberOf _ - * @since 0.7.0 - * @category Number - * @param {number} [lower=0] The lower bound. - * @param {number} [upper=1] The upper bound. - * @param {boolean} [floating] Specify returning a floating-point number. - * @returns {number} Returns the random number. - * @example - * - * _.random(0, 5); - * // => an integer between 0 and 5 - * - * _.random(5); - * // => also an integer between 0 and 5 - * - * _.random(5, true); - * // => a floating-point number between 0 and 5 - * - * _.random(1.2, 5.2); - * // => a floating-point number between 1.2 and 5.2 - */ - function random(lower, upper, floating) { - if (floating && typeof floating != 'boolean' && isIterateeCall(lower, upper, floating)) { - upper = floating = undefined; - } - if (floating === undefined) { - if (typeof upper == 'boolean') { - floating = upper; - upper = undefined; - } - else if (typeof lower == 'boolean') { - floating = lower; - lower = undefined; - } - } - if (lower === undefined && upper === undefined) { - lower = 0; - upper = 1; - } - else { - lower = toFinite(lower); - if (upper === undefined) { - upper = lower; - lower = 0; - } else { - upper = toFinite(upper); - } - } - if (lower > upper) { - var temp = lower; - lower = upper; - upper = temp; - } - if (floating || lower % 1 || upper % 1) { - var rand = nativeRandom(); - return nativeMin(lower + (rand * (upper - lower + freeParseFloat('1e-' + ((rand + '').length - 1)))), upper); - } - return baseRandom(lower, upper); - } - - /*------------------------------------------------------------------------*/ - - /** - * Converts `string` to [camel case](https://en.wikipedia.org/wiki/CamelCase). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to convert. - * @returns {string} Returns the camel cased string. - * @example - * - * _.camelCase('Foo Bar'); - * // => 'fooBar' - * - * _.camelCase('--foo-bar--'); - * // => 'fooBar' - * - * _.camelCase('__FOO_BAR__'); - * // => 'fooBar' - */ - var camelCase = createCompounder(function(result, word, index) { - word = word.toLowerCase(); - return result + (index ? capitalize(word) : word); - }); - - /** - * Converts the first character of `string` to upper case and the remaining - * to lower case. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to capitalize. - * @returns {string} Returns the capitalized string. - * @example - * - * _.capitalize('FRED'); - * // => 'Fred' - */ - function capitalize(string) { - return upperFirst(toString(string).toLowerCase()); - } - - /** - * Deburrs `string` by converting - * [Latin-1 Supplement](https://en.wikipedia.org/wiki/Latin-1_Supplement_(Unicode_block)#Character_table) - * and [Latin Extended-A](https://en.wikipedia.org/wiki/Latin_Extended-A) - * letters to basic Latin letters and removing - * [combining diacritical marks](https://en.wikipedia.org/wiki/Combining_Diacritical_Marks). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to deburr. - * @returns {string} Returns the deburred string. - * @example - * - * _.deburr('déjà vu'); - * // => 'deja vu' - */ - function deburr(string) { - string = toString(string); - return string && string.replace(reLatin, deburrLetter).replace(reComboMark, ''); - } - - /** - * Checks if `string` ends with the given target string. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to inspect. - * @param {string} [target] The string to search for. - * @param {number} [position=string.length] The position to search up to. - * @returns {boolean} Returns `true` if `string` ends with `target`, - * else `false`. - * @example - * - * _.endsWith('abc', 'c'); - * // => true - * - * _.endsWith('abc', 'b'); - * // => false - * - * _.endsWith('abc', 'b', 2); - * // => true - */ - function endsWith(string, target, position) { - string = toString(string); - target = baseToString(target); - - var length = string.length; - position = position === undefined - ? length - : baseClamp(toInteger(position), 0, length); - - var end = position; - position -= target.length; - return position >= 0 && string.slice(position, end) == target; - } - - /** - * Converts the characters "&", "<", ">", '"', and "'" in `string` to their - * corresponding HTML entities. - * - * **Note:** No other characters are escaped. To escape additional - * characters use a third-party library like [_he_](https://mths.be/he). - * - * Though the ">" character is escaped for symmetry, characters like - * ">" and "/" don't need escaping in HTML and have no special meaning - * unless they're part of a tag or unquoted attribute value. See - * [Mathias Bynens's article](https://mathiasbynens.be/notes/ambiguous-ampersands) - * (under "semi-related fun fact") for more details. - * - * When working with HTML you should always - * [quote attribute values](http://wonko.com/post/html-escaping) to reduce - * XSS vectors. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category String - * @param {string} [string=''] The string to escape. - * @returns {string} Returns the escaped string. - * @example - * - * _.escape('fred, barney, & pebbles'); - * // => 'fred, barney, & pebbles' - */ - function escape(string) { - string = toString(string); - return (string && reHasUnescapedHtml.test(string)) - ? string.replace(reUnescapedHtml, escapeHtmlChar) - : string; - } - - /** - * Escapes the `RegExp` special characters "^", "$", "\", ".", "*", "+", - * "?", "(", ")", "[", "]", "{", "}", and "|" in `string`. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to escape. - * @returns {string} Returns the escaped string. - * @example - * - * _.escapeRegExp('[lodash](https://lodash.com/)'); - * // => '\[lodash\]\(https://lodash\.com/\)' - */ - function escapeRegExp(string) { - string = toString(string); - return (string && reHasRegExpChar.test(string)) - ? string.replace(reRegExpChar, '\\$&') - : string; - } - - /** - * Converts `string` to - * [kebab case](https://en.wikipedia.org/wiki/Letter_case#Special_case_styles). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to convert. - * @returns {string} Returns the kebab cased string. - * @example - * - * _.kebabCase('Foo Bar'); - * // => 'foo-bar' - * - * _.kebabCase('fooBar'); - * // => 'foo-bar' - * - * _.kebabCase('__FOO_BAR__'); - * // => 'foo-bar' - */ - var kebabCase = createCompounder(function(result, word, index) { - return result + (index ? '-' : '') + word.toLowerCase(); - }); - - /** - * Converts `string`, as space separated words, to lower case. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category String - * @param {string} [string=''] The string to convert. - * @returns {string} Returns the lower cased string. - * @example - * - * _.lowerCase('--Foo-Bar--'); - * // => 'foo bar' - * - * _.lowerCase('fooBar'); - * // => 'foo bar' - * - * _.lowerCase('__FOO_BAR__'); - * // => 'foo bar' - */ - var lowerCase = createCompounder(function(result, word, index) { - return result + (index ? ' ' : '') + word.toLowerCase(); - }); - - /** - * Converts the first character of `string` to lower case. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category String - * @param {string} [string=''] The string to convert. - * @returns {string} Returns the converted string. - * @example - * - * _.lowerFirst('Fred'); - * // => 'fred' - * - * _.lowerFirst('FRED'); - * // => 'fRED' - */ - var lowerFirst = createCaseFirst('toLowerCase'); - - /** - * Pads `string` on the left and right sides if it's shorter than `length`. - * Padding characters are truncated if they can't be evenly divided by `length`. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to pad. - * @param {number} [length=0] The padding length. - * @param {string} [chars=' '] The string used as padding. - * @returns {string} Returns the padded string. - * @example - * - * _.pad('abc', 8); - * // => ' abc ' - * - * _.pad('abc', 8, '_-'); - * // => '_-abc_-_' - * - * _.pad('abc', 3); - * // => 'abc' - */ - function pad(string, length, chars) { - string = toString(string); - length = toInteger(length); - - var strLength = length ? stringSize(string) : 0; - if (!length || strLength >= length) { - return string; - } - var mid = (length - strLength) / 2; - return ( - createPadding(nativeFloor(mid), chars) + - string + - createPadding(nativeCeil(mid), chars) - ); - } - - /** - * Pads `string` on the right side if it's shorter than `length`. Padding - * characters are truncated if they exceed `length`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category String - * @param {string} [string=''] The string to pad. - * @param {number} [length=0] The padding length. - * @param {string} [chars=' '] The string used as padding. - * @returns {string} Returns the padded string. - * @example - * - * _.padEnd('abc', 6); - * // => 'abc ' - * - * _.padEnd('abc', 6, '_-'); - * // => 'abc_-_' - * - * _.padEnd('abc', 3); - * // => 'abc' - */ - function padEnd(string, length, chars) { - string = toString(string); - length = toInteger(length); - - var strLength = length ? stringSize(string) : 0; - return (length && strLength < length) - ? (string + createPadding(length - strLength, chars)) - : string; - } - - /** - * Pads `string` on the left side if it's shorter than `length`. Padding - * characters are truncated if they exceed `length`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category String - * @param {string} [string=''] The string to pad. - * @param {number} [length=0] The padding length. - * @param {string} [chars=' '] The string used as padding. - * @returns {string} Returns the padded string. - * @example - * - * _.padStart('abc', 6); - * // => ' abc' - * - * _.padStart('abc', 6, '_-'); - * // => '_-_abc' - * - * _.padStart('abc', 3); - * // => 'abc' - */ - function padStart(string, length, chars) { - string = toString(string); - length = toInteger(length); - - var strLength = length ? stringSize(string) : 0; - return (length && strLength < length) - ? (createPadding(length - strLength, chars) + string) - : string; - } - - /** - * Converts `string` to an integer of the specified radix. If `radix` is - * `undefined` or `0`, a `radix` of `10` is used unless `value` is a - * hexadecimal, in which case a `radix` of `16` is used. - * - * **Note:** This method aligns with the - * [ES5 implementation](https://es5.github.io/#x15.1.2.2) of `parseInt`. - * - * @static - * @memberOf _ - * @since 1.1.0 - * @category String - * @param {string} string The string to convert. - * @param {number} [radix=10] The radix to interpret `value` by. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {number} Returns the converted integer. - * @example - * - * _.parseInt('08'); - * // => 8 - * - * _.map(['6', '08', '10'], _.parseInt); - * // => [6, 8, 10] - */ - function parseInt(string, radix, guard) { - if (guard || radix == null) { - radix = 0; - } else if (radix) { - radix = +radix; - } - return nativeParseInt(toString(string).replace(reTrimStart, ''), radix || 0); - } - - /** - * Repeats the given string `n` times. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to repeat. - * @param {number} [n=1] The number of times to repeat the string. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {string} Returns the repeated string. - * @example - * - * _.repeat('*', 3); - * // => '***' - * - * _.repeat('abc', 2); - * // => 'abcabc' - * - * _.repeat('abc', 0); - * // => '' - */ - function repeat(string, n, guard) { - if ((guard ? isIterateeCall(string, n, guard) : n === undefined)) { - n = 1; - } else { - n = toInteger(n); - } - return baseRepeat(toString(string), n); - } - - /** - * Replaces matches for `pattern` in `string` with `replacement`. - * - * **Note:** This method is based on - * [`String#replace`](https://mdn.io/String/replace). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category String - * @param {string} [string=''] The string to modify. - * @param {RegExp|string} pattern The pattern to replace. - * @param {Function|string} replacement The match replacement. - * @returns {string} Returns the modified string. - * @example - * - * _.replace('Hi Fred', 'Fred', 'Barney'); - * // => 'Hi Barney' - */ - function replace() { - var args = arguments, - string = toString(args[0]); - - return args.length < 3 ? string : string.replace(args[1], args[2]); - } - - /** - * Converts `string` to - * [snake case](https://en.wikipedia.org/wiki/Snake_case). - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to convert. - * @returns {string} Returns the snake cased string. - * @example - * - * _.snakeCase('Foo Bar'); - * // => 'foo_bar' - * - * _.snakeCase('fooBar'); - * // => 'foo_bar' - * - * _.snakeCase('--FOO-BAR--'); - * // => 'foo_bar' - */ - var snakeCase = createCompounder(function(result, word, index) { - return result + (index ? '_' : '') + word.toLowerCase(); - }); - - /** - * Splits `string` by `separator`. - * - * **Note:** This method is based on - * [`String#split`](https://mdn.io/String/split). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category String - * @param {string} [string=''] The string to split. - * @param {RegExp|string} separator The separator pattern to split by. - * @param {number} [limit] The length to truncate results to. - * @returns {Array} Returns the string segments. - * @example - * - * _.split('a-b-c', '-', 2); - * // => ['a', 'b'] - */ - function split(string, separator, limit) { - if (limit && typeof limit != 'number' && isIterateeCall(string, separator, limit)) { - separator = limit = undefined; - } - limit = limit === undefined ? MAX_ARRAY_LENGTH : limit >>> 0; - if (!limit) { - return []; - } - string = toString(string); - if (string && ( - typeof separator == 'string' || - (separator != null && !isRegExp(separator)) - )) { - separator = baseToString(separator); - if (!separator && hasUnicode(string)) { - return castSlice(stringToArray(string), 0, limit); - } - } - return string.split(separator, limit); - } - - /** - * Converts `string` to - * [start case](https://en.wikipedia.org/wiki/Letter_case#Stylistic_or_specialised_usage). - * - * @static - * @memberOf _ - * @since 3.1.0 - * @category String - * @param {string} [string=''] The string to convert. - * @returns {string} Returns the start cased string. - * @example - * - * _.startCase('--foo-bar--'); - * // => 'Foo Bar' - * - * _.startCase('fooBar'); - * // => 'Foo Bar' - * - * _.startCase('__FOO_BAR__'); - * // => 'FOO BAR' - */ - var startCase = createCompounder(function(result, word, index) { - return result + (index ? ' ' : '') + upperFirst(word); - }); - - /** - * Checks if `string` starts with the given target string. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category String - * @param {string} [string=''] The string to inspect. - * @param {string} [target] The string to search for. - * @param {number} [position=0] The position to search from. - * @returns {boolean} Returns `true` if `string` starts with `target`, - * else `false`. - * @example - * - * _.startsWith('abc', 'a'); - * // => true - * - * _.startsWith('abc', 'b'); - * // => false - * - * _.startsWith('abc', 'b', 1); - * // => true - */ - function startsWith(string, target, position) { - string = toString(string); - position = position == null - ? 0 - : baseClamp(toInteger(position), 0, string.length); - - target = baseToString(target); - return string.slice(position, position + target.length) == target; - } - - /** - * Creates a compiled template function that can interpolate data properties - * in "interpolate" delimiters, HTML-escape interpolated data properties in - * "escape" delimiters, and execute JavaScript in "evaluate" delimiters. Data - * properties may be accessed as free variables in the template. If a setting - * object is given, it takes precedence over `_.templateSettings` values. - * - * **Note:** In the development build `_.template` utilizes - * [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl) - * for easier debugging. - * - * For more information on precompiling templates see - * [lodash's custom builds documentation](https://lodash.com/custom-builds). - * - * For more information on Chrome extension sandboxes see - * [Chrome's extensions documentation](https://developer.chrome.com/extensions/sandboxingEval). - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category String - * @param {string} [string=''] The template string. - * @param {Object} [options={}] The options object. - * @param {RegExp} [options.escape=_.templateSettings.escape] - * The HTML "escape" delimiter. - * @param {RegExp} [options.evaluate=_.templateSettings.evaluate] - * The "evaluate" delimiter. - * @param {Object} [options.imports=_.templateSettings.imports] - * An object to import into the template as free variables. - * @param {RegExp} [options.interpolate=_.templateSettings.interpolate] - * The "interpolate" delimiter. - * @param {string} [options.sourceURL='lodash.templateSources[n]'] - * The sourceURL of the compiled template. - * @param {string} [options.variable='obj'] - * The data object variable name. - * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`. - * @returns {Function} Returns the compiled template function. - * @example - * - * // Use the "interpolate" delimiter to create a compiled template. - * var compiled = _.template('hello <%= user %>!'); - * compiled({ 'user': 'fred' }); - * // => 'hello fred!' - * - * // Use the HTML "escape" delimiter to escape data property values. - * var compiled = _.template('<%- value %>'); - * compiled({ 'value': '', + ), + }), + ); + + await getBillboard(); + + expect(window.someGlobalVar).toBe('test'); + expect(window.someOtherGlobalVar).toBe('test2'); + }); + + test('should handle fetch errors gracefully', async () => { + global.fetch = jest.fn(() => Promise.reject(new Error('NetworkError'))); + + await getBillboard(); + + expect(global.Honeybadger.notify).not.toHaveBeenCalled(); + }); + + test('should report non-network errors to Honeybadger', async () => { + global.fetch = jest.fn(() => Promise.reject(new Error('Some other error'))); + + await getBillboard(); + + expect(global.Honeybadger.notify).toHaveBeenCalledWith( + new Error('Some other error'), + ); + }); + + test('should clone and re-insert script tags in fetched content', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => + Promise.resolve( + '', + ), + }), + ); + + await getBillboard(); + + const scriptElements = document.querySelectorAll( + '.js-billboard-container script', + ); + expect(scriptElements.length).toBe(2); + scriptElements.forEach((script) => { + expect(script.type).toEqual('text/javascript'); + expect(script.innerHTML).toEqual('console.log("test")'); + }); + }); + + test('should add current URL parameters to asyncUrl if bb_test_placement_area exists', async () => { + delete window.location; + window.location = new URL( + 'http://example.com?bb_test_placement_area=post_sidebar&bb_test_id=1', + ); + + document.body.innerHTML = ` +
+
+
+ `; + + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => Promise.resolve('
Some HTML content
'), + }), + ); + + await getBillboard(); + + expect(global.fetch).toHaveBeenCalledWith( + '/billboards/post_sidebar?bb_test_placement_area=post_sidebar&bb_test_id=1', + ); + }); + + test('should have null content if dismissal SKU matches', async () => { + window.localStorage.setItem( + 'dismissal_skus_triggered', + JSON.stringify(['sku123']), + ); + + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => + Promise.resolve( + '
Billboard Content
', + ), + }), + ); + + await getBillboard(); + + expect(document.querySelector('.js-billboard-container div')).toBe(null); + }); + + test('should display billboard content if there is no matching dismissal SKU', async () => { + window.localStorage.setItem( + 'dismissal_skus_triggered', + JSON.stringify(['sku999']), + ); + + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => + Promise.resolve( + '
Billboard Content
', + ), + }), + ); + + await getBillboard(); + + const billboardContent = document.querySelector( + '.js-billboard-container div', + ); + expect( + billboardContent.closest('.js-billboard-container').style.display, + ).toBe(''); // Not marked as display none + }); +}); + +describe('executeBBScripts', () => { + let container; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + test('should execute script when script tag is present', () => { + container.innerHTML = ''; + + executeBBScripts(container); + + expect(window.someGlobalVar).toBe('executed'); + }); + + test('should skip null or undefined script elements', () => { + container.innerHTML = ''; + const spiedGetElementsByTagName = jest + .spyOn(container, 'getElementsByTagName') + .mockReturnValue([null, undefined]); + + executeBBScripts(container); + + expect(spiedGetElementsByTagName).toBeCalled(); + }); + + test('should copy attributes of original script element', () => { + container.innerHTML = + ''; + + executeBBScripts(container); + + const newScript = container.querySelector('script'); + expect(newScript.type).toBe('text/javascript'); + }); + + test('should remove the original script element', () => { + container.innerHTML = ''; + + executeBBScripts(container); + + const allScripts = container.getElementsByTagName('script'); + expect(allScripts.length).toBe(1); + }); + + test('should insert the new script element at the same position as the original', () => { + container.innerHTML = + '
'; + + executeBBScripts(container); + + const middleChild = container.children[1]; + expect(middleChild.tagName).toBe('SCRIPT'); + expect(middleChild.textContent).toBe('window.someGlobalVar = "executed";'); + }); +}); diff --git a/app/javascript/__tests__/convertCoauthorIdsToUsernameInputs.test.js b/app/javascript/__tests__/convertCoauthorIdsToUsernameInputs.test.js new file mode 100644 index 0000000000000..63f9dab8a1821 --- /dev/null +++ b/app/javascript/__tests__/convertCoauthorIdsToUsernameInputs.test.js @@ -0,0 +1,166 @@ +import fetch from 'jest-fetch-mock'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import { convertCoauthorIdsToUsernameInputs } from '../packs/dashboards/convertCoauthorIdsToUsernameInputs'; + +global.fetch = fetch; + +jest.mock('@utilities/debounceAction', () => ({ + debounceAction: fn => fn +})); + +function fakeFetchResponseJSON() { + return JSON.stringify([ + { name: 'Alice', username: 'alice', id: 1 }, + { name: 'Bob', username: 'bob', id: 2 }, + { name: 'Charlie', username: 'charlie', id: 3 }, + ]); +} + +describe('convertCoauthorIdsToUsernameInputs', () => { + beforeEach(() => { + global.Honeybadger = { notify: jest.fn() }; + + fetch.resetMocks(); + fetch.mockResponse(fakeFetchResponseJSON()); + }); + + describe('when there is no pre-existing id value', () => { + beforeEach(() => { + window.document.body.innerHTML = ` +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ `; + }); + + it('calls fetch, with exception for author ID', async () => { + await convertCoauthorIdsToUsernameInputs(); + expect(fetch).toHaveBeenCalledWith('/org7053/members.json'); + }); + + it('makes the co-author field hidden', async () => { + await convertCoauthorIdsToUsernameInputs(); + const co_author_field = document.getElementById( + 'article_ID_co_author_ids_list', + ); + expect(co_author_field.type).toBe('hidden'); + }); + + it('renders matching snapshot', async () => { + await convertCoauthorIdsToUsernameInputs(); + expect(document.forms[0].innerHTML).toMatchSnapshot(); + }); + + it('works as expected', async () => { + await convertCoauthorIdsToUsernameInputs(); + const input = document.querySelector( + "input[placeholder='Add up to 4...']", + ); + input.focus(); + await userEvent.type(input, 'Bob,'); + + const hiddenField = document.querySelector( + "input[name='article[co_author_ids_list]']", + ); + expect(hiddenField.value).toBe('2'); + }); + }); + + describe('when there *is* a pre-existing id value', () => { + beforeEach(() => { + window.document.body.innerHTML = ` +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ `; + }); + + it('renders matching snapshot', async () => { + await convertCoauthorIdsToUsernameInputs(); + expect(document.forms[0].innerHTML).toMatchSnapshot(); + }); + + it('works as expected', async () => { + await convertCoauthorIdsToUsernameInputs(); + const input = document.querySelector( + "input[placeholder='Add another...']", + ); + input.focus(); + await userEvent.type(input, 'Bob,'); + + const hiddenField = document.querySelector( + "input[name='article[co_author_ids_list]']", + ); + expect(hiddenField.value).toBe('3, 2'); + }); + + it('can remove previously selected', async () => { + await convertCoauthorIdsToUsernameInputs(); + const deselect = document.querySelector( + '.c-autocomplete--multi__selected', + ); + await userEvent.click(deselect); + + const hiddenField = document.querySelector( + "input[name='article[co_author_ids_list]']", + ); + expect(hiddenField.value).toBe(''); + }); + }); +}); diff --git a/app/javascript/actionsPanel/__tests__/actionsPanel.test.js b/app/javascript/actionsPanel/__tests__/actionsPanel.test.js index 1f509a559a6e6..8b78e295ca9ce 100644 --- a/app/javascript/actionsPanel/__tests__/actionsPanel.test.js +++ b/app/javascript/actionsPanel/__tests__/actionsPanel.test.js @@ -1,11 +1,24 @@ /* eslint-disable no-restricted-globals */ +import { waitFor } from '@testing-library/dom'; +import { fireEvent } from '@testing-library/preact'; import fetch from 'jest-fetch-mock'; import { addCloseListener, - initializeHeight, addReactionButtonListeners, - addAdjustTagListeners, + handleAddModTagButtonsListeners, + handleAddTagButtonListeners, + handleRemoveTagButtonsListeners, + initializeHeight, } from '../actionsPanel'; +import { postReactions } from '../services/reactions'; + +global.fetch = fetch; +global.top.addSnackbarItem = jest.fn(); +global.alert = jest.fn(); + +jest.mock('../services/reactions', () => ({ + postReactions: jest.fn(), +})); describe('addCloseListener()', () => { test('toggles the mod actions panel and its button on click', () => { @@ -86,138 +99,539 @@ describe('addReactionButtonListeners()', () => { } describe('when no reactions are already reacted on', () => { - test('it marks thumbs up reaction as reacted', async () => { - let category = 'thumbsup'; - fetch.mockResponse(sampleResponse(category)); - addReactionButtonListeners(); + describe('when postReaction is called successfully', () => { + beforeEach(() => { + postReactions.mockResolvedValue({ + outcome: { + result: 'create', + category: 'thumbsup', + }, + }); + }); - const thumbsupButton = document.querySelector( - `.reaction-button[data-category="${category}"]`, - ); - thumbsupButton.click(); - expect(thumbsupButton.classList).toContain('reacted'); + test.each([['thumbsup'], ['thumbsdown']])( + 'it marks %s reaction as reacted', + (category) => { + addReactionButtonListeners(); - category = 'thumbsdown'; - const thumbsdownButton = document.querySelector( - `.reaction-button[data-category="${category}"]`, - ); - thumbsdownButton.click(); - expect(thumbsdownButton.classList).toContain('reacted'); + const button = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + button.click(); - category = 'vomit'; - fetch.resetMocks(); - fetch.mockResponse(sampleResponse(category)); - const vomitButton = document.querySelector( - `.reaction-vomit-button[data-category="${category}"]`, + expect(button.classList).toContain('reacted'); + }, ); - vomitButton.click(); - expect(vomitButton.classList).toContain('reacted'); - }); - test('it unmarks the proper reaction(s) when positive/negative reactions are clicked', async () => { - let category = 'thumbsup'; - fetch.mockResponse(sampleResponse(category)); - addReactionButtonListeners(); - const thumbsupButton = document.querySelector( - `.reaction-button[data-category="${category}"]`, - ); - thumbsupButton.click(); - category = 'thumbsdown'; - fetch.resetMocks(); - fetch.mockResponse(sampleResponse(category)); - const thumbsdownButton = document.querySelector( - `.reaction-button[data-category="${category}"]`, + test('it marks vomit flag to admins', () => { + const category = 'vomit'; + + addReactionButtonListeners(); + + const vomitButton = document.querySelector( + `.reaction-vomit-button[data-category="${category}"]`, + ); + + vomitButton.click(); + expect(vomitButton.classList).toContain('reacted'); + }); + + test('it unmarks the proper reaction(s) when positive/negative reactions are clicked', async () => { + let category = 'thumbsup'; + fetch.mockResponse(sampleResponse(category)); + addReactionButtonListeners(); + const thumbsupButton = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + thumbsupButton.click(); + + category = 'thumbsdown'; + fetch.resetMocks(); + fetch.mockResponse(sampleResponse(category)); + const thumbsdownButton = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + thumbsdownButton.click(); + expect(thumbsupButton.classList).not.toContain('reacted'); + + fetch.resetMocks(); + category = 'thumbsup'; + fetch.mockResponse(sampleResponse(category, false)); + thumbsupButton.click(); + expect(thumbsdownButton.classList).not.toContain('reacted'); + expect(thumbsupButton.classList).toContain('reacted'); + + category = 'vomit'; + fetch.resetMocks(); + fetch.mockResponse(sampleResponse(category)); + const vomitButton = document.querySelector( + `.reaction-vomit-button[data-category="${category}"]`, + ); + vomitButton.click(); + expect(vomitButton.classList).toContain('reacted'); + expect(thumbsupButton.classList).not.toContain('reacted'); + }); + }); + + describe('when postReaction fails', () => { + test.each([['thumbsup'], ['thumbsdown']])( + 'it revert marks %s reaction', + async (category) => { + postReactions.mockImplementation(() => Promise.reject()); + + addReactionButtonListeners(); + + const button = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + + button.click(); + + expect(button.classList).toContain('reacted'); + + await waitFor(() => { + expect(button.classList).not.toContain('reacted'); + }); + }, ); - thumbsdownButton.click(); - expect(thumbsupButton.classList).not.toContain('reacted'); - fetch.resetMocks(); - category = 'thumbsup'; - fetch.mockResponse(sampleResponse(category, false)); - thumbsupButton.click(); - expect(thumbsdownButton.classList).not.toContain('reacted'); - expect(thumbsupButton.classList).toContain('reacted'); + test('it revert marks vomit flag to admins', async () => { + postReactions.mockImplementation(() => Promise.reject()); - category = 'vomit'; - fetch.resetMocks(); - fetch.mockResponse(sampleResponse(category)); - const vomitButton = document.querySelector( - `.reaction-vomit-button[data-category="${category}"]`, + addReactionButtonListeners(); + + const vomitButton = document.querySelector( + `.reaction-vomit-button[data-category="vomit"]`, + ); + + vomitButton.click(); + + expect(vomitButton.classList).toContain('reacted'); + + await waitFor(() => { + expect(vomitButton.classList).not.toContain('reacted'); + }); + }); + }); + + describe('when outcome result is create and category is thumbsup', () => { + test('calls addSnackbarItem with "This post will be more visible." message', async () => { + const category = 'thumbsup'; + + postReactions.mockResolvedValue({ result: 'create', category }); + + addReactionButtonListeners(); + + const button = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + + button.click(); + + await waitFor(() => { + expect(top.addSnackbarItem).toHaveBeenCalledWith({ + message: 'This post will be more visible.', + addCloseButton: true, + }); + }); + }); + }); + + describe('when outcome result is create and category is thumbsdown', () => { + test('calls addSnackbarItem with "This post will be less visible." message', async () => { + const category = 'thumbsdown'; + + postReactions.mockResolvedValue({ result: 'create', category }); + + addReactionButtonListeners(); + + const button = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + + button.click(); + + await waitFor(() => { + expect(top.addSnackbarItem).toHaveBeenCalledWith({ + message: 'This post will be less visible.', + addCloseButton: true, + }); + }); + }); + }); + + describe('when outcome result is create and category is vomit', () => { + test("calls addSnackbarItem with `You've flagged this post as abusive or spam.` message", async () => { + const category = 'vomit'; + + postReactions.mockResolvedValue({ result: 'create', category }); + + addReactionButtonListeners(); + + const button = document.querySelector( + `.reaction-vomit-button[data-category="${category}"]`, + ); + + button.click(); + + await waitFor(() => { + expect(top.addSnackbarItem).toHaveBeenCalledWith({ + message: "You've flagged this post as abusive or spam.", + addCloseButton: true, + }); + }); + }); + }); + + describe('when outcome result is destroy', () => { + test('calls addSnackbarItem with "Your quality rating was removed." message', async () => { + const category = 'vomit'; + + postReactions.mockResolvedValue({ result: 'destroy', category }); + + addReactionButtonListeners(); + + const button = document.querySelector( + `.reaction-vomit-button[data-category="${category}"]`, + ); + + button.click(); + + await waitFor(() => { + expect(top.addSnackbarItem).toHaveBeenCalledWith({ + message: "Your quality rating was removed.", + addCloseButton: true, + }); + }); + }); + }); + + describe('when outcome have an error', () => { + test.each([['thumbsup'], ['thumbsdown']])( + 'it revert marks %s reaction', + async (category) => { + postReactions.mockResolvedValue({ error: 'error' }); + + addReactionButtonListeners(); + + const button = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + + button.click(); + + expect(button.classList).toContain('reacted'); + + await waitFor(() => { + expect(button.classList).not.toContain('reacted'); + }); + }, ); - vomitButton.click(); - expect(vomitButton.classList).toContain('reacted'); - expect(thumbsupButton.classList).not.toContain('reacted'); + + test('it revert marks vomit flag to admins', async () => { + postReactions.mockResolvedValue({ error: 'error' }); + + addReactionButtonListeners(); + + const vomitButton = document.querySelector( + `.reaction-vomit-button[data-category="vomit"]`, + ); + + vomitButton.click(); + + expect(vomitButton.classList).toContain('reacted'); + + await waitFor(() => { + expect(vomitButton.classList).not.toContain('reacted'); + }); + }); }); }); }); describe('addAdjustTagListeners()', () => { - describe('when the user is tag moderator of #discuss', () => { + describe('when an article has no tags', () => { beforeEach(() => { - const tagName = 'discuss'; + fetch.resetMocks(); + document.body.innerHTML = ` - ${tagName} - -
- - -
- `; + + `; - addAdjustTagListeners(); + handleAddTagButtonListeners(); }); - describe('when an article is tagged with #discuss', () => { - it('toggles the tag button and the form', () => { - const tagBtn = document.getElementsByClassName('adjustable-tag')[0]; - tagBtn.click(); - expect(tagBtn.classList).toContain('active'); + + function tagResponse() { + return JSON.stringify({ + status: 'Success', + result: 'addition', + colors: { + bg: '#8c5595', + text: '#39ad55', + }, }); + } + + it('shows the add tag button', () => { + expect(document.getElementById('add-tag-button').classList).not.toContain( + 'hidden', + ); + + expect(document.getElementById('add-tag-container').classList).toContain( + 'hidden', + ); }); - }); - describe('when the user is an admin and the article has room for tags', () => { - describe('tag adjustment interactions', () => { - beforeEach(() => { - const tagName = 'discuss'; - document.body.innerHTML = ` -
- -
- ${tagName} - - -
- - -
- `; - addAdjustTagListeners(); + it('click on add tag button shows the container and hides the button', () => { + document.getElementById('add-tag-button').click(); + expect(document.getElementById('add-tag-button').classList).toContain( + 'hidden', + ); + + expect( + document.getElementById('add-tag-container').classList, + ).not.toContain('hidden'); + }); + + it('by default the add tag submit button is disabled and gets enabled when entered tag', () => { + document.getElementById('add-tag-button').click(); + expect( + document.getElementById('tag-add-submit').hasAttribute('disabled'), + ).toBeTruthy(); + + fireEvent.input(document.getElementById('admin-add-tag'), { + target: { value: 'New Tag' }, }); - it('shows the adjustment container when admin input is focused', () => { - document.getElementById('admin-add-tag').focus(); - expect( - document.getElementById('adjustment-reason-container').classList, - ).not.toContain('hidden'); + + expect( + document.getElementById('tag-add-submit').hasAttribute('disabled'), + ).toBeFalsy(); + }); + + it('click on cancel button hides the container and shows add-tag button', () => { + document.getElementById('add-tag-button').click(); + document.getElementById('cancel-add-tag-button').click(); + expect(document.getElementById('add-tag-button').classList).not.toContain( + 'hidden', + ); + + expect(document.getElementById('add-tag-container').classList).toContain( + 'hidden', + ); + }); + + it('click on add-tag button hides the container and shows add-tag button', async () => { + fetch.mockResponseOnce(tagResponse()); + + const addTagButton = document.getElementById('add-tag-button'); + + addTagButton.click(); + + fireEvent.input(document.getElementById('admin-add-tag'), { + target: { value: 'New Tag' }, }); - it('triggers a confirmation if the admin add tag input was filled in', () => { - window.confirm = jest.fn(); - document.getElementById('admin-add-tag').value = 'pizza'; - document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click(); - expect(window.confirm).toHaveBeenCalled(); + expect( + document.getElementById('tag-add-submit').hasAttribute('disabled'), + ).toBeFalsy(); + + document.getElementById('tag-add-reason').value = 'Adding a new tag'; + document.getElementById('tag-add-submit').click(); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('shows the adjustment container when admin input is focused', () => { + document.getElementById('add-tag-button').click(); + document.getElementById('admin-add-tag').value = 'pizza'; + document.getElementById('tag-add-reason').value = 'Adding a new tag'; + document.getElementById('tag-add-submit').click(); + + expect( + document.getElementById('add-reason-container').classList, + ).not.toContain('hidden'); + }); + }); + + describe('article has 1 tag', () => { + const discussTag = 'discuss'; + + function removeTagResponse() { + return JSON.stringify({ + status: 'Success', + result: 'removal', + colors: { + bg: '#8c5595', + text: '#39ad55', + }, }); - it('does not the hide reason container when going from one tag to another tag', () => { - document.getElementsByClassName('adjustable-tag')[0].click(); - document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click(); - expect( - document.getElementById('adjustment-reason-container').classList, - ).not.toContain('hidden'); + } + + beforeEach(() => { + fetch.resetMocks(); + + document.body.innerHTML = ` + + + `; + + handleRemoveTagButtonsListeners(); + }); + + it('remove tag container is hidden', () => { + expect( + document.getElementById(`remove-tag-container-${discussTag}`).classList, + ).toContain('hidden'); + }); + + it('hides remove icon on button click', () => { + document.getElementById(`remove-tag-button-${discussTag}`).click(); + + expect( + document.getElementById(`remove-tag-icon-${discussTag}`).style.display, + ).toEqual('none'); + }); + + it('shows tag container on button click', () => { + document.getElementById(`remove-tag-button-${discussTag}`).click(); + + expect( + document.getElementById(`remove-tag-container-${discussTag}`).classList, + ).not.toContain('hidden'); + }); + + it('click on cancel button hides the container', () => { + document.getElementById(`remove-tag-button-${discussTag}`).click(); + document.getElementById(`cancel-remove-tag-button-${discussTag}`).click(); + + expect( + document.getElementById(`remove-tag-container-${discussTag}`).classList, + ).toContain('hidden'); + }); + + it('click on remove button should successfully remove the item', async () => { + fetch.mockResponseOnce(removeTagResponse()); + + document.getElementById(`remove-tag-button-${discussTag}`).click(); + document.getElementById(`tag-removal-reason-${discussTag}`).value = + 'Removing a tag'; + document.getElementById(`remove-tag-submit-${discussTag}`).click(); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('tag moderator role', () => { + const discussTag = 'discuss'; + + function addModTagResponse() { + return JSON.stringify({ + status: 'Success', + result: 'addition', + colors: { + bg: '#8c5595', + text: '#39ad55', + }, }); + } + + beforeEach(() => { + fetch.resetMocks(); + + document.body.innerHTML = ` + + + `; + + handleAddModTagButtonsListeners(); + }); + + it('add moderator tag container is hidden', () => { + expect( + document.getElementById(`add-tag-container-${discussTag}`).classList, + ).toContain('hidden'); + }); + + it('hides add icon on button click', () => { + document.getElementById(`add-tag-button-${discussTag}`).click(); + + expect( + document.getElementById(`add-tag-icon-${discussTag}`).style.display, + ).toEqual('none'); + }); + + it('shows tag container on button click', () => { + document.getElementById(`add-tag-button-${discussTag}`).click(); + + expect( + document.getElementById(`add-tag-container-${discussTag}`).classList, + ).not.toContain('hidden'); + }); + + it('click on cancel button hides the container', () => { + document.getElementById(`add-tag-button-${discussTag}`).click(); + document.getElementById(`cancel-add-tag-button-${discussTag}`).click(); + + expect( + document.getElementById(`add-tag-container-${discussTag}`).classList, + ).toContain('hidden'); + }); + + it('click on add button should successfully add the item', async () => { + fetch.mockResponseOnce(addModTagResponse()); + + document.getElementById(`add-tag-button-${discussTag}`).click(); + document.getElementById(`tag-add-reason-${discussTag}`).value = + 'Add a tag'; + document.getElementById(`tag-add-submit-${discussTag}`).click(); + + expect(fetch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js b/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js index ba059bd7b1604..a90373f8b1b02 100644 --- a/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js +++ b/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js @@ -4,25 +4,17 @@ describe('toggling the actions panel', () => { describe('when the page is the article show page', () => { document.body.innerHTML = `
-
+
+ +
`; const path = '/fakeuser/fake-article-slug-1d3a'; - test('it should render the mod actions menu button', () => { - initializeActionsPanel(path); - expect( - document.querySelector( - `iframe#mod-container[src="${path}/actions_panel"]`, - ), - ).toBeDefined(); - expect( - document.getElementsByClassName('mod-actions-menu-btn')[0], - ).not.toBeNull(); - expect( - document.getElementsByClassName('actions-menu-svg')[0], - ).not.toBeNull(); - }); - test('it should have a click listener that toggles the appropriate classes', () => { initializeActionsPanel(path); diff --git a/app/javascript/actionsPanel/actionsPanel.js b/app/javascript/actionsPanel/actionsPanel.js index 9a9950938c26c..d54fc277ff4ca 100644 --- a/app/javascript/actionsPanel/actionsPanel.js +++ b/app/javascript/actionsPanel/actionsPanel.js @@ -2,10 +2,16 @@ import { toggleFlagUserModal } from '../packs/toggleUserFlagModal'; import { toggleSuspendUserModal } from '../packs/toggleUserSuspensionModal'; import { toggleUnpublishPostModal } from '../packs/unpublishPostModal'; import { toggleUnpublishAllPostsModal } from '../packs/modals/unpublishAllPosts'; +import { postReactions } from './services/reactions'; import { request } from '@utilities/http'; export function addCloseListener() { const button = document.getElementsByClassName('close-actions-panel')[0]; + const parentPath = window.parent.location.pathname; + if (!parentPath.startsWith('/mod')) { + button.classList.remove('hidden'); + } + button.addEventListener('click', () => { // getting the article show page document because this is called within an iframe // eslint-disable-next-line no-restricted-globals @@ -51,30 +57,45 @@ function applyReactedClass(category) { } export function addReactionButtonListeners() { - const butts = Array.from( + const reactionButtons = Array.from( document.querySelectorAll('.reaction-button, .reaction-vomit-button'), ); + const initialButtonsState = {}; + + reactionButtons.forEach((button) => { + const { classList } = button; + + initialButtonsState[button.getAttribute('data-category')] = + classList.contains('reacted'); + }); + + const rollbackReactionButtonsState = () => { + reactionButtons.forEach(({ classList, dataset }) => { + if (initialButtonsState[dataset.category]) classList.add('reacted'); + else classList.remove('reacted'); + }); + }; + /* eslint-disable camelcase */ - butts.forEach((butt) => { - butt.addEventListener('click', async (event) => { + reactionButtons.forEach((button) => { + button.addEventListener('click', async (event) => { event.preventDefault(); const { reactableType: reactable_type, category, reactableId: reactable_id, - } = butt.dataset; + } = button.dataset; applyReactedClass(category); - butt.classList.toggle('reacted'); + button.classList.toggle('reacted'); try { - const response = await request('/reactions', { - method: 'POST', - body: { reactable_type, category, reactable_id }, + const outcome = await postReactions({ + reactable_type, + category, + reactable_id, }); - const outcome = await response.json(); - let message; /* eslint-disable no-restricted-globals */ if (outcome.result === 'create' && outcome.category === 'thumbsup') { @@ -93,6 +114,7 @@ export function addReactionButtonListeners() { message = 'Your quality rating was removed.'; } else if (outcome.error) { message = `Error: ${outcome.error}`; + rollbackReactionButtonsState(); } top.addSnackbarItem({ message, @@ -102,6 +124,7 @@ export function addReactionButtonListeners() { } catch (error) { // eslint-disable-next-line no-alert alert(error); + rollbackReactionButtonsState(); } }); }); @@ -182,10 +205,6 @@ const adminFeatureArticle = async (id, featured) => { } }; -function clearAdjustmentReason() { - document.getElementById('tag-adjustment-reason').value = ''; -} - function renderTagOnArticle(tagName, colors) { const articleTagsContainer = getArticleContainer().getElementsByClassName('spec__tags')[0]; @@ -208,24 +227,31 @@ function getArticleContainer() { : window.parent.document.getElementById('main-content'); } -async function adjustTag(el) { - const reasonForAdjustment = document.getElementById( - 'tag-adjustment-reason', - ).value; +/** + * This function sends an asynchronous request to the server to add or remove + * a specific tag from an article. + */ +async function adjustTag(el, reasonElement) { + const tagName = el.dataset.tagName || el.value; const body = { tag_adjustment: { // TODO: change to tag ID - tag_name: el.dataset.tagName || el.value, + tag_name: tagName, article_id: el.dataset.articleId, adjustment_type: el.dataset.adjustmentType === 'subtract' ? 'removal' : 'addition', - reason_for_adjustment: reasonForAdjustment, + reason_for_adjustment: reasonElement.value, }, }; try { - const response = await request('/tag_adjustments', { + const response = await fetch('/tag_adjustments', { method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, body: JSON.stringify(body), }); @@ -235,15 +261,12 @@ async function adjustTag(el) { let adjustedTagName; if (el.tagName === 'BUTTON') { adjustedTagName = el.dataset.tagName; - el.remove(); } else { adjustedTagName = el.value; // eslint-disable-next-line no-param-reassign, require-atomic-updates el.value = ''; } - clearAdjustmentReason(); - if (outcome.result === 'addition') { renderTagOnArticle(adjustedTagName, outcome.colors); } else { @@ -259,6 +282,9 @@ async function adjustTag(el) { }.`, addCloseButton: true, }); + // TODO: explore possible alternatives to reloading window, which seems to have the side effect + // of making deferred JS load-in times unpredictable in e2e tests + window.location.reload(); } else { // eslint-disable-next-line no-restricted-globals top.addSnackbarItem({ @@ -272,81 +298,174 @@ async function adjustTag(el) { } } -export function handleAdjustTagBtn(btn) { - const currentActiveTags = document.querySelectorAll( - 'button.adjustable-tag.active', +export function handleAddModTagButton(btn) { + const { tagName } = btn.dataset; + const addButton = document.getElementById(`add-tag-button-${tagName}`); + const addIcon = document.getElementById(`add-tag-icon-${tagName}`); + const addTagContainer = document.getElementById( + `add-tag-container-${tagName}`, ); - const adminTagInput = document.getElementById('admin-add-tag'); - /* eslint-disable no-restricted-globals */ - /* eslint-disable no-alert */ - if ( - adminTagInput && - adminTagInput.value !== '' && - confirm( - 'This will clear your current "Add a tag" input. Do you want to continue?', - ) - ) { - /* eslint-enable no-restricted-globals */ - /* eslint-enable no-alert */ - adminTagInput.value = ''; - } else if (currentActiveTags.length > 0) { - currentActiveTags.forEach((tag) => { - if (tag !== btn) { - tag.classList.remove('active'); - } - btn.classList.toggle('active'); - }); + + const containerIsVisible = addTagContainer.classList.contains('hidden'); + if (containerIsVisible) { + addIcon.style.display = 'none'; + addTagContainer.classList.remove('hidden'); + addButton.classList.add('fw-bold'); + addButton.classList.remove('fw-normal'); } else { - btn.classList.toggle('active'); + addIcon.style.display = 'flex'; + addTagContainer.classList.add('hidden'); + addButton.classList.remove('fw-bold'); + addButton.classList.add('fw-normal'); + } + + const cancelAddModTagButton = document.getElementById( + `cancel-add-tag-button-${tagName}`, + ); + cancelAddModTagButton.addEventListener('click', () => { + handleAddModTagButton(btn); + }); + + const addTagButton = document.getElementById(`tag-add-submit-${tagName}`); + if (addTagButton) { + addTagButton.addEventListener('click', (e) => { + e.preventDefault(); + const dataSource = document.getElementById(`add-tag-button-${tagName}`); + const reasonFoRemoval = document.getElementById( + `tag-add-reason-${tagName}`, + ); + adjustTag(dataSource, reasonFoRemoval); + }); } } -function handleAdminInput() { - const addTagInput = document.getElementById('admin-add-tag'); +/** + * Handles various listeners required to handle remove tag functionality. + */ +export function handleRemoveTagButton(btn) { + const { tagName } = btn.dataset; + + const removeButton = document.getElementById(`remove-tag-button-${tagName}`); + const removeIcon = document.getElementById(`remove-tag-icon-${tagName}`); + const removeTagContainer = document.getElementById( + `remove-tag-container-${tagName}`, + ); - if (addTagInput) { - addTagInput.addEventListener('focus', () => { - const activeTagBtns = Array.from( - document.querySelectorAll('button.adjustable-tag.active'), + if (!(removeButton && removeIcon && removeTagContainer)) { + return false; + } + + const containerIsVisible = removeTagContainer?.classList.contains('hidden'); + if (containerIsVisible) { + removeIcon.style.display = 'none'; + removeTagContainer.classList.remove('hidden'); + removeButton.classList.add('fw-bold'); + removeButton.classList.remove('fw-normal'); + } else { + removeIcon.style.display = 'flex'; + removeTagContainer.classList.add('hidden'); + removeButton.classList.remove('fw-bold'); + removeButton.classList.add('fw-normal'); + } + + const cancelRemoveTagButton = document.getElementById( + `cancel-remove-tag-button-${tagName}`, + ); + cancelRemoveTagButton.addEventListener('click', () => { + handleRemoveTagButton(btn); + }); + + const removeTagButton = document.getElementById( + `remove-tag-submit-${tagName}`, + ); + if (removeTagButton) { + removeTagButton.addEventListener('click', (e) => { + e.preventDefault(); + + const dataSource = document.getElementById( + `remove-tag-button-${tagName}`, ); - activeTagBtns.forEach((btn) => { - btn.classList.remove('active'); - }); + const reasonFoRemoval = document.getElementById( + `tag-removal-reason-${tagName}`, + ); + adjustTag(dataSource, reasonFoRemoval); }); - addTagInput.addEventListener('focusout', () => { - if (addTagInput.value === '') { + } +} + +/** + * Handles various listeners required to handle add tag functionality. + */ +export function handleAddTagButtonListeners() { + const inputTag = document.getElementById('admin-add-tag'); + const submitButton = document.getElementById('tag-add-submit'); + + if (inputTag) { + inputTag.addEventListener('input', () => { + if (inputTag.value.trim().length > 0) { + submitButton.removeAttribute('disabled'); + } else { + submitButton.setAttribute('disabled', 'disabled'); } }); } + + const addTagButton = document.getElementById('add-tag-button'); + + if (addTagButton) { + addTagButton.addEventListener('click', () => { + const addTagContainer = document.getElementById('add-tag-container'); + addTagContainer.classList.remove('hidden'); + addTagButton.classList.add('hidden'); + }); + + const cancelAddTagButton = document.getElementById('cancel-add-tag-button'); + + if (cancelAddTagButton) { + cancelAddTagButton.addEventListener('click', () => { + const addTagContainer = document.getElementById('add-tag-container'); + addTagContainer.classList.add('hidden'); + addTagButton.classList.remove('hidden'); + }); + } + + const addTagSubmitButton = document.getElementById('tag-add-submit'); + if (addTagSubmitButton) { + addTagSubmitButton.addEventListener('click', (e) => { + e.preventDefault(); + + const dataSource = document.getElementById('admin-add-tag'); + const reasonFoAddition = document.getElementById('tag-add-reason'); + adjustTag(dataSource, reasonFoAddition); + }); + } + } } -export function addAdjustTagListeners() { - Array.from(document.getElementsByClassName('adjustable-tag')).forEach( +export function handleAddModTagButtonsListeners() { + Array.from(document.getElementsByClassName('adjustable-tag add-tag')).forEach( (btn) => { btn.addEventListener('click', () => { - handleAdjustTagBtn(btn); + handleAddModTagButton(btn); }); }, ); +} - const form = document.getElementById('tag-adjust-submit')?.form; - if (form) { - form.addEventListener('submit', (e) => { - e.preventDefault(); - - const dataSource = - document.querySelector('button.adjustable-tag.active') ?? - document.getElementById('admin-add-tag'); - - adjustTag(dataSource); - }); - - handleAdminInput(); - } +export function handleRemoveTagButtonsListeners() { + Array.from(document.getElementsByClassName('adjustable-tag')).forEach( + (btn) => { + btn.addEventListener('click', () => { + handleRemoveTagButton(btn); + }); + }, + ); } export function addModActionsListeners() { - addAdjustTagListeners(); + handleAddTagButtonListeners(); + handleAddModTagButtonsListeners(); + handleRemoveTagButtonsListeners(); Array.from(document.getElementsByClassName('other-things-btn')).forEach( (btn) => { btn.addEventListener('click', () => { diff --git a/app/javascript/actionsPanel/initializeActionsPanelToggle.js b/app/javascript/actionsPanel/initializeActionsPanelToggle.js index 1e2959e065247..c1f5d08884caa 100644 --- a/app/javascript/actionsPanel/initializeActionsPanelToggle.js +++ b/app/javascript/actionsPanel/initializeActionsPanelToggle.js @@ -7,15 +7,6 @@ export function initializeActionsPanel(user, path) { `; - const modActionsMenuIconHTML = ` - - `; - function toggleModActionsMenu() { document .getElementById('mod-actions-menu-btn-area') @@ -39,14 +30,9 @@ export function initializeActionsPanel(user, path) { document.getElementsByClassName('mod-actions-menu')[0].innerHTML = modActionsMenuHTML; - document - .getElementById('mod-actions-menu-btn-area') - .classList.remove('hidden'); // eslint-disable-next-line no-restricted-globals if (!isModerationPage()) { // don't show mod button in mod center page - document.getElementById('mod-actions-menu-btn-area').innerHTML = - modActionsMenuIconHTML; document .getElementsByClassName('mod-actions-menu-btn')[0] .addEventListener('click', toggleModActionsMenu); diff --git a/app/javascript/actionsPanel/services/reactions.js b/app/javascript/actionsPanel/services/reactions.js new file mode 100644 index 0000000000000..c673f53722bc5 --- /dev/null +++ b/app/javascript/actionsPanel/services/reactions.js @@ -0,0 +1,19 @@ +import { request } from '@utilities/http'; + +export const postReactions = async ({ + reactable_type, + category, + reactable_id, +}) => { + const response = await request('/reactions', { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + body: { reactable_type, category, reactable_id }, + }); + + return await response.json(); +}; diff --git a/app/javascript/admin/controllers/confirmation_modal_controller.js b/app/javascript/admin/controllers/confirmation_modal_controller.js index 2c0f817bd933a..12c3cec018750 100644 --- a/app/javascript/admin/controllers/confirmation_modal_controller.js +++ b/app/javascript/admin/controllers/confirmation_modal_controller.js @@ -15,7 +15,7 @@ window.addEventListener('load', () => { const nonRedirectEndpoints = [ '/admin/content_manager/badge_achievements', - '/admin/customization/display_ads', + '/admin/customization/billboards', ]; const redirectEndpoints = ['/admin/advanced/broadcasts']; diff --git a/app/javascript/admin/controllers/reaction_controller.js b/app/javascript/admin/controllers/reaction_controller.js index ccd2ecc72d92d..9d3ffdbb97086 100644 --- a/app/javascript/admin/controllers/reaction_controller.js +++ b/app/javascript/admin/controllers/reaction_controller.js @@ -7,7 +7,7 @@ export default class ReactionController extends Controller { url: String, }; - updateReaction(status) { + updateReaction(status, removeElement = true) { const id = this.idValue; fetch(this.urlValue, { @@ -15,7 +15,7 @@ export default class ReactionController extends Controller { headers: { Accept: 'application/json', 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']") - .content, + ?.content, 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -28,8 +28,16 @@ export default class ReactionController extends Controller { .json() .then((json) => { if (json.outcome === 'Success') { - this.element.remove(); - document.getElementById(`js__reaction__div__hr__${id}`).remove(); + if (removeElement === true) { + this.element.remove(); + document.getElementById(`js__reaction__div__hr__${id}`).remove(); + } else { + // TODO (#19531): Code Optimisation- avoid reloading entire page for this minor item change. + // Once the status of item gets updated in admin/content_manager/articles/, we + // reload the entire page here. Ideally we should only re-render the item which was updated + // but given the case that this feature is used by internal-team, for now its fine. + location.reload(); + } } else { window.alert(json.error); } @@ -40,12 +48,14 @@ export default class ReactionController extends Controller { ); } - updateReactionInvalid() { - this.updateReaction(this.invalidStatus); + updateReactionInvalid(event) { + const { removeElement } = event.target.dataset; + this.updateReaction(this.invalidStatus, removeElement); } - updateReactionConfirmed() { - this.updateReaction(this.confirmedStatus); + updateReactionConfirmed(event) { + const { removeElement } = event.target.dataset; + this.updateReaction(this.confirmedStatus, removeElement); } reactableUserCheck() { diff --git a/app/javascript/admin/controllers/snackbar_controller.js b/app/javascript/admin/controllers/snackbar_controller.js index 316af6c9e833c..1e6b721fed870 100644 --- a/app/javascript/admin/controllers/snackbar_controller.js +++ b/app/javascript/admin/controllers/snackbar_controller.js @@ -8,7 +8,7 @@ export default class SnackbarController extends Controller { const [{ h, render }, { Snackbar }] = await Promise.all([ import('preact'), // eslint-disable-next-line import/no-unresolved - import('Snackbar'), + import('../../Snackbar'), ]); render(, this.snackZoneTarget); @@ -25,7 +25,7 @@ export default class SnackbarController extends Controller { const { message, addCloseButton = false } = event.detail; // eslint-disable-next-line import/no-unresolved - const { addSnackbarItem } = await import('Snackbar'); + const { addSnackbarItem } = await import('../../Snackbar'); addSnackbarItem({ message, addCloseButton }); } } diff --git a/app/javascript/analytics/dashboard.js b/app/javascript/analytics/dashboard.js index d309dba8389e0..ddf8557b1860c 100644 --- a/app/javascript/analytics/dashboard.js +++ b/app/javascript/analytics/dashboard.js @@ -31,17 +31,14 @@ function writeCards(data, timeRangeLabel) { const readers = sumAnalytics(data, 'page_views'); const reactions = sumAnalytics(data, 'reactions'); const comments = sumAnalytics(data, 'comments'); - const follows = sumAnalytics(data, 'follows'); const reactionCard = document.getElementById('reactions-card'); const commentCard = document.getElementById('comments-card'); - const followerCard = document.getElementById('followers-card'); const readerCard = document.getElementById('readers-card'); readerCard.innerHTML = cardHTML(readers, `Readers ${timeRangeLabel}`); commentCard.innerHTML = cardHTML(comments, `Comments ${timeRangeLabel}`); reactionCard.innerHTML = cardHTML(reactions, `Reactions ${timeRangeLabel}`); - followerCard.innerHTML = cardHTML(follows, `Followers ${timeRangeLabel}`); } function drawChart({ id, showPoints = true, title, labels, datasets }) { @@ -121,7 +118,6 @@ function drawCharts(data, timeRangeLabel) { const likes = parsedData.map((date) => date.reactions.like); const readingList = parsedData.map((date) => date.reactions.readinglist); const unicorns = parsedData.map((date) => date.reactions.unicorn); - const followers = parsedData.map((date) => date.follows.total); const readers = parsedData.map((date) => date.page_views.total); // When timeRange is "Infinity" we hide the points to avoid over-crowding the UI @@ -185,23 +181,6 @@ function drawCharts(data, timeRangeLabel) { ], }); - drawChart({ - id: 'followers-chart', - showPoints, - title: `New Followers ${timeRangeLabel}`, - labels, - datasets: [ - { - label: 'Followers', - data: followers, - fill: false, - borderColor: 'rgb(10, 133, 255)', - backgroundColor: 'rgb(10, 133, 255)', - lineTension: 0.1, - }, - ], - }); - drawChart({ id: 'readers-chart', showPoints, @@ -250,20 +229,21 @@ function renderReferrers(data) { } function removeCardElements() { - const el = document.getElementsByClassName("summary-stats")[0]; + const el = document.getElementsByClassName('summary-stats')[0]; el && el.remove(); } function showErrorsOnCharts() { - const target = ['reactions-chart', 'comments-chart', 'followers-chart', 'readers-chart']; - target.forEach(id => { + const target = ['reactions-chart', 'comments-chart', 'readers-chart']; + target.forEach((id) => { const el = document.getElementById(id); el.outerHTML = `

Failed to fetch chart data. If this error persists for a minute, you can try to disable adblock etc. on this page or site.

`; }); } function showErrorsOnReferrers() { - document.getElementById('referrers-container').outerHTML = '

Failed to fetch referrer data. If this error persists for a minute, you can try to disable adblock etc. on this page or site.

'; + document.getElementById('referrers-container').outerHTML = + '

Failed to fetch referrer data. If this error persists for a minute, you can try to disable adblock etc. on this page or site.

'; } function callAnalyticsAPI(date, timeRangeLabel, { organizationId, articleId }) { diff --git a/app/javascript/article-form/articleForm.jsx b/app/javascript/article-form/articleForm.jsx index 585f6ff84accd..4e2980b0fced8 100644 --- a/app/javascript/article-form/articleForm.jsx +++ b/app/javascript/article-form/articleForm.jsx @@ -65,6 +65,8 @@ export class ArticleForm extends Component { organizations: PropTypes.string, siteLogo: PropTypes.string.isRequired, schedulingEnabled: PropTypes.bool.isRequired, + coverImageHeight: PropTypes.string.isRequired, + coverImageCrop: PropTypes.string.isRequired, }; static defaultProps = { @@ -73,7 +75,14 @@ export class ArticleForm extends Component { constructor(props) { super(props); - const { article, version, siteLogo, schedulingEnabled } = this.props; + const { + article, + version, + siteLogo, + schedulingEnabled, + coverImageHeight, + coverImageCrop, + } = this.props; let { organizations } = this.props; this.article = JSON.parse(article); organizations = organizations ? JSON.parse(organizations) : null; @@ -136,6 +145,8 @@ export class ArticleForm extends Component { updatedAt: this.article.updated_at, version, siteLogo, + coverImageHeight, + coverImageCrop, helpFor: null, helpPosition: null, isModalOpen: false, @@ -397,14 +408,16 @@ export class ArticleForm extends Component { } }; - switchHelpContext = ({ target }) => { - this.setState({ - ...this.setCommonProps({ - helpFor: target.id, - helpPosition: target.getBoundingClientRect().y, - }), - }); - }; + switchHelpContext = (event, override = null) => { + if (!this.state.previewShowing) { + const id = override || event.target.id; + this.setState({ + ...this.setCommonProps({ + helpFor: id, + helpPosition: event.target.getBoundingClientRect().y, + }), + }); + }}; render() { const { @@ -431,6 +444,8 @@ export class ArticleForm extends Component { siteLogo, markdownLintErrors, formKey, + coverImageHeight, + coverImageCrop, } = this.state; return ( @@ -442,6 +457,8 @@ export class ArticleForm extends Component { className="crayons-article-form" onSubmit={this.onSubmit} onInput={this.toggleEdit} + coverImageHeight={coverImageHeight} + coverImageCrop={coverImageCrop} aria-label="Edit post" >
)} @@ -528,6 +547,7 @@ export class ArticleForm extends Component { onConfigChange={this.handleConfigChange} submitting={submitting} previewLoading={previewLoading} + switchHelpContext={this.switchHelpContext} /> isUploadingImage ? null : ( @@ -46,14 +48,15 @@ const StandardImageUpload = ({ className="screen-reader-only" data-max-file-size-mb="25" /> - - Use a ratio of 100:42 for best results. + + {coverImageCrop === 'crop' ? `Use a ratio of 1000:${coverImageHeight} ` : 'Minimum 1000px wide '} + for best results. ); -export const ArticleCoverImage = ({ onMainImageUrlChange, mainImage }) => { +export const ArticleCoverImage = ({ onMainImageUrlChange, mainImage, coverImageHeight, coverImageCrop }) => { const [uploadError, setUploadError] = useState(false); const [uploadErrorMessage, setUploadErrorMessage] = useState(null); const [uploadingImage, setUploadingImage] = useState(false); @@ -100,10 +103,11 @@ export const ArticleCoverImage = ({ onMainImageUrlChange, mainImage }) => { const initNativeImagePicker = (e) => { e.preventDefault(); - window.ForemMobile?.injectNativeMessage('coverUpload', { - action: 'coverImageUpload', - ratio: `${100.0 / 42.0}`, - }); + let options = { action: 'coverImageUpload' }; + if (coverImageCrop === 'crop') { + options = { ...options, ratio: `1000.0 / ${coverImageHeight}.0`, }; + } + window.ForemMobile?.injectNativeMessage('coverUpload', options); }; const handleNativeMessage = (e) => { @@ -204,6 +208,8 @@ export const ArticleCoverImage = ({ onMainImageUrlChange, mainImage }) => { )} @@ -226,6 +232,8 @@ export const ArticleCoverImage = ({ onMainImageUrlChange, mainImage }) => { ArticleCoverImage.propTypes = { mainImage: PropTypes.string, onMainImageUrlChange: PropTypes.func.isRequired, + coverImageHeight: PropTypes.string.isRequired, + coverImageCrop: PropTypes.string.isRequired, }; ArticleCoverImage.displayName = 'ArticleCoverImage'; diff --git a/app/javascript/article-form/components/EditorActions.jsx b/app/javascript/article-form/components/EditorActions.jsx index fd7eb7f1efae5..140c6bd1a6a6e 100644 --- a/app/javascript/article-form/components/EditorActions.jsx +++ b/app/javascript/article-form/components/EditorActions.jsx @@ -18,6 +18,7 @@ export const EditorActions = ({ onConfigChange, submitting, previewLoading, + switchHelpContext, }) => { const isVersion1 = version === 'v1'; const isVersion2 = version === 'v2'; @@ -60,12 +61,17 @@ export const EditorActions = ({ } return ( -
+
@@ -75,6 +81,7 @@ export const EditorActions = ({ className="mr-2 whitespace-nowrap" onClick={onSaveDraft} disabled={previewLoading} + onFocus={(event) => switchHelpContext(event, 'editor-actions')} > Save draft @@ -87,6 +94,7 @@ export const EditorActions = ({ onConfigChange={onConfigChange} onSaveDraft={onSaveDraft} previewLoading={previewLoading} + onFocus={(event) => switchHelpContext(event, 'editor-actions')} /> )} @@ -95,6 +103,7 @@ export const EditorActions = ({ onClick={onClearChanges} className="whitespace-nowrap fw-normal fs-s" disabled={previewLoading} + onFocus={(event) => switchHelpContext(event, 'editor-actions')} > Revert new changes diff --git a/app/javascript/article-form/components/Form.jsx b/app/javascript/article-form/components/Form.jsx index f56d3e35f6c3d..8feb2536c4baf 100644 --- a/app/javascript/article-form/components/Form.jsx +++ b/app/javascript/article-form/components/Form.jsx @@ -17,6 +17,8 @@ export const Form = ({ onMainImageUrlChange, switchHelpContext, errors, + coverImageCrop, + coverImageHeight, }) => { return (
@@ -31,6 +33,8 @@ export const Form = ({ mainImage={mainImage} onMainImageUrlChange={onMainImageUrlChange} switchHelpContext={switchHelpContext} + coverImageCrop={coverImageCrop} + coverImageHeight={coverImageHeight} /> )} @@ -58,6 +62,8 @@ Form.propTypes = { onMainImageUrlChange: PropTypes.func.isRequired, switchHelpContext: PropTypes.func.isRequired, errors: PropTypes.object, + coverImageHeight: PropTypes.string.isRequired, + coverImageCrop: PropTypes.string.isRequired, }; Form.displayName = 'Form'; diff --git a/app/javascript/article-form/components/Help/ArticleTips.jsx b/app/javascript/article-form/components/Help/ArticleTips.jsx new file mode 100644 index 0000000000000..d85727e244670 --- /dev/null +++ b/app/javascript/article-form/components/Help/ArticleTips.jsx @@ -0,0 +1,25 @@ +import { h } from 'preact'; + +export const ArticleTips = () => ( +
+

Publishing Tips

+
    +
  • + Ensure your post has a cover image set to make the most of the home feed + and social media platforms. +
  • +
  • + Share your post on social media platforms or with your co-workers or + local communities. +
  • +
  • + Ask people to leave questions for you in the comments. It's a great way + to spark additional discussion describing personally why you wrote it or + why people might find it helpful. +
  • +
+
+); diff --git a/app/javascript/article-form/components/Help/TagInput.jsx b/app/javascript/article-form/components/Help/TagInput.jsx index c36102879f7bd..7a9fd1beeca71 100644 --- a/app/javascript/article-form/components/Help/TagInput.jsx +++ b/app/javascript/article-form/components/Help/TagInput.jsx @@ -7,18 +7,17 @@ export const TagInput = () => ( >

Tagging Guidelines

    -
  • Tags help people find your post.
  • - Think of tags as the topics or categories that best describe your post. + Tags help people find your post - think of them as the topics or + categories that best describe your post.
  • - Add up to four comma-separated tags per post. Combine tags to reach the - appropriate subcommunities. + Add up to four comma-separated tags per post. Use existing tags whenever + possible.
  • -
  • Use existing tags whenever possible.
  • - Some tags, such as “help” or “healthydebate”, have special posting - guidelines. + Some tags have special posting guidelines - double check to make sure + your post complies with them.
diff --git a/app/javascript/article-form/components/Help/index.jsx b/app/javascript/article-form/components/Help/index.jsx index 9ca8dbe54a9cf..faab63852ec14 100644 --- a/app/javascript/article-form/components/Help/index.jsx +++ b/app/javascript/article-form/components/Help/index.jsx @@ -5,6 +5,7 @@ import { ArticleFormTitle } from './ArticleFormTitle'; import { TagInput } from './TagInput'; import { BasicEditor } from './BasicEditor'; import { EditorFormattingHelp } from './EditorFormattingHelp'; +import { ArticleTips } from './ArticleTips'; import { Modal } from '@crayons'; const renderModal = (onClose, title, selector) => { @@ -60,14 +61,16 @@ export const Help = ({ previewShowing, helpFor, helpPosition, version }) => { className="sticky" style={{ top: version === 'v1' ? '56px' : helpPosition }} > - {helpFor === 'article-form-title' && } - {helpFor === 'tag-input' && } - {version === 'v1' && } - {(helpFor === 'article_body_markdown' || version === 'v1') && ( )} + + {helpFor === 'article-form-title' && } + {helpFor === 'tag-input' && } + {(helpFor === 'editor-actions' || version === 'v1') && ( + + )}
)} {liquidShowing && diff --git a/app/javascript/article-form/components/Meta.jsx b/app/javascript/article-form/components/Meta.jsx index ffba2388d7128..b86c1de3d9e62 100644 --- a/app/javascript/article-form/components/Meta.jsx +++ b/app/javascript/article-form/components/Meta.jsx @@ -12,12 +12,16 @@ export const Meta = ({ mainImage, onMainImageUrlChange, switchHelpContext, + coverImageCrop, + coverImageHeight, }) => { return (
</div> diff --git a/app/javascript/article-form/components/Title.jsx b/app/javascript/article-form/components/Title.jsx index 36583f3e187ec..923dc661282a7 100644 --- a/app/javascript/article-form/components/Title.jsx +++ b/app/javascript/article-form/components/Title.jsx @@ -28,7 +28,6 @@ export const Title = ({ onChange, defaultValue, switchHelpContext }) => { > <textarea ref={textAreaRef} - data-gramm_editor="false" className="crayons-textfield crayons-textfield--ghost fs-3xl m:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight" type="text" id="article-form-title" diff --git a/app/javascript/article-form/components/__tests__/Form.test.jsx b/app/javascript/article-form/components/__tests__/Form.test.jsx index fe54e999f39f7..44016781d96a5 100644 --- a/app/javascript/article-form/components/__tests__/Form.test.jsx +++ b/app/javascript/article-form/components/__tests__/Form.test.jsx @@ -2,12 +2,22 @@ import { h } from 'preact'; import { render, waitFor } from '@testing-library/preact'; import fetch from 'jest-fetch-mock'; import '@testing-library/jest-dom'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { Form } from '../Form'; fetch.enableMocks(); +// Mock Algolia +jest.mock('algoliasearch/lite', () => { + const searchClient = { + initIndex: jest.fn(() => ({ + search: jest.fn().mockResolvedValue({ hits: [] }) + })) + }; + return jest.fn(() => searchClient); +}); + let bodyMarkdown; let mainImage; @@ -323,4 +333,4 @@ describe('<Form />', () => { expect(errorMsg.textContent).toContain('title'); expect(errorMsg.textContent).toContain('main_image'); }); -}); +}); \ No newline at end of file diff --git a/app/javascript/article-form/components/__tests__/Help.test.jsx b/app/javascript/article-form/components/__tests__/Help.test.jsx index 103590a41b2a0..094c258e6896b 100644 --- a/app/javascript/article-form/components/__tests__/Help.test.jsx +++ b/app/javascript/article-form/components/__tests__/Help.test.jsx @@ -68,6 +68,7 @@ describe('<Help />', () => { expect(getByTestId('article-form__help-section')).toBeInTheDocument(); expect(getByTestId('basic-editor-help')).toBeInTheDocument(); expect(getByTestId('format-help')).toBeInTheDocument(); + expect(getByTestId('article-publishing-tips')).toBeInTheDocument(); expect(queryByTestId('title-help')).not.toBeInTheDocument(); expect(queryByTestId('basic-tag-input-help')).not.toBeInTheDocument(); diff --git a/app/javascript/article-form/components/__tests__/Title.test.jsx b/app/javascript/article-form/components/__tests__/Title.test.jsx index 59f1807a4b2c8..55615e09109f1 100644 --- a/app/javascript/article-form/components/__tests__/Title.test.jsx +++ b/app/javascript/article-form/components/__tests__/Title.test.jsx @@ -28,6 +28,6 @@ describe('<Title />', () => { expect( queryByPlaceholderText(/post title/i, { selector: 'textarea' }), - ).toBeDefined(); + ).toExist(); }); }); diff --git a/app/javascript/articles/Article.jsx b/app/javascript/articles/Article.jsx index 471cd2d727e5c..e2508007f1b58 100644 --- a/app/javascript/articles/Article.jsx +++ b/app/javascript/articles/Article.jsx @@ -29,6 +29,7 @@ export const Article = ({ return <PodcastArticle article={article} />; } + const isArticle = article.class_name === 'Article'; const clickableClassList = [ 'crayons-story', 'crayons-story__top', @@ -53,6 +54,7 @@ export const Article = ({ isFeatured ? ' crayons-story--featured' : '' }`} id={isFeatured ? 'featured-story-marker' : `article-${article.id}`} + data-feed-content-id={isArticle ? article.id : null} data-content-user-id={article.user_id} > <a @@ -81,7 +83,7 @@ export const Article = ({ {article.cloudinary_video_url && <Video article={article} />} {showCover && <ArticleCoverImage article={article} />} - <div className="crayons-story__body"> + <div className={`crayons-story__body crayons-story__body-${article.type_of}`}> <div className="crayons-story__top"> <Meta article={article} organization={article.organization} /> {pinned && ( @@ -108,9 +110,11 @@ export const Article = ({ <div className="crayons-story__indention"> <ContentTitle article={article} /> - <TagList tags={article.tag_list} flare_tag={article.flare_tag} /> + {article.type_of !== 'status' && (<TagList tags={article.tag_list} flare_tag={article.flare_tag} />)} - {article.class_name === 'Article' && ( + {article.type_of === 'status' && article.body_preview && article.body_preview.length > 0 && (<div className='crayons-story__contentpreview text-styles' dangerouslySetInnerHTML={{__html: article.body_preview}} />)} + + {isArticle && ( // eslint-disable-next-line no-underscore-dangle <SearchSnippet highlightText={article.highlight} /> )} @@ -128,7 +132,7 @@ export const Article = ({ )} <div className="crayons-story__save"> - <ReadingTime readingTime={article.reading_time} /> + <ReadingTime readingTime={article.reading_time} typeOf={article.type_of} /> <SaveButton article={article} diff --git a/app/javascript/articles/Feed.jsx b/app/javascript/articles/Feed.jsx index a29e2086fdd42..ec7863a166401 100644 --- a/app/javascript/articles/Feed.jsx +++ b/app/javascript/articles/Feed.jsx @@ -3,92 +3,296 @@ import { useEffect, useState } from 'preact/hooks'; import PropTypes from 'prop-types'; import { useListNavigation } from '../shared/components/useListNavigation'; import { useKeyboardShortcuts } from '../shared/components/useKeyboardShortcuts'; +import { insertInArrayIf } from '../../javascript/utilities/insertInArrayIf'; +import { initializeDropdown } from '@utilities/dropdownUtils'; /* global userData sendHapticMessage showLoginModal buttonFormData renderNewSidebarCount */ -export const Feed = ({ timeFrame, renderFeed }) => { +export const Feed = ({ timeFrame, renderFeed, afterRender }) => { const { reading_list_ids = [] } = userData(); // eslint-disable-line camelcase const [bookmarkedFeedItems, setBookmarkedFeedItems] = useState( new Set(reading_list_ids), ); - const [pinnedArticle, setPinnedArticle] = useState(null); + const [pinnedItem, setPinnedItem] = useState(null); + const [imageItem, setimageItem] = useState(null); const [feedItems, setFeedItems] = useState([]); - const [podcastEpisodes, setPodcastEpisodes] = useState([]); const [onError, setOnError] = useState(false); useEffect(() => { - setPodcastEpisodes(getPodcastEpisodes()); - }, []); + // /** + // * Retrieves data for the feed. The data will include articles and billboards. + // * + // * @param {number} [page=1] Page of feed data to retrieve + // * @param {string} The time frame of feed data to retrieve + // * + // * @returns {Promise} A promise containing the JSON response for the feed data. + // */ + async function fetchFeedItems(timeFrame = '', page = 1) { + const feedTypeOf = localStorage?.getItem('current_feed') || 'discover'; + const promises = [ + fetch(`/stories/feed/${timeFrame}?page=${page}&type_of=${feedTypeOf}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }), + fetch(`/billboards/feed_first`), + fetch(`/billboards/feed_second`), + fetch(`/billboards/feed_third`), + ]; + + const results = await Promise.allSettled(promises); + const feedItems = []; + for (const result of results) { + if (result.status === 'fulfilled') { + let resolvedValue; + if (isJSON(result)) { + resolvedValue = await result.value.json(); + } - useEffect(() => { - const fetchFeedItems = async () => { + if (isHTML(result)) { + resolvedValue = await result.value.text(); + } + feedItems.push(resolvedValue); + } else { + Honeybadger.notify( + `failed to fetch some items on the home feed: ${result.reason}`, + ); + // we push an undefined item because we want to maintain the placement of the deconstructed array. + // it gets removed before display when we further organize. + feedItems.push(undefined); + } + } + return feedItems; + } + + // /** + // * Sets the Pinned Item into state. + // * + // * @param {Object} The pinnedPost + // * @param {Object} The imagePost + // * + // * @returns {boolean} If we set the pinned post we return true else we return false + // */ + function setPinnedPostItem(pinnedPost, imagePost) { + // We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected) + if (!pinnedPost || timeFrame !== '') return false; + + // If the pinned and the image post aren't the same, (either because imagePost is missing or + // because they represent two different posts), we set the pinnedPost + if (pinnedPost.id !== imagePost?.id) { + setPinnedItem(pinnedPost); + return true; + } + + return false; + } + + const organizeFeedItems = async () => { try { if (onError) setOnError(false); - let feedItems = await getFeedItems(timeFrame); + fetchFeedItems(timeFrame).then( + ([ + feedPosts, + feedFirstBillboard, + feedSecondBillboard, + feedThirdBillboard, + ]) => { + const imagePost = getImagePost(feedPosts); + const pinnedPost = getPinnedPost(feedPosts); + const podcastPost = getPodcastEpisodes(); + + const hasSetPinnedPost = setPinnedPostItem(pinnedPost, imagePost); + const hasSetImagePostItem = setImagePostItem(imagePost); + + const updatedFeedPosts = updateFeedPosts( + feedPosts, + imagePost, + pinnedPost, + ); + + // We implement the following organization for the feed: + // 1. Place the pinned post first (if the timeframe is relevant) + // 2. Place the image post next + // 3. If you follow podcasts, place the podcast episodes that are + // published today (this is an array) + // 4. Place the rest of the stories for the feed + // 5. Insert the billboards in that array accordingly + // - feed_first: Before all home page posts + // - feed_second: Between 2nd and 3rd posts in the feed + // - feed_third: Between 7th and 8th posts in the feed + + const organizedFeedItems = [ + ...insertInArrayIf(hasSetPinnedPost, pinnedPost), + ...insertInArrayIf(hasSetImagePostItem, imagePost), + ...insertInArrayIf(podcastPost.length > 0, podcastPost), + ...updatedFeedPosts, + ]; + + const organizedFeedItemsWithBillboards = insertBillboardsInFeed( + organizedFeedItems, + feedFirstBillboard, + feedSecondBillboard, + feedThirdBillboard, + ); + + setFeedItems(organizedFeedItemsWithBillboards); + }, + ); + } catch { + if (!onError) setOnError(true); + } + }; + organizeFeedItems(); + }, [timeFrame, onError]); - // Here we extract from the feed two special items: pinned and featured + useEffect(() => { + if (feedItems.length > 0) { + afterRender(); + } + }, [afterRender, feedItems.length]); + + // /** + // * Retrieves the imagePost which will later appear at the top of the feed, + // * with a larger main_image than any of the stories or feed elements. + // * + // * @param {Array} The original feed posts that are retrieved from the endpoint. + // * + // * @returns {Object} The first post with a main_image + // */ + function getImagePost(feedPosts) { + return feedPosts.find((post) => post.main_image !== null); + } - const pinnedArticle = feedItems.find((story) => story.pinned === true); + // /** + // * Retrieves the pinnedPost which will later appear at the top the feed with a pin. + // * + // * @param {Array} The original feed posts that are retrieved from the endpoint. + // * + // * @returns {Object} The first post that has pinned set to true + // */ + function getPinnedPost(feedPosts) { + return feedPosts.find((post) => post.pinned === true); + } - // Ensure first article is one with a main_image - // This is important because the featuredStory will - // appear at the top of the feed, with a larger - // main_image than any of the stories or feed elements. - const featuredStory = feedItems.find( - (story) => story.main_image !== null, - ); + // /** + // * Sets the Image Item into state. + // * + // * @param {Object} The imagePost + // * + // * @returns {boolean} If we set the pinned post we return true + // */ + function setImagePostItem(imagePost) { + if (imagePost) { + setimageItem(imagePost); + return true; + } + } - // If pinned and featured article aren't the same, - // (either because featuredStory is missing or because they represent two different articles), - // we set the pinnedArticle and remove it from feedItems. - // If pinned and featured are the same, we just remove it from feedItems without setting it as state. - // NB: We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected) - if (pinnedArticle && timeFrame === '') { - feedItems = feedItems.filter((item) => item.id !== pinnedArticle.id); + // /** + // * Updates the feedPosts to remove the relevant items like the pinned + // * post and the image post that will be added to the top of final organized feed + // * items separately. We do not want duplication. + // * + // * @param {Array} The original feed posts that are retrieved from the endpoint. + // * @param {Object} The imagePost + // * @param {Object} The pinnedPost + // * + // * @returns {Array} We return the new array that no longer contains the pinned post or the image post. + // */ + function updateFeedPosts(feedPosts, imagePost, pinnedPost) { + let filteredFeedPost = feedPosts; + if (pinnedPost) { + filteredFeedPost = feedPosts.filter((item) => item.id !== pinnedPost.id); + } - if (pinnedArticle.id !== featuredStory?.id) { - setPinnedArticle(pinnedArticle); - } - } + if (imagePost) { + const imagePostIndex = filteredFeedPost.indexOf(imagePost); + filteredFeedPost.splice(imagePostIndex, 1); + } - // Remove that first story from the array to - // prevent it from rendering twice in the feed. - const featuredIndex = feedItems.indexOf(featuredStory); - if (featuredStory) { - feedItems.splice(featuredIndex, 1); - } - const organizedFeedItems = [featuredStory, feedItems].flat(); + return filteredFeedPost; + } - setFeedItems(organizedFeedItems); - } catch { - if (!onError) setOnError(true); - } - }; + // /** + // * Inserts the billboards (if they exist) into the feed. + // * + // * @param {organizedFeedItems} The partially organized feed items. + // * @param {String} feedFirstBillboard is the feed_first billboard retrieved from an endpoint. + // * @param {String} feedSecondBillboard is the feed_second billboard retrieved from an endpoint. + // * @param {String} feedThirdBillboard is the feed_third billboard retrieved from an endpoint. + // * + // * @returns {Array} We return the array containing the billboards slotted into the correct positions. + // */ + function insertBillboardsInFeed( + organizedFeedItems, + feedFirstBillboard, + feedSecondBillboard, + feedThirdBillboard, + ) { + if ( + organizedFeedItems.length >= 9 && + feedThirdBillboard && + !isDismissed(feedThirdBillboard) + ) { + organizedFeedItems.splice(7, 0, feedThirdBillboard); + } - fetchFeedItems(); - }, [timeFrame, onError]); + if ( + organizedFeedItems.length >= 3 && + feedSecondBillboard && + !isDismissed(feedSecondBillboard) + ) { + organizedFeedItems.splice(2, 0, feedSecondBillboard); + } - /** - * Retrieves feed data. - * - * @param {number} [page=1] Page of feed data to retrieve - * - * @returns {Promise} A promise containing the JSON response for the feed data. - */ - async function getFeedItems(timeFrame = '', page = 1) { - const response = await fetch(`/stories/feed/${timeFrame}?page=${page}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'X-CSRF-Token': window.csrfToken, - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - }); - return await response.json(); + if ( + organizedFeedItems.length >= 0 && + feedFirstBillboard && + !isDismissed(feedFirstBillboard) + ) { + organizedFeedItems.splice(0, 0, feedFirstBillboard); + } + + return organizedFeedItems; + } + + function isJSON(result) { + return result.value.headers + ?.get('content-type') + ?.includes('application/json'); + } + + function isHTML(result) { + return result.value.headers?.get('content-type')?.includes('text/html'); + } + + function isDismissed(bb) { + const parser = new DOMParser(); + const doc = parser.parseFromString(bb, 'text/html'); + const element = doc.querySelector('.crayons-story'); + const dismissalSku = element?.dataset?.dismissalSku; + if (localStorage && dismissalSku && dismissalSku.length > 0) { + const skuArray = + JSON.parse(localStorage.getItem('dismissal_skus_triggered')) || []; + if (skuArray.includes(dismissalSku)) { + return true; + } + } else { + return false; + } } + // /** + // * Retrieves the podcasts for the feed from the user data and the `followed-podcasts` + // * div item. + // * + // * @returns {Object} An Object containing today's podcast episodes for the podcasts found in followed_podcast_ids. + // */ function getPodcastEpisodes() { const el = document.getElementById('followed-podcasts'); const user = userData(); // Global @@ -109,7 +313,8 @@ export const Feed = ({ timeFrame, renderFeed }) => { } /** - * Dispatches a click event to bookmark/unbookmark an article. + * Dispatches a click event to bookmark/unbookmark an article and sets the ID's of the + * updated bookmark feed items. * * @param {Event} event */ @@ -182,9 +387,9 @@ export const Feed = ({ timeFrame, renderFeed }) => { </div> ) : ( renderFeed({ - pinnedArticle, + pinnedItem, + imageItem, feedItems, - podcastEpisodes, bookmarkedFeedItems, bookmarkClick, }) @@ -193,6 +398,52 @@ export const Feed = ({ timeFrame, renderFeed }) => { ); }; +function initializeMainStatusForm() { + + initializeDropdown({ + triggerElementId: 'feed-dropdown-trigger', + dropdownContentId: 'feed-dropdown-menu', + }); + + let lastClickedElement = null; + document.addEventListener("mousedown", (event) => { + lastClickedElement = event.target; + }); + const mainForm = document.getElementById('main-status-form'); + if (!mainForm) { + return; + } + + const waitingForCSRF = setInterval(() => { + if (window.csrfToken !== undefined) { + mainForm.querySelector('input[name="authenticity_token"]').value = window.csrfToken; + clearInterval(waitingForCSRF); + } + }, 25); + + document.getElementById('article_title').onfocus = function (e) { + const textarea = e.target; + textarea.classList.add('element-focused') + document.getElementById('main-status-form-controls').classList.add('flex'); + document.getElementById('main-status-form-controls').classList.remove('hidden'); + textarea.style.height = `${textarea.scrollHeight + 3}px`; // Set height to content height + + } + document.getElementById('article_title').onblur = function (e) { + if (mainForm.contains(lastClickedElement)) { + e.preventDefault(); + e.target.focus(); + } + else { + e.target.classList.remove('element-focused') + document.getElementById('main-status-form-controls').classList.remove('flex'); + document.getElementById('main-status-form-controls').classList.add('hidden'); + } + } + // Prevent return element from creating linebreak + +} + Feed.defaultProps = { timeFrame: '', }; @@ -203,3 +454,11 @@ Feed.propTypes = { }; Feed.displayName = 'Feed'; + +if (window && window.InstantClick) { + window.InstantClick.on('change', () => { + initializeMainStatusForm(); + }); +} + +initializeMainStatusForm(); diff --git a/app/javascript/articles/LoadingArticle.jsx b/app/javascript/articles/LoadingArticle.jsx index da97741792a76..02ea95aaabb57 100644 --- a/app/javascript/articles/LoadingArticle.jsx +++ b/app/javascript/articles/LoadingArticle.jsx @@ -6,6 +6,7 @@ export const LoadingArticle = ({ version }) => { <div className="crayons-story__cover"> <div className="crayons-scaffold crayons-story__cover__image" + style={{paddingBottom: "42%"}} loading="lazy" /> </div> diff --git a/app/javascript/articles/__tests__/Article.test.jsx b/app/javascript/articles/__tests__/Article.test.jsx index b3ea22d687960..d083e306e7db7 100644 --- a/app/javascript/articles/__tests__/Article.test.jsx +++ b/app/javascript/articles/__tests__/Article.test.jsx @@ -4,6 +4,7 @@ import { render } from '@testing-library/preact'; import { axe } from 'jest-axe'; import '@testing-library/jest-dom'; import { Article } from '..'; +import { reactionImagesSupport } from '../../__support__/reaction_images'; import { locale } from '../../utilities/locale'; import { article, @@ -12,6 +13,7 @@ import { articleWithReactions, videoArticle, articleWithComments, + articleWithCommentWithLongParagraph, podcastArticle, podcastEpisodeArticle, userArticle, @@ -26,6 +28,10 @@ const commonProps = { }; describe('<Article /> component', () => { + beforeAll(() => { + reactionImagesSupport(); + }); + it('should have no a11y violations for a standard article', async () => { const { container } = render( <Article @@ -68,7 +74,7 @@ describe('<Article /> component', () => { expect(container.firstChild).not.toHaveClass('crayons-story--featured', { exact: false, }); - expect(queryByAltText('Emil99 profile')).toBeDefined(); + expect(queryByAltText('Emil99 profile')).toExist(); }); it('should render a featured article', () => { @@ -85,7 +91,7 @@ describe('<Article /> component', () => { expect(container.firstChild).toHaveClass('crayons-story--featured', { exact: false, }); - expect(queryByAltText('Emil99 profile')).toBeDefined(); + expect(queryByAltText('Emil99 profile')).toExist(); }); it('should render a rich feed', () => { @@ -116,8 +122,8 @@ describe('<Article /> component', () => { expect(container.firstChild).toHaveClass('crayons-story--featured', { exact: false, }); - expect(queryByAltText('Web info-mediaries logo')).toBeDefined(); - expect(queryByAltText('Emil99 profile')).toBeDefined(); + expect(queryByAltText('Web info-mediaries logo')).toExist(); + expect(queryByAltText('Emil99 profile')).toExist(); }); it('should render a featured article for a video post', () => { @@ -131,7 +137,7 @@ describe('<Article /> component', () => { />, ); - expect(queryByTitle(/video duration/i)).toBeDefined(); + expect(queryByTitle(/video duration/i)).toExist(); }); it('should render with an organization', () => { @@ -144,8 +150,8 @@ describe('<Article /> component', () => { />, ); - expect(queryByAltText('Web info-mediaries logo')).toBeDefined(); - expect(queryByAltText('Emil99 profile')).toBeDefined(); + expect(queryByAltText('Web info-mediaries logo')).toExist(); + expect(queryByAltText('Emil99 profile')).toExist(); }); it('should render with a flare tag', () => { @@ -169,7 +175,7 @@ describe('<Article /> component', () => { queryByText( '…copying Rest withdrawal Handcrafted multi-state Pre-emptive e-markets feed...overriding RSS Fantastic Plastic Gloves invoice productize systemic Monaco…', ), - ).toBeDefined(); + ).toExist(); }); it('should render with reactions', () => { @@ -200,12 +206,42 @@ describe('<Article /> component', () => { expect(comments.textContent).toEqual(`213 ${locale('core.comment')}s`); }); + it('should render second paragraph, but not third', () => { + const { queryByTestId } = render( + <Article + {...commonProps} + isBookmarked={false} + article={articleWithComments} + />, + ); + + const comments = queryByTestId('comment-content'); + + expect(comments.textContent).toContain('Kitsch hoodie artisan'); + expect(comments.classList).not.toContain('Third paragraph'); + }); + + it('should render the first part of a long paragraph', () => { + const { queryByTestId } = render( + <Article + {...commonProps} + isBookmarked={false} + article={articleWithCommentWithLongParagraph} + />, + ); + + const comments = queryByTestId('comment-content'); + + expect(comments.textContent).toContain('Start of paragraph'); + expect(comments.classList).not.toContain('End of paragraph'); + }); + it('should render with an add comment button when there are no comments', () => { const { queryByTestId } = render( <Article {...commonProps} isBookmarked={false} article={article} />, ); - expect(queryByTestId('add-a-comment')).toBeDefined(); + expect(queryByTestId('add-a-comment')).toExist(); }); it('should render as saved on reading list', () => { @@ -234,7 +270,7 @@ describe('<Article /> component', () => { />, ); - expect(queryByTitle(/video duration/i)).toBeDefined(); + expect(queryByTitle(/video duration/i)).toExist(); }); it('should render a podcast article', () => { @@ -246,8 +282,8 @@ describe('<Article /> component', () => { />, ); - expect(queryByAltText('Rubber local')).toBeDefined(); - expect(queryByText('podcast', { selector: 'span' })).toBeDefined(); + expect(queryByAltText('Rubber local')).toExist(); + expect(queryByText('podcast', { selector: 'span' })).toExist(); }); it('should render a podcast episode', () => { @@ -255,13 +291,13 @@ describe('<Article /> component', () => { <Article isBookmarked={false} article={podcastEpisodeArticle} />, ); - expect(queryByText('podcast', { selector: 'span' })).toBeDefined(); + expect(queryByText('podcast', { selector: 'span' })).toExist(); }); it('should render a user article', () => { const { queryByText } = render(<Article article={userArticle} />); - expect(queryByText('person', { selector: 'span' })).toBeDefined(); + expect(queryByText('person', { selector: 'span' })).toExist(); }); it('should show bookmark button when article is saveable (default)', () => { diff --git a/app/javascript/articles/__tests__/ArticleLoading.test.jsx b/app/javascript/articles/__tests__/ArticleLoading.test.jsx index 5cfeab9543e68..eac130705e1c6 100644 --- a/app/javascript/articles/__tests__/ArticleLoading.test.jsx +++ b/app/javascript/articles/__tests__/ArticleLoading.test.jsx @@ -14,6 +14,6 @@ describe('<LoadingArticle />', () => { it('should render', () => { const { queryByTitle } = render(<LoadingArticle />); - expect(queryByTitle('Loading posts...')).toBeDefined(); + expect(queryByTitle('Loading posts...')).toExist(); }); }); diff --git a/app/javascript/articles/__tests__/Feed.test.jsx b/app/javascript/articles/__tests__/Feed.test.jsx new file mode 100644 index 0000000000000..8e4214d260c0a --- /dev/null +++ b/app/javascript/articles/__tests__/Feed.test.jsx @@ -0,0 +1,300 @@ +/* eslint-disable no-irregular-whitespace */ +import { h } from 'preact'; +import { render, waitFor } from '@testing-library/preact'; +import fetch from 'jest-fetch-mock'; +import '@testing-library/jest-dom'; +import { Feed } from '../Feed'; +import { + feedPosts, + feedPostsWherePinnedAndImagePostsSame, + firstBillboard, + secondBillboard, + thirdBillboard, + podcastEpisodes, +} from './utilities/feedUtilities'; +import '../../../assets/javascripts/lib/xss'; +import '../../../assets/javascripts/utilities/timeAgo'; + +global.fetch = fetch; + +describe('<Feed /> component', () => { + const getUserData = () => { + return { + followed_tag_names: ['javascript'], + followed_podcast_ids: [1], //should we make this dynamic + profile_image_90: 'mock_url_link', + name: 'firstname lastname', + username: 'username', + reading_list_ids: [], + }; + }; + + beforeAll(() => { + global.userData = jest.fn(() => getUserData()); + document.body.setAttribute('data-user', JSON.stringify(getUserData())); + + const node = document.createElement('div'); + node.setAttribute('id', 'followed-podcasts'); + node.setAttribute('data-episodes', JSON.stringify(podcastEpisodes)); + document.body.appendChild(node); + }); + + describe('feedItem organization', () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts), { + headers: { 'content-type': 'application/json' }, + }); + fetch.mockResponseOnce(firstBillboard, { + headers: { 'content-type': 'text/html' }, + }); + fetch.mockResponseOnce(secondBillboard, { + headers: { 'content-type': 'text/html' }, + }); + fetch.mockResponseOnce(thirdBillboard, { + headers: { 'content-type': 'text/html' }, + }); + + callback = jest.fn(); + render(<Feed timeFrame="" renderFeed={callback} />); + }); + + it('should return the correct length of feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(14); + }); + }); + + it('should set the pinnedItem and place it correctly in the feed', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + const firstPinnedItem = feedPosts.find((o) => o.pinned === true); + expect(lastCallbackResult.pinnedItem).toEqual(firstPinnedItem); + expect(lastCallbackResult.feedItems[1]).toEqual(firstPinnedItem); + }); + }); + + it('should set the imageItem and place it correctly in the feed', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + const firstImageItem = feedPosts.find( + (post) => post.main_image !== null, + ); + expect(lastCallbackResult.imageItem).toEqual(firstImageItem); + expect(lastCallbackResult.feedItems[2]).toEqual(firstImageItem); + }); + }); + + it('should place the billboards correctly within the feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard); + expect(lastCallbackResult.feedItems[3]).toEqual(secondBillboard); + expect(lastCallbackResult.feedItems[9]).toEqual(thirdBillboard); + }); + }); + + it('should place the podcasts correctly within feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems[4]).toEqual(podcastEpisodes); + }); + }); + }); + + describe('when pinned and image posts are the same', () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce( + JSON.stringify(feedPostsWherePinnedAndImagePostsSame), + { headers: { 'content-type': 'application/json' } }, + ); + fetch.mockResponseOnce(firstBillboard, { + headers: { 'content-type': 'text/html' }, + }); + fetch.mockResponseOnce(secondBillboard, { + headers: { 'content-type': 'text/html' }, + }); + fetch.mockResponseOnce(thirdBillboard, { + headers: { 'content-type': 'text/html' }, + }); + + callback = jest.fn(); + render(<Feed timeFrame="" renderFeed={callback} />); + }); + + it('should not set a pinned item', async () => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + const postAndImageItem = feedPostsWherePinnedAndImagePostsSame.find( + (post) => post.main_image !== null && post.pinned === true, + ); + + expect(lastCallbackResult.pinnedItem).toEqual(null); + expect(lastCallbackResult.imageItem).toEqual(postAndImageItem); + expect(lastCallbackResult.feedItems[1]).toEqual(postAndImageItem); + expect(lastCallbackResult.feedItems[2]).not.toEqual(postAndImageItem); + }); + }); + + describe("when the timeframe prop is 'latest'", () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts), { + headers: { 'content-type': 'application/json' }, + }); + fetch.mockResponseOnce(firstBillboard, { + headers: { 'content-type': 'text/html' }, + }); + fetch.mockResponseOnce(secondBillboard, { + headers: { 'content-type': 'text/html' }, + }); + fetch.mockResponseOnce(thirdBillboard, { + headers: { 'content-type': 'text/html' }, + }); + + callback = jest.fn(); + render(<Feed timeFrame="latest" renderFeed={callback} />); + }); + + it('should not set the pinned items', async () => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.pinnedItem).toEqual(null); + }); + + it('should return the correct length of feedItems (by excluding pinned item)', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(13); + }); + }); + }); + + describe("when we there isn't all three billboards on the home feed", () => { + describe("when there isn't a feed_second billboard", () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts), { + headers: { 'content-type': 'application/json' }, + }); + fetch.mockResponseOnce(firstBillboard, { + headers: { 'content-type': 'text/html' }, + }); + fetch.mockResponseOnce(undefined); + fetch.mockResponseOnce(thirdBillboard, { + headers: { 'content-type': 'text/html' }, + }); + + callback = jest.fn(); + render(<Feed timeFrame="" renderFeed={callback} />); + }); + + it('should return the correct length of feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(13); + }); + }); + + it('should still amend the organization of the feedItems correctly', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard); + // there is no second billboard so podcasts get rendered in 4th place + expect(lastCallbackResult.feedItems[3]).toEqual(podcastEpisodes); + expect(lastCallbackResult.feedItems[8]).toEqual(thirdBillboard); + }); + }); + }); + + describe("when there isn't a feed_first or feed_second billboard", () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts), { + headers: { 'content-type': 'application/json' }, + }); + fetch.mockResponseOnce(undefined); + fetch.mockResponseOnce(undefined); + fetch.mockResponseOnce(thirdBillboard, { + headers: { 'content-type': 'text/html' }, + }); + + callback = jest.fn(); + render(<Feed timeFrame="" renderFeed={callback} />); + }); + + it('should return the correct length of feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(12); + }); + }); + + it('should still amend the organization of the feedItems correctly', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + + const pinnedItem = feedPosts.find((o) => o.pinned === true); + // there is no first billboard + expect(lastCallbackResult.feedItems[0]).toEqual(pinnedItem); + // there is no second billboard so podcasts get rendered in 3rd place + expect(lastCallbackResult.feedItems[2]).toEqual(podcastEpisodes); + expect(lastCallbackResult.feedItems[7]).toEqual(thirdBillboard); + }); + }); + }); + + describe('when items that we fetch for the feed throw an error', () => { + const callback = jest.fn(); + + beforeAll(() => { + global.Honeybadger = { notify: jest.fn() }; + + fetch.mockResponseOnce(JSON.stringify(feedPosts), { + headers: { 'content-type': 'application/json' }, + }); + fetch.mockRejectOnce(); + fetch.mockRejectOnce(); + fetch.mockResponseOnce(thirdBillboard, { + headers: { 'content-type': 'text/html' }, + }); + render(<Feed timeFrame="" renderFeed={callback} />); + }); + + it('should render and return the other feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(12); + }); + }); + + it('should organize the feedItems correctly', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + + const pinnedItem = feedPosts.find((o) => o.pinned === true); + // we will not be rendering the first billboard since it errored + expect(lastCallbackResult.feedItems[0]).toEqual(pinnedItem); + // we will not be rendering the second billboard since it errored + // so podcasts get rendered in 3rd place + expect(lastCallbackResult.feedItems[2]).toEqual(podcastEpisodes); + expect(lastCallbackResult.feedItems[7]).toEqual(thirdBillboard); + }); + }); + }); + }); +}); diff --git a/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap b/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap index 0115a3110c1f9..01fc8daef533d 100644 --- a/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap +++ b/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap @@ -4,10 +4,57 @@ exports[`<Article /> component should render a rich feed 1`] = ` Object { "asFragment": [Function], "baseElement": <body> + <div + aria-hidden="true" + class="hidden" + id="reaction-category-resources" + > + <img + data-name="Like" + data-position="1" + data-slug="like" + height="18" + src="/assets/sparkle-heart.svg" + width="18" + /> + <img + data-name="Unicorn" + data-position="2" + data-slug="unicorn" + height="18" + src="/assets/multi-unicorn.svg" + width="18" + /> + <img + data-name="Exploding Head" + data-position="3" + data-slug="exploding_head" + height="18" + src="/assets/exploding-head.svg" + width="18" + /> + <img + data-name="Raised Hands" + data-position="4" + data-slug="raised_hands" + height="18" + src="/assets/raised-hands.svg" + width="18" + /> + <img + data-name="Fire" + data-position="5" + data-slug="fire" + height="18" + src="/assets/fire.svg" + width="18" + /> + </div> <div> <article class="crayons-story cursor-pointer crayons-story--featured" data-content-user-id="23289" + data-feed-content-id="62407" id="featured-story-marker" > <a @@ -31,14 +78,13 @@ Object { <img alt="Unbranded Home Loan Account" class="crayons-article__cover__image__feed" - height="275" src="/images/10.png" - width="650" + width="1000" /> </a> </div> <div - class="crayons-story__body" + class="crayons-story__body crayons-story__body-" > <div class="crayons-story__top" @@ -79,6 +125,8 @@ Object { id="story-author-preview-trigger-62407" > Stella Macejkovic + + </button> <div class="profile-preview-card__content crayons-dropdown p-4 pt-0 branded-7" @@ -151,7 +199,7 @@ Object { class="crayons-story__indention" > <h3 - class="crayons-story__title" + class="crayons-story__title crayons-story__title-" > <a href="/some-post/path" @@ -277,6 +325,7 @@ Object { <article class="crayons-story cursor-pointer crayons-story--featured" data-content-user-id="23289" + data-feed-content-id="62407" id="featured-story-marker" > <a @@ -300,14 +349,13 @@ Object { <img alt="Unbranded Home Loan Account" class="crayons-article__cover__image__feed" - height="275" src="/images/10.png" - width="650" + width="1000" /> </a> </div> <div - class="crayons-story__body" + class="crayons-story__body crayons-story__body-" > <div class="crayons-story__top" @@ -348,6 +396,8 @@ Object { id="story-author-preview-trigger-62407" > Stella Macejkovic + + </button> <div class="profile-preview-card__content crayons-dropdown p-4 pt-0 branded-7" @@ -420,7 +470,7 @@ Object { class="crayons-story__indention" > <h3 - class="crayons-story__title" + class="crayons-story__title crayons-story__title-" > <a href="/some-post/path" diff --git a/app/javascript/articles/__tests__/utilities/articleUtilities.js b/app/javascript/articles/__tests__/utilities/articleUtilities.js index b0993a18a4cb3..edc4217134277 100644 --- a/app/javascript/articles/__tests__/utilities/articleUtilities.js +++ b/app/javascript/articles/__tests__/utilities/articleUtilities.js @@ -128,6 +128,32 @@ export const articleWithReactions = { readable_publish_date: 'February 18', top_comments: [], public_reactions_count: 232, + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + { + slug: 'raised_hands', + name: 'Raised Hands', + icon: 'raised-hands', + position: 4, + }, + { + slug: 'fire', + name: 'Fire', + icon: 'fire', + position: 5, + }, + ], }; export const articleWithoutReactions = { @@ -135,6 +161,11 @@ export const articleWithoutReactions = { public_reactions_count: 428, }; +export const articleWithOneReaction = { + ...article, + public_reactions_count: 1, +}; + export const articleWithComments = { id: 62407, title: 'Unbranded Home Loan Account', @@ -167,7 +198,53 @@ export const articleWithComments = { user_id: 6, published_timestamp: '2020-04-21T19:41:40Z', published_at_int: new Date(), - safe_processed_html: '<p>Kitsch hoodie artisan.</p>\n\n', + safe_processed_html: + '<p>Kitsch hoodie artisan.</p><p>Second paragraph.</p><p>Third paragraph.</p>\n\n', + path: '/naoma_dr_rice/comment/n', + username: 'naoma_dr_rice', + name: 'Dr. Naoma Rice', + profile_image_90: '/images/7.png', + }, + ], + public_reactions_count: 428, + comments_count: 213, +}; + +export const articleWithCommentWithLongParagraph = { + id: 62407, + title: 'Unbranded Home Loan Account', + path: '/some-post/path', + type_of: '', + class_name: 'Article', + flare_tag: { + id: 35682, + name: 'javascript', + hotness_score: 99, + points: 23, + bg_color_hex: '#000000', + text_color_hex: '#ffffff', + }, + tag_list: ['javascript', 'ruby', 'go'], + cached_tag_list_array: [], + user_id: 23289, + user: { + username: 'Emil99', + name: 'Stella Macejkovic', + profile_image_90: '/images/10.png', + }, + published_at_int: 1582037964819, + published_timestamp: 'Tue, 18 Feb 2020 14:59:24 GMT', + published_at: '2020-03-19T10:04:15-05:00', + readable_publish_date: 'February 18', + top_comments: [ + { + comment_id: 23, + user_id: 6, + published_timestamp: '2020-04-21T19:41:40Z', + published_at_int: new Date(), + safe_processed_html: + `<p>Start of paragraph. This is a long test paragraph. This is a long test paragraph. This is a long test paragraph. This is a long test paragraph. This is a long test paragraph. + This is a long test paragraph. This is a long test paragraph. Yes it is really really really really really long. For real, like really real. Okay, this is long enough. End of paragraph</p>\n\n`, path: '/naoma_dr_rice/comment/n', username: 'naoma_dr_rice', name: 'Dr. Naoma Rice', diff --git a/app/javascript/articles/__tests__/utilities/feedUtilities.js b/app/javascript/articles/__tests__/utilities/feedUtilities.js new file mode 100644 index 0000000000000..32013d692b1c9 --- /dev/null +++ b/app/javascript/articles/__tests__/utilities/feedUtilities.js @@ -0,0 +1,685 @@ +export const assetPath = (relativeUrl) => `/images/${relativeUrl}`; + +export const feedPosts = [ + { + title: 'That Good Night Et molestias', + path: '/douglas_esmeralda/that-good-night-et-molestias-5fak', + id: 85, + user_id: 9, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Esmeralda "The Esmeralda" Douglas \\:/', + username: 'douglas_esmeralda', + slug: 'douglas_esmeralda', + profile_image_90: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + profile_image_url: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png', + tag_list: ['performance', 'be', 'javascript'], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156003, + published_timestamp: '2023-05-15T13:06:43Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'After Many a Summer Dies the Swan Qui pariatur', + path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p', + id: 142, + user_id: 6, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Tyler "The Tyler" Blanda \\:/', + username: 'tyler_blanda', + slug: 'tyler_blanda', + profile_image_90: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + profile_image_url: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/12.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Number the Stars Et ab', + path: '/schroeder_irvin/number-the-stars-et-ab-18p4', + id: 143, + user_id: 2, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Irvin "The Irvin" Schroeder \\:/', + username: 'schroeder_irvin', + slug: 'schroeder_irvin', + profile_image_90: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + profile_image_url: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Now Sleeps the Crimson Petal Nihil aut', + path: '/schroeder_irvin/now-sleeps-the-crimson-petal-nihil-aut-a8o', + id: 141, + user_id: 2, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Irvin "The Irvin" Schroeder \\:/', + username: 'schroeder_irvin', + slug: 'schroeder_irvin', + profile_image_90: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + profile_image_url: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + }, + pinned: true, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Dying of the Light Et libero', + path: '/thiel_erminia/dying-of-the-light-et-libero-2b70', + id: 151, + user_id: 68, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Erminia "The Erminia" Thiel \\:/', + username: 'thiel_erminia', + slug: 'thiel_erminia', + profile_image_90: + '/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png', + profile_image_url: + '/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156198, + published_timestamp: '2023-05-15T13:09:58Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + ], + top_comments: [], + }, + { + title: 'A Confederacy of Dunces Ut et', + path: '/macgyver_anamaria/a-confederacy-of-dunces-ut-et-5618', + id: 107, + user_id: 69, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Anamaria "The Anamaria" MacGyver \\:/', + username: 'macgyver_anamaria', + slug: 'macgyver_anamaria', + profile_image_90: + '/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png', + profile_image_url: + '/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/5.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156194, + published_timestamp: '2023-05-15T13:09:54Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'The Torment of Others Quia recusandae', + path: '/douglas_esmeralda/the-torment-of-others-quia-recusandae-4jmh', + id: 126, + user_id: 9, + comments_count: 0, + public_reactions_count: 2, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Esmeralda "The Esmeralda" Douglas \\:/', + username: 'douglas_esmeralda', + slug: 'douglas_esmeralda', + profile_image_90: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + profile_image_url: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/13.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156195, + published_timestamp: '2023-05-15T13:09:55Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Ah, Wilderness! Ad consectetur', + path: '/tyler_blanda/ah-wilderness-ad-consectetur-2cj9', + id: 146, + user_id: 6, + comments_count: 0, + public_reactions_count: 2, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Tyler "The Tyler" Blanda \\:/', + username: 'tyler_blanda', + slug: 'tyler_blanda', + profile_image_90: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + profile_image_url: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/13.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Nectar in a Sieve Voluptas iusto', + path: '/o_hara_aaron/nectar-in-a-sieve-voluptas-iusto-2937', + id: 109, + user_id: 5, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Aaron "The Aaron" O\'Hara \\:/', + username: 'o_hara_aaron', + slug: 'o_hara_aaron', + profile_image_90: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + profile_image_url: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/10.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156194, + published_timestamp: '2023-05-15T13:09:54Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'The Golden Bowl Unde velit', + path: '/o_hara_aaron/the-golden-bowl-unde-velit-2d8e', + id: 106, + user_id: 5, + comments_count: 0, + public_reactions_count: 2, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Aaron "The Aaron" O\'Hara \\:/', + username: 'o_hara_aaron', + slug: 'o_hara_aaron', + profile_image_90: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + profile_image_url: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/10.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156193, + published_timestamp: '2023-05-15T13:09:53Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, +]; + +export const feedPostsWherePinnedAndImagePostsSame = [ + { + title: 'That Good Night Et molestias', + path: '/douglas_esmeralda/that-good-night-et-molestias-5fak', + id: 85, + user_id: 9, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Esmeralda "The Esmeralda" Douglas \\:/', + username: 'douglas_esmeralda', + slug: 'douglas_esmeralda', + profile_image_90: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + profile_image_url: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + }, + pinned: true, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png', + tag_list: ['performance', 'be', 'javascript'], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156003, + published_timestamp: '2023-05-15T13:06:43Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'After Many a Summer Dies the Swan Qui pariatur', + path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p', + id: 142, + user_id: 6, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Tyler "The Tyler" Blanda \\:/', + username: 'tyler_blanda', + slug: 'tyler_blanda', + profile_image_90: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + profile_image_url: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/12.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Number the Stars Et ab', + path: '/schroeder_irvin/number-the-stars-et-ab-18p4', + id: 143, + user_id: 2, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Irvin "The Irvin" Schroeder \\:/', + username: 'schroeder_irvin', + slug: 'schroeder_irvin', + profile_image_90: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + profile_image_url: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, +]; + +export const podcastEpisodes = [ + { + slug: 's22e8-from-opera-to-code-anna-mcdougall', + title: 'S22:E8 - From Opera to Code (Anna McDougall)', + podcast_id: 1, + image: { url: null }, + id: null, + tag_list: [], + podcast: { + id: 1, + title: 'CodeNewbie', + slug: 'codenewbie', + image_90: + '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg', + }, + }, + { + slug: 's22e2-building-the-bridge-across-the-tech-gap-michelle-glauser', + title: + 'S22:E2 - Building the bridge across the tech gap (Michelle Glauser)', + podcast_id: 1, + image: { url: null }, + id: null, + tag_list: [], + podcast: { + id: 1, + title: 'CodeNewbie', + slug: 'codenewbie', + image_90: + '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg', + }, + }, + { + slug: 's22e7-starting-out-in-open-source-brian-douglas', + title: 'S22:E7 - Starting out in Open Source (Brian Douglas)', + podcast_id: 1, + image: { url: null }, + id: null, + tag_list: [], + podcast: { + id: 1, + title: 'CodeNewbie', + slug: 'codenewbie', + image_90: + '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg', + }, + }, +]; + +export const firstBillboard = 'billboard one'; +export const secondBillboard = 'billboard two'; +export const thirdBillboard = 'billboard three'; diff --git a/app/javascript/articles/components/ArticleCoverImage.jsx b/app/javascript/articles/components/ArticleCoverImage.jsx index ca3269113b6fe..a22dabfcf2d4f 100644 --- a/app/javascript/articles/components/ArticleCoverImage.jsx +++ b/app/javascript/articles/components/ArticleCoverImage.jsx @@ -3,7 +3,12 @@ import { articlePropTypes } from '../../common-prop-types'; export const ArticleCoverImage = ({ article }) => { return ( - <div className="crayons-article__cover crayons-article__cover__image__feed"> + <div + className="crayons-article__cover crayons-article__cover__image__feed" + style={{ + aspectRatio: `auto 1000 / ${article.main_image_height}`, + }} + > <a href={article.path} className="crayons-article__cover__image__feed crayons-story__cover__image" @@ -12,12 +17,9 @@ export const ArticleCoverImage = ({ article }) => { <img className="crayons-article__cover__image__feed" src={article.main_image} - width="650" - height="275" + width="1000" + height={article.main_image_height} alt={article.title} - style={{ - backgroundColor: `${article.main_image_background_hex_color}`, - }} /> </a> </div> diff --git a/app/javascript/articles/components/CommentListItem.jsx b/app/javascript/articles/components/CommentListItem.jsx index c1e36f89cfd1d..e3472a4bfb77f 100644 --- a/app/javascript/articles/components/CommentListItem.jsx +++ b/app/javascript/articles/components/CommentListItem.jsx @@ -8,11 +8,45 @@ function userProfilePage(username) { return str; } +function contentAwareComments(comment) { + const parser = new DOMParser(); + const htmlDoc = parser.parseFromString( + comment.safe_processed_html, + 'text/html', + ); + const nodes = htmlDoc.body.childNodes; + let text = ''; + let nodesSelected = 0; + nodes.forEach((node) => { + if ( + node.outerHTML && + (node.tagName === 'P' || node.className.includes('highlight')) && + nodesSelected < 2 && + node.outerHTML.length > 250 + && !node.outerHTML.includes('article-body-image-wrapper') + ) { + node.innerHTML = `${node.innerHTML.substring(0, 230)}...`; + text = `${text} ${node.outerHTML}`; + nodesSelected = 2; + } else if (node.outerHTML && nodesSelected < 2) { + text = text + node.outerHTML; + nodesSelected++; + } else if (node.outerHTML && nodesSelected < 3) { + text = `${text} <div class="crayons-comment__readmore">See more</div>`; + nodesSelected++; + } + }); + return text; +} + export const CommentListItem = ({ comment }) => ( <div className="crayons-comment cursor-pointer" role="presentation" onClick={(_event) => { + if (_event.target.closest('a')) { + return; + } if (_event.which > 1 || _event.metaKey || _event.ctrlKey) { // Indicates should open in _blank window.open(comment.path, '_blank'); @@ -35,23 +69,28 @@ export const CommentListItem = ({ comment }) => ( alt="{comment.username} avatar" /> </span> - {comment.name} - </a> - <a href={comment.path} className="crayons-story__tertiary ml-1"> - <time> - {timeAgo({ - oldTimeInSeconds: comment.published_at_int, - formatter: (x) => x, - maxDisplayedAge: NaN, - })} - </time> </a> </div> - <div - className="crayons-comment__body" - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ __html: comment.safe_processed_html }} - /> + <div className="crayons-comment__body"> + <div class="crayons-comment__metainner"> + <span class="fw-medium">{comment.name}</span> + <a href={comment.path} className="crayons-story__tertiary ml-1"> + <time> + {timeAgo({ + oldTimeInSeconds: comment.published_at_int, + formatter: (x) => x, + maxDisplayedAge: NaN, + })} + </time> + </a> + </div> + <div + data-testid="comment-content" + className="crayons-comment__inner" + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: contentAwareComments(comment) }} + /> + </div> </div> ); diff --git a/app/javascript/articles/components/CommentsCount.jsx b/app/javascript/articles/components/CommentsCount.jsx index d6c53874c3b4d..05996b52f31c8 100644 --- a/app/javascript/articles/components/CommentsCount.jsx +++ b/app/javascript/articles/components/CommentsCount.jsx @@ -25,6 +25,7 @@ export const CommentsCount = ({ count, articlePath, articleTitle }) => { url={`${articlePath}#comments`} icon={commentsSVG} tagName="a" + className="flex items-center" aria-label={commentsAriaLabelText} > <span title="Number of comments"> diff --git a/app/javascript/articles/components/ContentTitle.jsx b/app/javascript/articles/components/ContentTitle.jsx index bcd2212cec73f..7ac78881a875e 100644 --- a/app/javascript/articles/components/ContentTitle.jsx +++ b/app/javascript/articles/components/ContentTitle.jsx @@ -2,7 +2,7 @@ import { h } from 'preact'; import { articlePropTypes } from '../../common-prop-types'; export const ContentTitle = ({ article }) => ( - <h3 className="crayons-story__title"> + <h3 className={`crayons-story__title crayons-story__title-${article.type_of}`}> <a href={article.path} id={`article-link-${article.id}`}> {article.class_name === 'PodcastEpisode' && ( <span className="crayons-story__flare-tag">podcast</span> diff --git a/app/javascript/articles/components/Meta.jsx b/app/javascript/articles/components/Meta.jsx index cc2977a0222b1..3a4999c2d88c6 100644 --- a/app/javascript/articles/components/Meta.jsx +++ b/app/javascript/articles/components/Meta.jsx @@ -4,6 +4,8 @@ import { articlePropTypes } from '../../common-prop-types'; import { MinimalProfilePreviewCard } from '../../profilePreviewCards/MinimalProfilePreviewCard'; import { PublishDate } from './PublishDate'; +/* global timeAgo */ + export const Meta = ({ article, organization }) => { const orgArticleIndexClassAbsent = !document.getElementById( 'organization-article-index', @@ -52,8 +54,7 @@ export const Meta = ({ article, organization }) => { ? article.user.username : article.user.name, )} - </a> - + </a> <MinimalProfilePreviewCard triggerId={`story-author-preview-trigger-${article.id}`} contentId={`story-author-preview-content-${article.id}`} @@ -61,6 +62,7 @@ export const Meta = ({ article, organization }) => { name={article.user.name} profileImage={article.user.profile_image_90} userId={article.user_id} + subscriber={article.user.cached_base_subscriber ? 'true' : 'false'} /> {organization && !document.getElementById('organization-article-index') && ( @@ -76,14 +78,20 @@ export const Meta = ({ article, organization }) => { </a> </span> )} + {article.type_of === 'status' && (<div class='color-base-60 pl-1 inline-block fs-xs'>{timeAgo({ + oldTimeInSeconds: article.published_at_int, + formatter: (x) => x, + maxDisplayedAge: 60 * 60 * 24 * 7, + })}</div>)} + {article.type_of === 'status' && article.edited_at > article.published_timestamp && (<div class='color-base-60 pl-1 inline-block fs-xs'>(Edited)</div>)} </div> - <a href={article.path} className="crayons-story__tertiary fs-xs"> + {article.type_of !== 'status' && (<a href={article.path} className="crayons-story__tertiary fs-xs"> <PublishDate readablePublishDate={article.readable_publish_date} publishedTimestamp={article.published_timestamp} publishedAtInt={article.published_at_int} /> - </a> + </a>)} </div> </div> ); diff --git a/app/javascript/articles/components/ReactionsCount.jsx b/app/javascript/articles/components/ReactionsCount.jsx index 6da4c139e0dc3..de39639a7ca5a 100644 --- a/app/javascript/articles/components/ReactionsCount.jsx +++ b/app/javascript/articles/components/ReactionsCount.jsx @@ -1,42 +1,75 @@ import { h } from 'preact'; import { articlePropTypes } from '../../common-prop-types'; -import { Button } from '../../crayons/Button'; import { locale } from '../../utilities/locale'; export const ReactionsCount = ({ article }) => { const totalReactions = article.public_reactions_count || 0; - const reactionsSVG = () => ( - <svg - className="crayons-icon" - width="24" - height="24" - xmlns="http://www.w3.org/2000/svg" - > - <path d="M18.884 12.595l.01.011L12 19.5l-6.894-6.894.01-.01A4.875 4.875 0 0112 5.73a4.875 4.875 0 016.884 6.865zM6.431 7.037a3.375 3.375 0 000 4.773L12 17.38l5.569-5.569a3.375 3.375 0 10-4.773-4.773L9.613 10.22l-1.06-1.062 2.371-2.372a3.375 3.375 0 00-4.492.25v.001z" /> - </svg> - ); if (totalReactions === 0) { return; } - return ( - <Button - variant="ghost" - size="s" - contentType="icon-left" - url={article.path} - icon={reactionsSVG} - tagName="a" - > - <span title="Number of reactions"> - {totalReactions} - <span className="hidden s:inline"> -   - {`${totalReactions == 1 ? locale('core.reaction') : `${locale('core.reaction')}s`}`} + function buildIcons() { + const reversable = article.public_reaction_categories; + const reactionIcons = document.getElementById( + 'reaction-category-resources', + ); + + if (reversable === undefined) { + return; + } + reversable.reverse(); + + const icons = reversable.map((category) => { + const path = reactionIcons.querySelector( + `img[data-slug=${category.slug}]`, + ).src; + const alt = category.name; + return ( + <span className="crayons_icon_container" key={category.slug}> + <img src={path} width="18" height="18" alt={alt} /> </span> + ); + }); + + return ( + <span + className="multiple_reactions_icons_container" + dir="rtl" + data-testid="multiple-reactions-icons-container" + > + {icons} </span> - </Button> + ); + } + + function buildCounter() { + const reactionText = `${ + totalReactions == 1 + ? locale('core.reaction') + : `${locale('core.reaction')}s` + }`; + return ( + <span className="aggregate_reactions_counter"> + <span className="hidden s:inline" title="Number of reactions"> + {totalReactions} {reactionText} + </span> + </span> + ); + } + + return ( + <a + href={article.path} + className="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left" + data-reaction-count + data-reactable-id={article.id} + > + <div className="multiple_reactions_aggregate"> + {buildIcons()} + {buildCounter()} + </div> + </a> ); }; diff --git a/app/javascript/articles/components/ReadingTime.jsx b/app/javascript/articles/components/ReadingTime.jsx index b4d3c313a06de..8c2ce09941ef9 100644 --- a/app/javascript/articles/components/ReadingTime.jsx +++ b/app/javascript/articles/components/ReadingTime.jsx @@ -1,11 +1,11 @@ import { h } from 'preact'; import PropTypes from 'prop-types'; -export const ReadingTime = ({ readingTime }) => { +export const ReadingTime = ({ readingTime, typeOf }) => { // we have ` ... || null` for the case article.reading_time is undefined return ( <small className="crayons-story__tertiary mr-2 fs-xs"> - {`${readingTime < 1 ? 1 : readingTime} min read`} + {typeOf === 'status' ? '' : `${readingTime < 1 ? 1 : readingTime} min read`} </small> ); }; @@ -16,6 +16,7 @@ ReadingTime.defaultProps = { ReadingTime.propTypes = { readingTime: PropTypes.number, + typeOf: PropTypes.string.isRequired, }; ReadingTime.displayName = 'ReadingTime'; diff --git a/app/javascript/articles/components/__tests__/CommentsList.test.jsx b/app/javascript/articles/components/__tests__/CommentsList.test.jsx index ccafb7c2d3b5e..4c8031f6a1609 100644 --- a/app/javascript/articles/components/__tests__/CommentsList.test.jsx +++ b/app/javascript/articles/components/__tests__/CommentsList.test.jsx @@ -76,6 +76,6 @@ describe('<CommentsList />', () => { />, ); - expect(queryByTestId('see-all-comments')).toBeDefined(); + expect(queryByTestId('see-all-comments')).toExist(); }); }); diff --git a/app/javascript/articles/components/__tests__/ReactionsCount.test.jsx b/app/javascript/articles/components/__tests__/ReactionsCount.test.jsx index 156fa36e53fd4..4c406705d7b7c 100644 --- a/app/javascript/articles/components/__tests__/ReactionsCount.test.jsx +++ b/app/javascript/articles/components/__tests__/ReactionsCount.test.jsx @@ -1,26 +1,68 @@ import { h } from 'preact'; -import { render } from '@testing-library/preact'; +import { render, within } from '@testing-library/preact'; import '@testing-library/jest-dom'; +import { i18nSupport } from '../../../__support__/i18n'; +import { reactionImagesSupport } from '../../../__support__/reaction_images'; import { ReactionsCount } from '..'; import { articleWithReactions, articleWithoutReactions, + articleWithOneReaction, } from '../../__tests__/utilities/articleUtilities.js'; describe('<ReactionsCount /> component', () => { + beforeAll(() => { + i18nSupport(); + reactionImagesSupport(); + }); + it('should not display reactions data when there are no reactions', async () => { const { queryByText } = render( <ReactionsCount article={articleWithoutReactions} />, ); - expect(queryByText('0 reactions')).toBeNull(); + expect(queryByText(/0 reactions/i)).not.toExist(); + }); + + it('should display reaction count when there are exactly one reaction', async () => { + const { queryByText } = render( + <ReactionsCount article={articleWithOneReaction} />, + ); + + expect(queryByText(/1 reaction/i)).toExist(); }); it('should display reactions data when there are reactions', async () => { - const { findByText } = render( + const { queryByText } = render( + <ReactionsCount article={articleWithReactions} />, + ); + + expect(queryByText(/232 reactions/i)).toExist(); + }); + + it('should display multiple reactions when there are reactions', async () => { + const { getByTestId } = render( <ReactionsCount article={articleWithReactions} />, ); - expect(findByText('232 reactions')).toBeDefined(); + const container = getByTestId('multiple-reactions-icons-container'); + const { queryByAltText } = within(container); + + // `articleWithReactions` has all reactions except exploding head + // also, we are not using `toHaveAttribute` directly because Jest inserts a + // base URL and we are only interested in the path + expect(queryByAltText('Like').getAttribute('src')).toContain( + '/assets/sparkle-heart.svg', + ); + expect(queryByAltText('Unicorn').getAttribute('src')).toContain( + '/assets/multi-unicorn.svg', + ); + expect(queryByAltText('Fire').getAttribute('src')).toContain( + '/assets/fire.svg', + ); + expect(queryByAltText('Raised Hands').getAttribute('src')).toContain( + '/assets/raised-hands.svg', + ); + expect(queryByAltText('Exploding Head')).not.toExist(); }); }); diff --git a/app/javascript/billboard/locations/index.jsx b/app/javascript/billboard/locations/index.jsx new file mode 100644 index 0000000000000..0c01ff0e9d8ed --- /dev/null +++ b/app/javascript/billboard/locations/index.jsx @@ -0,0 +1,62 @@ +import { h } from 'preact'; +import { useCallback } from 'preact/hooks'; +import PropTypes from 'prop-types'; +import { MultiSelectAutocomplete } from '@crayons'; + +export { SelectedLocation } from './templates'; + +export const Locations = ({ + defaultValue = [], + allLocations, + inputId, + onChange, + placeholder = 'Enter a country name...', + template, +}) => { + const autocompleteLocations = useCallback( + (query) => { + return new Promise((resolve) => { + queueMicrotask(() => { + const suggestions = []; + const caseInsensitiveQuery = query.toLowerCase(); + Object.keys(allLocations).forEach((name) => { + if (name.toLowerCase().indexOf(caseInsensitiveQuery) > -1) { + suggestions.push(allLocations[name]); + } + }); + resolve(suggestions); + }); + }); + }, + [allLocations], + ); + + return ( + <MultiSelectAutocomplete + defaultValue={defaultValue} + fetchSuggestions={autocompleteLocations} + border + labelText="Enabled countries for targeting" + placeholder={placeholder} + SelectionTemplate={template} + onSelectionsChanged={onChange} + inputId={inputId} + allowUserDefinedSelections={false} + /> + ); +}; + +const locationsShape = PropTypes.shape({ + name: PropTypes.string.isRequired, + code: PropTypes.string.isRequired, + withRegions: PropTypes.bool, +}); + +Locations.propTypes = { + defaultValue: PropTypes.arrayOf(locationsShape), + allLocations: PropTypes.objectOf(locationsShape).isRequired, + inputId: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + template: PropTypes.elementType, +}; diff --git a/app/javascript/billboard/locations/templates.jsx b/app/javascript/billboard/locations/templates.jsx new file mode 100644 index 0000000000000..1493f85831064 --- /dev/null +++ b/app/javascript/billboard/locations/templates.jsx @@ -0,0 +1,48 @@ +import { h } from 'preact'; +import { useCallback } from 'preact/hooks'; +import { ButtonNew as Button, Icon } from '@crayons'; +import Close from '@images/x.svg'; + +/** + * Higher-order component that returns a template responsible for the layout of + * a selected location + * + * @returns {h.JSX.ElementType} + */ +export const SelectedLocation = ({ + displayName, + onNameClick, + label, + ExtraInfo, +}) => { + const Template = ({ onEdit: _, onDeselect, ...location }) => { + const onClick = useCallback(() => onNameClick(location), [location]); + + return ( + <div + role="group" + aria-label={location.name} + className="c-autocomplete--multi__tag-selection flex mr-2 mb-2 w-max" + > + <Button + aria-label={label} + onClick={onClick} + className="c-autocomplete--multi__selected p-1 flex flex-col" + > + {location.name} + {ExtraInfo && <ExtraInfo {...location} />} + </Button> + <Button + aria-label={`Remove ${location.name}`} + onClick={onDeselect} + className="c-autocomplete--multi__selected p-1" + > + <Icon src={Close} /> + </Button> + </div> + ); + }; + + Template.displayName = displayName; + return Template; +}; diff --git a/app/javascript/display-ad/tags.jsx b/app/javascript/billboard/tags.jsx similarity index 93% rename from app/javascript/display-ad/tags.jsx rename to app/javascript/billboard/tags.jsx index f7d15ad8e4e2c..73352a9716ff9 100644 --- a/app/javascript/display-ad/tags.jsx +++ b/app/javascript/billboard/tags.jsx @@ -8,7 +8,7 @@ import { TagAutocompleteSelection } from '@crayons/MultiSelectAutocomplete/TagAu import { MultiSelectAutocomplete } from '@crayons'; /** - * Tags for the display ads admin form. Allows users to search and select up to 10 tags. + * Tags for the billboards admin form. Allows users to search and select up to 10 tags. * * @param {Function} onInput Callback to sync selections to article form state * @param {string} defaultValue Comma separated list of any currently selected tags @@ -44,7 +44,7 @@ export const Tags = ({ onInput, defaultValue, switchHelpContext }) => { SelectionTemplate={TagAutocompleteSelection} onSelectionsChanged={syncSelections} onFocus={switchHelpContext} - inputId="display-ad-targeted-tags" + inputId="billboard-targeted-tags" allowUserDefinedSelections={true} /> ); diff --git a/app/javascript/colorPickers/replaceTextInputWithColorPicker.jsx b/app/javascript/colorPickers/replaceTextInputWithColorPicker.jsx index 37cbc76751435..0eae08f360243 100644 --- a/app/javascript/colorPickers/replaceTextInputWithColorPicker.jsx +++ b/app/javascript/colorPickers/replaceTextInputWithColorPicker.jsx @@ -1,4 +1,5 @@ import { h, render } from 'preact'; +import { createRootFragment } from '../shared/preact/preact-root-fragment'; import { ColorPicker } from '@crayons'; /** @@ -31,8 +32,7 @@ export function replaceTextInputWithColorPicker({ buttonLabelText={labelText} onChange={onChange} />, - input.parentElement, - input, + createRootFragment(input.parentElement, input), ); input.remove(); } diff --git a/app/javascript/crayons/AutocompleteTriggerTextArea/AutocompleteTriggerTextArea.jsx b/app/javascript/crayons/AutocompleteTriggerTextArea/AutocompleteTriggerTextArea.jsx index 7a1fef4ad3d25..072dcc257f163 100644 --- a/app/javascript/crayons/AutocompleteTriggerTextArea/AutocompleteTriggerTextArea.jsx +++ b/app/javascript/crayons/AutocompleteTriggerTextArea/AutocompleteTriggerTextArea.jsx @@ -385,7 +385,6 @@ export const AutocompleteTriggerTextArea = forwardRef( {...inputProps} {...comboboxProps} id={id} - data-gramm_editor="false" ref={mergeInputRefs([inputRef, forwardedRef])} onChange={(e) => { onChange?.(e); diff --git a/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/AutocompleteTriggerTextArea.test.jsx b/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/AutocompleteTriggerTextArea.test.jsx index 5f529ea81c324..37295c980ef14 100644 --- a/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/AutocompleteTriggerTextArea.test.jsx +++ b/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/AutocompleteTriggerTextArea.test.jsx @@ -1,6 +1,6 @@ import { h } from 'preact'; import { render, waitFor } from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { AutocompleteTriggerTextArea } from '../AutocompleteTriggerTextArea'; diff --git a/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/__snapshots__/AutocompleteTriggerTextArea.test.jsx.snap b/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/__snapshots__/AutocompleteTriggerTextArea.test.jsx.snap index 515d9b800fdb5..8dc6540ed667a 100644 --- a/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/__snapshots__/AutocompleteTriggerTextArea.test.jsx.snap +++ b/app/javascript/crayons/AutocompleteTriggerTextArea/__tests__/__snapshots__/AutocompleteTriggerTextArea.test.jsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<AutocompleteTriggerTextArea /> should render when not replacing an element 1`] = `"<div class=\\"c-autocomplete \\" data-testid=\\"autocomplete-wrapper\\"><span class=\\"screen-reader-only\\" aria-live=\\"assertive\\"></span><textarea data-gramm_editor=\\"false\\"></textarea></div>"`; +exports[`<AutocompleteTriggerTextArea /> should render when not replacing an element 1`] = `"<div data-testid=\\"autocomplete-wrapper\\" class=\\"c-autocomplete \\"><span aria-live=\\"assertive\\" class=\\"screen-reader-only\\"></span><textarea></textarea></div>"`; -exports[`<AutocompleteTriggerTextArea /> should render when replacing an element 1`] = `"<div class=\\"c-autocomplete \\" data-testid=\\"autocomplete-wrapper\\"><span class=\\"screen-reader-only\\" aria-live=\\"assertive\\"></span><textarea data-gramm_editor=\\"false\\" aria-label=\\"test text area\\" id=\\"test-text-area\\" style=\\"font: -webkit-small-control; text-rendering: auto; letter-spacing: normal; word-spacing: normal; line-height: normal; text-transform: none; text-indent: 0; text-shadow: none; display: inline-block; text-align: start; background-color: white; border: 1px solid; flex-direction: column; resize: auto; cursor: auto; padding: 2px; white-space: pre-wrap; word-wrap: break-word; visibility: visible; transition: none;\\"></textarea></div>"`; +exports[`<AutocompleteTriggerTextArea /> should render when replacing an element 1`] = `"<div data-testid=\\"autocomplete-wrapper\\" class=\\"c-autocomplete \\"><span aria-live=\\"assertive\\" class=\\"screen-reader-only\\"></span><textarea aria-label=\\"test text area\\" id=\\"test-text-area\\" style=\\"font: -webkit-small-control; text-rendering: auto; letter-spacing: normal; word-spacing: normal; line-height: normal; text-transform: none; text-indent: 0; text-shadow: none; display: inline-block; text-align: start; background-color: white; border: 1px solid; flex-direction: column; resize: auto; cursor: auto; padding: 2px; white-space: pre-wrap; word-wrap: break-word; visibility: visible; transition: none;\\"></textarea></div>"`; diff --git a/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap b/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap index e6a2387d8f8e5..f436142ce967e 100644 --- a/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap +++ b/app/javascript/crayons/Button/__tests__/__snapshots__/Button.test.jsx.snap @@ -1,27 +1,27 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<Button /> component should render a button as a specific button type (HTML type attribute) when buttonType is set. 1`] = `"<button class=\\"crayons-btn\\" type=\\"submit\\">Hello world!</button>"`; +exports[`<Button /> component should render a button as a specific button type (HTML type attribute) when buttonType is set. 1`] = `"<button type=\\"submit\\" class=\\"crayons-btn\\">Hello world!</button>"`; -exports[`<Button /> component should render a button as an anchor element if "tagName" is set to "a" 1`] = `"<a class=\\"crayons-btn\\" href=\\"https://dev.to\\">Hello world!</a>"`; +exports[`<Button /> component should render a button as an anchor element if "tagName" is set to "a" 1`] = `"<a href=\\"https://dev.to\\" class=\\"crayons-btn\\">Hello world!</a>"`; -exports[`<Button /> component should render a button with additional CSS classes when className is set 1`] = `"<button class=\\"crayons-btn some-additional-class-name\\" type=\\"button\\" disabled=\\"\\">Hello world!</button>"`; +exports[`<Button /> component should render a button with additional CSS classes when className is set 1`] = `"<button type=\\"button\\" disabled=\\"\\" class=\\"crayons-btn some-additional-class-name\\">Hello world!</button>"`; -exports[`<Button /> component should render a button with with an icon when an icon is set and there is button text 1`] = `"<button class=\\"crayons-btn crayons-btn--icon-left\\" type=\\"button\\"><svg width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon\\"><path d=\\"M9.99999 15.172L19.192 5.979L20.607 7.393L9.99999 18L3.63599 11.636L5.04999 10.222L9.99999 15.172Z\\"></path></svg>Hello world!</button>"`; +exports[`<Button /> component should render a button with with an icon when an icon is set and there is button text 1`] = `"<button type=\\"button\\" class=\\"crayons-btn crayons-btn--icon-left\\"><svg width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon\\"><path d=\\"M9.99999 15.172L19.192 5.979L20.607 7.393L9.99999 18L3.63599 11.636L5.04999 10.222L9.99999 15.172Z\\"></path></svg>Hello world!</button>"`; -exports[`<Button /> component should render a button with with an icon when an icon is set and there is no button text 1`] = `"<button class=\\"crayons-btn crayons-btn--icon\\" type=\\"button\\"><svg width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon\\"><path d=\\"M9.99999 15.172L19.192 5.979L20.607 7.393L9.99999 18L3.63599 11.636L5.04999 10.222L9.99999 15.172Z\\"></path></svg></button>"`; +exports[`<Button /> component should render a button with with an icon when an icon is set and there is no button text 1`] = `"<button type=\\"button\\" class=\\"crayons-btn crayons-btn--icon\\"><svg width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon\\"><path d=\\"M9.99999 15.172L19.192 5.979L20.607 7.393L9.99999 18L3.63599 11.636L5.04999 10.222L9.99999 15.172Z\\"></path></svg></button>"`; -exports[`<Button /> component should render a danger button when using the variant "danger" 1`] = `"<button class=\\"crayons-btn crayons-btn--danger\\" type=\\"button\\">Hello world!</button>"`; +exports[`<Button /> component should render a danger button when using the variant "danger" 1`] = `"<button type=\\"button\\" class=\\"crayons-btn crayons-btn--danger\\">Hello world!</button>"`; -exports[`<Button /> component should render a disabled button when disabled is true 1`] = `"<button class=\\"crayons-btn\\" type=\\"button\\" disabled=\\"\\">Hello world!</button>"`; +exports[`<Button /> component should render a disabled button when disabled is true 1`] = `"<button type=\\"button\\" disabled=\\"\\" class=\\"crayons-btn\\">Hello world!</button>"`; -exports[`<Button /> component should render a primary button when using default values 1`] = `"<button class=\\"crayons-btn\\" type=\\"button\\">Hello world!</button>"`; +exports[`<Button /> component should render a primary button when using default values 1`] = `"<button type=\\"button\\" class=\\"crayons-btn\\">Hello world!</button>"`; -exports[`<Button /> component should render a secondary button when using the variant "secondary" 1`] = `"<button class=\\"crayons-btn crayons-btn--secondary\\" type=\\"button\\">Hello world!</button>"`; +exports[`<Button /> component should render a secondary button when using the variant "secondary" 1`] = `"<button type=\\"button\\" class=\\"crayons-btn crayons-btn--secondary\\">Hello world!</button>"`; -exports[`<Button /> component should render an enabled button when using default values 1`] = `"<button class=\\"crayons-btn\\" type=\\"button\\">Hello world!</button>"`; +exports[`<Button /> component should render an enabled button when using default values 1`] = `"<button type=\\"button\\" class=\\"crayons-btn\\">Hello world!</button>"`; -exports[`<Button /> component should render an outlined button when using the variant "outlined" 1`] = `"<button class=\\"crayons-btn crayons-btn--outlined\\" type=\\"button\\">Hello world!</button>"`; +exports[`<Button /> component should render an outlined button when using the variant "outlined" 1`] = `"<button type=\\"button\\" class=\\"crayons-btn crayons-btn--outlined\\">Hello world!</button>"`; -exports[`<Button /> component should render with a tabIndex 1`] = `"<button class=\\"crayons-btn\\" tabindex=\\"0\\" type=\\"button\\">Hello world!</button>"`; +exports[`<Button /> component should render with a tabIndex 1`] = `"<button tabindex=\\"0\\" type=\\"button\\" class=\\"crayons-btn\\">Hello world!</button>"`; -exports[`<Button /> component should render with a tooltip 1`] = `"<button class=\\"crayons-btn crayons-tooltip__activator\\" type=\\"button\\">Hello world!<span class=\\"crayons-tooltip__content \\">tooltip text</span></button>"`; +exports[`<Button /> component should render with a tooltip 1`] = `"<button type=\\"button\\" class=\\"crayons-btn crayons-tooltip__activator\\">Hello world!<span class=\\"crayons-tooltip__content \\">tooltip text</span></button>"`; diff --git a/app/javascript/crayons/ButtonGroup/__stories__/ButtonGroup.stories.jsx b/app/javascript/crayons/ButtonGroup/__stories__/ButtonGroup.stories.jsx index bfc9510502921..07495200fc144 100644 --- a/app/javascript/crayons/ButtonGroup/__stories__/ButtonGroup.stories.jsx +++ b/app/javascript/crayons/ButtonGroup/__stories__/ButtonGroup.stories.jsx @@ -21,6 +21,7 @@ Default.storyName = 'Text buttons'; export const TextIcon = () => { const Icon = () => ( <svg + title="Check" width="24" height="24" xmlns="http://www.w3.org/2000/svg" diff --git a/app/javascript/crayons/ButtonGroup/__tests__/__snapshots__/ButtonGroup.test.jsx.snap b/app/javascript/crayons/ButtonGroup/__tests__/__snapshots__/ButtonGroup.test.jsx.snap index 739297bcca9d0..c7b01531d1785 100644 --- a/app/javascript/crayons/ButtonGroup/__tests__/__snapshots__/ButtonGroup.test.jsx.snap +++ b/app/javascript/crayons/ButtonGroup/__tests__/__snapshots__/ButtonGroup.test.jsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<ButtonGroup /> component should render 1`] = `"<div role=\\"group\\" aria-label=\\"Test button group\\" class=\\"crayons-btn-group\\"><button class=\\"crayons-btn\\" type=\\"button\\">Hello World!</button><button class=\\"crayons-btn\\" type=\\"button\\"></button></div>"`; +exports[`<ButtonGroup /> component should render 1`] = `"<div role=\\"group\\" aria-label=\\"Test button group\\" class=\\"crayons-btn-group\\"><button type=\\"button\\" class=\\"crayons-btn\\">Hello World!</button><button type=\\"button\\" class=\\"crayons-btn\\"></button></div>"`; diff --git a/app/javascript/crayons/Buttons/__tests__/__snapshots__/Buttons.test.jsx.snap b/app/javascript/crayons/Buttons/__tests__/__snapshots__/Buttons.test.jsx.snap index d14852194a73c..826466360b308 100644 --- a/app/javascript/crayons/Buttons/__tests__/__snapshots__/Buttons.test.jsx.snap +++ b/app/javascript/crayons/Buttons/__tests__/__snapshots__/Buttons.test.jsx.snap @@ -14,8 +14,8 @@ exports[`<Button /> renders with a tooltip 1`] = `"<button type=\\"button\\" cla exports[`<Button /> renders with additional classnames 1`] = `"<button type=\\"button\\" class=\\"c-btn one two three\\">Hello world!</button>"`; -exports[`<Button /> renders with an icon and text 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--icon-left\\"><svg class=\\"crayons-icon c-btn__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg>Hello world!</button>"`; +exports[`<Button /> renders with an icon and text 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--icon-left\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon c-btn__icon\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg>Hello world!</button>"`; -exports[`<Button /> renders with an icon only 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--icon-alone\\"><svg class=\\"crayons-icon c-btn__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg></button>"`; +exports[`<Button /> renders with an icon only 1`] = `"<button type=\\"button\\" class=\\"c-btn c-btn--icon-alone\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon c-btn__icon\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg></button>"`; exports[`<Button /> should render a button as a specific button type (HTML type attribute) when buttonType is set. 1`] = `"<button type=\\"submit\\" class=\\"c-btn\\">Hello world!</button>"`; diff --git a/app/javascript/crayons/Links/__tests__/__snapshots__/Link.test.jsx.snap b/app/javascript/crayons/Links/__tests__/__snapshots__/Link.test.jsx.snap index 8ef189e6af431..82909fa1b2dd8 100644 --- a/app/javascript/crayons/Links/__tests__/__snapshots__/Link.test.jsx.snap +++ b/app/javascript/crayons/Links/__tests__/__snapshots__/Link.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<Link /> renders a link with icon alone 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--icon-alone\\"><svg class=\\"crayons-icon c-link__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg></a>"`; +exports[`<Link /> renders a link with icon alone 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--icon-alone\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon c-link__icon\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg></a>"`; exports[`<Link /> renders block link 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--block\\">Hello world!</a>"`; @@ -12,4 +12,4 @@ exports[`<Link /> renders rounded link 1`] = `"<a href=\\"/url\\" class=\\"c-lin exports[`<Link /> renders with additional classnames 1`] = `"<a href=\\"/url\\" class=\\"c-link one two three\\">Hello world!</a>"`; -exports[`<Link /> renders with icon and text 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--icon-left\\"><svg class=\\"crayons-icon c-link__icon\\" aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg>Hello world!</a>"`; +exports[`<Link /> renders with icon and text 1`] = `"<a href=\\"/url\\" class=\\"c-link c-link--icon-left\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon c-link__icon\\"><path d=\\"m12 1 9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\\"></path></svg>Hello world!</a>"`; diff --git a/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx b/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx index 778ab65b30b97..40535435d20fb 100644 --- a/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx +++ b/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx @@ -2,7 +2,7 @@ import { h } from 'preact'; import { axe } from 'jest-axe'; import '@testing-library/jest-dom'; import { render, waitFor } from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import { MobileDrawer } from '../MobileDrawer'; describe('<MobileDrawer />', () => { diff --git a/app/javascript/crayons/Modal/__tests__/Modal.test.jsx b/app/javascript/crayons/Modal/__tests__/Modal.test.jsx index 561757494d1ac..062d160122f15 100644 --- a/app/javascript/crayons/Modal/__tests__/Modal.test.jsx +++ b/app/javascript/crayons/Modal/__tests__/Modal.test.jsx @@ -2,7 +2,7 @@ import { h } from 'preact'; import { axe } from 'jest-axe'; import '@testing-library/jest-dom'; import { render, waitFor } from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import { Modal } from '../Modal'; describe('Modal', () => { diff --git a/app/javascript/crayons/MultiInput/__tests__/MultiInput.test.jsx b/app/javascript/crayons/MultiInput/__tests__/MultiInput.test.jsx index 753a4fa04bf2f..023a0837bbf3a 100644 --- a/app/javascript/crayons/MultiInput/__tests__/MultiInput.test.jsx +++ b/app/javascript/crayons/MultiInput/__tests__/MultiInput.test.jsx @@ -1,6 +1,6 @@ import { h } from 'preact'; import { render, waitFor } from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import '@testing-library/jest-dom'; diff --git a/app/javascript/crayons/MultiInput/__tests__/__snapshots__/MultiInput.test.jsx.snap b/app/javascript/crayons/MultiInput/__tests__/__snapshots__/MultiInput.test.jsx.snap index 2e788f29fc9a8..61d6b13658c91 100644 --- a/app/javascript/crayons/MultiInput/__tests__/__snapshots__/MultiInput.test.jsx.snap +++ b/app/javascript/crayons/MultiInput/__tests__/__snapshots__/MultiInput.test.jsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<MultiInput /> renders default UI 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"\\">Example label</label><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul class=\\"screen-reader-only list-none\\" aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\"></ul></div><div class=\\"c-input--multi relative\\"><div class=\\"c-input--multi__wrapper-border crayons-textfield flex items-center cursor-text pb-9\\"><ul class=\\"list-none flex flex-wrap w-100\\"><li class=\\"self-center\\" style=\\"order: 1;\\"><input autocomplete=\\"off\\" class=\\"c-input--multi__input\\" type=\\"text\\" aria-labelledby=\\"multi-select-label\\" placeholder=\\"Add an email address...\\"></li></ul></div></div>"`; +exports[`<MultiInput /> renders default UI 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\">Example label</label><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-input--multi relative\\"><div class=\\"c-input--multi__wrapper-border crayons-textfield flex items-center cursor-text pb-9\\"><ul class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"self-center\\"><input autocomplete=\\"off\\" type=\\"text\\" aria-labelledby=\\"multi-select-label\\" placeholder=\\"Add an email address...\\" class=\\"c-input--multi__input\\"></li></ul></div></div>"`; diff --git a/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx index 25d738fdd4742..c39533b9cfb99 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx +++ b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx @@ -4,6 +4,7 @@ import { h, Fragment } from 'preact'; import PropTypes from 'prop-types'; import { useEffect, useRef, useReducer } from 'preact/hooks'; import { DefaultSelectionTemplate } from '../../shared/components/defaultSelectionTemplate'; +import { debounceAction } from '@utilities/debounceAction'; const KEYS = { UP: 'ArrowUp', @@ -295,15 +296,7 @@ export const MultiSelectAutocomplete = ({ } }; - const handleInputChange = async ({ target: { value } }) => { - // When the input appears inline in "edit" mode, we need to dynamically calculate the width to ensure it occupies the right space - // (an input cannot resize based on its text content). We use a hidden <span> to track the size. - inputSizerRef.current.innerText = value; - - if (inputPosition !== null) { - resizeInputToContentSize(); - } - + const updateSuggestion = debounceAction(async (value) => { // If max selections have already been reached, no need to fetch further suggestions if (!allowSelections) { return; @@ -343,6 +336,17 @@ export const MultiSelectAutocomplete = ({ ), ), }); + }) + + const handleInputChange = async ({ target: { value } }) => { + // When the input appears inline in "edit" mode, we need to dynamically calculate the width to ensure it occupies the right space + // (an input cannot resize based on its text content). We use a hidden <span> to track the size. + inputSizerRef.current.innerText = value; + + if (inputPosition !== null) { + resizeInputToContentSize(); + } + await updateSuggestion(value) }; const clearInput = () => { @@ -598,7 +602,12 @@ export const MultiSelectAutocomplete = ({ className={`c-autocomplete--multi__wrapper${ border ? '-border crayons-textfield' : ' border-none p-0' } flex items-center cursor-text`} - onClick={() => inputRef.current?.focus()} + onClick={(event) => { + // Stopping propagation here so that clicks on the 'x' close button + // don't appear to be "outside" of any container (eg, dropdown) + event.stopPropagation(); + inputRef.current?.focus(); + }} > <ul id="combo-selected" className="list-none flex flex-wrap w-100"> {allSelectedItemElements} @@ -640,9 +649,9 @@ export const MultiSelectAutocomplete = ({ </ul> </div> {showMaxSelectionsReached ? ( - <div className="c-autocomplete--multi__popover"> - <span className="p-3">Only {maxSelections} selections allowed</span> - </div> + <span className="p-3"> + {`Only ${maxSelections} ${maxSelections == 1 ? 'selection' : 'selections'} allowed`} + </span> ) : null} {suggestions.length > 0 && allowSelections ? ( <div className="c-autocomplete--multi__popover" ref={popoverRef}> diff --git a/app/javascript/crayons/MultiSelectAutocomplete/TagAutocompleteSelection.jsx b/app/javascript/crayons/MultiSelectAutocomplete/TagAutocompleteSelection.jsx index 2e4f556d93ccc..f536425abf270 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/TagAutocompleteSelection.jsx +++ b/app/javascript/crayons/MultiSelectAutocomplete/TagAutocompleteSelection.jsx @@ -1,7 +1,7 @@ import { h } from 'preact'; import PropTypes from 'prop-types'; import { ButtonNew as Button, Icon } from '@crayons'; -import { Close } from '@images/x.svg'; +import Close from '@images/x.svg'; /** * Responsible for the layout of a tag autocomplete selected item in the article form diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx index 32b141c3b106f..e3c5a3c9ae2e8 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx +++ b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx @@ -1,11 +1,15 @@ import { h } from 'preact'; import { render, waitFor } from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { MultiSelectAutocomplete } from '../MultiSelectAutocomplete'; +jest.mock('@utilities/debounceAction', () => ({ + debounceAction: (fn) => fn, +})); + describe('<MultiSelectAutocomplete />', () => { it('renders default UI', () => { const { container } = render( diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/__snapshots__/MultiSelectAutocomplete.test.jsx.snap b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/__snapshots__/MultiSelectAutocomplete.test.jsx.snap index 851060faa8036..a38af43eef337 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/__snapshots__/MultiSelectAutocomplete.test.jsx.snap +++ b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/__snapshots__/MultiSelectAutocomplete.test.jsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<MultiSelectAutocomplete /> renders default UI 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul class=\\"screen-reader-only list-none\\" aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li class=\\"self-center\\" style=\\"order: 1;\\"><input autocomplete=\\"off\\" class=\\"c-autocomplete--multi__input\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add...\\"></li></ul></div></div>"`; +exports[`<MultiSelectAutocomplete /> renders default UI 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"self-center\\"><input autocomplete=\\"off\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add...\\" class=\\"c-autocomplete--multi__input\\"></li></ul></div></div>"`; -exports[`<MultiSelectAutocomplete /> renders with customisation 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"screen-reader-only\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul class=\\"screen-reader-only list-none\\" aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper border-none p-0 flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li class=\\"self-center\\" style=\\"order: 1;\\"><input id=\\"example-input-id\\" autocomplete=\\"off\\" class=\\"c-autocomplete--multi__input\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Example placeholder\\"></li></ul></div></div>"`; +exports[`<MultiSelectAutocomplete /> renders with customisation 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"screen-reader-only\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper border-none p-0 flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"self-center\\"><input id=\\"example-input-id\\" autocomplete=\\"off\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Example placeholder\\" class=\\"c-autocomplete--multi__input\\"></li></ul></div></div>"`; -exports[`<MultiSelectAutocomplete /> renders with default values 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul class=\\"screen-reader-only list-none\\" aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li class=\\"c-autocomplete--multi__selection-list-item w-max\\" style=\\"order: 1;\\"><div role=\\"group\\" aria-label=\\"Default one\\" class=\\"flex mr-1 mb-1 w-max\\"><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1 cursor-text\\" aria-label=\\"Edit Default one\\">Default one</button><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1\\" aria-label=\\"Remove Default one\\"><svg class=\\"crayons-icon\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z\\"></path></svg></button></div></li><li class=\\"c-autocomplete--multi__selection-list-item w-max\\" style=\\"order: 2;\\"><div role=\\"group\\" aria-label=\\"Default two\\" class=\\"flex mr-1 mb-1 w-max\\"><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1 cursor-text\\" aria-label=\\"Edit Default two\\">Default two</button><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1\\" aria-label=\\"Remove Default two\\"><svg class=\\"crayons-icon\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z\\"></path></svg></button></div></li><li class=\\"self-center\\" style=\\"order: 3;\\"><input autocomplete=\\"off\\" class=\\"c-autocomplete--multi__input\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add another...\\"></li></ul></div></div>"`; +exports[`<MultiSelectAutocomplete /> renders with default values 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"c-autocomplete--multi__selection-list-item w-max\\"><div role=\\"group\\" aria-label=\\"Default one\\" class=\\"flex mr-1 mb-1 w-max\\"><button type=\\"button\\" aria-label=\\"Edit Default one\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1 cursor-text\\">Default one</button><button type=\\"button\\" aria-label=\\"Remove Default one\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1\\"><svg width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon\\"><path d=\\"m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z\\"></path></svg></button></div></li><li style=\\"order: 2;\\" class=\\"c-autocomplete--multi__selection-list-item w-max\\"><div role=\\"group\\" aria-label=\\"Default two\\" class=\\"flex mr-1 mb-1 w-max\\"><button type=\\"button\\" aria-label=\\"Edit Default two\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1 cursor-text\\">Default two</button><button type=\\"button\\" aria-label=\\"Remove Default two\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1\\"><svg width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon\\"><path d=\\"m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z\\"></path></svg></button></div></li><li style=\\"order: 3;\\" class=\\"self-center\\"><input autocomplete=\\"off\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add another...\\" class=\\"c-autocomplete--multi__input\\"></li></ul></div></div>"`; diff --git a/app/javascript/crayons/formElements/ColorPicker/__tests__/__snapshots__/ColorPicker.test.jsx.snap b/app/javascript/crayons/formElements/ColorPicker/__tests__/__snapshots__/ColorPicker.test.jsx.snap index c1eb328811dde..5ba300b217f02 100644 --- a/app/javascript/crayons/formElements/ColorPicker/__tests__/__snapshots__/ColorPicker.test.jsx.snap +++ b/app/javascript/crayons/formElements/ColorPicker/__tests__/__snapshots__/ColorPicker.test.jsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<ColorPicker /> should render 1`] = `"<div class=\\"c-color-picker relative w-100\\"><button type=\\"button\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(0, 0, 0);\\" aria-label=\\"Choose a color\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\"></button><input aria-label=\\"Choose a color\\" id=\\"color-picker\\" class=\\"c-color-picker__input crayons-textfield\\" spellcheck=\\"false\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div class=\\"react-colorful__saturation\\" style=\\"background-color: rgb(255, 0, 0);\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 0%\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__saturation-pointer\\" style=\\"top: 100%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(0, 0, 0);\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuenow=\\"0\\" aria-valuemax=\\"360\\" aria-valuemin=\\"0\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__hue-pointer\\" style=\\"top: 50%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(255, 0, 0);\\"></div></div></div></div></div></div></div>"`; +exports[`<ColorPicker /> should render 1`] = `"<div class=\\"c-color-picker relative w-100\\"><button type=\\"button\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(0, 0, 0);\\" aria-label=\\"Choose a color\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\" data-dropdown-initialized=\\"true\\"></button><input aria-label=\\"Choose a color\\" id=\\"color-picker\\" spellcheck=\\"false\\" class=\\"c-color-picker__input crayons-textfield\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div style=\\"background-color: rgb(255, 0, 0);\\" class=\\"react-colorful__saturation\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 0%\\" tabindex=\\"0\\" role=\\"slider\\" class=\\"react-colorful__interactive\\"><div style=\\"top: 100%; left: 0%;\\" class=\\"react-colorful__pointer react-colorful__saturation-pointer\\"><div style=\\"background-color: rgb(0, 0, 0);\\" class=\\"react-colorful__pointer-fill\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuenow=\\"0\\" aria-valuemax=\\"360\\" aria-valuemin=\\"0\\" tabindex=\\"0\\" role=\\"slider\\" class=\\"react-colorful__interactive\\"><div style=\\"top: 50%; left: 0%;\\" class=\\"react-colorful__pointer react-colorful__hue-pointer\\"><div style=\\"background-color: rgb(255, 0, 0);\\" class=\\"react-colorful__pointer-fill\\"></div></div></div></div></div></div></div>"`; -exports[`<ColorPicker /> should render with a default value 1`] = `"<div class=\\"c-color-picker relative w-100\\"><button type=\\"button\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(171, 171, 171);\\" aria-label=\\"Choose a color\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\"></button><input aria-label=\\"Choose a color\\" id=\\"color-picker\\" class=\\"c-color-picker__input crayons-textfield\\" spellcheck=\\"false\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div class=\\"react-colorful__saturation\\" style=\\"background-color: rgb(255, 0, 0);\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 67%\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__saturation-pointer\\" style=\\"top: 32.99999999999999%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(171, 171, 171);\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuenow=\\"0\\" aria-valuemax=\\"360\\" aria-valuemin=\\"0\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__hue-pointer\\" style=\\"top: 50%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(255, 0, 0);\\"></div></div></div></div></div></div></div>"`; +exports[`<ColorPicker /> should render with a default value 1`] = `"<div class=\\"c-color-picker relative w-100\\"><button type=\\"button\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(171, 171, 171);\\" aria-label=\\"Choose a color\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\" data-dropdown-initialized=\\"true\\"></button><input aria-label=\\"Choose a color\\" id=\\"color-picker\\" spellcheck=\\"false\\" class=\\"c-color-picker__input crayons-textfield\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div style=\\"background-color: rgb(255, 0, 0);\\" class=\\"react-colorful__saturation\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 67%\\" tabindex=\\"0\\" role=\\"slider\\" class=\\"react-colorful__interactive\\"><div style=\\"top: 32.99999999999999%; left: 0%;\\" class=\\"react-colorful__pointer react-colorful__saturation-pointer\\"><div style=\\"background-color: rgb(171, 171, 171);\\" class=\\"react-colorful__pointer-fill\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuenow=\\"0\\" aria-valuemax=\\"360\\" aria-valuemin=\\"0\\" tabindex=\\"0\\" role=\\"slider\\" class=\\"react-colorful__interactive\\"><div style=\\"top: 50%; left: 0%;\\" class=\\"react-colorful__pointer react-colorful__hue-pointer\\"><div style=\\"background-color: rgb(255, 0, 0);\\" class=\\"react-colorful__pointer-fill\\"></div></div></div></div></div></div></div>"`; diff --git a/app/javascript/crayons/formElements/DateRangePicker/__tests__/DateRangePicker.test.jsx b/app/javascript/crayons/formElements/DateRangePicker/__tests__/DateRangePicker.test.jsx index 591dbe335898a..2ea6ec1960806 100644 --- a/app/javascript/crayons/formElements/DateRangePicker/__tests__/DateRangePicker.test.jsx +++ b/app/javascript/crayons/formElements/DateRangePicker/__tests__/DateRangePicker.test.jsx @@ -1,6 +1,6 @@ import { h } from 'preact'; import { render, waitFor, within } from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import { DateRangePicker, MONTH_UNTIL_TODAY, diff --git a/app/javascript/crayons/formElements/FormField/__tests__/__snapshots__/FormField.test.jsx.snap b/app/javascript/crayons/formElements/FormField/__tests__/__snapshots__/FormField.test.jsx.snap index bc62b5191b5de..b4e5bb8183439 100644 --- a/app/javascript/crayons/formElements/FormField/__tests__/__snapshots__/FormField.test.jsx.snap +++ b/app/javascript/crayons/formElements/FormField/__tests__/__snapshots__/FormField.test.jsx.snap @@ -2,4 +2,4 @@ exports[`<FormField /> component should render 1`] = `"<div class=\\"crayons-field\\"></div>"`; -exports[`<FormField /> component should render with content 1`] = `"<div class=\\"crayons-field\\"><input id=\\"some-id\\" name=\\"some-name\\" class=\\"crayons-radio\\" type=\\"radio\\" value=\\"some-value\\"><label for=\\"some-id\\">Some Label</label></div>"`; +exports[`<FormField /> component should render with content 1`] = `"<div class=\\"crayons-field\\"><input id=\\"some-id\\" name=\\"some-name\\" type=\\"radio\\" class=\\"crayons-radio\\" value=\\"some-value\\"><label for=\\"some-id\\">Some Label</label></div>"`; diff --git a/app/javascript/crayons/formElements/RadioButton/__tests__/__snapshots__/RadioButton.test.jsx.snap b/app/javascript/crayons/formElements/RadioButton/__tests__/__snapshots__/RadioButton.test.jsx.snap index 11f8955b0f835..5133ce292b3d7 100644 --- a/app/javascript/crayons/formElements/RadioButton/__tests__/__snapshots__/RadioButton.test.jsx.snap +++ b/app/javascript/crayons/formElements/RadioButton/__tests__/__snapshots__/RadioButton.test.jsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`<RadioButton /> component should render a radio button checked 1`] = `"<input class=\\"crayons-radio\\" type=\\"radio\\">"`; +exports[`<RadioButton /> component should render a radio button checked 1`] = `"<input type=\\"radio\\" class=\\"crayons-radio\\">"`; -exports[`<RadioButton /> component should render a radio button unchecked by default 1`] = `"<input class=\\"crayons-radio\\" type=\\"radio\\">"`; +exports[`<RadioButton /> component should render a radio button unchecked by default 1`] = `"<input type=\\"radio\\" class=\\"crayons-radio\\">"`; -exports[`<RadioButton /> component should render a radio button with given props 1`] = `"<input id=\\"some-id\\" name=\\"some-name\\" class=\\"crayons-radio additional-css-class-name\\" type=\\"radio\\" value=\\"some-value\\">"`; +exports[`<RadioButton /> component should render a radio button with given props 1`] = `"<input id=\\"some-id\\" name=\\"some-name\\" type=\\"radio\\" class=\\"crayons-radio additional-css-class-name\\" value=\\"some-value\\">"`; diff --git a/app/javascript/crayons/formElements/Toggles/__tests__/__snapshots__/Toggle.test.jsx.snap b/app/javascript/crayons/formElements/Toggles/__tests__/__snapshots__/Toggle.test.jsx.snap index 6533291829d69..3440cbc9dd277 100644 --- a/app/javascript/crayons/formElements/Toggles/__tests__/__snapshots__/Toggle.test.jsx.snap +++ b/app/javascript/crayons/formElements/Toggles/__tests__/__snapshots__/Toggle.test.jsx.snap @@ -2,4 +2,4 @@ exports[`<Toggle /> should render default 1`] = `"<input type=\\"checkbox\\" class=\\"c-toggle\\">"`; -exports[`<Toggle /> should render with additional input props 1`] = `"<input type=\\"checkbox\\" class=\\"example-class\\" disabled=\\"\\">"`; +exports[`<Toggle /> should render with additional input props 1`] = `"<input type=\\"checkbox\\" disabled=\\"\\" class=\\"example-class\\">"`; diff --git a/app/javascript/githubRepos/__tests__/githubRepos.test.jsx b/app/javascript/githubRepos/__tests__/githubRepos.test.jsx index b7e823a1d6c11..cac5fd790f886 100644 --- a/app/javascript/githubRepos/__tests__/githubRepos.test.jsx +++ b/app/javascript/githubRepos/__tests__/githubRepos.test.jsx @@ -228,14 +228,14 @@ describe('<GithubRepos />', () => { // No need to test it's contents as this is the <SingleRepo /> component // which has it's own tests. - expect(repoList).toBeDefined(); + expect(repoList).toExist(); }); it('should render with no repositories', () => { fetch.mockResponse('[]'); const { queryByTitle } = render(<GithubRepos />); - expect(queryByTitle('Loading GitHub repositories')).toBeDefined(); + expect(queryByTitle('Loading GitHub repositories')).toExist(); }); it('should render error message when repositories cannot be loaded', async () => { diff --git a/app/javascript/hooks/useTagsField.js b/app/javascript/hooks/useTagsField.js index 93c8d954dfbdd..3a73e83b304ef 100644 --- a/app/javascript/hooks/useTagsField.js +++ b/app/javascript/hooks/useTagsField.js @@ -1,6 +1,8 @@ import { useEffect, useState } from 'preact/hooks'; +import algoliasearch from 'algoliasearch/lite' import { fetchSearch } from '@utilities/search'; + /** * Custom hook to manage the logic for the tags-fields based on the `MultiSelectAutocomplete` component * @@ -10,6 +12,15 @@ import { fetchSearch } from '@utilities/search'; * An object containing `defaultSelections` list, `fetchSuggestions` function, and `syncSelections` function */ export const useTagsField = ({ defaultValue, onInput }) => { + const useFetchSearch = document.body.dataset.algoliaId?.length === 0; + let algoliaIndex; + if (!useFetchSearch) { + const env = document.querySelector('meta[name="environment"]')?.content; + const {algoliaId, algoliaSearchKey} = document.body.dataset; + const algoliaClient = algoliasearch(algoliaId, algoliaSearchKey); + algoliaIndex = algoliaClient.initIndex(`Tag_${env}`); + } + const [defaultSelections, setDefaultSelections] = useState([]); const [defaultsLoaded, setDefaultsLoaded] = useState(false); @@ -20,13 +31,21 @@ export const useTagsField = ({ defaultValue, onInput }) => { if (defaultValue && defaultValue !== '' && !defaultsLoaded) { const tagNames = defaultValue.split(', '); - const tagRequests = tagNames.map((tagName) => - fetchSearch('tags', { name: tagName }).then(({ result = [] }) => { - const [potentialMatch = {}] = result; - return potentialMatch.name === tagName - ? potentialMatch - : { name: tagName }; - }), + const tagRequests = tagNames.map((tagName) => + (useFetchSearch ? + fetchSearch('tags', { name: tagName }).then(({ result = [] }) => { + const [potentialMatch = {}] = result; + return potentialMatch.name === tagName + ? potentialMatch + : { name: tagName }; + }) : + algoliaIndex.search(tagName).then(({ hits }) => { + const [potentialMatch = {}] = hits; + return potentialMatch.name === tagName + ? potentialMatch + : { name: tagName }; + }) + ) ); Promise.all(tagRequests).then((data) => { @@ -34,7 +53,7 @@ export const useTagsField = ({ defaultValue, onInput }) => { }); } setDefaultsLoaded(true); - }, [defaultValue, defaultsLoaded]); + }, [defaultValue, defaultsLoaded, useFetchSearch]); /** * Converts the array of selected items into a plain string, @@ -54,9 +73,16 @@ export const useTagsField = ({ defaultValue, onInput }) => { * @param {string} searchTerm The text to search for * @returns {Promise} Promise which resolves to the tag search results */ - const fetchSuggestions = (searchTerm) => - fetchSearch('tags', { name: searchTerm }).then( - (response) => response.result, + const fetchSuggestions = (searchTerm) => + (useFetchSearch ? + fetchSearch('tags', { name: searchTerm }).then( + (response) => response.result, + ) : + algoliaIndex.search(searchTerm, { + facetFilters: ["supported:true"] + }).then( + (response) => response.hits, + ) ); return { diff --git a/app/javascript/leftSidebar/__tests__/TagsFollowed.test.jsx b/app/javascript/leftSidebar/__tests__/TagsFollowed.test.jsx index 64a342f0d42ff..8b207209a4fab 100644 --- a/app/javascript/leftSidebar/__tests__/TagsFollowed.test.jsx +++ b/app/javascript/leftSidebar/__tests__/TagsFollowed.test.jsx @@ -48,8 +48,8 @@ describe('<TagsFollowed />', () => { it('should render the tags followed when tags are passed in', () => { const { queryByTitle } = render(<TagsFollowed tags={getTags()} />); - expect(queryByTitle('javascript tag')).toBeDefined(); - expect(queryByTitle('webdev tag')).toBeDefined(); - expect(queryByTitle('react tag')).toBeDefined(); + expect(queryByTitle('javascript tag')).toExist(); + expect(queryByTitle('webdev tag')).toExist(); + expect(queryByTitle('react tag')).toExist(); }); }); diff --git a/app/javascript/listings/__tests__/ListingFilterTags.test.jsx b/app/javascript/listings/__tests__/ListingFilterTags.test.jsx index e6d245d4b7c46..bfbd9b3e85052 100644 --- a/app/javascript/listings/__tests__/ListingFilterTags.test.jsx +++ b/app/javascript/listings/__tests__/ListingFilterTags.test.jsx @@ -29,13 +29,13 @@ describe('<ListingFilterTags />', () => { it('should have "search" as placeholder', () => { const { queryByPlaceholderText } = renderListingFilterTags(); - expect(queryByPlaceholderText(/search/i)).toBeDefined(); + expect(queryByPlaceholderText(/search/i)).toExist(); }); it(`should have "${getProps().message}" as default value`, () => { const { queryByDisplayValue } = renderListingFilterTags(); - expect(queryByDisplayValue(getProps().message)).toBeDefined(); + expect(queryByDisplayValue(getProps().message)).toExist(); }); it('should have auto-complete as off', () => { @@ -50,7 +50,7 @@ describe('<ListingFilterTags />', () => { it('should render the clear query button', () => { const { queryByTestId } = renderListingFilterTags(); - expect(queryByTestId('clear-query-button')).toBeDefined(); + expect(queryByTestId('clear-query-button')).toExist(); }); it('should not render the clear query button', () => { @@ -65,7 +65,7 @@ describe('<ListingFilterTags />', () => { const { queryByText } = renderListingFilterTags(); getTags().forEach((tag) => { - expect(queryByText(`${tag}`)).toBeDefined(); + expect(queryByText(`${tag}`)).toExist(); }); }); }); diff --git a/app/javascript/listings/__tests__/ListingFiltersCategories.test.jsx b/app/javascript/listings/__tests__/ListingFiltersCategories.test.jsx index 6f51c22cea4f8..37bdbb440454b 100644 --- a/app/javascript/listings/__tests__/ListingFiltersCategories.test.jsx +++ b/app/javascript/listings/__tests__/ListingFiltersCategories.test.jsx @@ -49,11 +49,10 @@ describe('<ListingFiltersCategories />', () => { it('should be "selected" when there is no category selected', () => { const propsWithoutCategory = { ...getProps(), category: '' }; - const { queryByTestId } = renderListingFilterCategories( - propsWithoutCategory, - ); + const { queryByTestId } = + renderListingFilterCategories(propsWithoutCategory); - expect(queryByTestId('selected')).toBeDefined(); + expect(queryByTestId('selected')).toExist(); }); }); diff --git a/app/javascript/listings/__tests__/ListingTagsField.test.jsx b/app/javascript/listings/__tests__/ListingTagsField.test.jsx index 2c30ace0ffda4..ccf8ee215a9af 100644 --- a/app/javascript/listings/__tests__/ListingTagsField.test.jsx +++ b/app/javascript/listings/__tests__/ListingTagsField.test.jsx @@ -1,13 +1,23 @@ import fetch from 'jest-fetch-mock'; import { h } from 'preact'; import { render, waitFor } from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { ListingTagsField } from '../components/ListingTagsField'; import '@testing-library/jest-dom'; fetch.enableMocks(); +// Mock Algolia +jest.mock('algoliasearch/lite', () => { + const searchClient = { + initIndex: jest.fn(() => ({ + search: jest.fn().mockResolvedValue({ hits: [] }) + })) + }; + return jest.fn(() => searchClient); +}); + let renderResult; const csrfToken = 'this-is-a-csrf-token'; jest.mock('../../utilities/http/csrfToken', () => ({ diff --git a/app/javascript/listings/__tests__/ModalBackground.test.jsx b/app/javascript/listings/__tests__/ModalBackground.test.jsx index d3ed15d1e73e4..2a3ab6c09b4fd 100644 --- a/app/javascript/listings/__tests__/ModalBackground.test.jsx +++ b/app/javascript/listings/__tests__/ModalBackground.test.jsx @@ -19,7 +19,7 @@ describe('<ModalBackground />', () => { it('should render', () => { const { queryByTestId } = render(<ModalBackground {...defaultProps} />); - expect(queryByTestId('listings-modal-background')).toBeDefined(); + expect(queryByTestId('listings-modal-background')).toExist(); }); it('should call the onClick handler', () => { diff --git a/app/javascript/listings/__tests__/NextPageButton.test.jsx b/app/javascript/listings/__tests__/NextPageButton.test.jsx index 50c5e5b2d929d..7fe8c67839fb9 100644 --- a/app/javascript/listings/__tests__/NextPageButton.test.jsx +++ b/app/javascript/listings/__tests__/NextPageButton.test.jsx @@ -20,7 +20,7 @@ describe('<NextPageButton />', () => { it('should show a button', () => { const { queryByText } = render(<NextPageButton {...defaultProps} />); - expect(queryByText(/load more/i)).toBeDefined(); + expect(queryByText(/load more/i)).toExist(); }); it('should call the onclick handler', () => { diff --git a/app/javascript/listings/__tests__/SelectedTags.test.jsx b/app/javascript/listings/__tests__/SelectedTags.test.jsx index 4139290581847..3728438136382 100644 --- a/app/javascript/listings/__tests__/SelectedTags.test.jsx +++ b/app/javascript/listings/__tests__/SelectedTags.test.jsx @@ -23,7 +23,7 @@ describe('<SelectedTags />', () => { const { queryByText } = renderSelectedTags(); tags.forEach((tag) => { - expect(queryByText(tag)).toBeDefined(); + expect(queryByText(tag)).toExist(); }); }); diff --git a/app/javascript/listings/__tests__/SingleListing.test.jsx b/app/javascript/listings/__tests__/SingleListing.test.jsx index 5dbbeb79f999c..4423aa78cf390 100644 --- a/app/javascript/listings/__tests__/SingleListing.test.jsx +++ b/app/javascript/listings/__tests__/SingleListing.test.jsx @@ -61,7 +61,7 @@ describe('<SingleListing />', () => { it('shows a listing title', () => { const { queryByText } = renderSingleListing(); - expect(queryByText('Illo iure quos perspiciatis.')).toBeDefined(); + expect(queryByText('Illo iure quos perspiciatis.')).toExist(); }); it('shows a dropdown', () => { diff --git a/app/javascript/listings/components/CategoryLinksMobile.jsx b/app/javascript/listings/components/CategoryLinksMobile.jsx index 893ce04077d86..4e834008ee27a 100644 --- a/app/javascript/listings/components/CategoryLinksMobile.jsx +++ b/app/javascript/listings/components/CategoryLinksMobile.jsx @@ -1,8 +1,6 @@ -/* - global selectNavigation -*/ import { h, Component } from 'preact'; import PropTypes from 'prop-types'; +import { selectNavigation } from '../../packs/initializers/initializeDashboardSort'; export class CategoryLinksMobile extends Component { componentDidMount() { diff --git a/app/javascript/modCenter/moderationArticles.jsx b/app/javascript/modCenter/moderationArticles.jsx index 1b9c828aa26f4..c0cdce129632a 100644 --- a/app/javascript/modCenter/moderationArticles.jsx +++ b/app/javascript/modCenter/moderationArticles.jsx @@ -28,11 +28,6 @@ export class ModerationArticles extends Component { if (selectedDetailsPanel.getAttribute('open') !== null) { selectedArticle.innerHTML = ` - <div class="article-referrer-heading"> - <a class="article-title-link fw-bold" href=${path}> - ${title} - </a> - </div> <div class="iframes-container"> <iframe class="article-iframe" src="${path}"></iframe> <iframe data-testid="mod-iframe-${id}" id="mod-iframe-${id}" class="actions-panel-iframe" id="mod-iframe-${id}" src="${path}/actions_panel/?is_mod_center=true"></iframe> @@ -58,6 +53,7 @@ export class ModerationArticles extends Component { path, cached_tag_list: cachedTagList, published_at: publishedAt, + nth_published_by_author: nthPublishedByAuthor, user, } = article; return ( @@ -68,6 +64,7 @@ export class ModerationArticles extends Component { cachedTagList={cachedTagList} key={id} publishedAt={publishedAt} + nthPublishedByAuthor={nthPublishedByAuthor} user={user} articleOpened={id === prevSelectedArticleId} toggleArticle={this.toggleArticle} diff --git a/app/javascript/modCenter/singleArticle/__tests__/singleArticle.test.jsx b/app/javascript/modCenter/singleArticle/__tests__/singleArticle.test.jsx index fcd5ebd792ce3..a1903afd2913b 100644 --- a/app/javascript/modCenter/singleArticle/__tests__/singleArticle.test.jsx +++ b/app/javascript/modCenter/singleArticle/__tests__/singleArticle.test.jsx @@ -14,6 +14,7 @@ const getTestArticle = () => ({ articles_count: 1, name: 'hello', }, + nthPublishedByAuthor: 1, }); describe('<SingleArticle />', () => { @@ -94,7 +95,7 @@ describe('<SingleArticle />', () => { expect(text).toContain(getTestArticle().user.name); }); - it('renders the hand wave emoji if the author has less than 3 articles', () => { + it("renders the hand wave emoji if the article is the author's first, second or third", () => { const { container } = render( <Fragment> <SingleArticle {...getTestArticle()} toggleArticle={jest.fn()} /> diff --git a/app/javascript/modCenter/singleArticle/index.jsx b/app/javascript/modCenter/singleArticle/index.jsx index 34b53c1b2b8f3..fb6d180397809 100644 --- a/app/javascript/modCenter/singleArticle/index.jsx +++ b/app/javascript/modCenter/singleArticle/index.jsx @@ -1,12 +1,14 @@ import PropTypes from 'prop-types'; import { h, Fragment } from 'preact'; import { formatDate } from './util'; +import ExternalLinkIcon from '@images/external-link.svg'; export const SingleArticle = ({ id, title, publishedAt, cachedTagList, + nthPublishedByAuthor, user, key, articleOpened, @@ -28,7 +30,7 @@ export const SingleArticle = ({ const tags = cachedTagList.split(', ').map((tag) => tagsFormat(tag, key)); - const newAuthorNotification = user.articles_count <= 3 ? '👋 ' : ''; + const newAuthorNotification = nthPublishedByAuthor <= 3 ? '👋 ' : ''; return ( <Fragment> @@ -40,6 +42,9 @@ export const SingleArticle = ({ > <summary> <div className="article-details-container"> + <a href={path}> + <ExternalLinkIcon aria-label="Open in new tab" className="link-icon" /> + </a> <span className="article-title"> <header> <h3 className="fs-base fw-bold lh-tight article-title-heading"> diff --git a/app/javascript/onboarding/Onboarding.jsx b/app/javascript/onboarding/Onboarding.jsx index 11aaee493a04e..59d3ef53b896c 100644 --- a/app/javascript/onboarding/Onboarding.jsx +++ b/app/javascript/onboarding/Onboarding.jsx @@ -1,7 +1,7 @@ import { h, Component } from 'preact'; import PropTypes from 'prop-types'; import { FocusTrap } from '../shared/components/focusTrap'; -import { IntroSlide } from './components/IntroSlide'; +import { postReactions } from '../actionsPanel/services/reactions.js'; import { EmailPreferencesForm } from './components/EmailPreferencesForm'; import { FollowTags } from './components/FollowTags'; import { FollowUsers } from './components/FollowUsers'; @@ -11,16 +11,9 @@ export class Onboarding extends Component { constructor(props) { super(props); - const url = new URL(window.location); - const previousLocation = url.searchParams.get('referrer'); + this.recordBillboardConversion(); - const slides = [ - IntroSlide, - FollowTags, - ProfileForm, - FollowUsers, - EmailPreferencesForm, - ]; + const slides = [ProfileForm, FollowTags, FollowUsers, EmailPreferencesForm]; this.nextSlide = this.nextSlide.bind(this); this.prevSlide = this.prevSlide.bind(this); @@ -38,7 +31,6 @@ export class Onboarding extends Component { currentSlideIndex={index} key={index} communityConfig={props.communityConfig} - previousLocation={previousLocation} /> )); } @@ -50,8 +42,17 @@ export class Onboarding extends Component { this.setState({ currentSlide: nextSlide, }); + } else if ( + localStorage && + localStorage.getItem('last_interacted_billboard') + ) { + const obj = JSON.parse(localStorage.getItem('last_interacted_billboard')); + if (obj.path && obj.time && Date.parse(obj.time) > Date.now() - 900000) { + window.location.href = obj.path; + } else { + window.location.href = '/'; + } } else { - // Redirect to the main feed at the end of onboarding. window.location.href = '/'; } } @@ -66,6 +67,56 @@ export class Onboarding extends Component { } } + recordBillboardConversion() { + if (!localStorage || !localStorage.getItem('last_interacted_billboard')) { + return; + } + const dataBody = JSON.parse( + localStorage.getItem('last_interacted_billboard'), + ); + + if (dataBody && dataBody['billboard_event']) { + dataBody['billboard_event']['category'] = 'signup'; + + const tokenMeta = document.querySelector("meta[name='csrf-token']"); + const csrfToken = tokenMeta && tokenMeta.getAttribute('content'); + window.fetch('/bb_tabulations', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin', + }); + } + + if ( + dataBody && + dataBody['billboard_event'] && + dataBody['billboard_event']['article_id'] + ) { + window + .fetch(`/api/articles/${dataBody['billboard_event']['article_id']}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }) + .then((response) => response.json()) + .then((data) => { + if (data.id) { + localStorage.setItem('onboarding_article', JSON.stringify(data)); + postReactions({ + reactable_type: 'Article', + category: 'like', + reactable_id: data.id, + }); + } + }); + } + } // TODO: Update main element id to enable skip link. See issue #1153. render() { const { currentSlide } = this.state; @@ -74,11 +125,13 @@ export class Onboarding extends Component { <main className="onboarding-body" style={ - communityConfig.communityBackground + communityConfig.communityBackgroundColor && + communityConfig.communityBackgroundColor2 ? { - backgroundImage: `url(${communityConfig.communityBackground})`, + background: `linear-gradient(${communityConfig.communityBackgroundColor}, + ${communityConfig.communityBackgroundColor2})`, } - : null + : { top: 777 } } > <FocusTrap @@ -95,7 +148,7 @@ export class Onboarding extends Component { Onboarding.propTypes = { communityConfig: PropTypes.shape({ communityName: PropTypes.string.isRequired, - communityBackground: PropTypes.string.isRequired, + communityBackgroundColor: PropTypes.string.isRequired, communityLogo: PropTypes.string.isRequired, communityDescription: PropTypes.string.isRequired, }).isRequired, diff --git a/app/javascript/onboarding/__tests__/Onboarding.test.jsx b/app/javascript/onboarding/__tests__/Onboarding.test.jsx index 99ac1c6241122..2ceaf56b1599e 100644 --- a/app/javascript/onboarding/__tests__/Onboarding.test.jsx +++ b/app/javascript/onboarding/__tests__/Onboarding.test.jsx @@ -16,7 +16,8 @@ describe('<Onboarding />', () => { communityConfig={{ communityName: 'Community Name', communityLogo: '/x.png', - communityBackground: '/y.jpg', + communityBackgroundColor: '#e6d800', + communityBackgroundColor2: '#999000', communityDescription: 'Some community description', }} />, @@ -36,6 +37,29 @@ describe('<Onboarding />', () => { document.body.setAttribute('data-user', getUserData()); const csrfToken = 'this-is-a-csrf-token'; global.getCsrfToken = async () => csrfToken; + + // Mock localStorage + const localStorageMock = (function () { + let store = {}; + return { + getItem(key) { + return store[key] || null; + }, + setItem(key, value) { + store[key] = value.toString(); + }, + removeItem(key) { + delete store[key]; + }, + clear() { + store = {}; + }, + }; + })(); + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + }); }); it('should have no a11y violations', async () => { @@ -44,24 +68,49 @@ describe('<Onboarding />', () => { expect(results).toHaveNoViolations(); }); - it('should render the IntroSlide first', () => { + it('should record billboard conversion correctly', async () => { + const fakeBillboardData = { + billboard_event: { category: 'signup', someData: 'test' }, // Ensure this structure matches what your code expects + }; + window.localStorage.setItem( + 'last_interacted_billboard', + JSON.stringify(fakeBillboardData), + ); + + fetch.mockResponseOnce(() => Promise.resolve(JSON.stringify({}))); // Mock for the billboard event + fetch.mockResponseOnce(() => Promise.resolve(JSON.stringify({}))); // Mock for any subsequent fetch calls, if necessary + + renderOnboarding(); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/bb_tabulations', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-CSRF-Token': expect.any(String), + }), + body: JSON.stringify(fakeBillboardData), + }), + ); + }); + + // Cleanup + window.localStorage.clear(); + fetch.resetMocks(); + }); + + it('should render the ProfileForm first', () => { const { queryByTestId } = renderOnboarding(); - expect(queryByTestId('onboarding-intro-slide')).toBeDefined(); + expect(queryByTestId('onboarding-profile-form')).toExist(); }); it('should allow the modal to move forward and backward a step where relevant', async () => { // combined back and forward into one test to avoid a long test running time const { getByTestId, findByText, findByTestId } = renderOnboarding(); - getByTestId('onboarding-intro-slide'); - - fetch.mockResponseOnce({}); - const codeOfConductCheckbox = getByTestId('checked-code-of-conduct'); - codeOfConductCheckbox.click(); - const termsCheckbox = getByTestId('checked-terms-and-conditions'); - termsCheckbox.click(); - // click to next step const nextButton = await findByText(/continue/i); @@ -77,22 +126,14 @@ describe('<Onboarding />', () => { const backButton = getByTestId('back-button'); backButton.click(); - // we should be on the Intro Slide step - const introSlide = await findByTestId('onboarding-intro-slide'); + // we should be on the Profile Form Slide step + const introSlide = await findByTestId('onboarding-profile-form'); - expect(introSlide).toBeDefined(); + expect(introSlide).toExist(); }); it("should skip the step when 'Skip for now' is clicked", async () => { - const { getByTestId, getByText, findByText, findByTestId } = - renderOnboarding(); - getByTestId('onboarding-intro-slide'); - - fetch.mockResponseOnce({}); - const codeOfConductCheckbox = getByTestId('checked-code-of-conduct'); - codeOfConductCheckbox.click(); - const termsCheckbox = getByTestId('checked-terms-and-conditions'); - termsCheckbox.click(); + const { getByText, findByText, findByTestId } = renderOnboarding(); // click to next step const nextButton = await findByText(/continue/i); @@ -104,31 +145,26 @@ describe('<Onboarding />', () => { // we should be on the Follow tags step const followTagsStep = await findByTestId('onboarding-follow-tags'); - expect(followTagsStep).toBeDefined(); + expect(followTagsStep).toExist(); // click on skip for now const skipButton = getByText(/Skip for now/i); skipButton.click(); // we should be on the Profile Form step - const profileStep = await findByTestId('onboarding-profile-form'); + const profileStep = await findByTestId('onboarding-follow-users'); - expect(profileStep).toBeDefined(); + expect(profileStep).toExist(); }); it('should redirect the users to the correct steps every time', async () => { const { getByTestId, getByText, findByText, findByTestId } = renderOnboarding(); - getByTestId('onboarding-intro-slide'); - fetch.mockResponseOnce({}); - const codeOfConductCheckbox = getByTestId('checked-code-of-conduct'); - codeOfConductCheckbox.click(); - const termsCheckbox = getByTestId('checked-terms-and-conditions'); - termsCheckbox.click(); + getByTestId('onboarding-profile-form'); // click to next step - let nextButton = await findByText(/continue/i); + const nextButton = await findByText(/continue/i); await waitFor(() => expect(nextButton).not.toHaveAttribute('disabled')); fetch.mockResponse(fakeEmptyResponse); @@ -141,14 +177,6 @@ describe('<Onboarding />', () => { let skipButton = getByText(/Skip for now/i); skipButton.click(); - // we should be on the Profile Form step - await findByTestId('onboarding-profile-form'); - - // click on continue without adjusting form fields - nextButton = getByText(/Continue/i); - fetch.mockResponse(fakeEmptyResponse); - nextButton.click(); - // we should be on the Follow Users step await findByTestId('onboarding-follow-users'); @@ -175,4 +203,42 @@ describe('<Onboarding />', () => { expect(href).toEqual(url); }); + + it('should redirect to the specified path in localStorage when conditions are met', async () => { + // Setup: Mock localStorage with a recent interaction + const expectedPath = '/expected-path'; + const recentInteraction = { + path: expectedPath, + time: new Date(), // Simulate an interaction right now + }; + window.localStorage.setItem( + 'last_interacted_billboard', + JSON.stringify(recentInteraction), + ); + + // Mock fetch responses if needed + fetch.mockResponseOnce(JSON.stringify({})); + + // Setup a spy/mock for window.location.href to verify it gets set + delete window.location; + window.location = { href: '' }; // Simplified mock; you might need a more robust solution + + const { getByText } = renderOnboarding(); + + // Trigger the logic that includes the redirect + const nextButton = getByText(/continue/i); // Assuming "continue" triggers the nextSlide logic + nextButton.click(); + nextButton.click(); + nextButton.click(); + nextButton.click(); + + // Assert that window.location.href was updated to the expected path + await waitFor(() => { + expect(window.location.href).toBe(expectedPath); + }); + + // Cleanup + window.localStorage.clear(); + fetch.resetMocks(); + }); }); diff --git a/app/javascript/onboarding/components/EmailPreferencesForm.jsx b/app/javascript/onboarding/components/EmailPreferencesForm.jsx index 5e42ff4d4a5b2..2ac88f8406fa0 100644 --- a/app/javascript/onboarding/components/EmailPreferencesForm.jsx +++ b/app/javascript/onboarding/components/EmailPreferencesForm.jsx @@ -8,29 +8,66 @@ export class EmailPreferencesForm extends Component { constructor(props) { super(props); - this.handleChange = this.handleChange.bind(this); this.onSubmit = this.onSubmit.bind(this); - this.state = { - email_newsletter: false, - email_digest_periodic: false, + content: '<p>Loading...</p>', + askingToReconsiderEmail: false, }; } componentDidMount() { + fetch('/onboarding/newsletter') + .then((response) => response.json()) + .then((json) => { + this.setState({ content: json['content'] }); + }); + updateOnboarding('v2: email preferences form'); } onSubmit() { const csrfToken = getContentOfToken('csrf-token'); + const newsletterEl = document.getElementById('email_newsletter'); + const newsletterChecked = newsletterEl ? newsletterEl.checked : false + + if (newsletterChecked) { + fetch('/onboarding/notifications', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ notifications: { email_newsletter: newsletterChecked } }), + credentials: 'same-origin', + }).then((response) => { + if (response.ok) { + localStorage.setItem('shouldRedirectToOnboarding', false); + const { next } = this.props; + next(); + } + }); + } else if (!this.state.askingToReconsiderEmail) { + this.setState({ + askingToReconsiderEmail: true, + }); + } + } - fetch('/onboarding_notifications_checkbox_update', { + finishWithoutEmail = () => { + localStorage.setItem('shouldRedirectToOnboarding', false); + const { next } = this.props; + next(); + } + + finishWithEmail = () => { + const csrfToken = getContentOfToken('csrf-token'); + fetch('/onboarding/notifications', { method: 'PATCH', headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json', }, - body: JSON.stringify({ notifications: this.state }), + body: JSON.stringify({ notifications: { email_newsletter: true, email_digest_periodic: true } }), credentials: 'same-origin', }).then((response) => { if (response.ok) { @@ -41,15 +78,34 @@ export class EmailPreferencesForm extends Component { }); } - handleChange(event) { - const { name } = event.target; - this.setState((currentState) => ({ - [name]: !currentState[name], - })); + renderEmailReconsideration() { + if(!this.state.askingToReconsiderEmail) { + return ''; + } + return ( + <div> + <div style='position:absolute;left:0;right:0;top:0;bottom:0;background:black;opacity:0.8;z-index:99;' /> + <div className='crayons-card onboarding-inner-popover'> + <p style='padding: 3vh 0 1.5vh;color:var(--base-60);'> + 👋 One last check + </p> + <h2 className="crayons-heading crayons-heading--bold"> + We Recommend Subscribing to Emails + </h2> + <p style='padding: 4vh 0 1.5vh;color:var(--base-60);max-width:660px;margin:auto;line-height:135%;'> + Newsletters are a part of keeping up with the pulse of the overall DEV ecosystem. + <span style='display:inline-block'>It's easy to unsubscribe later if it's not for you.</span> + </p> + <div className="align-center" style="padding: 5vh 0;"> + <button className="inline-block m-4 c-btn c-btn--ghost" style="opacity:0.8;" onClick={this.finishWithoutEmail}>No thank you</button> + <button className="inline-block m-4 c-btn c-btn--primary" onClick={this.finishWithEmail}>Count me in</button> + </div> + </div> + </div>); } + render() { - const { email_newsletter, email_digest_periodic } = this.state; const { prev, slidesCount, currentSlideIndex } = this.props; return ( <div @@ -62,55 +118,18 @@ export class EmailPreferencesForm extends Component { aria-labelledby="title" aria-describedby="subtitle" > + <div + className="onboarding-content email-preferences-wrapper" + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: this.state.content }} + /> + {this.renderEmailReconsideration()} <Navigation prev={prev} next={this.onSubmit} slidesCount={slidesCount} currentSlideIndex={currentSlideIndex} /> - <div className="onboarding-content terms-and-conditions-wrapper"> - <header className="onboarding-content-header"> - <h1 id="title" className="title"> - Almost there! - </h1> - <h2 id="subtitle" className="subtitle"> - Review your email preferences before we continue. - </h2> - </header> - - <form> - <fieldset> - <legend>Email preferences</legend> - <ul> - <li className="checkbox-item"> - <label htmlFor="email_newsletter"> - <input - type="checkbox" - id="email_newsletter" - name="email_newsletter" - checked={email_newsletter} - onChange={this.handleChange} - /> - I want to receive weekly newsletter emails. - </label> - </li> - <li className="checkbox-item"> - <label htmlFor="email_digest_periodic"> - <input - type="checkbox" - id="email_digest_periodic" - name="email_digest_periodic" - checked={email_digest_periodic} - onChange={this.handleChange} - /> - I want to receive a periodic digest of top posts from my - tags. - </label> - </li> - </ul> - </fieldset> - </form> - </div> </div> </div> ); diff --git a/app/javascript/onboarding/components/FollowTags.jsx b/app/javascript/onboarding/components/FollowTags.jsx index de2c82af50909..e4e1ebdd350cd 100644 --- a/app/javascript/onboarding/components/FollowTags.jsx +++ b/app/javascript/onboarding/components/FollowTags.jsx @@ -8,20 +8,31 @@ export class FollowTags extends Component { constructor(props) { super(props); + this.handleChange = this.handleChange.bind(this); this.handleClick = this.handleClick.bind(this); this.handleComplete = this.handleComplete.bind(this); + const emailState = + document.body.dataset.default_email_optin_allowed === 'true'; this.state = { allTags: [], selectedTags: [], + article: null, + email_digest_periodic: emailState, }; } componentDidMount() { - fetch('/tags/onboarding') + fetch('/onboarding/tags') .then((response) => response.json()) .then((data) => { - this.setState({ allTags: data }); + const newState = { allTags: data }; + if (localStorage && localStorage.getItem('onboarding_article')) { + const article = JSON.parse(localStorage.getItem('onboarding_article')); + newState.article = article; + newState.selectedTags = data.filter((tag) => article.tags.includes(tag.name)); + } + this.setState(newState); }); const csrfToken = getContentOfToken('csrf-token'); @@ -38,6 +49,31 @@ export class FollowTags extends Component { }); } + handleContainerClick = () => { + const checkbox = document.getElementById('email_digest_periodic'); + checkbox.checked = !checkbox.checked; + + const event = new Event('change', { bubbles: true }); + checkbox.dispatchEvent(event); + + this.setState({ email_digest_periodic: checkbox.checked }); + }; + + handleContainerKeyDown = (event) => { + if (event.key === 'Enter' || event.key === ' ') { + this.handleContainerClick(); + } + }; + + handleCheckboxClick = (event) => { + event.stopPropagation(); + }; + + handleChange = (event) => { + const { name, checked } = event.target; + this.setState({ [name]: checked }); + }; + handleClick(tag) { let { selectedTags } = this.state; if (!selectedTags.includes(tag)) { @@ -56,7 +92,7 @@ export class FollowTags extends Component { handleComplete() { const csrfToken = getContentOfToken('csrf-token'); - const { selectedTags } = this.state; + const { selectedTags, email_digest_periodic } = this.state; Promise.all( selectedTags.map((tag) => @@ -74,10 +110,31 @@ export class FollowTags extends Component { credentials: 'same-origin', }), ), - ).then((_) => { - const { next } = this.props; - next(); - }); + ) + .then(() => { + if (email_digest_periodic) { + return fetch('/onboarding/notifications', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + notifications: { + email_digest_periodic: this.state.email_digest_periodic, + }, + }), + credentials: 'same-origin', + }); + } + return Promise.resolve(); + }) + .then((response) => { + if (!email_digest_periodic || response.ok) { + const { next } = this.props; + next(); + } + }); } renderFollowCount() { @@ -89,18 +146,29 @@ export class FollowTags extends Component { followingStatus = `${selectedTags.length} tags selected`; } - const classStyle = - selectedTags.length > 0 - ? 'fw-bold color-base-60 fs-base' - : 'color-base-60 fs-base'; - return <p className={classStyle}>{followingStatus}</p>; + return <p className="color-base-60 fs-base">{followingStatus}</p>; + } + + renderArticleContent() { + const { article } = this.state; + if (!article) { + return ''; + } + if (article) { + return ( + <div class='py-2 fs-xs color-base-70' style='line-height: 125% !important'> + <em> + Tags improve your feed. Please leave reactions to further improve your feed. To get started, a "like" reaction has been added to the post <strong>{article.title}</strong>. Feel free to undo it later. + </em> + </div> + ); + } } render() { const { prev, currentSlideIndex, slidesCount } = this.props; - const { selectedTags, allTags } = this.state; + const { selectedTags, allTags, email_digest_periodic } = this.state; const canSkip = selectedTags.length === 0; - return ( <div data-testid="onboarding-follow-tags" @@ -112,14 +180,7 @@ export class FollowTags extends Component { aria-labelledby="title" aria-describedby="subtitle" > - <Navigation - prev={prev} - next={this.handleComplete} - canSkip={canSkip} - slidesCount={slidesCount} - currentSlideIndex={currentSlideIndex} - /> - <div className="onboarding-content toggle-bottom"> + <div className="onboarding-content onboarding-content__tags toggle-bottom "> <header className="onboarding-content-header"> <h1 id="title" className="title"> What are you interested in? @@ -134,55 +195,93 @@ export class FollowTags extends Component { const selected = selectedTags.includes(tag); return ( <div + data-testid={`onboarding-tag-item-${tag.id}`} className={`onboarding-tags__item ${ - selected && 'onboarding-tags__item--selected' + selected ? 'onboarding-tags__item--selected' : '' }`} - style={{ - boxShadow: selected - ? `inset 0 0 0 100px ${tag.bg_color_hex}` - : `inset 0 0 0 0px ${tag.bg_color_hex}`, - color: selected ? tag.text_color_hex : '', - }} + aria-label={`Follow ${tag.name}`} key={tag.id} + onClick={() => this.handleClick(tag)} + onKeyDown={(event) => { + // Trigger for enter (13) and space (32) keys + if (event.keyCode === 13 || event.keyCode === 32) { + this.handleClick(tag); + } + }} + tabIndex={0} + role="button" > <div className="onboarding-tags__item__inner"> - #{tag.name} - <button - type="button" - onClick={() => this.handleClick(tag)} - className={`onboarding-tags__button ${ - selected && - 'onboarding-tags__button--selected crayons-btn--icon-left' - }`} - aria-pressed={selected} - aria-label={`Follow ${tag.name}`} - style={{ - backgroundColor: selected - ? tag.text_color_hex - : tag.bg_color_hex, - color: selected - ? tag.bg_color_hex - : tag.text_color_hex, - }} - > - {selected && ( - <svg - width="24" - height="24" - xmlns="http://www.w3.org/2000/svg" - className="crayons-icon" - > - <path d="M9.99999 15.172L19.192 5.979L20.607 7.393L9.99999 18L3.63599 11.636L5.04999 10.222L9.99999 15.172Z" /> - </svg> - )} - {selected ? 'Following' : 'Follow'} - </button> + <div className="onboarding-tags__item__inner__content"> + <div className="onboarding-tags__item__inner__content-name"> + #{tag.name} + </div> + <div className="onboarding-tags__item__inner__content-count"> + {tag.taggings_count === 1 + ? '1 post' + : `${tag.taggings_count} posts`} + </div> + </div> + <input + class="crayons-checkbox" + type="checkbox" + checked={selected} + tabindex="-1" + /> </div> </div> ); })} </div> + {this.renderArticleContent()} </div> + <span class="onboarding-content-separator" /> + <div + class="onboarding-email-digest" + onClick={this.handleContainerClick} + onKeyDown={this.handleContainerKeyDown} + role="button" + tabIndex="0" + > + <span class="onboarding-email-digest__rectangle" /> + <div class="flex items-start my-4 ml-1 mr-4"> + <form> + <fieldset> + <ul> + <li className="checkbox-item"> + <label htmlFor="email_digest_periodic"> + <input + type="checkbox" + id="email_digest_periodic" + name="email_digest_periodic" + checked={email_digest_periodic} + onChange={this.handleChange} + onClick={this.handleCheckboxClick} + tabIndex="-1" + /> + </label> + </li> + </ul> + </fieldset> + </form> + <div class="flex flex-col items-start"> + <p class="crayons-subtitle-3 fw-medium"> + Get a Periodic Digest of Top Posts + </p> + <p class="fs-s fw-normal lh-base color-secondary"> + We'll email you with a curated selection of top posts based on + the tags you follow. + </p> + </div> + </div> + </div> + <Navigation + prev={prev} + next={this.handleComplete} + canSkip={canSkip} + slidesCount={slidesCount} + currentSlideIndex={currentSlideIndex} + /> </div> </div> ); diff --git a/app/javascript/onboarding/components/FollowUsers.jsx b/app/javascript/onboarding/components/FollowUsers.jsx index 93774e2a4409e..55cfbb227a4bf 100644 --- a/app/javascript/onboarding/components/FollowUsers.jsx +++ b/app/javascript/onboarding/components/FollowUsers.jsx @@ -2,7 +2,25 @@ import { h, Component } from 'preact'; import PropTypes from 'prop-types'; import he from 'he'; import { getContentOfToken } from '../utilities'; +import { locale } from '../../utilities/locale'; import { Navigation } from './Navigation'; +import { Spinner } from '@crayons/Spinner/Spinner'; + +function groupFollowsByType(array) { + return array.reduce((returning, item) => { + const type = item.type_identifier; + returning[type] = (returning[type] || []).concat(item); + return returning; + }, {}); +} + +function groupFollowIdsByType(array) { + return array.reduce((returning, item) => { + const type = item.type_identifier; + returning[type] = (returning[type] || []).concat({ id: item.id }); + return returning; + }, {}); +} export class FollowUsers extends Component { constructor(props) { @@ -12,13 +30,14 @@ export class FollowUsers extends Component { this.handleComplete = this.handleComplete.bind(this); this.state = { - users: [], - selectedUsers: [], + follows: [], + selectedFollows: [], + loading: true, }; } componentDidMount() { - fetch('/users?state=follow_suggestions', { + fetch('/onboarding/users_and_organizations', { headers: { Accept: 'application/json', 'Content-Type': 'application/json', @@ -27,7 +46,11 @@ export class FollowUsers extends Component { }) .then((response) => response.json()) .then((data) => { - this.setState({ users: data }); + this.setState({ + selectedFollows: data, + follows: data, + loading: false, + }); }); const csrfToken = getContentOfToken('csrf-token'); @@ -46,8 +69,9 @@ export class FollowUsers extends Component { handleComplete() { const csrfToken = getContentOfToken('csrf-token'); - const { selectedUsers } = this.state; + const { selectedFollows } = this.state; const { next } = this.props; + const idsGroupedByType = groupFollowIdsByType(selectedFollows); fetch('/api/follows', { method: 'POST', @@ -55,7 +79,10 @@ export class FollowUsers extends Component { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json', }, - body: JSON.stringify({ users: selectedUsers }), + body: JSON.stringify({ + users: idsGroupedByType['user'], + organizations: idsGroupedByType['organization'], + }), credentials: 'same-origin', }); @@ -63,64 +90,81 @@ export class FollowUsers extends Component { } handleSelectAll() { - const { selectedUsers, users } = this.state; - if (selectedUsers.length === users.length) { + const { selectedFollows, follows } = this.state; + if (selectedFollows.length === follows.length) { this.setState({ - selectedUsers: [], + selectedFollows: [], }); } else { this.setState({ - selectedUsers: users, + selectedFollows: follows, }); } } - handleClick(user) { - let { selectedUsers } = this.state; + handleClick(follow) { + let { selectedFollows } = this.state; - if (!selectedUsers.includes(user)) { + if (!selectedFollows.includes(follow)) { this.setState((prevState) => ({ - selectedUsers: [...prevState.selectedUsers, user], + selectedFollows: [...prevState.selectedFollows, follow], })); } else { - selectedUsers = [...selectedUsers]; - const indexToRemove = selectedUsers.indexOf(user); - selectedUsers.splice(indexToRemove, 1); + selectedFollows = [...selectedFollows]; + const indexToRemove = selectedFollows.indexOf(follow); + selectedFollows.splice(indexToRemove, 1); this.setState({ - selectedUsers, + selectedFollows, }); } } renderFollowCount() { - const { users, selectedUsers } = this.state; + const { follows, selectedFollows } = this.state; + let followingStatus; - if (selectedUsers.length === 0) { - followingStatus = "You're not following anyone"; - } else if (selectedUsers.length === 1) { - followingStatus = "You're following 1 person"; - } else if (selectedUsers.length === users.length) { - followingStatus = `You're following ${selectedUsers.length} people (everyone) -`; + if (selectedFollows.length === 0) { + followingStatus = locale('core.not_following'); } else { - followingStatus = `You're following ${selectedUsers.length} people -`; + const groups = groupFollowsByType(selectedFollows); + let together = []; + for (const type in groups) { + const counted = locale(`core.counted_${type}`, { + count: groups[type].length, + }); + together = together.concat(counted); + } + + const anded_together = together.join(` ${locale('core.and')} `); + + if (selectedFollows.length === follows.length) { + followingStatus = `${locale('core.following_everyone', { + details: anded_together, + })}`; + } else { + followingStatus = `${locale( + 'core.you_are_following', + )} ${anded_together}`; + } } + const klassName = - selectedUsers.length > 0 + selectedFollows.length > 0 ? 'fw-bold color-base-60 inline-block fs-base' : 'color-base-60 inline-block fs-base'; - return <p className={klassName}>{followingStatus}</p>; + return <p className={klassName}>{followingStatus} -</p>; } renderFollowToggle() { - const { users, selectedUsers } = this.state; + const { follows, selectedFollows } = this.state; let followText = ''; - if (selectedUsers.length !== users.length) { - if (users.length === 1) { - followText = `Select ${users.length} person`; + if (selectedFollows.length !== follows.length) { + if (follows.length === 1) { + followText = `Select ${follows.length}`; } else { - followText = `Select all ${users.length} people`; + followText = `Select all ${follows.length}`; } } else { followText = 'Deselect all'; @@ -138,9 +182,9 @@ export class FollowUsers extends Component { } render() { - const { users, selectedUsers } = this.state; + const { follows, selectedFollows, loading } = this.state; const { prev, slidesCount, currentSlideIndex } = this.props; - const canSkip = selectedUsers.length === 0; + const canSkip = selectedFollows.length === 0; return ( <div @@ -153,34 +197,34 @@ export class FollowUsers extends Component { aria-labelledby="title" aria-describedby="subtitle" > - <Navigation - prev={prev} - next={this.handleComplete} - canSkip={canSkip} - slidesCount={slidesCount} - currentSlideIndex={currentSlideIndex} - /> <div className="onboarding-content toggle-bottom"> <header className="onboarding-content-header"> <h1 id="title" className="title"> - Suggested people to follow + Suggested follows </h1> <h2 id="subtitle" className="subtitle"> - Let's review a few things first + Kickstart your community </h2> <div className="onboarding-selection-status"> {this.renderFollowCount()} {this.renderFollowToggle()} </div> + <div + className={`loading-spinner align-center ${ + loading ? '' : 'hidden' + }`} + > + <Spinner /> + </div> </header> <fieldset data-testid="onboarding-users"> - {users.map((user) => { - const selected = selectedUsers.includes(user); + {follows.map((follow) => { + const selected = selectedFollows.includes(follow); return ( <div - key={user.id} + key={`${follow.id}-${follow.type_identifier}`} data-testid="onboarding-user-button" className={`user content-row ${ selected ? 'selected' : 'unselected' @@ -189,30 +233,28 @@ export class FollowUsers extends Component { <figure className="user-avatar-container"> <img className="user-avatar" - src={user.profile_image_url} + src={follow.profile_image_url} alt="" loading="lazy" /> </figure> <div className="user-info"> - <h4 className="user-name">{user.name}</h4> + <h4 className="user-name">{follow.name}</h4> <p className="user-summary"> - {he.unescape(user.summary || '')} + {he.unescape(follow.summary || '')} </p> </div> <label className={`relative user-following-status crayons-btn ${ - selected - ? 'color-base-inverted' - : 'crayons-btn--outlined' + selected ? 'crayons-btn--outlined' : 'color-primary' }`} > <input - aria-label={`Follow ${user.name}`} + aria-label={`Follow ${follow.name}`} type="checkbox" checked={selected} className="absolute opacity-0 absolute top-0 bottom-0 right-0 left-0" - onClick={() => this.handleClick(user)} + onClick={() => this.handleClick(follow)} data-testid="onboarding-user-following-status" /> {selected ? 'Following' : 'Follow'} @@ -222,6 +264,13 @@ export class FollowUsers extends Component { })} </fieldset> </div> + <Navigation + prev={prev} + next={this.handleComplete} + canSkip={canSkip} + slidesCount={slidesCount} + currentSlideIndex={currentSlideIndex} + /> </div> </div> ); diff --git a/app/javascript/onboarding/components/IntroSlide.jsx b/app/javascript/onboarding/components/IntroSlide.jsx deleted file mode 100644 index 258d9b8f702e4..0000000000000 --- a/app/javascript/onboarding/components/IntroSlide.jsx +++ /dev/null @@ -1,211 +0,0 @@ -import { h, Component } from 'preact'; -import PropTypes from 'prop-types'; - -import { getContentOfToken, userData, updateOnboarding } from '../utilities'; -import { Navigation } from './Navigation'; - -/* eslint-disable camelcase */ -export class IntroSlide extends Component { - constructor(props) { - super(props); - - this.handleChange = this.handleChange.bind(this); - this.onSubmit = this.onSubmit.bind(this); - this.user = userData(); - - this.state = { - checked_code_of_conduct: false, - checked_terms_and_conditions: false, - text: null, - }; - } - - componentDidMount() { - updateOnboarding('v2: intro, code of conduct, terms & conditions'); - } - - onSubmit() { - const { next } = this.props; - const csrfToken = getContentOfToken('csrf-token'); - - fetch('/onboarding_checkbox_update', { - method: 'PATCH', - headers: { - 'X-CSRF-Token': csrfToken, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ user: this.state }), - credentials: 'same-origin', - }).then((response) => { - if (response.ok) { - localStorage.setItem('shouldRedirectToOnboarding', false); - next(); - } - }); - } - - handleChange(event) { - const { name } = event.target; - this.setState((currentState) => ({ - [name]: !currentState[name], - })); - } - - handleShowText(event, id) { - event.preventDefault(); - this.setState({ text: document.getElementById(id).innerHTML }); - } - - isButtonDisabled() { - const { checked_code_of_conduct, checked_terms_and_conditions } = - this.state; - - return !checked_code_of_conduct || !checked_terms_and_conditions; - } - - render() { - const { slidesCount, currentSlideIndex, prev, communityConfig } = - this.props; - const { checked_code_of_conduct, checked_terms_and_conditions, text } = - this.state; - - if (text) { - return ( - <div className="onboarding-main crayons-modal crayons-modal--large"> - <div className="crayons-modal__box overflow-auto"> - <div className="onboarding-content terms-and-conditions-wrapper"> - <button - type="button" - onClick={() => this.setState({ text: null })} - > - Back - </button> - <div - className="terms-and-conditions-content" - /* eslint-disable react/no-danger */ - dangerouslySetInnerHTML={{ __html: text }} - /* eslint-enable react/no-danger */ - /> - </div> - </div> - </div> - ); - } - - return ( - <div - data-testid="onboarding-intro-slide" - className="onboarding-main introduction crayons-modal" - > - <div - className="crayons-modal__box overflow-auto" - role="dialog" - aria-labelledby="title" - aria-describedby="subtitle" - > - <div className="onboarding-content"> - <figure> - <img - src={communityConfig.communityLogo} - className="sticker-logo" - alt={communityConfig.communityName} - /> - </figure> - <h1 - id="title" - data-testid="onboarding-introduction-title" - className="introduction-title" - > - {this.user.name} - — welcome to {communityConfig.communityName}! - </h1> - <h2 id="subtitle" className="introduction-subtitle"> - {communityConfig.communityDescription} - </h2> - </div> - - <div className="checkbox-form-wrapper"> - <form className="checkbox-form"> - <fieldset> - <ul> - <li className="checkbox-item"> - <label - data-testid="checked-code-of-conduct" - htmlFor="checked_code_of_conduct" - className="lh-base py-1" - > - <input - type="checkbox" - id="checked_code_of_conduct" - name="checked_code_of_conduct" - checked={checked_code_of_conduct} - onChange={this.handleChange} - /> - You agree to uphold our  - <a - href="/code-of-conduct" - data-no-instant - onClick={(e) => this.handleShowText(e, 'coc')} - > - Code of Conduct - </a> - . - </label> - </li> - - <li className="checkbox-item"> - <label - data-testid="checked-terms-and-conditions" - htmlFor="checked_terms_and_conditions" - className="lh-base py-1" - > - <input - type="checkbox" - id="checked_terms_and_conditions" - name="checked_terms_and_conditions" - checked={checked_terms_and_conditions} - onChange={this.handleChange} - /> - You agree to our  - <a - href="/terms" - data-no-instant - onClick={(e) => this.handleShowText(e, 'terms')} - > - Terms and Conditions - </a> - . - </label> - </li> - </ul> - </fieldset> - </form> - <Navigation - disabled={this.isButtonDisabled()} - className="intro-slide" - prev={prev} - slidesCount={slidesCount} - currentSlideIndex={currentSlideIndex} - next={this.onSubmit} - hidePrev - /> - </div> - </div> - </div> - ); - } -} - -IntroSlide.propTypes = { - prev: PropTypes.func.isRequired, - next: PropTypes.func.isRequired, - slidesCount: PropTypes.number.isRequired, - currentSlideIndex: PropTypes.number.isRequired, - communityConfig: PropTypes.shape({ - communityLogo: PropTypes.string.isRequired, - communityName: PropTypes.string.isRequired, - communityDescription: PropTypes.string.isRequired, - }).isRequired, -}; - -/* eslint-enable camelcase */ diff --git a/app/javascript/onboarding/components/Navigation.jsx b/app/javascript/onboarding/components/Navigation.jsx index c6ab28e32a971..3b255e206e1d1 100644 --- a/app/javascript/onboarding/components/Navigation.jsx +++ b/app/javascript/onboarding/components/Navigation.jsx @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; export class Navigation extends Component { /** * A function to render the progress stepper within the `Navigation` component. - * By default, it does not show the stepper for the first slide (the `IntroSlide` component). * It builds a list of `<span>` elements corresponding to the slides, and adds an "active" * class to any slide that has already been seen or is currently being seen. * @@ -12,14 +11,9 @@ export class Navigation extends Component { */ createStepper() { const { currentSlideIndex, slidesCount } = this.props; - if (currentSlideIndex === 0) { - return ''; - } - const stepsList = []; - // We do not show the stepper on the IntroSlide so we start with `i = 1`. - for (let i = 1; i < slidesCount; i += 1) { + for (let i = 0; i < slidesCount; i += 1) { const active = i <= currentSlideIndex; stepsList.push(<span class={`dot ${active ? 'active' : ''}`} />); @@ -65,27 +59,25 @@ export class Navigation extends Component { className && className.length > 0 ? ` ${className}` : '' }`} > - {!hidePrev && ( - <div class="back-button-container"> - <button - onClick={prev} - data-testid="back-button" - class="back-button" - type="button" - aria-label="Back to previous onboarding step" + <div class={`back-button-container ${hidePrev ? `hide-button` : ''}`}> + <button + onClick={prev} + data-testid="back-button" + class="back-button" + type="button" + aria-label="Back to previous onboarding step" + > + <svg + width="24" + height="24" + fill="none" + class="crayons-icon" + xmlns="http://www.w3.org/2000/svg" > - <svg - width="24" - height="24" - fill="none" - class="crayons-icon" - xmlns="http://www.w3.org/2000/svg" - > - <path d="M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414L7.828 11z" /> - </svg> - </button> - </div> - )} + <path d="M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414L7.828 11z" /> + </svg> + </button> + </div> {this.createStepper()} diff --git a/app/javascript/onboarding/components/ProfileForm.jsx b/app/javascript/onboarding/components/ProfileForm.jsx index cbb2865cf397f..2bf7c4d142254 100644 --- a/app/javascript/onboarding/components/ProfileForm.jsx +++ b/app/javascript/onboarding/components/ProfileForm.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { userData, updateOnboarding } from '../utilities'; +import { ProfileImage } from './ProfileForm/ProfileImage'; import { Navigation } from './Navigation'; import { TextArea } from './ProfileForm/TextArea'; import { TextInput } from './ProfileForm/TextInput'; @@ -21,9 +22,13 @@ export class ProfileForm extends Component { this.user = userData(); this.state = { groups: [], - formValues: { username: this.user.username }, + formValues: { + username: this.user.username, + profile_image_90: this.user.profile_image_90, + }, canSkip: false, last_onboarding_page: 'v2: personal info form', + profile_image_90: this.user.profile_image_90, }; } @@ -48,12 +53,12 @@ export class ProfileForm extends Component { async onSubmit() { const { formValues, last_onboarding_page } = this.state; - const { username, ...newFormValues } = formValues; + const { username, profile_image_90, ...newFormValues } = formValues; try { const response = await request('/onboarding', { method: 'PATCH', body: { - user: { last_onboarding_page, username }, + user: { last_onboarding_page, profile_image_90, username }, profile: { ...newFormValues }, }, }); @@ -63,7 +68,7 @@ export class ProfileForm extends Component { const { next } = this.props; next(); } catch (error) { - Honeybadger.notify(error.statusText); + Honeybadger.notify(error); let errorMessage = 'Unable to continue, please try again.'; if (error.status === 422) { // parse validation error messages from UsersController#onboarding @@ -142,10 +147,18 @@ export class ProfileForm extends Component { } } + onProfileImageUrlChange = (url) => { + this.setState({ profile_image_90: url }, () => { + this.handleFieldChange({ + target: { name: 'profile_image_90', value: url }, + }); + }); + }; + render() { const { prev, slidesCount, currentSlideIndex, communityConfig } = this.props; - const { profile_image_90, username, name } = this.user; + const { username, name } = this.user; const { canSkip, groups = [], error, errorMessage } = this.state; const SUMMARY_MAXLENGTH = 200; const summaryCharacters = this.state?.formValues?.summary?.length || 0; @@ -177,13 +190,6 @@ export class ProfileForm extends Component { aria-labelledby="title" aria-describedby="subtitle" > - <Navigation - prev={prev} - next={this.onSubmit} - canSkip={canSkip} - slidesCount={slidesCount} - currentSlideIndex={currentSlideIndex} - /> {error && ( <div role="alert" class="crayons-notice crayons-notice--danger m-2"> An error occurred: {errorMessage} @@ -204,15 +210,13 @@ export class ProfileForm extends Component { able to edit this later in your Settings. </h2> </header> - <div className="current-user-info"> - <figure className="current-user-avatar-container"> - <img - className="current-user-avatar" - alt="profile" - src={profile_image_90} - /> - </figure> - <h3>{name}</h3> + <div className="onboarding-profile-sub-section mt-8"> + <ProfileImage + onMainImageUrlChange={this.onProfileImageUrlChange} + mainImage={this.state.profile_image_90} + userId={this.user.id} + name={name} + /> </div> <div className="onboarding-profile-sub-section"> <TextInput @@ -222,6 +226,9 @@ export class ProfileForm extends Component { default_value: username, required: true, maxLength: 20, + placeholder_text: 'johndoe', + description: '', + input_type: 'text', }} onFieldChange={this.handleFieldChange} /> @@ -234,6 +241,8 @@ export class ProfileForm extends Component { placeholder_text: 'Tell us a little about yourself', required: false, maxLength: SUMMARY_MAXLENGTH, + description: '', + input_type: 'text_area', }} onFieldChange={this.handleFieldChange} /> @@ -252,6 +261,14 @@ export class ProfileForm extends Component { {sections} </div> + <Navigation + prev={prev} + next={this.onSubmit} + canSkip={canSkip} + slidesCount={slidesCount} + currentSlideIndex={currentSlideIndex} + hidePrev + /> </div> </div> ); diff --git a/app/javascript/onboarding/components/ProfileForm/ProfileImage.jsx b/app/javascript/onboarding/components/ProfileForm/ProfileImage.jsx new file mode 100644 index 0000000000000..9ac1e3822457b --- /dev/null +++ b/app/javascript/onboarding/components/ProfileForm/ProfileImage.jsx @@ -0,0 +1,111 @@ +import { h, Fragment } from 'preact'; +import { useState } from 'preact/hooks'; +import PropTypes from 'prop-types'; +import { generateMainImage } from '../actions'; +import { validateFileInputs } from '../../../packs/validateFileInputs'; +import { Spinner } from '@crayons/Spinner/Spinner'; + +const StandardImageUpload = ({ handleImageUpload, isUploadingImage }) => + isUploadingImage ? null : ( + <Fragment> + <label className="cursor-pointer crayons-btn crayons-btn--secondary"> + Edit profile image + <input + data-testid="profile-image-input" + id="profile-image-input" + type="file" + onChange={handleImageUpload} + accept="image/*" + className="screen-reader-only" + data-max-file-size-mb="25" + /> + </label> + </Fragment> + ); + +export const ProfileImage = ({ + onMainImageUrlChange, + mainImage, + userId, + name, +}) => { + const [uploadError, setUploadError] = useState(false); + const [uploadErrorMessage, setUploadErrorMessage] = useState(null); + const [uploadingImage, setUploadingImage] = useState(false); + + const onImageUploadSuccess = (url) => { + onMainImageUrlChange(url); + setUploadingImage(false); + }; + + const handleMainImageUpload = (event) => { + event.preventDefault(); + + setUploadingImage(true); + clearUploadError(); + + if (validateFileInputs()) { + const { files: image } = event.dataTransfer || event.target; + const payload = { image, userId }; + + generateMainImage({ + payload, + successCb: onImageUploadSuccess, + failureCb: onUploadError, + }); + } else { + setUploadingImage(false); + } + }; + + const clearUploadError = () => { + setUploadError(false); + setUploadErrorMessage(null); + }; + + const onUploadError = (error) => { + setUploadingImage(false); + setUploadError(true); + setUploadErrorMessage(error.message); + }; + + return ( + <div className="onboarding-profile-details-container" role="presentation"> + {!uploadingImage && mainImage && ( + <img + className="onboarding-profile-image" + alt="profile" + src={mainImage} + /> + )} + <div className="onboarding-profile-details-sub-container"> + <h3 className="onboarding-profile-user-name">{name}</h3> + {uploadingImage && ( + <span class="lh-base pl-1 border-0 py-2 inline-block"> + <Spinner /> Uploading... + </span> + )} + + <Fragment> + <StandardImageUpload + isUploadingImage={uploadingImage} + handleImageUpload={handleMainImageUpload} + /> + </Fragment> + + {uploadError && ( + <p className="onboarding-profile-upload-error"> + {uploadErrorMessage} + </p> + )} + </div> + </div> + ); +}; + +ProfileImage.propTypes = { + mainImage: PropTypes.string, + onMainImageUrlChange: PropTypes.func.isRequired, +}; + +ProfileImage.displayName = 'ProfileImage'; diff --git a/app/javascript/onboarding/components/__tests__/EmailPreferencesForm.test.jsx b/app/javascript/onboarding/components/__tests__/EmailPreferencesForm.test.jsx index 91ffbb62f6943..4765d6ea4fd9b 100644 --- a/app/javascript/onboarding/components/__tests__/EmailPreferencesForm.test.jsx +++ b/app/javascript/onboarding/components/__tests__/EmailPreferencesForm.test.jsx @@ -1,5 +1,5 @@ import { h } from 'preact'; -import { render } from '@testing-library/preact'; +import { render, fireEvent, waitFor } from '@testing-library/preact'; import fetch from 'jest-fetch-mock'; import { axe } from 'jest-axe'; @@ -18,7 +18,7 @@ describe('EmailPreferencesForm', () => { communityConfig={{ communityName: 'Community Name', communityLogo: '/x.png', - communityBackground: '/y.jpg', + communityBackgroundColor: '#FFF000', communityDescription: 'Some community description', }} previousLocation={null} @@ -33,6 +33,26 @@ describe('EmailPreferencesForm', () => { username: 'username', }); + const fakeResponse = JSON.stringify({ + content: ` + <h1>Almost there!</h1> + <form> + <fieldset> + <ul> + <li class="checkbox-item"> + <label for="email_newsletter"><input type="checkbox" id="email_newsletter" name="email_newsletter">I want to receive weekly newsletter emails.</label> + </li> + </ul> + </fieldset> + </form> + `, + }); + + beforeEach(() => { + fetch.resetMocks(); + fetch.mockResponseOnce(fakeResponse); + }); + beforeAll(() => { document.head.innerHTML = '<meta name="csrf-token" content="some-csrf-token" />'; @@ -46,38 +66,78 @@ describe('EmailPreferencesForm', () => { expect(results).toHaveNoViolations(); }); - it('should load the appropriate text', () => { - const { queryByText } = renderEmailPreferencesForm(); - - expect(queryByText(/almost there!/i)).toBeDefined(); - expect( - queryByText(/review your email preferences before we continue./i), - ).toBeDefined(); - expect(queryByText('Email preferences')).toBeDefined(); + it('should load the appropriate text', async () => { + const { findByLabelText } = renderEmailPreferencesForm(); + await findByLabelText(/receive weekly newsletter/i); + expect(document.body.innerHTML).toMatchSnapshot(); }); - it('should show the two checkboxes unchecked', () => { - const { queryByLabelText } = renderEmailPreferencesForm(); - - expect(queryByLabelText(/receive weekly newsletter/i).checked).toBe(false); - expect(queryByLabelText(/receive a periodic digest/i).checked).toBe(false); + it('should show the checkbox unchecked', async () => { + const { findByLabelText } = renderEmailPreferencesForm(); + const checkbox = await findByLabelText(/receive weekly newsletter/i); + expect(checkbox.checked).toBe(false); }); it('should render a stepper', () => { const { queryByTestId } = renderEmailPreferencesForm(); - - expect(queryByTestId('stepper')).toBeDefined(); + expect(queryByTestId('stepper')).not.toBeNull(); }); it('should render a back button', () => { const { queryByTestId } = renderEmailPreferencesForm(); - - expect(queryByTestId('back-button')).toBeDefined(); + expect(queryByTestId('back-button')).not.toBeNull(); }); it('should render a button that says Finish', () => { const { queryByText } = renderEmailPreferencesForm(); + expect(queryByText('Finish')).not.toBeNull(); + }); + + it('should show the reconsideration prompt when the checkbox is not checked', async () => { + const { getByText } = renderEmailPreferencesForm(); + const finishButton = getByText('Finish'); + + fireEvent.click(finishButton); + + await waitFor(() => { + expect(getByText(/We Recommend Subscribing to Emails/i)).not.toBeNull(); + }); + }); + + it('should handle "No thank you" button click in the reconsideration prompt', async () => { + const { getByText, findByLabelText } = renderEmailPreferencesForm(); + const checkbox = await findByLabelText(/receive weekly newsletter/i); + const finishButton = getByText('Finish'); + + fireEvent.click(finishButton); + + await waitFor(() => { + expect(getByText(/We Recommend Subscribing to Emails/i)).not.toBeNull(); + }); + + const noThankYouButton = getByText('No thank you'); + fireEvent.click(noThankYouButton); + + // Verify that the `finishWithoutEmail` function is called + expect(checkbox.checked).toBe(false); + }); + + it('should handle "Count me in" button click in the reconsideration prompt', async () => { + const { getByText } = renderEmailPreferencesForm(); + const finishButton = getByText('Finish'); - expect(queryByText('Finish')).toBeDefined(); + fireEvent.click(finishButton); + + await waitFor(() => { + expect(getByText(/We Recommend Subscribing to Emails/i)).not.toBeNull(); + }); + + const countMeInButton = getByText('Count me in'); + fireEvent.click(countMeInButton); + + // Verify that the `finishWithEmail` function is called + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/onboarding/notifications', expect.any(Object)); + }); }); }); diff --git a/app/javascript/onboarding/components/__tests__/FollowTags.test.jsx b/app/javascript/onboarding/components/__tests__/FollowTags.test.jsx index 67b2ffbca90c8..c3b4816923bef 100644 --- a/app/javascript/onboarding/components/__tests__/FollowTags.test.jsx +++ b/app/javascript/onboarding/components/__tests__/FollowTags.test.jsx @@ -1,5 +1,5 @@ import { h } from 'preact'; -import { render } from '@testing-library/preact'; +import { render, waitFor, fireEvent } from '@testing-library/preact'; import fetch from 'jest-fetch-mock'; import '@testing-library/jest-dom'; @@ -18,7 +18,7 @@ describe('FollowTags', () => { communityConfig={{ communityName: 'Community Name', communityLogo: '/x.png', - communityBackground: '/y.jpg', + communityBackgroundColor: '#FFF000', communityDescription: 'Some community description', }} previousLocation={null} @@ -39,18 +39,21 @@ describe('FollowTags', () => { id: 715, name: 'discuss', text_color_hex: '#ffffff', + taggings_count: 12, }, { bg_color_hex: '#f7df1e', id: 6, name: 'javascript', text_color_hex: '#000000', + taggings_count: 0, }, { bg_color_hex: '#2a2566', id: 630, name: 'career', text_color_hex: '#ffffff', + taggings_count: 1, }, ]); @@ -60,53 +63,196 @@ describe('FollowTags', () => { document.body.setAttribute('data-user', getUserData()); }); - it('should render the correct tags', async () => { + it('should render the correct tags and counts', async () => { fetch.mockResponseOnce(fakeTagsResponse); const { findByText } = renderFollowTags(); const javascriptTag = await findByText(/javascript/i); + const javascriptCount = await findByText('0 posts'); const discussTag = await findByText(/discuss/i); + const discussCount = await findByText('12 posts'); const careerTag = await findByText(/career/i); + const careerCount = await findByText('1 post'); expect(javascriptTag).toBeInTheDocument(); + expect(javascriptCount).toBeInTheDocument(); expect(discussTag).toBeInTheDocument(); + expect(discussCount).toBeInTheDocument(); expect(careerTag).toBeInTheDocument(); + expect(careerCount).toBeInTheDocument(); }); it('should render the correct navigation button on first load', () => { fetch.mockResponseOnce(fakeTagsResponse); - const { queryByText } = renderFollowTags(); + const { getByText } = renderFollowTags(); - expect(queryByText(/skip for now/i)).toBeDefined(); + expect(getByText(/skip for now/i)).toExist(); }); - it('should update the navigation button text, follow status and count when you follow a tag', async () => { + it('should update the status and count when you follow a tag', async () => { fetch.mockResponse(fakeTagsResponse); - const { queryByText, findByText, findAllByText } = renderFollowTags(); - const followButtons = await findAllByText('Follow'); + const { getByText, findByTestId } = renderFollowTags(); - findByText(/skip for now/); + const javascriptTag = await findByTestId(`onboarding-tag-item-6`); + javascriptTag.click(); - // click on the first follow button - const button = followButtons[0]; - button.click(); - - // it should change to Following and update the count - await findByText(/Following/i); - - expect(queryByText(/1 tag selected/i)).toBeDefined(); - expect(queryByText(/continue/i)).toBeDefined(); + await waitFor(() => + expect(getByText('1 tag selected')).toBeInTheDocument(), + ); + await waitFor(() => expect(getByText(/continue/i)).toBeInTheDocument()); }); it('should render a stepper', () => { const { queryByTestId } = renderFollowTags(); - expect(queryByTestId('stepper')).toBeDefined(); + expect(queryByTestId('stepper')).toExist(); }); it('should render a back button', () => { const { queryByTestId } = renderFollowTags(); - expect(queryByTestId('back-button')).toBeDefined(); + expect(queryByTestId('back-button')).toExist(); + }); + + it('should call handleClick when enter key is pressed', async () => { + fetch.mockResponseOnce(fakeTagsResponse); + const { findByTestId, getByText } = renderFollowTags(); + const javascriptTag = await findByTestId(`onboarding-tag-item-6`); + + // Simulate 'Enter' key press + fireEvent.keyDown(javascriptTag, { + key: 'Enter', + code: 'Enter', + keyCode: 13, + charCode: 13, + }); + + await waitFor(() => + expect(getByText('1 tag selected')).toBeInTheDocument(), + ); + }); + + it('should call handleClick when space key is pressed', async () => { + fetch.mockResponseOnce(fakeTagsResponse); + const { findByTestId, getByText } = renderFollowTags(); + const javascriptTag = await findByTestId(`onboarding-tag-item-6`); + + // Simulate 'Space' key press + fireEvent.keyDown(javascriptTag, { + key: ' ', + code: 'Space', + keyCode: 32, + charCode: 32, + }); + + await waitFor(() => + expect(getByText('1 tag selected')).toBeInTheDocument(), + ); + }); + + it('should call handleClick and not select the tag when any other key is pressed', async () => { + fetch.mockResponseOnce(fakeTagsResponse); + const { findByTestId, getByText } = renderFollowTags(); + const javascriptTag = await findByTestId(`onboarding-tag-item-6`); + + // Simulate 'A' key press + fireEvent.keyDown(javascriptTag, { key: 'A', code: 'KeyA', charCode: 65 }); + + await waitFor(() => + expect(getByText('0 tags selected')).toBeInTheDocument(), + ); + }); + + it('should toggle the checkbox when container is clicked', async () => { + const { container } = renderFollowTags(); + const checkbox = container.querySelector('#email_digest_periodic'); + + expect(checkbox.checked).toBeFalsy(); + + fireEvent.click(container.querySelector('.onboarding-email-digest')); + + expect(checkbox.checked).toBeTruthy(); + + fireEvent.click(container.querySelector('.onboarding-email-digest')); + + expect(checkbox.checked).toBeFalsy(); + }); + + it('should toggle the checkbox when Enter or Space key is pressed', async () => { + const { container } = renderFollowTags(); + const checkbox = container.querySelector('#email_digest_periodic'); + + expect(checkbox.checked).toBeFalsy(); + + fireEvent.keyDown(container.querySelector('.onboarding-email-digest'), { + key: 'Enter', + code: 'Enter', + keyCode: 13, + charCode: 13, + }); + + expect(checkbox.checked).toBeTruthy(); + + fireEvent.keyDown(container.querySelector('.onboarding-email-digest'), { + key: ' ', + code: 'Space', + keyCode: 32, + charCode: 32, + }); + + expect(checkbox.checked).toBeFalsy(); + }); + + it('should prevent checkbox click event from propagating', async () => { + const { container } = renderFollowTags(); + const checkbox = container.querySelector('#email_digest_periodic'); + + let clicked = false; + + checkbox.addEventListener('click', () => { + clicked = true; + }); + + fireEvent.click(container.querySelector('.onboarding-email-digest')); + + expect(clicked).toBeFalsy(); + }); + + it('should call /onboarding/notifications API when email_digest_periodic is true', async () => { + const { getByText, container } = renderFollowTags(); + + fireEvent.click(container.querySelector('.onboarding-email-digest')); + + const skipButton = getByText(/Skip for now/i); + fireEvent.click(skipButton); + + await waitFor(() => { + const [lastFetchUri] = fetch.mock.calls[fetch.mock.calls.length - 1]; + expect(lastFetchUri).toEqual('/onboarding/notifications'); + }); + }); + + describe('emailDigestPeriodic state initialization', () => { + it('should initialize email_digest_periodic to true when data-default-email-optin-allowed is true', async () => { + // Simulate setting data-default-email-optin-allowed to true + document.body.dataset.default_email_optin_allowed = 'true'; + + const { container } = renderFollowTags(); + const checkbox = container.querySelector('#email_digest_periodic'); + + // Assert that the checkbox is checked, indicating email_digest_periodic state is true + expect(checkbox.checked).toBeTruthy(); + }); + + it('should initialize email_digest_periodic to false when data-default-email-optin-allowed is false', async () => { + // Simulate setting data-default-email-optin-allowed to false + document.body.dataset.default_email_optin_allowed = 'false'; + + const { container } = renderFollowTags(); + const checkbox = container.querySelector('#email_digest_periodic'); + + // Assert that the checkbox is not checked, indicating email_digest_periodic state is false + expect(checkbox.checked).toBeFalsy(); + }); }); }); diff --git a/app/javascript/onboarding/components/__tests__/FollowUsers.test.jsx b/app/javascript/onboarding/components/__tests__/FollowUsers.test.jsx index 21336564788fc..03db10bbad043 100644 --- a/app/javascript/onboarding/components/__tests__/FollowUsers.test.jsx +++ b/app/javascript/onboarding/components/__tests__/FollowUsers.test.jsx @@ -1,9 +1,10 @@ import { h } from 'preact'; -import { render } from '@testing-library/preact'; +import { render, fireEvent } from '@testing-library/preact'; import fetch from 'jest-fetch-mock'; import '@testing-library/jest-dom'; import { axe } from 'jest-axe'; +import { i18nSupport } from '../../../__support__/i18n'; import { FollowUsers } from '../FollowUsers'; global.fetch = fetch; @@ -19,7 +20,7 @@ describe('FollowUsers', () => { communityConfig={{ communityName: 'Community Name', communityLogo: '/x.png', - communityBackground: '/y.jpg', + communityBackgroundColor: '#FFF000', communityDescription: 'Some community description', }} previousLocation={null} @@ -39,20 +40,24 @@ describe('FollowUsers', () => { id: 1, name: 'Ben Halpern', profile_image_url: 'apple-icon.png', + type_identifier: 'user', }, { id: 2, name: 'Krusty the Clown', profile_image_url: 'clown.jpg', + type_identifier: 'user', }, { id: 3, name: 'dev.to staff', profile_image_url: 'dev.jpg', + type_identifier: 'user', }, ]); beforeAll(() => { + i18nSupport(); document.head.innerHTML = '<meta name="csrf-token" content="some-csrf-token" />'; document.body.setAttribute('data-user', getUserData()); @@ -80,83 +85,137 @@ describe('FollowUsers', () => { expect(user3).toBeInTheDocument(); }); - it('should render the correct navigation button on first load', () => { + it('should follow all suggested users by default', async () => { fetch.mockResponseOnce(fakeUsersResponse); - const { queryByText } = renderFollowUsers(); + const { queryByText, findAllByLabelText } = renderFollowUsers(); - expect(queryByText(/skip for now/i)).toBeDefined(); + const selectedUsers = await findAllByLabelText('Following'); + expect(selectedUsers).toHaveLength(3); + expect(queryByText(/Continue/i)).toExist(); }); - it('should update the navigation button text and follow status when you follow users', async () => { + it('should properly pluralize with small follower count', async () => { + fetch.mockResponseOnce( + JSON.stringify(JSON.parse(fakeUsersResponse).slice(-1)), + ); + + const { queryByText, findByText, findAllByLabelText, queryAllByLabelText } = + renderFollowUsers(); + + const selectedUsers = await findAllByLabelText('Following'); + expect(selectedUsers).toHaveLength(1); + expect(queryByText(/Continue/i)).toExist(); + + // deselect all then test following count + const deselectAllSelector = await findByText(/Deselect all/i); + + fireEvent.click(deselectAllSelector); + + expect(queryAllByLabelText('Follow')).toHaveLength(1); + expect(queryByText('Following')).not.toExist(); + expect(queryByText(/You're not following anyone/i)).toExist(); + + // select all then test following count + const followAllSelector = await findByText(/Select 1/i); + + fireEvent.click(followAllSelector); + expect(queryByText(/You're following 1 person \(everyone\)/i)).toExist(); + }); + + it('should update the navigation button text and follow status when you follow/unfollow users', async () => { fetch.mockResponse(fakeUsersResponse); - const { queryByText, findByText, findAllByTestId } = renderFollowUsers(); + const { queryByText, queryAllByLabelText, findAllByTestId } = + renderFollowUsers(); const userButtons = await findAllByTestId( 'onboarding-user-following-status', ); - expect(queryByText(/skip for now/i)).toBeDefined(); - expect(queryByText(/You're not following anyone/i)).toBeDefined(); + expect(queryAllByLabelText('Following')).toHaveLength(3); + expect(queryByText(/You're following 3 people \(everyone\)/i)).toExist(); + expect(queryByText(/Continue/i)).toExist(); + + // Unfollow the first user + fireEvent.click(userButtons[0]); - // follow the first user - const firstUser = userButtons[0]; - firstUser.click(); + expect(queryAllByLabelText('Following')).toHaveLength(2); + expect(queryByText(/You're following 2 people/i)).toExist(); + expect(queryByText(/continue/i)).toExist(); - await findByText('Following'); + // Unfollow the second user + fireEvent.click(userButtons[1]); - expect(queryByText(/You're following 1 person/i)).toBeDefined(); - expect(queryByText(/continue/i)).toBeDefined(); + expect(queryAllByLabelText('Following')).toHaveLength(1); + expect(queryByText(/You're following 1 person/i)).toExist(); + expect(queryByText(/continue/i)).toExist(); - // follow the second user - const secondUser = userButtons[1]; - secondUser.click(); + // Unfollow the third user + fireEvent.click(userButtons[2]); - await findByText('Following'); + expect(queryByText('Following')).not.toExist(); + expect(queryByText(/You're not following anyone/i)).toExist(); + expect(queryByText(/skip for now/i)).toExist(); - expect(queryByText(/You're following 2 people/i)).toBeDefined(); - expect(queryByText(/continue/i)).toBeDefined(); + // Follow the third user again + fireEvent.click(userButtons[2]); + + expect(queryAllByLabelText('Following')).toHaveLength(1); + expect(queryByText(/You're following 1 person/i)).toExist(); + expect(queryByText(/continue/i)).toExist(); }); it('should have a functioning de/select all toggle', async () => { fetch.mockResponse(fakeUsersResponse); - const { - getByText, - queryByText, - findByText, - findAllByText, - } = renderFollowUsers(); - - // select all then test following count - const followAllSelector = await findByText(/Select all 3 people/i); + const { queryByText, findByText, queryAllByLabelText } = + renderFollowUsers(); - followAllSelector.click(); + // deselect all then test following count + const deselectAllSelector = await findByText(/Deselect all/i); - await findAllByText('Following'); + fireEvent.click(deselectAllSelector); - expect(queryByText('Follow')).toBeNull(); - queryByText(/You're following 3 people (everyone)/i); + expect(queryAllByLabelText('Follow')).toHaveLength(3); + expect(queryByText('Following')).not.toExist(); + expect(queryByText(/You're not following anyone/i)).toExist(); - // deselect all then test following count - const deselecAllSelector = await findByText(/Deselect all/i); + // select all then test following count + const followAllSelector = await findByText(/Select all 3/i); - deselecAllSelector.click(); - await findAllByText('Follow'); + fireEvent.click(followAllSelector); - expect(queryByText('Following')).toBeNull(); - getByText(/You're not following anyone/i); + expect(queryByText('Follow')).not.toExist(); + expect(queryAllByLabelText('Following')).toHaveLength(3); + expect(queryByText(/You're following 3 people \(everyone\)/i)).toExist(); }); it('should render a stepper', () => { const { queryByTestId } = renderFollowUsers(); - expect(queryByTestId('stepper')).toBeDefined(); + expect(queryByTestId('stepper')).toExist(); + }); + + it('should be able to continue to the next step', async () => { + fetch.mockResponseOnce(fakeUsersResponse); + + const { queryByText, findAllByLabelText } = renderFollowUsers(); + + const selectedUsers = await findAllByLabelText('Following'); + expect(selectedUsers).toHaveLength(3); + + const clickToContinue = queryByText(/Continue/i); + fireEvent.click(clickToContinue); + + const idsToFollow = '{"users":[{"id":1},{"id":2},{"id":3}]}'; + const [uri, request] = fetch.mock.calls.slice(-1)[0]; + expect(uri).toEqual('/api/follows'); + expect(request['body']).toEqual(idsToFollow); }); it('should render a back button', () => { const { queryByTestId } = renderFollowUsers(); - expect(queryByTestId('back-button')).toBeDefined(); + expect(queryByTestId('back-button')).toExist(); }); }); diff --git a/app/javascript/onboarding/components/__tests__/IntroSlide.test.jsx b/app/javascript/onboarding/components/__tests__/IntroSlide.test.jsx deleted file mode 100644 index 029b7a8373d94..0000000000000 --- a/app/javascript/onboarding/components/__tests__/IntroSlide.test.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import { h } from 'preact'; -import { render, waitFor } from '@testing-library/preact'; -import fetch from 'jest-fetch-mock'; -import '@testing-library/jest-dom'; -import { axe } from 'jest-axe'; - -import { IntroSlide } from '../IntroSlide'; - -global.fetch = fetch; - -describe('IntroSlide', () => { - const renderIntroSlide = () => - render( - <IntroSlide - next={jest.fn()} - prev={jest.fn()} - currentSlideIndex={0} - slidesCount={5} - communityConfig={{ - communityName: 'Community Name', - communityLogo: '/x.png', - communityBackground: '/y.jpg', - communityDescription: 'Some community description', - }} - previousLocation={null} - />, - ); - - const getUserData = () => - JSON.stringify({ - followed_tag_names: ['javascript'], - profile_image_90: 'mock_url_link', - name: 'firstname lastname', - username: 'username', - }); - - beforeAll(() => { - document.head.innerHTML = - '<meta name="csrf-token" content="some-csrf-token" />'; - document.body.setAttribute('data-user', getUserData()); - }); - - it('should have no a11y violations', async () => { - const { container } = render(renderIntroSlide()); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); - - it('should load the appropriate text and images', () => { - const { getByTestId, getByText, getByAltText } = renderIntroSlide(); - - expect(getByTestId('onboarding-introduction-title')).toHaveTextContent( - /firstname lastname— welcome to Community Name!/i, - ); - getByText('Some community description'); - expect(getByAltText('Community Name').getAttribute('src')).toEqual( - '/x.png', - ); - }); - - it('should link to the code of conduct', () => { - const { getByText } = renderIntroSlide(); - expect(getByText(/code of conduct/i)).toHaveAttribute('href'); - expect(getByText(/code of conduct/i).getAttribute('href')).toContain( - '/code-of-conduct', - ); - }); - - it('should link to the terms and conditions', () => { - const { getByText } = renderIntroSlide(); - expect(getByText(/terms and conditions/i)).toHaveAttribute('href'); - expect(getByText(/terms and conditions/i).getAttribute('href')).toContain( - '/terms', - ); - }); - - it('should not render a stepper', () => { - const { queryByTestId } = renderIntroSlide(); - expect(queryByTestId('stepper')).toBeNull(); - }); - - it('should not render a back button', () => { - const { queryByTestId } = renderIntroSlide(); - expect(queryByTestId('back-button')).toBeNull(); - }); - - it('should enable the button if required boxes are checked', async () => { - const { getByTestId, getByText, findByText } = renderIntroSlide(); - fetch.mockResponseOnce({}); - expect(getByText(/continue/i)).toBeDisabled(); - - const codeOfConductCheckbox = getByTestId('checked-code-of-conduct'); - codeOfConductCheckbox.click(); - - const termsCheckbox = getByTestId('checked-terms-and-conditions'); - termsCheckbox.click(); - - const nextButton = await findByText(/continue/i); - await waitFor(() => expect(nextButton).not.toBeDisabled()); - }); -}); diff --git a/app/javascript/onboarding/components/__tests__/ProfileForm.test.jsx b/app/javascript/onboarding/components/__tests__/ProfileForm.test.jsx index 9b334e8204306..bf0dd9d555876 100644 --- a/app/javascript/onboarding/components/__tests__/ProfileForm.test.jsx +++ b/app/javascript/onboarding/components/__tests__/ProfileForm.test.jsx @@ -7,6 +7,7 @@ import { axe } from 'jest-axe'; import { ProfileForm } from '../ProfileForm'; global.fetch = fetch; +global.Honeybadger = { notify: jest.fn() }; describe('ProfileForm', () => { const renderProfileForm = () => @@ -19,7 +20,7 @@ describe('ProfileForm', () => { communityConfig={{ communityName: 'Community Name', communityLogo: '/x.png', - communityBackground: '/y.jpg', + communityBackgroundColor: '#FFF000', communityDescription: 'Some community description', }} previousLocation={null} @@ -102,11 +103,71 @@ describe('ProfileForm', () => { ); }); + it('should render TextInput with placeholder text', () => { + const { getByPlaceholderText } = render( + <ProfileForm + prev={jest.fn()} + next={jest.fn()} + slidesCount={3} + currentSlideIndex={1} + communityConfig={{ communityName: 'Community' }} + />, + ); + + const usernameInput = getByPlaceholderText('johndoe'); + expect(usernameInput).toBeInTheDocument(); + }); + + it('should render TextArea with placeholder text', () => { + const { getByPlaceholderText } = render( + <ProfileForm + prev={jest.fn()} + next={jest.fn()} + slidesCount={3} + currentSlideIndex={1} + communityConfig={{ communityName: 'Community' }} + />, + ); + + const bioTextArea = getByPlaceholderText('Tell us a little about yourself'); + expect(bioTextArea).toBeInTheDocument(); + }); + + it('should render TextInput with input type "text"', () => { + const { getByPlaceholderText } = render( + <ProfileForm + prev={jest.fn()} + next={jest.fn()} + slidesCount={3} + currentSlideIndex={1} + communityConfig={{ communityName: 'Community' }} + />, + ); + + const usernameInput = getByPlaceholderText('johndoe'); + expect(usernameInput.type).toBe('text'); + }); + + it('should render TextArea with description text', () => { + const { getByText } = render( + <ProfileForm + prev={jest.fn()} + next={jest.fn()} + slidesCount={3} + currentSlideIndex={1} + communityConfig={{ communityName: 'Community' }} + />, + ); + + const bioDescription = getByText('Bio'); + expect(bioDescription).toBeInTheDocument(); + }); + it('should show the correct name and username', () => { const { queryByText } = renderProfileForm(); expect(queryByText('username')).toBeDefined(); - expect(queryByText('firstname lastname')).toBeDefined(); + expect(queryByText('firstname lastname')).toExist(); }); it('should show the correct profile picture', () => { @@ -146,13 +207,13 @@ describe('ProfileForm', () => { it('should render a stepper', () => { const { queryByTestId } = renderProfileForm(); - expect(queryByTestId('stepper')).toBeDefined(); + expect(queryByTestId('stepper')).toExist(); }); it('should show the back button', () => { const { queryByTestId } = renderProfileForm(); - expect(queryByTestId('back-button')).toBeDefined(); + expect(queryByTestId('back-button')).toExist(); }); it('should not be skippable', async () => { @@ -160,4 +221,26 @@ describe('ProfileForm', () => { expect(getByText(/continue/i)).toBeInTheDocument(); }); + + it('should render an error message if the request failed', async () => { + const { getByRole, findByText } = render( + <ProfileForm + prev={jest.fn()} + next={jest.fn()} + slidesCount={3} + currentSlideIndex={1} + communityConfig={{ communityName: 'Community' }} + />, + ); + fetch.mockResponse(async () => { + const body = JSON.stringify({ errors: 'Fake Error' }); + return new Response(body, { status: 422 }); + }); + + const submitButton = getByRole('button', { name: 'Continue' }); + submitButton.click(); + + const errorMessage = await findByText('An error occurred: Fake Error'); + expect(errorMessage).toBeInTheDocument(); + }); }); diff --git a/app/javascript/onboarding/components/__tests__/ProfileImage.test.jsx b/app/javascript/onboarding/components/__tests__/ProfileImage.test.jsx new file mode 100644 index 0000000000000..9850f5b8b7e1a --- /dev/null +++ b/app/javascript/onboarding/components/__tests__/ProfileImage.test.jsx @@ -0,0 +1,133 @@ +import { h } from 'preact'; +import { render, fireEvent, waitFor } from '@testing-library/preact'; +import { axe } from 'jest-axe'; +import fetch from 'jest-fetch-mock'; +import '@testing-library/jest-dom'; +import { ProfileImage } from '../ProfileForm/ProfileImage'; + +global.fetch = fetch; + +describe('<ProfileImage />', () => { + it('should render correctly', () => { + const onMainImageUrlChangeMock = jest.fn(); + const { getByTestId } = render( + <ProfileImage + onMainImageUrlChange={onMainImageUrlChangeMock} + mainImage="test.jpg" + userId="1" + name="Test User" + />, + ); + + expect(getByTestId('profile-image-input')).toBeInTheDocument(); + }); + + it('should have no a11y violations', async () => { + const { container } = render( + <ProfileImage + mainImage="/i/r5tvutqpl7th0qhzcw7f.png" + onMainImageUrlChange={jest.fn()} + userId="user1" + name="User 1" + />, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('displays an upload input when the image is not being uploaded', () => { + const { getByLabelText } = render( + <ProfileImage + mainImage="" + onMainImageUrlChange={jest.fn()} + userId="user1" + name="User 1" + />, + ); + const uploadInput = getByLabelText(/edit profile image/i); + expect(uploadInput.getAttribute('type')).toEqual('file'); + }); + + it('shows the uploaded image', () => { + const { getByRole, queryByText } = render( + <ProfileImage + mainImage="/some-fake-image.jpg" + onMainImageUrlChange={jest.fn()} + userId="user1" + name="User 1" + />, + ); + const uploadInput = getByRole('img', { name: 'profile' }); + expect(uploadInput.getAttribute('src')).toEqual('/some-fake-image.jpg'); + expect(queryByText('Uploading...')).not.toBeInTheDocument(); + }); + + it('shows the "Uploading..." message when an image is being uploaded', () => { + const { getByLabelText, getByText } = render( + <ProfileImage + mainImage="" + onMainImageUrlChange={jest.fn()} + userId="user1" + name="User 1" + />, + ); + + const file = new File(['file content'], 'filename.png', { + type: 'image/png', + }); + + const uploadInput = getByLabelText(/edit profile image/i); + fireEvent.change(uploadInput, { target: { files: [file] } }); + + expect(getByText('Uploading...')).toBeInTheDocument(); + }); + + it('displays an upload error when necessary', async () => { + const { getByLabelText, findByText, queryByText } = render( + <ProfileImage + onMainImageUrlChange={jest.fn()} + mainImage="test.png" + userId="1" + name="Test User" + />, + ); + const inputEl = getByLabelText('Edit profile image', { exact: false }); + + expect(inputEl.getAttribute('accept')).toEqual('image/*'); + + const file = new File(['(⌐□_□)'], 'chucknorris.png', { + type: 'image/png', + }); + fetch.mockReject({ + message: 'Some Fake Error', + }); + + fireEvent.change(inputEl, { target: { files: [file] } }); + const fakeError = await findByText(/some fake error/i); + expect(fakeError).toBeInTheDocument(); + expect(queryByText('Uploading...')).not.toBeInTheDocument(); + }); + + it('should handle image upload correctly', async () => { + const onMainImageUrlChangeMock = jest.fn(); + const { getByTestId } = render( + <ProfileImage + onMainImageUrlChange={onMainImageUrlChangeMock} + mainImage="" + userId="user1" + name="User 1" + />, + ); + + const file = new File(['file content'], 'filename.png', { + type: 'image/png', + }); + + const uploadInput = getByTestId('profile-image-input'); + fireEvent.change(uploadInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(uploadInput.files).toHaveLength(1); + }); + }); +}); diff --git a/app/javascript/onboarding/components/__tests__/__snapshots__/EmailPreferencesForm.test.jsx.snap b/app/javascript/onboarding/components/__tests__/__snapshots__/EmailPreferencesForm.test.jsx.snap new file mode 100644 index 0000000000000..96389e4c8f541 --- /dev/null +++ b/app/javascript/onboarding/components/__tests__/__snapshots__/EmailPreferencesForm.test.jsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmailPreferencesForm should load the appropriate text 1`] = ` +"<div><div data-testid=\\"onboarding-email-preferences-form\\" class=\\"onboarding-main crayons-modal crayons-modal--large\\"><div class=\\"crayons-modal__box\\" role=\\"dialog\\" aria-labelledby=\\"title\\" aria-describedby=\\"subtitle\\"><div class=\\"onboarding-content email-preferences-wrapper\\"> + <h1>Almost there!</h1> + <form> + <fieldset> + <ul> + <li class=\\"checkbox-item\\"> + <label for=\\"email_newsletter\\"><input type=\\"checkbox\\" id=\\"email_newsletter\\" name=\\"email_newsletter\\">I want to receive weekly newsletter emails.</label> + </li> + </ul> + </fieldset> + </form> + </div><nav class=\\"onboarding-navigation\\"><div class=\\"navigation-content\\"><div class=\\"back-button-container \\"><button data-testid=\\"back-button\\" class=\\"back-button\\" type=\\"button\\" aria-label=\\"Back to previous onboarding step\\"><svg width=\\"24\\" height=\\"24\\" fill=\\"none\\" class=\\"crayons-icon\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414L7.828 11z\\"></path></svg></button></div><div data-testid=\\"stepper\\" class=\\"stepper\\"><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span></div><button class=\\"next-button\\" type=\\"button\\">Finish</button></div></nav></div></div></div>" +`; diff --git a/app/javascript/onboarding/components/actions.js b/app/javascript/onboarding/components/actions.js new file mode 100644 index 0000000000000..35a14e0592c36 --- /dev/null +++ b/app/javascript/onboarding/components/actions.js @@ -0,0 +1,33 @@ +function generateUploadFormdata(image) { + const token = window.csrfToken; + const formData = new FormData(); + formData.append('authenticity_token', token); + formData.append('user[profile_image]', image); + return formData; +} + +export function generateMainImage({ payload, successCb, failureCb, signal }) { + const image = payload.image[0]; + const { userId } = payload; + + if (image) { + fetch(`/users/${userId}`, { + method: 'PUT', + headers: { + 'X-CSRF-Token': window.csrfToken, + Accept: 'application/json', + }, + body: generateUploadFormdata(image), + credentials: 'same-origin', + signal, + }) + .then((response) => response.json()) + .then((json) => { + if (json.error) { + throw new Error(json.error); + } + return successCb(json.user.profile_image.url); + }) + .catch((message) => failureCb(message)); + } +} diff --git a/app/javascript/organization/__tests__/OrganizationPicker.test.jsx b/app/javascript/organization/__tests__/OrganizationPicker.test.jsx index bc29ec7ca3da1..24cfa95c9810e 100644 --- a/app/javascript/organization/__tests__/OrganizationPicker.test.jsx +++ b/app/javascript/organization/__tests__/OrganizationPicker.test.jsx @@ -66,6 +66,6 @@ describe('<OrganizationPicker />', () => { />, ); - expect(queryByText('None')).toBeDefined(); + expect(queryByText('None')).toExist(); }); }); diff --git a/app/javascript/packs/Onboarding.jsx b/app/javascript/packs/Onboarding.jsx index f47ff1c78f05c..330a97767ed06 100644 --- a/app/javascript/packs/Onboarding.jsx +++ b/app/javascript/packs/Onboarding.jsx @@ -14,7 +14,8 @@ function renderPage() { const communityConfig = { communityName: dataElement.dataset.communityName, communityLogo: dataElement.dataset.communityLogo, - communityBackground: dataElement.dataset.communityBackground, + communityBackgroundColor: dataElement.dataset.communityBackgroundColor, + communityBackgroundColor2: dataElement.dataset.communityBackgroundColor2, communityDescription: dataElement.dataset.communityDescription, }; import('../onboarding/Onboarding') diff --git a/app/javascript/packs/Search.jsx b/app/javascript/packs/Search.jsx index 299867f2e562b..f8fb103ad5e2a 100644 --- a/app/javascript/packs/Search.jsx +++ b/app/javascript/packs/Search.jsx @@ -1,8 +1,22 @@ import { h, render } from 'preact'; import { SearchFormSync } from '../Search/SearchFormSync'; -document.addEventListener('DOMContentLoaded', () => { +if (document.readyState === "interactive" || document.readyState === "complete") { + // DOMContentLoaded has already been triggered const root = document.getElementById('header-search'); render(<SearchFormSync />, root); -}); + window.InstantClick.on('change', () => { + render(<SearchFormSync />, root); + }); +} else { + // Add event listener for DOMContentLoaded + document.addEventListener("DOMContentLoaded", function () { + const root = document.getElementById('header-search'); + + render(<SearchFormSync />, root); + window.InstantClick.on('change', () => { + render(<SearchFormSync />, root); + }); + }); +} \ No newline at end of file diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js index 2a100ad4aba27..287a2d90796a5 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/packs/admin.js @@ -1,6 +1,5 @@ import { Application } from '@hotwired/stimulus'; -import { definitionsFromContext } from '@hotwired/stimulus-webpack-helpers'; -import { LocalTimeElement } from '@github/time-elements'; // eslint-disable-line no-unused-vars +import { definitions } from 'stimulus:../admin/controllers'; // eslint-disable-line import/no-unresolved import Rails from '@rails/ujs'; import 'focus-visible'; @@ -12,5 +11,4 @@ Rails.start(); // section of the application. const application = Application.start(); -const context = require.context('admin/controllers', true, /.js$/); -application.load(definitionsFromContext(context)); +application.load(definitions); diff --git a/app/javascript/packs/admin/billboardEnabledCountries.jsx b/app/javascript/packs/admin/billboardEnabledCountries.jsx new file mode 100644 index 0000000000000..cb8b0a69afef1 --- /dev/null +++ b/app/javascript/packs/admin/billboardEnabledCountries.jsx @@ -0,0 +1,91 @@ +import { h, render } from 'preact'; +import { Locations, SelectedLocation } from '../../billboard/locations'; + +const RegionMarker = ({ withRegions }) => { + return ( + <span className="fs-xs fw-bold"> + {withRegions ? 'Including' : 'Excluding'} regions + </span> + ); +}; + +function parseDOMState(hiddenField) { + const countriesByCode = JSON.parse(hiddenField.dataset.allCountries); + + const allCountries = {}; + for (const [code, name] of Object.entries(countriesByCode)) { + allCountries[name] = { name, code }; + } + const existingSetting = JSON.parse(hiddenField.value); + const selectedCountries = Object.keys(existingSetting).map((code) => ({ + name: countriesByCode[code], + code, + withRegions: existingSetting[code] === 'with_regions', + })); + + return { allCountries, selectedCountries }; +} + +function syncSelectionsToDOM(hiddenField, countries) { + const newValue = countries.reduce((value, { code, withRegions }) => { + value[code] = withRegions ? 'with_regions' : 'without_regions'; + return value; + }, {}); + hiddenField.value = JSON.stringify(newValue); +} + +/** + * Sets up and renders a Preact component to handle searching for and enabling + * countries for targeting (and, per country, to enable region-level targeting). + */ +function setupEnabledCountriesEditor() { + const editor = document.getElementById('billboard-enabled-countries-editor'); + const hiddenField = document.querySelector('.geolocation-multiselect'); + + if (!(editor && hiddenField)) return; + + const { allCountries, selectedCountries } = parseDOMState(hiddenField); + let currentSelections = selectedCountries; + + function setCountriesSelection(countries) { + currentSelections = countries; + syncSelectionsToDOM(hiddenField, currentSelections); + } + + function updateRegionSetting(country) { + const selected = currentSelections.find( + (selectedCountry) => selectedCountry.code === country.code, + ); + selected.withRegions = !selected.withRegions; + syncSelectionsToDOM(hiddenField, currentSelections); + renderLocations(); + } + + const EnabledCountry = SelectedLocation({ + displayName: 'EnabledCountry', + onNameClick: updateRegionSetting, + label: 'Toggle region targeting', + ExtraInfo: RegionMarker, + }); + + function renderLocations() { + render( + <Locations + defaultValue={currentSelections} + onChange={setCountriesSelection} + inputId="billboard-enabled-countries-editor" + allLocations={allCountries} + template={EnabledCountry} + />, + editor, + ); + } + + renderLocations(); +} + +if (document.readyState !== 'loading') { + setupEnabledCountriesEditor(); +} else { + document.addEventListener('DOMContentLoaded', setupEnabledCountriesEditor); +} diff --git a/app/javascript/packs/admin/billboards.jsx b/app/javascript/packs/admin/billboards.jsx new file mode 100644 index 0000000000000..f495c1bdfcd7a --- /dev/null +++ b/app/javascript/packs/admin/billboards.jsx @@ -0,0 +1,207 @@ +import { h, render } from 'preact'; +import { Tags } from '../../billboard/tags'; + +Document.prototype.ready = new Promise((resolve) => { + if (document.readyState !== 'loading') { + return resolve(); + } + document.addEventListener('DOMContentLoaded', () => resolve()); + return null; +}); + +/** + * A callback that sets the hidden 'js-tags-textfield' with the selection string that was chosen via the + * MultiSelectAutocomplete component. + * + * @param {String} selectionString The selected tags represented as a string (e.g. "webdev, git, career") + */ +function saveTags(selectionString) { + document.getElementsByClassName('js-tags-textfield')[0].value = + selectionString; +} + +/** + * Shows and Renders a Tags preact component for the Targeted Tag(s) field + */ +function showPrecisionFields() { + const billboardsTargetedTags = document.getElementById( + 'billboard-targeted-tags', + ); + + if (billboardsTargetedTags) { + billboardsTargetedTags.classList.remove('hidden'); + render( + <Tags onInput={saveTags} defaultValue={defaultTagValues()} />, + billboardsTargetedTags, + ); + } + + const billboardPrecisionElements = document.querySelectorAll( + '.billboard-requires-precision-targeting', + ); + + billboardPrecisionElements.forEach((element) => { + element?.classList.remove('hidden'); + }); +} + +/** + * Hides the Targeted Tag(s) field + */ +function hidePrecisionFields() { + const billboardsTargetedTags = document.getElementById( + 'billboard-targeted-tags', + ); + + billboardsTargetedTags?.classList.add('hidden'); + + const billboardPrecisionElements = document.querySelectorAll( + '.billboard-requires-precision-targeting', + ); + + billboardPrecisionElements.forEach((element) => { + element?.classList.add('hidden'); + }); +} + +/** + * Clears the content (i.e. value) of the hidden tags textfield + */ +function clearTagList() { + const hiddenTagsField = + document.getElementsByClassName('js-tags-textfield')[0]; + + hiddenTagsField.value = ' '; +} + +/** + * Returns the value of the hidden text field to eventually pass as + * default values to the MultiSelectAutocomplete component. + */ +function defaultTagValues() { + let defaultValue = ''; + const hiddenTagsField = + document.getElementsByClassName('js-tags-textfield')[0]; + + if (hiddenTagsField) { + defaultValue = hiddenTagsField.value.trim(); + } + + return defaultValue; +} + +function displayUserTargets() { + const userTargetFields = document.getElementsByClassName('js-user-target'); + Array.from(userTargetFields).forEach((field) => { + field.parentElement.classList.remove('hidden'); + }); +} + +function hideUserTargets() { + const userTargetFields = document.getElementsByClassName('js-user-target'); + Array.from(userTargetFields).forEach((field) => { + field.parentElement.classList.add('hidden'); + }); +} + +function clearUserTargetSelection() { + const userTargetSelects = document.getElementsByClassName('js-user-target'); + Array.from(userTargetSelects).forEach((select) => { + select.value = ''; + }); +} + +/** + * Shows and Renders Exclude Article IDs group + */ +function showExcludeIds() { + const excludeField = document.getElementsByClassName( + 'js-exclude-ids-textfield', + )[0].parentElement; + excludeField?.classList.remove('hidden'); +} + +/** + * Hides the Exclude Article IDs group + */ +function hideExcludeIds() { + const excludeField = document.getElementsByClassName( + 'js-exclude-ids-textfield', + )[0].parentElement; + excludeField?.classList.add('hidden'); +} + +/** + * Clears the content (i.e. value) of the Exclude Article IDs group + */ +function clearExcludeIds() { + const excludeField = document.getElementsByClassName( + 'js-exclude-ids-textfield', + )[0]; + if (excludeField) { + excludeField.value = ''; + } +} + +/** + * Shows and sets up the Targeted Tag(s) field if the placement area value is "post_comments". + * Listens for change events on the select placement area dropdown + * and shows and hides the Targeted Tag(s) appropriately. + */ +document.ready.then(() => { + const select = document.getElementsByClassName('js-placement-area')[0]; + const articleSpecificPlacement = [ + 'post_comments', + 'post_sidebar', + 'post_fixed_bottom', + ]; + const targetedTagPlacements = [ + 'post_fixed_bottom', + 'post_comments', + 'post_sidebar', + 'sidebar_right', + 'sidebar_right_second', + 'sidebar_right_third', + 'feed_first', + 'feed_second', + 'feed_third', + ]; + + if (targetedTagPlacements.includes(select.value)) { + showPrecisionFields(); + } + + select.addEventListener('change', (event) => { + if (targetedTagPlacements.includes(event.target.value)) { + showPrecisionFields(); + } else { + hidePrecisionFields(); + clearTagList(); + } + }); + + if (articleSpecificPlacement.includes(select.value)) { + showExcludeIds(); + } + + select.addEventListener('change', (event) => { + if (articleSpecificPlacement.includes(event.target.value)) { + showExcludeIds(); + } else { + hideExcludeIds(); + clearExcludeIds(); + } + }); + + const userRadios = document.querySelectorAll('input[name=display_to]'); + userRadios.forEach((radio) => { + radio.addEventListener('change', (event) => { + if (event.target.value == 'logged_in') { + displayUserTargets(); + } else { + hideUserTargets(); + clearUserTargetSelection(); + } + }); + }); +}); diff --git a/app/javascript/packs/admin/convertUserIdsToUsernameInputs.js b/app/javascript/packs/admin/convertUserIdsToUsernameInputs.js new file mode 100644 index 0000000000000..83c34d58f9624 --- /dev/null +++ b/app/javascript/packs/admin/convertUserIdsToUsernameInputs.js @@ -0,0 +1,128 @@ +import { h, render } from 'preact'; +import { UsernameInput } from '@components/UsernameInput'; +import { UserStore } from '@components/UserStore'; +import '@utilities/document_ready'; + +const CO_AUTHOR_FIELD_CLASS_NAME = '.js-coauthor_username_id_input'; +const USERNAME_FIELD_CLASS_NAME = '.js-username_id_input'; + +function generateFetchUrl(ids) { + const searchParams = new URLSearchParams(); + ids.forEach((id) => searchParams.append('ids[]', Number(id))); + return `/admin/member_manager/users.json?${searchParams}'`; +} + +function extractUserIds(value) { + return value + .split(',') + .map((id) => id.replace(' ', '')) + .filter((id) => id !== ''); +} + +function getAllUserIdsFromTextFields(fieldClassNames) { + const uniqueIds = new Set(); + + fieldClassNames.forEach((className) => { + document.querySelectorAll(className).forEach((field) => { + extractUserIds(field.value).forEach((id) => uniqueIds.add(id)); + }); + }); + return [...uniqueIds]; +} + +async function fetchUsers(ids) { + if (ids.length <= 0) { + return new UserStore(); + } + return await UserStore.fetch(generateFetchUrl(ids)); +} + +const fetchSuggestions = async (term, searchOptions = {}) => { + const searchParams = new URLSearchParams(); + searchParams.append('limit', 10); + searchParams.append('search', term); + const userStore = await UserStore.fetch( + `/admin/member_manager/users.json?${searchParams}`, + ); + return userStore.search(term, searchOptions); +}; + +async function convertUserIdFieldToUsernameField(targetField, users) { + targetField.type = 'hidden'; + + const inputId = `auto${targetField.id}`; + const newDiv = document.createElement('div'); + targetField.parentElement.append(newDiv); + + const value = users.matchingIds(extractUserIds(targetField.value)); + + const handleSelectionsChanged = function (ids) { + targetField.value = ids; + }; + + render( + <UsernameInput + labelText="Enter a username" + placeholder="Enter a username" + maxSelections={1} + inputId={inputId} + defaultValue={value} + fetchSuggestions={fetchSuggestions} + handleSelectionsChanged={handleSelectionsChanged} + />, + newDiv, + ); +} + +async function convertCoAuthorIdsToUsernameInputs(targetField, users) { + targetField.type = 'hidden'; + + const exceptAuthorId = targetField.form.querySelector( + 'input[name="article[user_id]"]', + )?.value; + + const searchOptions = exceptAuthorId ? { except: exceptAuthorId } : {}; + const inputId = `auto${targetField.id}`; + const newDiv = document.createElement('div'); + targetField.parentElement.append(newDiv); + + const value = users.matchingIds(extractUserIds(targetField.value)); + + const handleSelectionsChanged = function (ids) { + targetField.value = ids; + }; + + render( + <UsernameInput + labelText="Add up to 4" + placeholder="Add up to 4..." + maxSelections={4} + inputId={inputId} + defaultValue={value} + fetchSuggestions={(term) => fetchSuggestions(term, searchOptions)} + handleSelectionsChanged={handleSelectionsChanged} + />, + newDiv, + ); +} + +export async function convertCoauthorIdsToUsernameInputs(users) { + const usernameFields = document.querySelectorAll(USERNAME_FIELD_CLASS_NAME); + for (const targetField of usernameFields) { + convertUserIdFieldToUsernameField(targetField, users); + } + + const coAuthorFields = document.querySelectorAll(CO_AUTHOR_FIELD_CLASS_NAME); + for (const coAuthorField of coAuthorFields) { + convertCoAuthorIdsToUsernameInputs(coAuthorField, users); + } +} + +document.ready.then(async () => { + const ids = getAllUserIdsFromTextFields([ + CO_AUTHOR_FIELD_CLASS_NAME, + USERNAME_FIELD_CLASS_NAME, + ]); + const users = await fetchUsers(ids); + convertCoauthorIdsToUsernameInputs(users); +}); diff --git a/app/javascript/packs/admin/displayAds.jsx b/app/javascript/packs/admin/displayAds.jsx deleted file mode 100644 index 6f807d32ba6d7..0000000000000 --- a/app/javascript/packs/admin/displayAds.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import { h, render } from 'preact'; -import { Tags } from '../../display-ad/tags'; - -Document.prototype.ready = new Promise((resolve) => { - if (document.readyState !== 'loading') { - return resolve(); - } - document.addEventListener('DOMContentLoaded', () => resolve()); - return null; -}); - -/** - * A callback that sets the hidden 'js-tags-textfield' with the selection string that was chosen via the - * MultiSelectAutocomplete component. - * - * @param {String} selectionString The selected tags represented as a string (e.g. "webdev, git, career") - */ -function saveTags(selectionString) { - document.getElementsByClassName('js-tags-textfield')[0].value = - selectionString; -} - -/** - * Shows and Renders a Tags preact component for the Targeted Tag(s) field - */ -function showTagsField() { - const displayAdsTargetedTags = document.getElementById( - 'display-ad-targeted-tags', - ); - - if (displayAdsTargetedTags) { - displayAdsTargetedTags.classList.remove('hidden'); - render( - <Tags onInput={saveTags} defaultValue={defaultTagValues()} />, - displayAdsTargetedTags, - ); - } -} - -/** - * Hides the Targeted Tag(s) field - */ -function hideTagsField() { - const displayAdsTargetedTags = document.getElementById( - 'display-ad-targeted-tags', - ); - - displayAdsTargetedTags?.classList.add('hidden'); -} - -/** - * Clears the content (i.e. value) of the hidden tags textfield - */ -function clearTagList() { - const hiddenTagsField = - document.getElementsByClassName('js-tags-textfield')[0]; - - hiddenTagsField.value = ' '; -} - -/** - * Returns the value of the hidden text field to eventually pass as - * default values to the MultiSelectAutocomplete component. - */ -function defaultTagValues() { - let defaultValue = ''; - const hiddenTagsField = - document.getElementsByClassName('js-tags-textfield')[0]; - - if (hiddenTagsField) { - defaultValue = hiddenTagsField.value.trim(); - } - - return defaultValue; -} - -/** - * Shows and sets up the Targeted Tag(s) field if the placement area value is "post_comments". - * Listens for change events on the select placement area dropdown - * and shows and hides the Targeted Tag(s) appropriately. - */ -document.ready.then(() => { - const select = document.getElementsByClassName('js-placement-area')[0]; - const placementAreasWithTags = ['post_comments', 'post_sidebar'] - if (placementAreasWithTags.includes(select.value)) { - showTagsField(); - } - - select.addEventListener('change', (event) => { - if (placementAreasWithTags.includes(event.target.value)) { - showTagsField(); - } else { - hideTagsField(); - clearTagList(); - } - }); -}); diff --git a/app/javascript/packs/admin/organizations.jsx b/app/javascript/packs/admin/organizations.jsx new file mode 100644 index 0000000000000..1601e1832683c --- /dev/null +++ b/app/javascript/packs/admin/organizations.jsx @@ -0,0 +1,9 @@ +import { showOrganizationModal } from '././organizations/modals'; +import { initializeDropdown } from '@utilities/dropdownUtils'; + +initializeDropdown({ + triggerElementId: 'options-dropdown-trigger', + dropdownContentId: 'options-dropdown', +}); + +document.body.addEventListener('click', showOrganizationModal); diff --git a/app/javascript/packs/admin/organizations/modals.js b/app/javascript/packs/admin/organizations/modals.js new file mode 100644 index 0000000000000..70937fb6042c2 --- /dev/null +++ b/app/javascript/packs/admin/organizations/modals.js @@ -0,0 +1,45 @@ +import { showWindowModal } from '@utilities/showModal'; + +const modalContents = new Map(); +/** + * Helper function to handle finding and caching modal content. Since our Preact modal helper works by duplicating HTML content, + * and our user modals rely on IDs to label form controls, we remove the original hidden content from the DOM to avoid ID conflicts. + * + * @param {string} modalContentSelector The CSS selector used to identify the correct modal content + */ +const getModalContents = (modalContentSelector) => { + if (!modalContents.has(modalContentSelector)) { + const modalContentElement = document.querySelector(modalContentSelector); + const modalContent = modalContentElement.innerHTML; + + modalContentElement.remove(); + modalContents.set(modalContentSelector, modalContent); + } + + return modalContents.get(modalContentSelector); +}; + +/** + * Helper function for views which use admin user modals. May be attached as an event listener, and its actions will only be triggered + * if the target of the event is a recognised user modal trigger. + * + * @param {Object} event + */ +export const showOrganizationModal = (event) => { + const { dataset } = event.target; + + if (!Object.prototype.hasOwnProperty.call(dataset, 'modalContentSelector')) { + // We're not trying to trigger a modal. + return; + } + + event.preventDefault(); + + const { modalTitle, modalSize, modalContentSelector } = dataset; + + showWindowModal({ + modalContent: getModalContents(modalContentSelector), + title: modalTitle, + size: modalSize, + }); +}; diff --git a/app/javascript/packs/admin/shared/flagReactionItemDropdownButton.js b/app/javascript/packs/admin/shared/flagReactionItemDropdownButton.js new file mode 100644 index 0000000000000..101723e63466b --- /dev/null +++ b/app/javascript/packs/admin/shared/flagReactionItemDropdownButton.js @@ -0,0 +1,70 @@ +import { openDropdown, closeDropdown } from '@utilities/dropdownUtils'; + +// We present up to 50 users in the UI at once, and for performance reasons we don't want to add individual click listeners to each dropdown menu or inner menu item +// Instead we listen for click events anywhere in the table, and identify required actions based on data attributes of the target +document + .getElementById('reaction-content') + ?.addEventListener('click', ({ target }) => { + const { + dataset: { markValid, markInvalid, toggleDropdown }, + } = target; + + if (markValid) { + closeCurrentlyOpenDropdown(); + return; + } + + if (markInvalid) { + closeCurrentlyOpenDropdown(); + return; + } + + if (toggleDropdown) { + handleDropdownToggle({ + triggerElementId: target.getAttribute('id'), + dropdownContentId: toggleDropdown, + }); + return; + } + }); + +// We keep track of the currently opened dropdown to make sure we only ever have one open at a time +let currentlyOpenDropdownId; + +const handleDropdownToggle = ({ triggerElementId, dropdownContentId }) => { + const triggerButton = document.getElementById(triggerElementId); + + const isCurrentlyOpen = + triggerButton.getAttribute('aria-expanded') === 'true'; + + if (isCurrentlyOpen) { + closeDropdown({ triggerElementId, dropdownContentId }); + triggerButton.focus(); + currentlyOpenDropdownId = null; + } else { + closeCurrentlyOpenDropdown(); + openDropdown({ triggerElementId, dropdownContentId }); + currentlyOpenDropdownId = dropdownContentId; + } +}; + +/** + * Make sure any currently opened dropdown is closed + */ +const closeCurrentlyOpenDropdown = (focusTrigger = false) => { + if (!currentlyOpenDropdownId) { + return; + } + const triggerButton = document.querySelector( + `[aria-controls='${currentlyOpenDropdownId}']`, + ); + + closeDropdown({ + dropdownContentId: currentlyOpenDropdownId, + triggerElementId: triggerButton?.getAttribute('id'), + }); + + if (focusTrigger) { + triggerButton.focus(); + } +}; diff --git a/app/javascript/packs/application.jsx b/app/javascript/packs/application.jsx new file mode 100644 index 0000000000000..a5afa95ffc586 --- /dev/null +++ b/app/javascript/packs/application.jsx @@ -0,0 +1,223 @@ +import 'focus-visible'; +import { + initializeMobileMenu, + setCurrentPageIconLink, + initializeMemberMenu, +} from '../topNavigation/utilities'; +import { waitOnBaseData } from '../utilities/waitOnBaseData'; +import { initializePodcastPlayback } from '../utilities/podcastPlayback'; +import { createRootFragment } from '../shared/preact/preact-root-fragment'; +import { trackCreateAccountClicks } from '@utilities/ahoy/trackEvents'; +import { showWindowModal, closeWindowModal } from '@utilities/showModal'; +import * as Runtime from '@utilities/runtime'; + +Document.prototype.ready = new Promise((resolve) => { + if (document.readyState !== 'loading') { + return resolve(); + } + document.addEventListener('DOMContentLoaded', () => resolve()); + return null; +}); + +// Namespace for functions which need to be accessed in plain JS initializers +window.Forem = { + audioInitialized: false, + preactImport: undefined, + getPreactImport() { + if (!this.preactImport) { + this.preactImport = import('preact'); + } + return this.preactImport; + }, + enhancedCommentTextAreaImport: undefined, + getEnhancedCommentTextAreaImports() { + if (!this.enhancedCommentTextAreaImport) { + this.enhancedCommentTextAreaImport = import( + './CommentTextArea/CommentTextArea' + ); + } + return Promise.all([ + this.enhancedCommentTextAreaImport, + this.getPreactImport(), + ]); + }, + initializeEnhancedCommentTextArea: async (originalTextArea) => { + const parentContainer = originalTextArea.parentElement; + + const alreadyInitialized = + parentContainer.classList.contains('c-autocomplete'); + + if (alreadyInitialized) { + return; + } + + const [{ CommentTextArea }, { render, h }] = + await window.Forem.getEnhancedCommentTextAreaImports(); + + render( + <CommentTextArea vanillaTextArea={originalTextArea} />, + createRootFragment(parentContainer, originalTextArea), + ); + }, + showModal: showWindowModal, + closeModal: () => closeWindowModal(), + Runtime, +}; + +if (document.location.pathname.startsWith('/dashboard')) { + import('./initializers/initializeDashboardSort').then(({ initializeDashboardSort }) => { + initializeDashboardSort(); + }); +} + +if (document.getElementById('video-player-source')) { + import('../utilities/videoPlayback').then(({ initializeVideoPlayback }) => { + initializeVideoPlayback(); + }); +} + +initializePodcastPlayback(); +InstantClick.on('change', () => { + if (document.location.pathname.startsWith('/dashboard')) { + import('./initializers/initializeDashboardSort').then(({ initializeDashboardSort }) => { + initializeDashboardSort(); + }); + } + + if (document.getElementById('video-player-source')) { + import('../utilities/videoPlayback').then(({ initializeVideoPlayback }) => { + initializeVideoPlayback(); + }); + } + + initializePodcastPlayback(); +}); + +// Initialize data-runtime context to the body data-attribute +document.body.dataset.runtime = window.Forem.Runtime.currentContext(); + +function getPageEntries() { + return Object.entries({ + 'notifications-index': document.getElementById('notifications-link'), + 'moderations-index': document.getElementById('moderation-link'), + 'articles_search-index': document.getElementById('search-link'), + }); +} + +/** + * Initializes the left hand side hamburger menu + */ +function initializeNav() { + const { currentPage } = document.getElementById('page-content').dataset; + const menuTriggers = [ + ...document.querySelectorAll('.js-hamburger-trigger, .hamburger a'), + ]; + + setCurrentPageIconLink(currentPage, getPageEntries()); + initializeMobileMenu(menuTriggers); +} + +const memberMenu = document.getElementById('crayons-header__menu'); +const menuNavButton = document.getElementById('member-menu-button'); + +if (memberMenu) { + initializeMemberMenu(memberMenu, menuNavButton); +} + +/** + * Fetches the html for the navigation_links from an endpoint and dynamically inserts it in the DOM. + */ +async function getNavigation() { + const placeholderElement = document.getElementsByClassName( + 'js-navigation-links-container', + )[0]; + + if (placeholderElement.innerHTML.trim() === '') { + const response = await window.fetch(`/async_info/navigation_links`); + const htmlContent = await response.text(); + + const generatedElement = document.createElement('div'); + generatedElement.innerHTML = htmlContent; + + placeholderElement.appendChild(generatedElement); + } +} + +// Initialize when asset pipeline (sprockets) initializers have executed +waitOnBaseData() + .then(() => { + InstantClick.on('change', () => { + initializeNav(); + }); + + if (Runtime.currentMedium() === 'ForemWebView') { + // Dynamic import of the namespace + import('../mobile/foremMobile.js').then((module) => { + // Load the namespace + window.ForemMobile = module.foremMobileNamespace(); + // Run the first session + window.ForemMobile.userSessionBroadcast(); + }); + } + }) + .catch((error) => { + Honeybadger.notify(error); + }); + +// we need to call initializeNav here for the initial page load +initializeNav(); + +async function loadCreatorSettings() { + try { + const [{ LogoUploadController }, { Application }] = await Promise.all([ + import('@admin/controllers/logo_upload_controller'), + import('@hotwired/stimulus'), + ]); + + const application = Application.start(); + application.register('logo-upload', LogoUploadController); + } catch (error) { + Honeybadger.notify( + `Error loading the creator settings controller: ${error.message}`, + ); + } +} + +if (document.location.pathname === '/admin/creator_settings/new') { + loadCreatorSettings(); +} + +document.ready.then(() => { + setTimeout(() => { + history.scrollRestoration = 'manual'; + }, 0); + + const hamburgerTrigger = document.getElementsByClassName( + 'js-hamburger-trigger', + )[0]; + hamburgerTrigger.addEventListener('click', getNavigation); + + // Dynamically loading the script.js. + // We don't currently have dynamic insert working, so using this + // method instead. + const hoverElement = document.querySelector("#search-input"); + + const scriptElement = document.querySelector('meta[name="search-script"]'); + if (scriptElement) { + hoverElement.addEventListener("mouseenter", function() { + const scriptPath = scriptElement.getAttribute("content"); + + // Check if the script is already added to the head + if (scriptPath && !document.querySelector(`script[src="${scriptPath}"]`)) { + const script = document.createElement("script"); + script.src = scriptPath; + script.defer = true; // Optional, if you want it to load in deferred mode + document.head.appendChild(script); + } + }, { once: true }); // Ensures the hover event only triggers once + } +}); + +trackCreateAccountClicks('authentication-hamburger-actions'); +trackCreateAccountClicks('authentication-top-nav-actions'); +trackCreateAccountClicks('comments-locked-cta'); diff --git a/app/javascript/packs/archivedPostFilters.js b/app/javascript/packs/archivedPostFilters.js new file mode 100644 index 0000000000000..1117aea9a7636 --- /dev/null +++ b/app/javascript/packs/archivedPostFilters.js @@ -0,0 +1,41 @@ +function initializeArchivedPostFilter() { + const link = document.getElementById('toggleArchivedLink'); + if (link) { + link.addEventListener('click', toggleArchivedPosts); + } +} + +function archivedPosts() { + return document.getElementsByClassName('story-archived'); +} + +function showArchivedPosts() { + const posts = archivedPosts(); + + for (let i = 0; i < posts.length; i += 1) { + posts[i].classList.remove('hidden'); + } +} + +function hideArchivedPosts() { + const posts = archivedPosts(); + + for (let i = 0; i < posts.length; i += 1) { + posts[i].classList.add('hidden'); + } +} + +function toggleArchivedPosts(e) { + e.preventDefault(); + const link = e.target; + + if (link.innerHTML.match(/Show/)) { + link.innerHTML = 'Hide archived'; + showArchivedPosts(); + } else { + link.innerHTML = 'Show archived'; + hideArchivedPosts(); + } +} + +initializeArchivedPostFilter(); diff --git a/app/javascript/packs/articleAnimations.jsx b/app/javascript/packs/articleAnimations.jsx new file mode 100644 index 0000000000000..6e284b2b71549 --- /dev/null +++ b/app/javascript/packs/articleAnimations.jsx @@ -0,0 +1,8 @@ +const animatedImages = document.querySelectorAll('[data-animated="true"]'); +if (animatedImages.length > 0) { + import('@utilities/animatedImageUtils').then( + ({ initializePausableAnimatedImages }) => { + initializePausableAnimatedImages(animatedImages); + }, + ); +} diff --git a/app/javascript/packs/articleForm.jsx b/app/javascript/packs/articleForm.jsx index 1b9e5a675be0a..4116c781eee6f 100644 --- a/app/javascript/packs/articleForm.jsx +++ b/app/javascript/packs/articleForm.jsx @@ -1,6 +1,7 @@ import { h, render } from 'preact'; import { ArticleForm } from '../article-form/articleForm'; import { Snackbar } from '../Snackbar'; +import { createRootFragment } from '../shared/preact/preact-root-fragment'; import { getUserDataAndCsrfToken } from '@utilities/getUserDataAndCsrfToken'; HTMLDocument.prototype.ready = new Promise((resolve) => { @@ -24,7 +25,7 @@ function loadForm() { window.csrfToken = csrfToken; const root = document.querySelector('main'); - const { article, organizations, version, siteLogo, schedulingEnabled } = + const { article, organizations, version, siteLogo, schedulingEnabled, coverImageHeight, coverImageCrop } = root.dataset; render( <ArticleForm @@ -32,10 +33,11 @@ function loadForm() { organizations={organizations} version={version} siteLogo={siteLogo} + coverImageHeight={coverImageHeight} + coverImageCrop={coverImageCrop} schedulingEnabled={schedulingEnabled == 'true'} />, - root, - root.firstElementChild, + createRootFragment(root, root.firstElementChild), ); }); } diff --git a/app/javascript/packs/articlePage.jsx b/app/javascript/packs/articlePage.jsx index 27fa9bc0d61e5..6f20d90db1714 100644 --- a/app/javascript/packs/articlePage.jsx +++ b/app/javascript/packs/articlePage.jsx @@ -1,20 +1,33 @@ -import { h, render } from 'preact'; -import ahoy from 'ahoy.js'; -import { Snackbar, addSnackbarItem } from '../Snackbar'; import { addFullScreenModeControl } from '../utilities/codeFullscreenModeSwitcher'; import { initializeDropdown } from '../utilities/dropdownUtils'; +import { setupBillboardInteractivity } from '../utilities/billboardInteractivity'; import { embedGists } from '../utilities/gist'; -import { initializeUserSubscriptionLiquidTagContent } from '../liquidTags/userSubscriptionLiquidTag'; -import { trackCommentClicks } from '@utilities/ahoy/trackEvents'; import { isNativeAndroid, copyToClipboard } from '@utilities/runtime'; -const animatedImages = document.querySelectorAll('[data-animated="true"]'); -if (animatedImages.length > 0) { - import('@utilities/animatedImageUtils').then( - ({ initializePausableAnimatedImages }) => { - initializePausableAnimatedImages(animatedImages); - }, - ); +// Open in new tab backfill +// We added this behavior on rendering, so this is a backfill for the existing articles +function backfillLinkTarget() { + const links = document.querySelectorAll('a[href]'); + const appDomain = window.location.hostname; + + links.forEach((link) => { + const href = link.getAttribute('href'); + + if (href && (href.startsWith('http://') || href.startsWith('https://')) && !href.includes(appDomain)) { + link.setAttribute('target', '_blank'); + + const existingRel = link.getAttribute('rel'); + const newRelValues = ["noopener", "noreferrer"]; + + if (existingRel) { + const existingRelValues = existingRel.split(" "); + const mergedRelValues = [...new Set([...existingRelValues, ...newRelValues])].join(" "); + link.setAttribute('rel', mergedRelValues); + } else { + link.setAttribute('rel', newRelValues.join(" ")); + } + } + }); } const fullscreenActionElements = document.getElementsByClassName( @@ -25,15 +38,6 @@ if (fullscreenActionElements) { addFullScreenModeControl(fullscreenActionElements); } -// The Snackbar for the article page -const snackZone = document.getElementById('snack-zone'); -if (snackZone) { - render(<Snackbar lifespan={3} />, snackZone); -} - -// eslint-disable-next-line no-restricted-globals -top.addSnackbarItem = addSnackbarItem; - const multiReactionDrawerTrigger = document.getElementById( 'reaction-drawer-trigger', ); @@ -87,26 +91,13 @@ function showAnnouncer() { document.getElementById('article-copy-link-announcer').hidden = false; } -// Temporary Ahoy Stats for displaying comments section either on page load or after scrolling -function trackCommentsSectionDisplayed() { - const callback = (entries, observer) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - ahoy.track('Comment section viewable', { page: location.href }); - observer.disconnect(); - } - if (location.hash === '#comments') { - //handle focus event on text area - const element = document.getElementById('text-area'); - const event = new FocusEvent('focus'); - element.dispatchEvent(event); - } - }); - }; - - const target = document.getElementById('comments'); - const observer = new IntersectionObserver(callback, {}); - observer.observe(target); +function focusOnComments() { + if (location.hash === '#comments') { + //handle focus event on text area + const element = document.getElementById('text-area'); + const event = new FocusEvent('focus'); + element.dispatchEvent(event); + } } function copyArticleLink() { @@ -121,58 +112,10 @@ document .getElementById('copy-post-url-button') ?.addEventListener('click', copyArticleLink); -// Comment Subscription -getCsrfToken().then(async () => { - const { user = null, userStatus } = document.body.dataset; - const root = document.getElementById('comment-subscription'); - const isLoggedIn = userStatus === 'logged-in'; - - if (!root) { - return; - } - try { - const { - getCommentSubscriptionStatus, - setCommentSubscriptionStatus, - CommentSubscription, - } = await import('../CommentSubscription'); - - const { articleId } = document.getElementById('article-body').dataset; - - let subscriptionType = 'not_subscribed'; - - if (isLoggedIn && user !== null) { - ({ config: subscriptionType } = await getCommentSubscriptionStatus( - articleId, - )); - } - - const subscriptionRequestHandler = async (type) => { - const message = await setCommentSubscriptionStatus(articleId, type); - - addSnackbarItem({ message, addCloseButton: true }); - }; - - render( - <CommentSubscription - subscriptionType={subscriptionType} - positionType="static" - onSubscribe={subscriptionRequestHandler} - onUnsubscribe={subscriptionRequestHandler} - isLoggedIn={isLoggedIn} - />, - root, - ); - } catch (e) { - root.innerHTML = - '<p className="color-accent-danger">Unable to load Comment Subscription component.<br />Try refreshing the page.</p>'; - } -}); - const targetNode = document.querySelector('#comments'); targetNode && embedGists(targetNode); -initializeUserSubscriptionLiquidTagContent(); +setupBillboardInteractivity(); +focusOnComments(); // Temporary Ahoy Stats for comment section clicks on controls -trackCommentClicks('comments'); -trackCommentsSectionDisplayed(); +backfillLinkTarget(); diff --git a/app/javascript/packs/articleReactions.js b/app/javascript/packs/articleReactions.js new file mode 100644 index 0000000000000..60d89eb31583b --- /dev/null +++ b/app/javascript/packs/articleReactions.js @@ -0,0 +1,330 @@ +/* global sendHapticMessage, showLoginModal, isTouchDevice, watchForLongTouch */ +import { showModalAfterError } from '../utilities/showUserAlertModal'; + +// Set reaction count to correct number +const setReactionCount = (reactionName, newCount) => { + const reactionButtons = document.getElementById( + `reaction-butt-${reactionName}`, + ).classList; + const reactionButtonCounter = document.getElementById( + `reaction-number-${reactionName}`, + ); + const reactionEngagementCounter = document.getElementById( + `reaction_engagement_${reactionName}_count`, + ); + if (newCount > 0) { + reactionButtons.add('activated'); + reactionButtonCounter.textContent = newCount; + if (reactionEngagementCounter) { + reactionEngagementCounter.parentElement.classList.remove('hidden'); + reactionEngagementCounter.textContent = newCount; + } + } else { + reactionButtons.remove('activated'); + reactionButtonCounter.textContent = '0'; + if (reactionEngagementCounter) { + reactionEngagementCounter.parentElement.classList.add('hidden'); + } + } +}; + +const setSumReactionCount = (counts) => { + const totalCountObj = document.getElementById('reaction_total_count'); + if (totalCountObj && counts.length > 2) { + let sum = 0; + for (const count of counts) { + if (count['category'] != 'readinglist') { + sum += count['count']; + } + } + totalCountObj.textContent = sum; + } +}; + +const showCommentCount = () => { + const commentCountObj = document.getElementById('reaction-number-comment'); + if (commentCountObj && commentCountObj.dataset.count) { + commentCountObj.textContent = commentCountObj.dataset.count; + } +}; + +const showUserReaction = (reactionName, animatedClass) => { + const reactionButton = document.getElementById( + `reaction-butt-${reactionName}`, + ); + reactionButton.classList.add('user-activated', animatedClass); + reactionButton.setAttribute('aria-pressed', 'true'); + + const reactionDrawerButton = document.getElementById( + 'reaction-drawer-trigger', + ); + + // special-case for readinglist, it's not in the drawer + if (reactionName === 'readinglist') { + return; + } + + reactionDrawerButton.classList.add('user-activated', 'user-animated'); +}; + +const hideUserReaction = (reactionName) => { + const reactionButton = document.getElementById( + `reaction-butt-${reactionName}`, + ); + reactionButton.classList.remove('user-activated', 'user-animated'); + reactionButton.setAttribute('aria-pressed', 'false'); + const reactionDrawerButton = document.getElementById( + 'reaction-drawer-trigger', + ); + const userActivatedReactions = document + .querySelector('.reaction-drawer') + .querySelectorAll('.user-activated'); + if (userActivatedReactions.length == 0) { + reactionDrawerButton.classList.remove('user-activated', 'user-animated'); + } +}; + +const hasUserReacted = (reactionName) => { + return document + .getElementById(`reaction-butt-${reactionName}`) + .classList.contains('user-activated'); +}; + +const getNumReactions = (reactionName) => { + const reactionEl = document.getElementById(`reaction-number-${reactionName}`); + if (!reactionEl || reactionEl.textContent === '') { + return 0; + } + + return parseInt(reactionEl.textContent, 10); +}; + +const reactToArticle = (articleId, reaction) => { + const reactionTotalCount = document.getElementById('reaction_total_count'); + + const isReadingList = reaction === 'readinglist'; + + // Visually toggle the reaction + function toggleReaction() { + const currentNum = getNumReactions(reaction); + if (hasUserReacted(reaction)) { + hideUserReaction(reaction); + setReactionCount(reaction, currentNum - 1); + if (reactionTotalCount && !isReadingList) { + reactionTotalCount.innerText = Number(reactionTotalCount.innerText) - 1; + } + } else { + showUserReaction(reaction, 'user-animated'); + setReactionCount(reaction, currentNum + 1); + if (reactionTotalCount && !isReadingList) { + reactionTotalCount.innerText = Number(reactionTotalCount.innerText) + 1; + } + } + } + const userStatus = document.body.getAttribute('data-user-status'); + sendHapticMessage('medium'); + if (userStatus === 'logged-out') { + showLoginModal({ + referring_source: 'reactions_toolbar', + trigger: reaction, + }); + return; + } + toggleReaction(); + document.getElementById(`reaction-butt-${reaction}`).disabled = true; + + function createFormdata() { + /* + * What's not shown here is that "authenticity_token" is included in this formData. + * The logic can be seen in sendFetch.js. + */ + const formData = new FormData(); + formData.append('reactable_type', 'Article'); + formData.append('reactable_id', articleId); + formData.append('category', reaction); + return formData; + } + + getCsrfToken() + .then(sendFetch('reaction-creation', createFormdata())) + .then((response) => { + if (response.status === 200) { + return response.json().then(() => { + document.getElementById(`reaction-butt-${reaction}`).disabled = false; + }); + } + toggleReaction(); + document.getElementById(`reaction-butt-${reaction}`).disabled = false; + showModalAfterError({ + response, + element: 'reaction', + action_ing: 'updating', + action_past: 'updated', + }); + return undefined; + }) + .catch((_error) => { + toggleReaction(); + document.getElementById(`reaction-butt-${reaction}`).disabled = false; + }); +}; + +const setCollectionFunctionality = () => { + if (document.getElementById('collection-link-inbetween')) { + const inbetweenLinks = document.getElementsByClassName( + 'series-switcher__link--inbetween', + ); + const inbetweenLinksLength = inbetweenLinks.length; + for (let i = 0; i < inbetweenLinks.length; i += 1) { + inbetweenLinks[i].onclick = (e) => { + e.preventDefault(); + const els = document.getElementsByClassName( + 'series-switcher__link--hidden', + ); + const elsLength = els.length; + for (let j = 0; j < elsLength; j += 1) { + els[0].classList.remove('series-switcher__link--hidden'); + } + for (let k = 0; k < inbetweenLinksLength; k += 1) { + inbetweenLinks[0].className = 'series-switcher__link--hidden'; + } + }; + } + } +}; + +const requestReactionCounts = (articleId) => { + const ajaxReq = new XMLHttpRequest(); + ajaxReq.onreadystatechange = () => { + if (ajaxReq.readyState === XMLHttpRequest.DONE) { + const json = JSON.parse(ajaxReq.response); + setSumReactionCount(json.article_reaction_counts); + showCommentCount(); + json.article_reaction_counts.forEach((reaction) => { + setReactionCount(reaction.category, reaction.count); + }); + json.reactions.forEach((reaction) => { + if (document.getElementById(`reaction-butt-${reaction.category}`)) { + showUserReaction(reaction.category, 'not-user-animated'); + } + }); + } + }; + ajaxReq.open('GET', `/reactions?article_id=${articleId}`, true); + ajaxReq.send(); +}; + +const openDrawerOnHover = () => { + let timer; + const drawerTrigger = document.getElementById('reaction-drawer-trigger'); + if (!drawerTrigger) { + return; + } + + drawerTrigger.addEventListener('click', (_event) => { + const { articleId } = document.getElementById('article-body').dataset; + reactToArticle(articleId, 'like'); + + drawerTrigger.parentElement.classList.add('open'); + }); + + if (isTouchDevice()) { + watchForLongTouch(drawerTrigger); + drawerTrigger.addEventListener('longTouch', () => { + drawerTrigger.parentElement.classList.add('open'); + }); + document.addEventListener('touchstart', (event) => { + if (!drawerTrigger.parentElement.contains(event.target)) { + drawerTrigger.parentElement.classList.remove('open'); + } + }); + } else { + document.querySelectorAll('.hoverdown').forEach((el) => { + el.addEventListener('mouseover', function (_event) { + this.classList.add('open'); + clearTimeout(timer); + }); + el.addEventListener('mouseout', (_event) => { + timer = setTimeout((_event) => { + document.querySelector('.hoverdown.open').classList.remove('open'); + }, 500); + }); + }); + } +}; + +const closeDrawerOnOutsideClick = () => { + document.addEventListener('click', (event) => { + const reactionDrawerElement = document.querySelector('.reaction-drawer'); + const reactionDrawerTriggerElement = document.querySelector( + '#reaction-drawer-trigger', + ); + if (reactionDrawerElement && reactionDrawerTriggerElement) { + const isClickInside = + reactionDrawerElement.contains(event.target) || + reactionDrawerTriggerElement.contains(event.target); + + const openDrawerElement = document.querySelector('.hoverdown.open'); + if (!isClickInside && openDrawerElement) { + openDrawerElement.classList.remove('open'); + } + } + }); +}; + +const initializeArticleReactions = () => { + setCollectionFunctionality(); + + openDrawerOnHover(); + closeDrawerOnOutsideClick(); + + setTimeout(() => { + const reactionButts = document.getElementsByClassName('crayons-reaction'); + + // we wait for the article to appear, + // we also check that reaction buttons are there as draft articles don't have them + if (document.getElementById('article-body') && reactionButts.length > 0) { + const { articleId } = document.getElementById('article-body').dataset; + + requestReactionCounts(articleId); + + for (let i = 0; i < reactionButts.length; i += 1) { + if (reactionButts[i].classList.contains('pseudo-reaction')) { + continue; + } + reactionButts[i].onclick = function addReactionOnClick(_event) { + reactToArticle(articleId, this.dataset.category); + }; + } + } + + const jumpToCommentsButt = document.getElementById('reaction-butt-comment'); + const commentsSection = document.getElementById('comments'); + if ( + document.getElementById('article-body') && + commentsSection && + jumpToCommentsButt + ) { + jumpToCommentsButt.onclick = function jumpToComments(_event) { + commentsSection.scrollIntoView({ behavior: 'smooth' }); + }; + } + + const boostButt = document.getElementById('reaction-butt-boost'); + if (boostButt) { + boostButt.onclick = function() { + if (document.body.getAttribute('data-user-status') === 'logged-out') { + showLoginModal({ + referring_source: 'reactions_toolbar', + trigger: 'boost', + }); + return; + } + document.getElementById('quickie-wrapper').classList.remove('hidden'); + document.getElementById('article_title').focus(); + } + } + }, 3); +}; + +initializeArticleReactions(); diff --git a/app/javascript/packs/articleSignedIn.jsx b/app/javascript/packs/articleSignedIn.jsx new file mode 100644 index 0000000000000..edfec92ecae5b --- /dev/null +++ b/app/javascript/packs/articleSignedIn.jsx @@ -0,0 +1,63 @@ +import { h, render } from 'preact'; +import { Snackbar, addSnackbarItem } from '../Snackbar'; +import { initializeUserSubscriptionLiquidTagContent } from '../liquidTags/userSubscriptionLiquidTag'; + +// The Snackbar for the article page +const snackZone = document.getElementById('snack-zone'); +if (snackZone) { + render(<Snackbar lifespan={3} />, snackZone); +} + +// eslint-disable-next-line no-restricted-globals +top.addSnackbarItem = addSnackbarItem; + + +// Comment Subscription +getCsrfToken().then(async () => { + const { user = null, userStatus } = document.body.dataset; + const root = document.getElementById('comment-subscription'); + const isLoggedIn = userStatus === 'logged-in'; + + if (!root) { + return; + } + try { + const { + getCommentSubscriptionStatus, + setCommentSubscriptionStatus, + CommentSubscription, + } = await import('../CommentSubscription'); + + const { articleId } = document.getElementById('article-body').dataset; + + let subscriptionType = 'not_subscribed'; + + if (isLoggedIn && user !== null) { + ({ config: subscriptionType } = await getCommentSubscriptionStatus( + articleId, + )); + } + + const subscriptionRequestHandler = async (type) => { + const message = await setCommentSubscriptionStatus(articleId, type); + + addSnackbarItem({ message, addCloseButton: true }); + }; + + render( + <CommentSubscription + subscriptionType={subscriptionType} + positionType="static" + onSubscribe={subscriptionRequestHandler} + onUnsubscribe={subscriptionRequestHandler} + isLoggedIn={isLoggedIn} + />, + root, + ); + } catch (e) { + root.innerHTML = + '<p className="color-accent-danger">Unable to load Comment Subscription component.<br />Try refreshing the page.</p>'; + } +}); + +initializeUserSubscriptionLiquidTagContent(); diff --git a/app/javascript/packs/asyncUserStatusCheck.js b/app/javascript/packs/asyncUserStatusCheck.js new file mode 100644 index 0000000000000..25c4d49d554a9 --- /dev/null +++ b/app/javascript/packs/asyncUserStatusCheck.js @@ -0,0 +1,33 @@ +import '@utilities/document_ready'; + +export async function asyncUserStatusCheck() { + const profile = document.querySelector('.profile-header__details'); + + if (profile && !profile.dataset.statusChecked) { + await window + .fetch(profile.dataset.url) + .then((res) => res.json()) + .then((data) => { + profile.dataset.statusChecked = true; + const { suspended, spam } = data; + + let status = ''; + if (spam) status = 'Spam'; + else if (suspended) status = 'Suspended'; + + if (status) { + const indicator = `<span data-testid="user-status" class="ml-3 c-indicator c-indicator--danger c-indicator--relaxed">${status}</span>`; + profile.querySelector('.js-username-container').innerHTML += indicator; + } + }); + } +} + + + + + + +document.ready.then(() => { + asyncUserStatusCheck(); +}); diff --git a/app/javascript/packs/base.jsx b/app/javascript/packs/base.jsx deleted file mode 100644 index c48a3812926c6..0000000000000 --- a/app/javascript/packs/base.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import 'focus-visible'; -import { - initializeMobileMenu, - setCurrentPageIconLink, - initializeMemberMenu, -} from '../topNavigation/utilities'; -import { waitOnBaseData } from '../utilities/waitOnBaseData'; -import { initializePodcastPlayback } from '../utilities/podcastPlayback'; -import { initializeVideoPlayback } from '../utilities/videoPlayback'; -import { trackCreateAccountClicks } from '@utilities/ahoy/trackEvents'; -import { showWindowModal, closeWindowModal } from '@utilities/showModal'; -import * as Runtime from '@utilities/runtime'; - -Document.prototype.ready = new Promise((resolve) => { - if (document.readyState !== 'loading') { - return resolve(); - } - document.addEventListener('DOMContentLoaded', () => resolve()); - return null; -}); - -// Namespace for functions which need to be accessed in plain JS initializers -window.Forem = { - audioInitialized: false, - preactImport: undefined, - getPreactImport() { - if (!this.preactImport) { - this.preactImport = import('preact'); - } - return this.preactImport; - }, - enhancedCommentTextAreaImport: undefined, - getEnhancedCommentTextAreaImports() { - if (!this.enhancedCommentTextAreaImport) { - this.enhancedCommentTextAreaImport = import( - './CommentTextArea/CommentTextArea' - ); - } - return Promise.all([ - this.enhancedCommentTextAreaImport, - this.getPreactImport(), - ]); - }, - initializeEnhancedCommentTextArea: async (originalTextArea) => { - const parentContainer = originalTextArea.parentElement; - - const alreadyInitialized = - parentContainer.classList.contains('c-autocomplete'); - - if (alreadyInitialized) { - return; - } - - const [{ CommentTextArea }, { render, h }] = - await window.Forem.getEnhancedCommentTextAreaImports(); - - render( - <CommentTextArea vanillaTextArea={originalTextArea} />, - parentContainer, - originalTextArea, - ); - }, - showModal: showWindowModal, - closeModal: () => closeWindowModal(), - Runtime, -}; - -initializePodcastPlayback(); -initializeVideoPlayback(); -InstantClick.on('change', () => { - initializePodcastPlayback(); - initializeVideoPlayback(); -}); - -// Initialize data-runtime context to the body data-attribute -document.body.dataset.runtime = window.Forem.Runtime.currentContext(); - -function getPageEntries() { - return Object.entries({ - 'notifications-index': document.getElementById('notifications-link'), - 'moderations-index': document.getElementById('moderation-link'), - 'articles_search-index': document.getElementById('search-link'), - }); -} - -/** - * Initializes the left hand side hamburger menu - */ -function initializeNav() { - const { currentPage } = document.getElementById('page-content').dataset; - const menuTriggers = [ - ...document.querySelectorAll('.js-hamburger-trigger, .hamburger a'), - ]; - - setCurrentPageIconLink(currentPage, getPageEntries()); - initializeMobileMenu(menuTriggers); -} - -const memberMenu = document.getElementById('crayons-header__menu'); -const menuNavButton = document.getElementById('member-menu-button'); - -if (memberMenu) { - initializeMemberMenu(memberMenu, menuNavButton); -} - -/** - * Fetches the html for the navigation_links from an endpoint and dynamically insterts it in the DOM. - */ -async function getNavigation() { - const placeholderElement = document.getElementsByClassName( - 'js-navigation-links-container', - )[0]; - - if (placeholderElement.innerHTML.trim() === '') { - const response = await window.fetch(`/async_info/navigation_links`); - const htmlContent = await response.text(); - - const generatedElement = document.createElement('div'); - generatedElement.innerHTML = htmlContent; - - placeholderElement.appendChild(generatedElement); - } -} - -// Initialize when asset pipeline (sprockets) initializers have executed -waitOnBaseData() - .then(() => { - InstantClick.on('change', () => { - initializeNav(); - }); - - if (Runtime.currentMedium() === 'ForemWebView') { - // Dynamic import of the namespace - import('../mobile/foremMobile.js').then((module) => { - // Load the namespace - window.ForemMobile = module.foremMobileNamespace(); - // Run the first session - window.ForemMobile.userSessionBroadcast(); - }); - } - }) - .catch((error) => { - Honeybadger.notify(error); - }); - -// we need to call initializeNav here for the initial page load -initializeNav(); - -async function loadCreatorSettings() { - try { - const [{ LogoUploadController }, { Application }] = await Promise.all([ - import('@admin/controllers/logo_upload_controller'), - import('@hotwired/stimulus'), - ]); - - const application = Application.start(); - application.register('logo-upload', LogoUploadController); - } catch (error) { - Honeybadger.notify( - `Error loading the creator settings controller: ${error.message}`, - ); - } -} - -if (document.location.pathname === '/admin/creator_settings/new') { - loadCreatorSettings(); -} - -document.ready.then(() => { - const hamburgerTrigger = document.getElementsByClassName( - 'js-hamburger-trigger', - )[0]; - hamburgerTrigger.addEventListener('click', getNavigation); -}); - -trackCreateAccountClicks('authentication-hamburger-actions'); -trackCreateAccountClicks('authentication-top-nav-actions'); -trackCreateAccountClicks('comments-locked-cta'); diff --git a/app/javascript/packs/baseInitializers.js b/app/javascript/packs/baseInitializers.js index 0eb1aabd01348..f854ad84d0856 100644 --- a/app/javascript/packs/baseInitializers.js +++ b/app/javascript/packs/baseInitializers.js @@ -1,5 +1,27 @@ import { initializeCommentDate } from './initializers/initializeCommentDate'; import { initializeCommentPreview } from './initializers/initializeCommentPreview'; +import { initializeTimeFixer } from './initializers/initializeTimeFixer'; +import { initializeNotifications } from './initializers/initializeNotifications'; +import { initializeDateHelpers } from './initializers/initializeDateTimeHelpers'; +import { initializeSettings } from './initializers/initializeSettings'; +import { + showUserAlertModal, + showModalAfterError, +} from '@utilities/showUserAlertModal'; initializeCommentDate(); initializeCommentPreview(); +initializeSettings(); +initializeNotifications(); +initializeTimeFixer(); +initializeDateHelpers(); + +InstantClick.on('change', () => { + initializeCommentDate(); + initializeCommentPreview(); + initializeSettings(); + initializeNotifications(); +}); + +window.showUserAlertModal = showUserAlertModal; +window.showModalAfterError = showModalAfterError; diff --git a/app/javascript/packs/baseTracking.js b/app/javascript/packs/baseTracking.js index 6734d0b07143f..d2123d73f657d 100644 --- a/app/javascript/packs/baseTracking.js +++ b/app/javascript/packs/baseTracking.js @@ -1,14 +1,20 @@ +/*eslint-disable prefer-rest-params*/ +/* global isTouchDevice */ + function initializeBaseTracking() { + showCookieConsentBanner(); trackGoogleAnalytics3(); trackGoogleAnalytics4(); trackCustomImpressions(); + trackEmailClicks(); } - + +// Google Anlytics 3 is deprecated, and mostly not supported, but some sites may still be using it for now. function trackGoogleAnalytics3() { let wait = 0; let addedGA = false; const gaTrackingCode = document.body.dataset.gaTracking; - if (gaTrackingCode) { + if (gaTrackingCode && localStorage.getItem('cookie_status') === 'allowed') { const waitingOnGA = setInterval(() => { if (!addedGA) { (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ @@ -30,6 +36,8 @@ function trackGoogleAnalytics3() { } }, 25); eventListening(); + } else if (gaTrackingCode) { + fallbackActivityRecording(); } } @@ -57,15 +65,17 @@ function trackGoogleAnalytics4() { window['gtag'] = window['gtag'] || function () { window.dataLayer.push(arguments) } - + const consent = localStorage.getItem('cookie_status') === 'allowed' ? 'granted' : 'denied'; gtag('js', new Date()); gtag('config', ga4MeasurementCode, { 'anonymize_ip': true }); + gtag('consent', 'default', { + 'ad_storage': consent, + 'analytics_storage': consent + }); clearInterval(waitingOnGA4); } if (wait > 85) { clearInterval(waitingOnGA4); - //The gem we're using server-side (Staccato) is not yet compatible with the Google Analytics 4 tracking code. - //More details: https://github.com/tpitale/staccato/issues/97 %> fallbackActivityRecording(); } }, 25); @@ -89,8 +99,8 @@ function fallbackActivityRecording() { user_language: navigator.language, referrer: document.referrer, user_agent: navigator.userAgent, - viewport_size: `${h }x${ w}`, - screen_resolution: `${screenH }x${ screenW}`, + viewport_size: `${h}x${w}`, + screen_resolution: `${screenH}x${screenW}`, document_title: document.title, document_encoding: document.characterSet, document_path: location.pathname + location.search, @@ -176,6 +186,116 @@ function trackCustomImpressions() { }, 1800) } +function trackEmailClicks() { + const urlParams = new URLSearchParams(window.location.search); + + if (urlParams.get('ahoy_click') === 'true' && urlParams.get('t') && urlParams.get('s') && urlParams.get('u')){ + const dataBody = { + t: urlParams.get('t'), + c: urlParams.get('c'), + u: decodeURIComponent(urlParams.get('u')), + s: urlParams.get('s'), + bb: urlParams.get('bb'), + }; + window.fetch('/ahoy/email_clicks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin' + }); + // Remove t,c,u,s params and ahoy_click param from url without modifying the history + urlParams.delete('t'); + urlParams.delete('c'); + urlParams.delete('u'); + urlParams.delete('s'); + urlParams.delete('ahoy_click'); + urlParams.delete('bb'); + const newUrl = `${window.location.pathname }?${ urlParams.toString()}`; + window.history.replaceState({}, null, newUrl); + } +} + +function showCookieConsentBanner() { + // if current url includes ?cookietest=true + if (shouldShowCookieBanner()) { + // show modal with cookie consent + const cookieDiv = document.getElementById('cookie-consent'); + + if (cookieDiv && localStorage.getItem('cookie_status') !== 'allowed' && localStorage.getItem('cookie_status') !== 'dismissed') { + cookieDiv.innerHTML = ` + <div class="cookie-consent-modal"> + <div class="cookie-consent-modal__content"> + <p> + <strong>Some content on our site requires cookies for personalization.</strong> + </p> + <p> + Read our full <a href="/privacy">privacy policy</a> to learn more. + </p> + <div class="cookie-consent-modal__actions"> + <button class="c-btn c-btn--secondary" id="cookie-dismiss"> + Dismiss + </button> + <button class="c-btn c-btn--primary" id="cookie-accept"> + Accept Cookies + </button> + </div + </div> + </div> + `; + + document.getElementById('cookie-accept').onclick = (() => { + localStorage.setItem('cookie_status', 'allowed'); + cookieDiv.style.display = 'none'; + if (window.gtag) { + gtag('consent', 'update', { + 'ad_storage': 'granted', + 'analytics_storage': 'granted' + }); + } + }); + + document.getElementById('cookie-dismiss').onclick = (() => { + localStorage.setItem('cookie_status', 'dismissed'); + cookieDiv.style.display = 'none'; + }); + } + } +} + +function shouldShowCookieBanner() { + const { userStatus, cookieBannerUserContext, cookieBannerPlatformContext } = document.body.dataset; + function determineActualPlatformContext() { + if (navigator.userAgent.includes('DEV-Native')) { + return 'mobile_app' + } else if (isTouchDevice()) { + return 'mobile_web' + } + return 'desktop_web' + } + + // Determine the actual platform context + const actualPlatformContext = determineActualPlatformContext(); + + // Check if either user or platform context is set to 'off' + if (cookieBannerUserContext === 'off' || cookieBannerPlatformContext === 'off') { + return false; + } + + // Check based on user status + const showForUserContext = (userStatus === 'logged-in' && cookieBannerUserContext === 'all') || + (userStatus !== 'logged-in' && cookieBannerUserContext !== 'off'); + + // Check based on platform context + const showForPlatformContext = (cookieBannerPlatformContext === 'all') || + (cookieBannerPlatformContext === 'all_web' && ['desktop_web', 'mobile_web'].includes(actualPlatformContext)) || + (cookieBannerPlatformContext === actualPlatformContext); + + // Return true if both user context and platform context conditions are met + return showForUserContext && showForPlatformContext; +} + function trackPageView(dataBody, csrfToken) { window.fetch('/page_views', { method: 'POST', @@ -199,4 +319,7 @@ function trackFifteenSecondsOnPage(articleId, csrfToken) { }).catch((error) => console.error(error)) } -initializeBaseTracking(); \ No newline at end of file +window.InstantClick.on('change', () => { + initializeBaseTracking(); +}); +initializeBaseTracking(); diff --git a/app/javascript/packs/billboard.js b/app/javascript/packs/billboard.js new file mode 100644 index 0000000000000..62cff73787280 --- /dev/null +++ b/app/javascript/packs/billboard.js @@ -0,0 +1,80 @@ +import { setupBillboardInteractivity } from '../utilities/billboardInteractivity'; +import { + observeBillboards, + executeBBScripts, + implementSpecialBehavior, +} from './billboardAfterRenderActions'; + +export async function getBillboard() { + const placeholderElements = document.getElementsByClassName( + 'js-billboard-container', + ); + + const promises = [...placeholderElements].map(generateBillboard); + await Promise.all(promises); +} + +async function generateBillboard(element) { + let { asyncUrl } = element.dataset; + const currentParams = window.location.href.split('?')[1]; + const cookieStatus = localStorage.getItem('cookie_status'); + if (currentParams && currentParams.includes('bb_test_placement_area')) { + asyncUrl = `${asyncUrl}?${currentParams}`; + } + + if (cookieStatus === 'allowed') { + asyncUrl += `${asyncUrl.includes('?') ? '&' : '?'}cookies_allowed=true`; + } + + + if (asyncUrl) { + try { + // When context is digest we don't show this billboard + // This is a hardcoded feature which should become more dynamic later. + const contentElement = document.getElementById('page-content-inner'); + const isInternalNav = contentElement && contentElement.dataset.internalNav === 'true' + if ( + asyncUrl?.includes('post_fixed_bottom') && + (currentParams?.includes('context=digest') || isInternalNav) + ) { + return; + } + + const response = await window.fetch(asyncUrl); + const htmlContent = await response.text(); + const generatedElement = document.createElement('div'); + generatedElement.innerHTML = htmlContent; + element.innerHTML = ''; + element.appendChild(generatedElement); + element.querySelectorAll('img').forEach((img) => { + img.onerror = function () { + this.style.display = 'none'; + }; + }); + const dismissalSku = + element.querySelector('.js-billboard')?.dataset.dismissalSku; + if (localStorage && dismissalSku && dismissalSku.length > 0) { + const skuArray = + JSON.parse(localStorage.getItem('dismissal_skus_triggered')) || []; + if (skuArray.includes(dismissalSku)) { + element.style.display = 'none'; + element.innerHTML = ''; + } + } + executeBBScripts(element); + implementSpecialBehavior(element); + setupBillboardInteractivity(); + // This is called here because the ad is loaded asynchronously. + // The original code is still in the asset pipeline, so is not importable. + // This could be refactored to be importable as we continue that migration. + // eslint-disable-next-line no-undef + observeBillboards(); + } catch (error) { + if (!/NetworkError/i.test(error.message)) { + Honeybadger.notify(error); + } + } + } +} + +getBillboard(); diff --git a/app/javascript/packs/billboardAfterRenderActions.js b/app/javascript/packs/billboardAfterRenderActions.js new file mode 100644 index 0000000000000..b53404a03b031 --- /dev/null +++ b/app/javascript/packs/billboardAfterRenderActions.js @@ -0,0 +1,172 @@ +/* global userData */ +// This is currently a duplicate of app/assets/javascript/initializers/initializeBillboardVisibility. +export function initializeBillboardVisibility() { + const billboards = document.querySelectorAll('[data-display-unit]'); + + if (billboards && billboards.length == 0) { + return; + } + + const user = userData(); + + billboards.forEach((ad) => { + if (user && !user.display_sponsors && ad.dataset['typeOf'] == 'external') { + ad.classList.add('hidden'); + } else { + ad.classList.remove('hidden'); + } + }); +} + +export function executeBBScripts(el) { + const scriptElements = el.getElementsByTagName('script'); + let originalElement, copyElement, parentNode, nextSibling, i; + + for (i = 0; i < scriptElements.length; i++) { + originalElement = scriptElements[i]; + if (!originalElement) { + continue; + } + copyElement = document.createElement('script'); + for (let j = 0; j < originalElement.attributes.length; j++) { + copyElement.setAttribute( + originalElement.attributes[j].name, + originalElement.attributes[j].value, + ); + } + copyElement.textContent = originalElement.textContent; + parentNode = originalElement.parentNode; + nextSibling = originalElement.nextSibling; + parentNode.removeChild(originalElement); + parentNode.insertBefore(copyElement, nextSibling); + } +} + +export function implementSpecialBehavior(element) { + if ( + element.querySelector('.js-billboard') && + element.querySelector('.js-billboard').dataset.special === 'delayed' + ) { + element.classList.add('hidden'); + setTimeout(() => { + showDelayed(); + }, 10000); + } +} + +export function observeBillboards() { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const elem = entry.target; + if (entry.intersectionRatio >= 0.25) { + setTimeout(() => { + trackAdImpression(elem); + }, 200); + } + } + }); + }, + { + root: null, // defaults to browser viewport + rootMargin: '0px', + threshold: 0.25, + }, + ); + + document.querySelectorAll('[data-display-unit]').forEach((ad) => { + const currentPath = window.location.pathname; + observer.observe(ad); + ad.removeEventListener('click', trackAdClick, false); + ad.addEventListener('click', () => trackAdClick(ad, event, currentPath)); + }); +} + +function showDelayed() { + document.querySelectorAll("[data-special='delayed']").forEach((el) => { + el.closest('.hidden').classList.remove('hidden'); + }); +} + +function trackAdImpression(adBox) { + const isBot = + /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test( + navigator.userAgent, + ); // is crawler + const adSeen = adBox.dataset.impressionRecorded; + if (isBot || adSeen) { + return; + } + + const tokenMeta = document.querySelector("meta[name='csrf-token']"); + const csrfToken = tokenMeta && tokenMeta.getAttribute('content'); + + const dataBody = { + billboard_event: { + billboard_id: adBox.dataset.id, + context_type: adBox.dataset.contextType, + category: adBox.dataset.categoryImpression, + article_id: adBox.dataset.articleId, + }, + }; + + window + .fetch('/bb_tabulations', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin', + }) + .catch((error) => console.error(error)); + + adBox.dataset.impressionRecorded = true; +} + +function trackAdClick(adBox, event, currentPath) { + if (!event.target.closest('a')) { + return; + } + + const dataBody = { + billboard_event: { + billboard_id: adBox.dataset.id, + context_type: adBox.dataset.contextType, + category: adBox.dataset.categoryClick, + article_id: adBox.dataset.articleId, + }, + }; + + if (localStorage) { + dataBody['path'] = currentPath; + dataBody['time'] = new Date(); + localStorage.setItem('last_interacted_billboard', JSON.stringify(dataBody)); + } + + const isBot = + /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test( + navigator.userAgent, + ); // is crawler + const adClicked = adBox.dataset.clickRecorded; + if (isBot || adClicked) { + return; + } + + const tokenMeta = document.querySelector("meta[name='csrf-token']"); + const csrfToken = tokenMeta && tokenMeta.getAttribute('content'); + + window.fetch('/bb_tabulations', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin', + }); + + adBox.dataset.clickRecorded = true; +} diff --git a/app/javascript/packs/commentDropdowns.js b/app/javascript/packs/commentDropdowns.js index a11c13c203c90..870a97d07c021 100644 --- a/app/javascript/packs/commentDropdowns.js +++ b/app/javascript/packs/commentDropdowns.js @@ -1,4 +1,3 @@ -import { addSnackbarItem } from '../Snackbar'; import { initializeDropdown, getDropdownRepositionListener, @@ -11,7 +10,7 @@ const handleCopyPermalink = (closeDropdown) => { event.preventDefault(); const permalink = event.target.href; copyToClipboard(permalink).then(() => { - addSnackbarItem({ message: 'Copied to clipboard' }); + top.addSnackbarItem({ message: 'Copied to clipboard' }); }); closeDropdown(); }; diff --git a/app/javascript/packs/commentModPage.js b/app/javascript/packs/commentModPage.js index 398f25d060702..0d8772008655b 100644 --- a/app/javascript/packs/commentModPage.js +++ b/app/javascript/packs/commentModPage.js @@ -1,20 +1,32 @@ import { updateExperienceLevel } from '../actionsPanel/actionsPanel'; -function applyReactedClass(category) { - const upVote = document.querySelector("[data-category='thumbsup']"); - const downVote = document.querySelector("[data-category='thumbsdown']"); - const vomitVote = document.querySelector("[data-category='vomit']"); +/** + * A thumbsup reaction on a comment/article will invalidate a previous thumbsdown + * or vomit reaction (they will be deleted on the server by the reaction handler) + * and vice versa. This function updates the UI to match. + * @param {HTMLButtonElement} clickedBtn The reaction button that was clicked + */ +function toggleContradictoryReactions(clickedBtn) { + const contentActions = document.querySelector('#content-mod-actions'); - if (category === 'thumbsup') { - downVote.classList.remove('reacted'); - vomitVote.classList.remove('reacted'); - } else { - upVote.classList.remove('reacted'); + if (clickedBtn.parentElement === contentActions) { + const upVote = contentActions.querySelector("[data-category='thumbsup']"); + const downVote = contentActions.querySelector( + "[data-category='thumbsdown']", + ); + const vomitVote = contentActions.querySelector("[data-category='vomit']"); + + if (clickedBtn.dataset.category === 'thumbsup') { + downVote.classList.remove('reacted'); + vomitVote.classList.remove('reacted'); + } else { + upVote.classList.remove('reacted'); + } } } -async function updateMainReactions(reactableType, category, reactableId) { - const clickedBtn = document.querySelector(`[data-category="${category}"]`); +async function updateMainReactions(clickedBtn) { + const { reactableType, category, reactableId } = clickedBtn.dataset; try { const response = await fetch('/reactions', { method: 'POST', @@ -34,6 +46,7 @@ async function updateMainReactions(reactableType, category, reactableId) { const outcome = await response.json(); if (outcome.result === 'create') { + toggleContradictoryReactions(clickedBtn); clickedBtn.classList.add('reacted'); } else if (outcome.result === 'destroy') { clickedBtn.classList.remove('reacted'); @@ -66,20 +79,17 @@ Array.from(document.getElementsByClassName('level-rating-button')).forEach( document .querySelectorAll('.reaction-button, .reaction-vomit-button') .forEach((btn) => { - btn.addEventListener('click', () => { - applyReactedClass(btn.dataset.category); - updateMainReactions( - btn.dataset.reactableType, - btn.dataset.category, - btn.dataset.reactableId, - ); + btn.addEventListener('click', async () => { + await updateMainReactions(btn); }); }); const form = document.getElementsByClassName('button_to')[0]; -form.addEventListener('submit', (e) => { - e.preventDefault(); - if (confirm('Are you SURE you want to delete this comment?')) { - form.submit(); - } -}); +if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + if (confirm('Are you SURE you want to delete this comment?')) { + form.submit(); + } + }); +} diff --git a/app/javascript/packs/dashboardTags.js b/app/javascript/packs/dashboardTags.js new file mode 100644 index 0000000000000..2905a6add5aa7 --- /dev/null +++ b/app/javascript/packs/dashboardTags.js @@ -0,0 +1,201 @@ +import { initializeDropdown } from '@utilities/dropdownUtils'; +import { showModalAfterError } from '@utilities/showUserAlertModal'; + +listenForButtonClicks(); + +/** + * Adds an event listener to the inner page content, to handle any and all follow button clicks with a single handler + */ +function listenForButtonClicks() { + document + .getElementById('following-wrapper') + .addEventListener('click', handleClick); +} + +/** + * Checks a click event's target to see which button was clicked and calls the relevant handlers + * + * @param {HTMLElement} target The target of the click event + */ +function handleClick({ target }) { + const tagContainer = target.closest('.dashboard__tag__container'); + + if (target.classList.contains('follow-button')) { + handleFollowingButtonClick(tagContainer); + } + + if (target.classList.contains('hide-button')) { + handleHideButtonClick(tagContainer); + } + + if (target.classList.contains('unhide-button')) { + handleUnhideButtonClick(tagContainer); + } +} + +function fetchFollows(body) { + const tokenMeta = document.querySelector("meta[name='csrf-token']"); + const csrfToken = tokenMeta && tokenMeta.getAttribute('content'); + + return window.fetch('/follows', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + credentials: 'same-origin', + }); +} + +function handleFollowingButtonClick(tagContainer) { + const { tagId, followId } = tagContainer.dataset; + + const data = { + followable_type: 'Tag', + followable_id: tagId, + verb: 'unfollow', + }; + + fetchFollows(data) + .then((response) => { + if (response.ok) { + removeElementFromPage(followId); + updateNavigationItemCount(); + } else { + showModalAfterError({ + response, + element: 'follow action', + action_ing: 'updating', + action_past: 'updated', + }); + } + }) + .catch((error) => console.error(error)); +} + +function handleHideButtonClick(tagContainer) { + const { tagId, followId } = tagContainer.dataset; + + const data = { + followable_type: 'Tag', + followable_id: tagId, + verb: 'follow', + explicit_points: -1, + }; + + fetchFollows(data) + .then((response) => { + if (response.ok) { + removeElementFromPage(followId); + + // update the current navigation item count + updateNavigationItemCount(); + + // update the hidden tags navigation item + const hiddenTagsNavigationItem = document.querySelector( + '.js-hidden-tags-link .c-indicator', + ); + updateNavigationItemCount(hiddenTagsNavigationItem, 1); + } else { + showModalAfterError({ + response, + element: 'hide action', + action_ing: 'updating', + action_past: 'updated', + }); + } + }) + .catch((error) => { + console.error('Unable to hide tag', error); + }); +} + +function handleUnhideButtonClick(tagContainer) { + const { tagId, followId } = tagContainer.dataset; + + const data = { + followable_type: 'Tag', + followable_id: tagId, + verb: 'unfollow', + }; + + fetchFollows(data) + .then((response) => { + if (response.ok) { + removeElementFromPage(followId); + // update the current navigation item count + updateNavigationItemCount(); + } else { + showModalAfterError({ + response, + element: 'unhide action', + action_ing: 'updating', + action_past: 'updated', + }); + } + }) + .catch((error) => console.error(error)); +} + +function removeElementFromPage(followId) { + document.getElementById(`follows-${followId}`).remove(); +} + +function updateNavigationItemCount( + navItem = document.querySelector('.crayons-link--current .c-indicator'), + adjustment = -1, +) { + const currentFollowingTagsCount = parseInt(navItem.innerHTML, 10); + navItem.textContent = currentFollowingTagsCount + adjustment; +} + +/** + * Initializes the dropdown within each card + */ +const allButtons = document.querySelectorAll('.follow-button'); +allButtons.forEach((button) => { + const { tagId } = button.closest('.dashboard__tag__container').dataset; + initializeDropdown({ + triggerElementId: `options-dropdown-trigger-${tagId}`, + dropdownContentId: `options-dropdown-${tagId}`, + }); +}); + +/** + * When there is a change to the DOMTree, we find the added node and initializes the dropdown + */ +const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + // to remove options like #text '\n ' + if (node.hasChildNodes()) { + const { tagId } = node.closest('.dashboard__tag__container').dataset; + initializeDropdown({ + triggerElementId: `options-dropdown-trigger-${tagId}`, + dropdownContentId: `options-dropdown-${tagId}`, + }); + } + }); + } + }); +}); + +/** + * Observes when there additions to the DOM(like when we paginate) within the wrapper + */ +document.querySelectorAll('#following-wrapper').forEach((tagContainer) => { + observer.observe(tagContainer, { + childList: true, + subtree: true, + }); +}); + +InstantClick.on('change', () => { + observer.disconnect(); +}); + +window.addEventListener('beforeunload', () => { + observer.disconnect(); +}); diff --git a/app/javascript/packs/dashboardTagsDisableUnchangedButtons.js b/app/javascript/packs/dashboardTagsDisableUnchangedButtons.js deleted file mode 100644 index 45ff28bc0771f..0000000000000 --- a/app/javascript/packs/dashboardTagsDisableUnchangedButtons.js +++ /dev/null @@ -1,36 +0,0 @@ -document - .getElementById('follows_update_form') - .addEventListener('submit', checkChanged); - -document.addEventListener('change', (event) => { - if (event.target && event.target.name == 'follows[][explicit_points]') { - addChanged(event.target); - } -}); - -function addChanged(element) { - element.setAttribute('changed', true); -} - -function checkChanged(event) { - if (document.querySelector('input[changed]')) { - disableAllUnchanged(); - } else { - event.preventDefault(); - } -} - -function disableAllUnchanged() { - document.querySelectorAll('div[id^="follows"]').forEach(disableUnchanged); -} - -function disableUnchanged(item) { - const inputs = item.getElementsByTagName('input'); - const id = inputs[0]; - const point = inputs[1]; - - if (!point.hasAttribute('changed')) { - point.setAttribute('disabled', true); - id.setAttribute('disabled', true); - } -} diff --git a/app/javascript/packs/dashboards/convertCoauthorIdsToUsernameInputs.js b/app/javascript/packs/dashboards/convertCoauthorIdsToUsernameInputs.js new file mode 100644 index 0000000000000..22bab7418cf87 --- /dev/null +++ b/app/javascript/packs/dashboards/convertCoauthorIdsToUsernameInputs.js @@ -0,0 +1,67 @@ +import { h, render } from 'preact'; +import { UsernameInput } from '@components/UsernameInput'; +import { UserStore } from '@components/UserStore'; +import '@utilities/document_ready'; + +async function fetchAllUsers(fetchUrls) { + const users = await Promise.all( + fetchUrls.map((fetchUrl) => + UserStore.fetch(fetchUrl).then((data) => [fetchUrl, data]), + ), + ); + return new Map(users); +} + +function extractFetchUrl(field) { + return field.dataset.fetchUsers; +} + +function extractFetchUrls(fields) { + const urls = [...fields].map((field) => extractFetchUrl(field)); + return [...new Set(urls)]; +} + +export async function convertCoauthorIdsToUsernameInputs() { + const usernameFields = document.getElementsByClassName( + 'article_org_co_author_ids_list', + ); + + const fetchUrls = extractFetchUrls(usernameFields); + const usersMap = await fetchAllUsers(fetchUrls); + + for (const targetField of usernameFields) { + targetField.type = 'hidden'; + const exceptAuthorId = + targetField.form.querySelector('#article_user_id').value; + const inputId = `auto${targetField.id}`; + + const users = usersMap.get(extractFetchUrl(targetField)); + const row = targetField.parentElement; + + const value = users.matchingIds(targetField.value.split(',')); + const fetchSuggestions = function (term) { + return users.search(term, { except: exceptAuthorId }); + }; + + const handleSelectionsChanged = function (ids) { + targetField.value = ids; + }; + + render( + <UsernameInput + labelText="Add up to 4" + placeholder="Add up to 4..." + maxSelections={4} + inputId={inputId} + defaultValue={value} + fetchSuggestions={fetchSuggestions} + handleSelectionsChanged={handleSelectionsChanged} + />, + row, + ); + } +} + +document.ready.then(() => { + convertCoauthorIdsToUsernameInputs(); +}); diff --git a/app/javascript/packs/drawerSliders.js b/app/javascript/packs/drawerSliders.js new file mode 100644 index 0000000000000..834e46324de9a --- /dev/null +++ b/app/javascript/packs/drawerSliders.js @@ -0,0 +1,34 @@ +/* global slideSidebar */ + +const initializeDrawerSliders = () => { + if (document.getElementById('on-page-nav-controls')) { + if (document.getElementById('sidebar-bg-left')) { + document.getElementById('sidebar-bg-left').onclick = (_event) => { + slideSidebar('left', 'outOfView'); + }; + } + if (document.getElementById('sidebar-bg-right')) { + document.getElementById('sidebar-bg-right').onclick = (_event) => { + slideSidebar('right', 'outOfView'); + }; + } + + if (document.getElementById('on-page-nav-butt-left')) { + document.getElementById('on-page-nav-butt-left').onclick = (_event) => { + slideSidebar('left', 'intoView'); + }; + } + if (document.getElementById('on-page-nav-butt-right')) { + document.getElementById('on-page-nav-butt-right').onclick = (_event) => { + slideSidebar('right', 'intoView'); + }; + } + InstantClick.on('change', (_event) => { + document.body.classList.remove('modal-open'); + slideSidebar('right', 'outOfView'); + slideSidebar('left', 'outOfView'); + }); + } +}; + +initializeDrawerSliders(); diff --git a/app/javascript/packs/feedEvents.js b/app/javascript/packs/feedEvents.js new file mode 100644 index 0000000000000..f5dc116d7df63 --- /dev/null +++ b/app/javascript/packs/feedEvents.js @@ -0,0 +1,184 @@ +const MAX_BATCH_SIZE = 20; // Maybe adjust? +const AUTOSEND_PERIOD = 5 * 1000; +const VISIBLE_THRESHOLD = 0.25; + +const tracker = { + queue: [], + processInterval: null, + observer: new IntersectionObserver(trackFeedImpressions, { + root: null, + rootMargin: '0px', + threshold: VISIBLE_THRESHOLD, + }), + beaconEnabled: true, + nextFeedPosition: null, +}; +window.observeFeedElements = observeFeedElements; + +/** + * Sets up the feed events tracker. + * Called every time posts are inserted into the feed. + * + * NOTE: this module has E2E tests at `seededFlows/homeFeedFlows/events.spec.js` + */ +export function observeFeedElements() { + const feedContainer = document.getElementById('index-container'); + // Default container for Preact-rendered home feed + const feedItemsRoot = document.getElementById('rendered-article-feed'); + + if (!(feedContainer && feedItemsRoot)) return; + + const { feedCategoryClick, feedCategoryImpression, feedContextType } = + feedContainer.dataset; + + // Reset all relevant state + tracker.categoryClick = feedCategoryClick; + tracker.categoryImpression = feedCategoryImpression; + tracker.contextType = feedContextType; + tracker.processInterval ||= setInterval(submitEventsBatch, AUTOSEND_PERIOD); + tracker.observer.disconnect(); + tracker.nextFeedPosition = 1; + + findAndTrackFeedItems(feedItemsRoot); + ensureQueueIsClearedBeforeExit(); +} + +/** + * Given how often it may be called, and the need to assign the correct positions + * in the feed, we take a more efficient approach to finding feed items than + * querying the entire DOM. + * This manual recursion (and a good chunk of `useListNavigation.js` would be + * unnecessary if `initScrolling.js` is updated to *not* create a waterfall of elements. + * @param {HTMLElement} root The (current) element with feed items as children. + */ +function findAndTrackFeedItems(root) { + Array.from(root.children).forEach((/** @type HTMLElement */ element) => { + if (element.classList.contains('paged-stories')) { + // This was inserted by `initScrolling, and will contain feed items within. + findAndTrackFeedItems(element); + } else if (element.dataset?.feedContentId) { + element.dataset.feedPosition = tracker.nextFeedPosition; + // Also captures right-click opens + element.addEventListener('mousedown', trackFeedClickListener, true); + tracker.observer.observe(element); + + tracker.nextFeedPosition += 1; + } + }); +} + +/** + * Attempts to send any pending queued events before state is lost - e.g. when + * navigating to a different page, or (on mobile) switching to a different app + * (which can eventually cause the browser tab to be discarded in the background + * at the operating system's discretion). + * Both the unload event and the page visibility API are used as each one covers + * some gaps that the other does not. + */ +function ensureQueueIsClearedBeforeExit() { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState == 'hidden') submitEventsBatch(); + }); + window.addEventListener('beforeunload', submitEventsBatch); +} + +/** + * Collects feed impressions, counted as at least a quarter of the article card + * coming into view. This is typically enough to at least see the title and/or + * a significant portion of the cover image. + * @param {IntersectionObserverEntry[]} entries + */ +function trackFeedImpressions(entries) { + entries.forEach((entry) => { + // At least a quarter of the card is in view; not quite enough to read the + // title for many articles, but it'll do + if (entry.isIntersecting && entry.intersectionRatio >= VISIBLE_THRESHOLD) { + queueMicrotask(() => { + const post = entry.target; + if (!post.dataset.impressionRecorded) { + queueEvent(post, tracker.categoryImpression); + post.dataset.impressionRecorded = true; + } + }); + } + }); +} + +/** + * Sends click events to the server immediately along with any currently-batched + * events. + * These may not necessarily be clicks that open the article (e.g. the user may + * have clicked on the author's profile image). + * TODO: Only track link-opening clicks instead? + * @param {MouseEvent} event + */ +function trackFeedClickListener(event) { + const post = event.currentTarget; + + if (!post.dataset.clickRecorded) { + queueEvent(post, tracker.categoryClick); + post.dataset.clickRecorded = true; + submitEventsBatch(); + } +} + +function queueEvent(post, category) { + const { feedContentId, feedPosition } = post.dataset; + + tracker.queue.push({ + article_id: feedContentId, + article_position: feedPosition, + category, + context_type: tracker.contextType, + }); + + if (tracker.queue.length >= MAX_BATCH_SIZE) { + submitEventsBatch(); + } +} + +/** + * Sends a batch of feed events to the server. + * Note: requests made with `navigator.sendBeacon` have greater guarantees to + * actually complete than regular fetch requests with the `keepalive` property + * set. However, the former is a bit tedious to implement and is often + * inadvertently blocked by users (e.g. via extensions like uBlock). So a fallback + * to `fetch` is included to cover that. + */ +function submitEventsBatch() { + if (tracker.queue.length === 0) return; + + const tokenMeta = document.querySelector("meta[name='csrf-token']"); + const authenticity_token = tokenMeta?.getAttribute('content'); + + if (tracker.beaconEnabled) { + // The Beacon API doesn't actually let you set headers, so we set the content + // type and CSRF token within the body itself (the browser will recognise the + // former, and Rails will recognise the latter) + const data = new Blob( + [JSON.stringify({ authenticity_token, feed_events: tracker.queue })], + { type: 'application/json' }, + ); + // `sendBeacon` returns true if sending a beacon worked, and false otherwise. + tracker.beaconEnabled = navigator.sendBeacon('/feed_events', data); + } else { + fallbackRequest(authenticity_token); + } + + tracker.queue = []; +} + +function fallbackRequest(authenticity_token) { + window + .fetch('/feed_events', { + method: 'POST', + headers: { + 'X-CSRF-Token': authenticity_token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ feed_events: tracker.queue }), + credentials: 'same-origin', + keepalive: true, + }) + .catch((error) => console.error(error)); +} diff --git a/app/javascript/packs/followButtons.js b/app/javascript/packs/followButtons.js index 1c65061d6e842..2ad625f961830 100644 --- a/app/javascript/packs/followButtons.js +++ b/app/javascript/packs/followButtons.js @@ -1,7 +1,8 @@ import { getInstantClick } from '../topNavigation/utilities'; +import { waitOnBaseData } from '../utilities/waitOnBaseData'; import { locale } from '@utilities/locale'; -/* global showLoginModal userData showModalAfterError*/ +/* global showLoginModal userData showModalAfterError browserStoreCache */ /** * Sets the text content of the button to the correct 'Follow' state @@ -219,6 +220,7 @@ function handleFollowButtonClick({ target }) { } optimisticallyUpdateButtonUI(target); + browserStoreCache('remove'); const { verb } = target.dataset; @@ -409,32 +411,36 @@ function initializeNonUserFollowButtons() { '.follow-action-button:not(.follow-user):not([data-fetched])', ); - const userLoggedIn = - document.body.getAttribute('data-user-status') === 'logged-in'; + waitOnBaseData().then(() => { + const userLoggedIn = + document.body.getAttribute('data-user-status') === 'logged-in'; - const user = userLoggedIn ? userData() : null; + const user = userLoggedIn ? userData() : null; + const followedTags = user + ? JSON.parse(user.followed_tags).map((tag) => tag.id) + : []; - const followedTags = user - ? JSON.parse(user.followed_tags).map((tag) => tag.id) - : []; + const followedTagIds = new Set(followedTags); - const followedTagIds = new Set(followedTags); - - nonUserFollowButtons.forEach((button) => { - const { info } = button.dataset; - const buttonInfo = JSON.parse(info); - const { className, name } = buttonInfo; - addAriaLabelToButton({ button, followType: className, followName: name }); - if (className === 'Tag' && user) { - // We don't need to make a network request to 'fetch' the status of tag buttons - button.dataset.fetched = true; - const initialButtonFollowState = followedTagIds.has(buttonInfo.id) - ? 'true' - : 'false'; - updateInitialButtonUI(initialButtonFollowState, button); - } else { - fetchFollowButtonStatus(button, buttonInfo); - } + nonUserFollowButtons.forEach((button) => { + const { info } = button.dataset; + const buttonInfo = JSON.parse(info); + const { className, name } = buttonInfo; + addAriaLabelToButton({ button, followType: className, followName: name }); + if (user === null) { + return; // No need to fetch the status if the user is logged out + } + if (className === 'Tag' && user) { + // We don't need to make a network request to 'fetch' the status of tag buttons + button.dataset.fetched = true; + const initialButtonFollowState = followedTagIds.has(buttonInfo.id) + ? 'true' + : 'false'; + updateInitialButtonUI(initialButtonFollowState, button); + } else { + fetchFollowButtonStatus(button, buttonInfo); + } + }); }); } diff --git a/app/javascript/packs/heroBannerClose.js b/app/javascript/packs/heroBannerClose.js new file mode 100644 index 0000000000000..fef556fac7e44 --- /dev/null +++ b/app/javascript/packs/heroBannerClose.js @@ -0,0 +1,17 @@ +const initializeHeroBannerClose = () => { + const bannerWrapper = document.getElementById('hero-html-wrapper'); + const closeIcon = document.getElementById('js-hero-banner__x'); + + // Currently js-hero-banner__x button icon ID needs to be written into the abstract html + // In the future this could be extracted so the implementer doesn't need to worry about it. + + if (bannerWrapper && closeIcon) { + closeIcon.setAttribute('aria-label', 'Close campaign banner'); + closeIcon.addEventListener('click', () => { + localStorage.setItem('exited_hero', bannerWrapper.dataset.name); + bannerWrapper.style.display = 'none'; + }); + } +}; + +initializeHeroBannerClose(); diff --git a/app/javascript/packs/hideBookmarkButtons.js b/app/javascript/packs/hideBookmarkButtons.js index 9a73548986c56..722cbea3ac83c 100644 --- a/app/javascript/packs/hideBookmarkButtons.js +++ b/app/javascript/packs/hideBookmarkButtons.js @@ -1,6 +1,6 @@ -import { getUserDataAndCsrfToken } from '@utilities/getUserDataAndCsrfToken'; +import { getUserDataAndCsrfTokenSafely } from '@utilities/getUserDataAndCsrfToken'; -getUserDataAndCsrfToken().then(({ currentUser }) => { +getUserDataAndCsrfTokenSafely().then(({ currentUser }) => { const currentUserId = currentUser && currentUser.id; document.querySelectorAll('.bookmark-button').forEach((button) => { diff --git a/app/javascript/packs/homePage.jsx b/app/javascript/packs/homePage.jsx index d6ee1b4bb2ca4..69c0a533c9a69 100644 --- a/app/javascript/packs/homePage.jsx +++ b/app/javascript/packs/homePage.jsx @@ -1,6 +1,12 @@ import { h, render } from 'preact'; import ahoy from 'ahoy.js'; import { TagsFollowed } from '../leftSidebar/TagsFollowed'; +import { + observeBillboards, + initializeBillboardVisibility, +} from '../packs/billboardAfterRenderActions'; +import { observeFeedElements } from '../packs/feedEvents'; +import { setupBillboardInteractivity } from '@utilities/billboardInteractivity'; import { trackCreateAccountClicks } from '@utilities/ahoy/trackEvents'; /* global userData */ @@ -12,6 +18,8 @@ const frontPageFeedPathNames = new Map([ ['/top/year', 'year'], ['/top/infinity', 'infinity'], ['/latest', 'latest'], + ['/following', ''], + ['/following/latest', 'latest'] ]); /** @@ -57,28 +65,33 @@ function trackTagCogIconClicks() { }); } +function removeLocalePath(pathname) { + return pathname.replace(/^\/locale\/[a-zA-Z-]+\/?/, '/'); +} + function renderSidebar() { const sidebarContainer = document.getElementById('sidebar-wrapper-right'); - const { pathname } = window.location; + const pathname = removeLocalePath(window.location.pathname); // If the screen's width is less than 640 we don't need this extra data. if ( sidebarContainer && screen.width >= 640 && - (pathname === '/' || pathname === '/latest' || pathname.includes('/top/')) + (pathname === '/' || pathname === '/latest' || pathname.includes('/top/') || pathname.includes('/discover') || pathname.includes('/following')) ) { window .fetch('/sidebars/home') .then((res) => res.text()) .then((response) => { sidebarContainer.innerHTML = response; + setupBillboardInteractivity(); }); } } const feedTimeFrame = frontPageFeedPathNames.get(window.location.pathname); -if (!document.getElementById('featured-story-marker')) { +if (document.getElementById('sidebar-nav-followed-tags')) { const waitingForDataLoad = setInterval(() => { const { user = null, userStatus } = document.body.dataset; if (userStatus === 'logged-out') { @@ -91,8 +104,14 @@ if (!document.getElementById('featured-story-marker')) { return; } import('./homePageFeed').then(({ renderFeed }) => { - // We have user data, render followed tags. - renderFeed(feedTimeFrame); + const callback = () => { + initializeBillboardVisibility(); + observeBillboards(); + setupBillboardInteractivity(); + observeFeedElements(); + }; + + renderFeed(feedTimeFrame, callback); InstantClick.on('change', () => { const { userStatus: currentUserStatus } = document.body.dataset; @@ -108,7 +127,14 @@ if (!document.getElementById('featured-story-marker')) { return; } - renderFeed(changedFeedTimeFrame); + const callback = () => { + initializeBillboardVisibility(); + observeBillboards(); + setupBillboardInteractivity(); + observeFeedElements(); + }; + + renderFeed(changedFeedTimeFrame, callback); }); }); @@ -130,4 +156,3 @@ InstantClick.on('change', () => { InstantClick.init(); trackCreateAccountClicks('sidebar-wrapper-left'); -trackCreateAccountClicks('authentication-feed-actions'); diff --git a/app/javascript/packs/homePageFeed.jsx b/app/javascript/packs/homePageFeed.jsx index 30326ecc2823c..3b225a08bd6c2 100644 --- a/app/javascript/packs/homePageFeed.jsx +++ b/app/javascript/packs/homePageFeed.jsx @@ -1,9 +1,10 @@ -import { h, render } from 'preact'; +import { h, Fragment, render } from 'preact'; import PropTypes from 'prop-types'; import { Article, LoadingArticle } from '../articles'; import { Feed } from '../articles/Feed'; import { TodaysPodcasts, PodcastEpisode } from '../podcasts'; import { articlePropTypes } from '../common-prop-types'; +import { createRootFragment } from '../shared/preact/preact-root-fragment'; import { getUserDataAndCsrfToken } from '@utilities/getUserDataAndCsrfToken'; /** @@ -43,6 +44,62 @@ function sendFeaturedArticleAnalyticsGA4(articleId) { })(); } +function feedConstruct( + pinnedItem, + imageItem, + feedItems, + bookmarkedFeedItems, + bookmarkClick, + currentUserId, +) { + const commonProps = { + bookmarkClick, + }; + + const feedStyle = JSON.parse(document.body.dataset.user).feed_style; + + if (imageItem) { + sendFeaturedArticleGoogleAnalytics(imageItem.id); + sendFeaturedArticleAnalyticsGA4(imageItem.id); + } + + return feedItems.map((item) => { + // billboard is an html string + if (typeof item === 'string') { + return ( + <div + key={item.id} + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ + __html: item, + }} + /> + ); + } + + if (Array.isArray(item) && item[0].podcast) { + return <PodcastEpisodes key={item.id} episodes={item} />; + } + + if (typeof item === 'object') { + return ( + <Article + {...commonProps} + key={item.id} + article={item} + pinned={item.id === pinnedItem?.id} + isFeatured={item.id === imageItem?.id} + feedStyle={feedStyle} + isBookmarked={bookmarkedFeedItems.has(item.id)} + saveable={item.user_id != currentUserId} + // For "saveable" props, "!=" is used instead of "!==" to compare user_id + // and currentUserId because currentUserId is a String while user_id is an Integer + /> + ); + } + }); +} + const FeedLoading = () => ( <div data-testid="feed-loading"> <LoadingArticle version="featured" /> @@ -74,85 +131,44 @@ PodcastEpisodes.propTypes = { /** * Renders the main feed. */ -export const renderFeed = async (timeFrame) => { +export const renderFeed = async (timeFrame, afterRender) => { const feedContainer = document.getElementById('homepage-feed'); const { currentUser } = await getUserDataAndCsrfToken(); const currentUserId = currentUser && currentUser.id; + const callback = ({ + pinnedItem, + imageItem, + feedItems, + bookmarkedFeedItems, + bookmarkClick, + }) => { + if (feedItems.length === 0) { + // Fancy loading ✨ + return <FeedLoading />; + } + + return ( + <Fragment> + {feedConstruct( + pinnedItem, + imageItem, + feedItems, + bookmarkedFeedItems, + bookmarkClick, + currentUserId, + )} + </Fragment> + ); + }; + render( <Feed timeFrame={timeFrame} - renderFeed={({ - pinnedArticle, - feedItems, - podcastEpisodes, - bookmarkedFeedItems, - bookmarkClick, - }) => { - if (feedItems.length === 0) { - // Fancy loading ✨ - return <FeedLoading />; - } - - const commonProps = { - bookmarkClick, - }; - - const feedStyle = JSON.parse(document.body.dataset.user).feed_style; - - const [featuredStory, ...subStories] = feedItems; - if (featuredStory) { - sendFeaturedArticleGoogleAnalytics(featuredStory.id); - sendFeaturedArticleAnalyticsGA4(featuredStory.id); - } - - // 1. Show the pinned article first - // 2. Show the featured story next - // 3. Podcast episodes out today - // 4. Rest of the stories for the feed - // For "saveable", "!=" is used instead of "!==" to compare user_id - // and currentUserId because currentUserId is a String while user_id is an Integer - return ( - <div> - {timeFrame === '' && pinnedArticle && ( - <Article - {...commonProps} - article={pinnedArticle} - pinned={true} - feedStyle={feedStyle} - isBookmarked={bookmarkedFeedItems.has(pinnedArticle.id)} - saveable={pinnedArticle.user_id != currentUserId} - /> - )} - {featuredStory && ( - <Article - {...commonProps} - article={featuredStory} - isFeatured - feedStyle={feedStyle} - isBookmarked={bookmarkedFeedItems.has(featuredStory.id)} - saveable={featuredStory.user_id != currentUserId} - /> - )} - {podcastEpisodes.length > 0 && ( - <PodcastEpisodes episodes={podcastEpisodes} /> - )} - {(subStories || []).map((story) => ( - <Article - {...commonProps} - key={story.id} - article={story} - feedStyle={feedStyle} - isBookmarked={bookmarkedFeedItems.has(story.id)} - saveable={story.user_id != currentUserId} - /> - ))} - </div> - ); - }} + renderFeed={callback} + afterRender={afterRender} />, - feedContainer, - feedContainer.firstElementChild, + createRootFragment(feedContainer, feedContainer.firstElementChild), ); }; diff --git a/app/javascript/packs/initializers/__tests__/initializeDashboardSort.test.js b/app/javascript/packs/initializers/__tests__/initializeDashboardSort.test.js new file mode 100644 index 0000000000000..43a154597ae90 --- /dev/null +++ b/app/javascript/packs/initializers/__tests__/initializeDashboardSort.test.js @@ -0,0 +1,34 @@ +import { initializeDashboardSort } from '../initializeDashboardSort'; + +describe('initializeDashboardSort', () => { + beforeEach(() => { + document.body.innerHTML = ` + <div> + <select id="dashboard_sort" <option value="views-desc">Most Views</option> </select> + <select id="dashboard_author" <option value="/dashboard">Personal</option> </select> + <select id="mobile_nav_dashboard" <option value="/dashboard">Posts (1)</option> </select> + </div> + `; + }); + + test('should add event listener to dashboard sort select when the element exists', async () => { + const sortSelect = document.getElementById('dashboard_sort'); + sortSelect.addEventListener = jest.fn(); + initializeDashboardSort(); + expect(sortSelect.addEventListener).toHaveBeenCalled(); + }); + + test('should add event listener to dashboard author select when the element exists', async () => { + const authorSelect = document.getElementById('dashboard_author'); + authorSelect.addEventListener = jest.fn(); + initializeDashboardSort(); + expect(authorSelect.addEventListener).toHaveBeenCalled(); + }); + + test('should add event listener to mobile nav dashboard select when the element exists', async () => { + const navSelect = document.getElementById('mobile_nav_dashboard'); + navSelect.addEventListener = jest.fn(); + initializeDashboardSort(); + expect(navSelect.addEventListener).toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/packs/initializers/__tests__/initializeTimeFixer.test.js b/app/javascript/packs/initializers/__tests__/initializeTimeFixer.test.js new file mode 100644 index 0000000000000..ed1c1e7e8a49c --- /dev/null +++ b/app/javascript/packs/initializers/__tests__/initializeTimeFixer.test.js @@ -0,0 +1,209 @@ +import { + initializeTimeFixer, + convertUtcDate, + convertUtcTime, + formatDateTime, + updateLocalDateTime, + convertCalEvent, +} from '../initializeTimeFixer'; + +describe('initializeTimeFixer', () => { + beforeEach(() => { + const utcTimeClassDiv = document.createElement('div'); + const utcDateClassDiv = document.createElement('div'); + const utcDiv = document.createElement('div'); + + utcTimeClassDiv.classList.add('utc-time'); + utcDateClassDiv.classList.add('utc-date'); + utcDiv.classList.add('utc'); + + utcTimeClassDiv.dataset.datetime = 823230245000; + }); + + test('should call event listener when preview button exist', async () => { + const button = document.createElement('button'); + button.classList.add('preview-toggle'); + button.addEventListener = jest.fn(); + initializeTimeFixer(); + + expect(button.addEventListener).not.toHaveBeenCalled(); + }); + + test('should call updateLocalDateTime', async () => { + const updateLocalDateTime = jest.fn(); + initializeTimeFixer(); + + expect(updateLocalDateTime).not.toHaveBeenCalled(); + }); + + test('should call convertUtcDate', async () => { + const convertUtcDate = jest.fn(); + initializeTimeFixer(); + + expect(convertUtcDate).not.toHaveBeenCalled(); + }); + + test('should convert Utc Dates', async () => { + const utcDate = Date.UTC(96, 1, 2, 3, 4, 5); + const dateConversion = await convertUtcDate(utcDate); + // const formatDateTime = jest.fn(); + + expect(dateConversion).toContain('Feb 2'); + }); + + test('convertUtcDate function with different options', () => { + const utcDate = 917924645000; + + const options1 = { + month: 'short', + day: 'numeric', + }; + expect(convertUtcDate(utcDate, options1)).toBe('Feb 2'); + + const options2 = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZoneName: 'short', + }; + expect(convertUtcDate(utcDate, options2)).toBe('Feb 2'); + }); + + test('convertUtcTime function with different options', () => { + const utcTime = 917924645000; + + const options1 = { + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + }; + expect(convertUtcTime(utcTime, options1)).toBe('3:04 AM UTC'); + + const options2 = { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZoneName: 'short', + }; + expect(convertUtcTime(utcTime, options2)).toBe('3:04 AM UTC'); + }); + + test('formatDateTime function with different options and values', () => { + const options1 = { + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + }; + const value1 = new Date(917924645000); + expect(formatDateTime(options1, value1)).toBe('3:04 AM UTC'); + + const options2 = { + year: 'numeric', + month: 'short', + day: 'numeric', + }; + const value2 = new Date(917924645000); + expect(formatDateTime(options2, value2)).toBe('Feb 2, 1999'); + }); +}); + +describe('formatDateTime', () => { + it('formats the date time with given options', () => { + const options = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + }; + const value = new Date('2022-04-13T12:34:56Z'); + const expected = 'Apr 13, 2022, 12:34 PM'; + + expect(formatDateTime(options, value)).toEqual(expected); + }); +}); + +describe('convertUtcTime', () => { + it('converts the UTC time to local time with proper format', () => { + const utcTime = 917924645000; + const expected = '3:04 AM UTC'; + + expect(convertUtcTime(utcTime)).toEqual(expected); + }); +}); + +describe('convertUtcDate', () => { + it('converts the UTC date to local date with proper format', () => { + const utcDate = 917924645000; + const expected = expect.stringMatching(/^\w{3} \d{1,2}$/); + + expect(convertUtcDate(utcDate)).toEqual(expected); + }); +}); + +describe('updateLocalDateTime', () => { + it('updates the innerHTML of given elements with local time', () => { + document.body.innerHTML = ` + <div> + <span class="utc-time" data-datetime=917924645000></span> + <span class="utc-date" data-datetime=917924645000></span> + <span class="utc">917924645000</span> + </div> + `; + + const utcTimeElements = document.querySelectorAll('.utc-time'); + const utcDateElements = document.querySelectorAll('.utc-date'); + const utcElements = document.querySelectorAll('.utc'); + + updateLocalDateTime( + utcTimeElements, + convertUtcTime, + (element) => element.dataset.datetime, + ); + updateLocalDateTime( + utcDateElements, + convertUtcDate, + (element) => element.dataset.datetime, + ); + updateLocalDateTime( + utcElements, + convertCalEvent, + (element) => element.innerHTML, + ); + + expect(utcTimeElements[0].innerHTML).toEqual('3:04 AM UTC'); + expect(utcDateElements[0].innerHTML).toEqual( + expect.stringMatching(/^\w{3} \d{1,2}$/), + ); + expect(utcElements[0].innerHTML).toEqual('Tuesday, February 2 at 3:04 AM'); + }); +}); + +// eslint-disable-next-line jest/no-identical-title +describe('formatDateTime', () => { + it('should format a date and time using the specified options', () => { + const options = { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + }; + const value = new Date(1682636630); + const expected = 'Tue, Jan 20, 1970, 11:23 AM'; + expect(formatDateTime(options, value)).toBe(expected); + }); +}); + +describe('convertCalEvent', () => { + it('should convert UTC to a formatted date and time string', () => { + const utc = 1682636630; + const expected = 'Tuesday, January 20 at 11:23 AM'; + expect(convertCalEvent(utc)).toBe(expected); + }); +}); diff --git a/app/javascript/packs/initializers/__tests__/subscribeButton.test.js b/app/javascript/packs/initializers/__tests__/subscribeButton.test.js new file mode 100644 index 0000000000000..406537f740dd9 --- /dev/null +++ b/app/javascript/packs/initializers/__tests__/subscribeButton.test.js @@ -0,0 +1,185 @@ +// subscribeButton.test.js + +import { + initializeSubscribeButton, + updateSubscribeButtonText, + determinePayloadAndEndpoint, +} from '../../subscribeButton'; + +describe('subscribeButton', () => { + let button; + let originalFetch; + + beforeEach(() => { + button = document.createElement('button'); + button.classList.add('subscribe-button'); + document.body.appendChild(button); + const spanElement = document.createElement('span'); + spanElement.textContent = 'Subscribe to comments'; + button.appendChild(spanElement); + + // Baseline scenario starts Subscribed to all comments on an article + button.setAttribute('data-subscription_id', '1'); + button.setAttribute('data-subscribed_to', 'article'); + button.setAttribute('data-subscription_mode', 'all_comments'); + button.setAttribute('data-article_id', '123'); + + // Store the original fetch function + originalFetch = global.fetch; + + // Mock the fetch function with a spy + global.fetch = jest.fn().mockImplementation(() => Promise.resolve({})); + + initializeSubscribeButton(); + }); + + afterEach(() => { + document.body.removeChild(button); + + // Restore the original fetch function + global.fetch = originalFetch; + }); + + it('should initialize subscribe buttons', () => { + expect(button.getAttribute('aria-label')).toBe('Subscribed to comments'); + expect(button.querySelector('span').innerText).toBe( + 'Subscribed to comments', + ); + }); + + it('should update to unsubscribed setting label, and *not* pressed', () => { + updateSubscribeButtonText(button, 'unsubscribe'); + + expect(button.getAttribute('aria-label')).toBe('Subscribe to comments'); + expect(button.querySelector('span').innerText).toBe( + 'Subscribe to comments', + ); + expect(button.getAttribute('aria-pressed')).toBe('false'); + }); + + it('should update without override with blank subscription_id setting label, and *not* pressed', () => { + button.setAttribute('data-subscription_id', ''); + + updateSubscribeButtonText(button); + + expect(button.getAttribute('aria-label')).toBe('Subscribe to comments'); + expect(button.querySelector('span').innerText).toBe( + 'Subscribe to comments', + ); + expect(button.getAttribute('aria-pressed')).toBe('false'); + }); + + it('"top_level_comments" scenario', () => { + button.setAttribute('data-subscription_config', 'top_level_comments'); + updateSubscribeButtonText(button); + + expect(button.getAttribute('aria-label')).toBe( + 'Subscribed to top-level comments', + ); + expect(button.querySelector('span').innerText).toBe( + 'Subscribed to top-level comments', + ); + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('"only_author_comments" scenario', () => { + button.setAttribute('data-subscription_config', 'only_author_comments'); + updateSubscribeButtonText(button); + + expect(button.getAttribute('aria-label')).toBe( + 'Subscribed to author comments', + ); + expect(button.querySelector('span').innerText).toBe( + 'Subscribed to author comments', + ); + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('unknown config scenario', () => { + button.setAttribute('data-subscription_config', 'unknown_config'); + updateSubscribeButtonText(button); + + expect(button.getAttribute('aria-label')).toBe('Subscribed to comments'); + expect(button.querySelector('span').innerText).toBe( + 'Subscribed to comments', + ); + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('subscribed-to-thread scenario', () => { + button.setAttribute('data-subscription_config', 'thread'); + button.setAttribute('data-subscribed_to', 'comment'); + button.setAttribute('data-comment_id', '456'); + updateSubscribeButtonText(button); + + expect(button.getAttribute('aria-label')).toBe('Subscribed to thread'); + expect(button.querySelector('span').innerText).toBe('Subscribed to thread'); + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('was-subscribed-to-thread scenario', () => { + button.setAttribute('data-subscription_id', ''); + button.setAttribute('data-subscription_config', 'thread'); + button.setAttribute('data-subscribed_to', 'comment'); + button.setAttribute('data-comment_id', '456'); + updateSubscribeButtonText(button, 'unsubscribe'); + + expect(button.getAttribute('aria-label')).toBe('Subscribe to thread'); + expect(button.querySelector('span').innerText).toBe('Subscribe to thread'); + expect(button.getAttribute('aria-pressed')).toBe('false'); + }); + + it('should capitalize the mobileLabel', () => { + const magicalWindowSize = 700; + updateSubscribeButtonText(button, 'subscribe', magicalWindowSize); + + expect(button.getAttribute('aria-label')).toBe('Subscribed to comments'); + expect(button.querySelector('span').innerText).toBe('Comments'); + + updateSubscribeButtonText(button, 'unsubscribe', magicalWindowSize); + expect(button.getAttribute('aria-label')).toBe('Subscribe to comments'); + expect(button.querySelector('span').innerText).toBe('Comments'); + + button.setAttribute('data-subscription_config', 'top_level_comments'); + updateSubscribeButtonText(button, 'subscribe', magicalWindowSize); + expect(button.getAttribute('aria-label')).toBe( + 'Subscribed to top-level comments', + ); + expect(button.querySelector('span').innerText).toBe('Top-level comments'); + + button.setAttribute('data-subscription_config', 'only_author_comments'); + updateSubscribeButtonText(button, 'subscribe', magicalWindowSize); + expect(button.getAttribute('aria-label')).toBe( + 'Subscribed to author comments', + ); + expect(button.querySelector('span').innerText).toBe('Author comments'); + + button.setAttribute('data-subscription_config', 'unknown_config'); + updateSubscribeButtonText(button, 'subscribe', magicalWindowSize); + expect(button.getAttribute('aria-label')).toBe('Subscribed to comments'); + expect(button.querySelector('span').innerText).toBe('Comments'); + }); + + it('should determine the expected payload', async () => { + let { payload, endpoint } = determinePayloadAndEndpoint(button); + + // Baseline case: **is** subscribed + expect(payload).toEqual({ subscription_id: '1' }); + expect(endpoint).toEqual('comment-unsubscribe'); + + // When unsubscribed from an article + button.setAttribute('data-subscription_id', ''); + ({ payload, endpoint } = determinePayloadAndEndpoint(button)); + expect(payload).toEqual({ article_id: '123' }); + expect(endpoint).toEqual('comment-subscribe'); + + // When unsubscribed from a thread + button.setAttribute('data-subscription_id', ''); + button.setAttribute('data-subscription_config', 'thread'); + button.setAttribute('data-subscribed_to', 'comment'); + button.setAttribute('data-comment_id', '456'); + ({ payload, endpoint } = determinePayloadAndEndpoint(button)); + expect(payload).toEqual({ comment_id: '456' }); + expect(endpoint).toEqual('comment-subscribe'); + }); +}); diff --git a/app/javascript/packs/initializers/initializeCommentPreview.js b/app/javascript/packs/initializers/initializeCommentPreview.js index 9c51f3198b552..dbfe30c2ee7c0 100644 --- a/app/javascript/packs/initializers/initializeCommentPreview.js +++ b/app/javascript/packs/initializers/initializeCommentPreview.js @@ -64,5 +64,6 @@ export function initializeCommentPreview() { return; } + window.handleCommentPreview = handleCommentPreview; previewButton.addEventListener('click', handleCommentPreview); } diff --git a/app/assets/javascripts/initializers/initializeDashboardSort.js b/app/javascript/packs/initializers/initializeDashboardSort.js similarity index 90% rename from app/assets/javascripts/initializers/initializeDashboardSort.js rename to app/javascript/packs/initializers/initializeDashboardSort.js index fd417e352c6eb..d3130381c0fcd 100644 --- a/app/assets/javascripts/initializers/initializeDashboardSort.js +++ b/app/javascript/packs/initializers/initializeDashboardSort.js @@ -1,10 +1,5 @@ -/* global InstantClick */ - -'use strict'; - function selectNavigation(select, urlPrefix) { const trigger = document.getElementById(select); - if (trigger) { trigger.addEventListener('change', (event) => { let url = event.target.value; @@ -23,3 +18,5 @@ function initializeDashboardSort() { selectNavigation('dashboard_author'); selectNavigation('mobile_nav_dashboard'); } + +export { selectNavigation, initializeDashboardSort }; diff --git a/app/assets/javascripts/initializers/initializeDateTimeHelpers.js b/app/javascript/packs/initializers/initializeDateTimeHelpers.js similarity index 81% rename from app/assets/javascripts/initializers/initializeDateTimeHelpers.js rename to app/javascript/packs/initializers/initializeDateTimeHelpers.js index ebfd5edb3d692..fab7aed8b69ef 100644 --- a/app/assets/javascripts/initializers/initializeDateTimeHelpers.js +++ b/app/javascript/packs/initializers/initializeDateTimeHelpers.js @@ -1,8 +1,6 @@ -/* global localizeTimeElements */ +import { localizeTimeElements } from '../../utilities/localDateTime'; -'use strict'; - -function initializeDateHelpers() { +export function initializeDateHelpers() { // Date without year: Jul 12 localizeTimeElements(document.querySelectorAll('time.date-no-year'), { month: 'short', diff --git a/app/javascript/packs/initializers/initializeNotifications.js b/app/javascript/packs/initializers/initializeNotifications.js new file mode 100644 index 0000000000000..65e9094fffeda --- /dev/null +++ b/app/javascript/packs/initializers/initializeNotifications.js @@ -0,0 +1,207 @@ +import { sendHapticMessage } from '../../utilities/sendHapticMessage'; +import { checkUserLoggedIn } from '../../utilities/checkUserLoggedIn'; +import { showModalAfterError } from '../../utilities/showUserAlertModal'; +import { initializeSubscribeButton } from '../../packs/subscribeButton'; +// eslint-disable-next-line no-redeclare +/* global InstantClick, instantClick */ + +function markNotificationsAsRead() { + setTimeout(() => { + if (document.getElementById('notifications-container')) { + getCsrfToken().then((csrfToken) => { + const locationAsArray = window.location.pathname.split('/'); + // Use regex to ensure only numbers in the original string are converted to integers + const parsedLastParam = parseInt( + locationAsArray[locationAsArray.length - 1].replace(/[^0-9]/g, ''), + 10, + ); + + const options = { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken }, + }; + + if (Number.isInteger(parsedLastParam)) { + fetch(`/notifications/reads?org_id=${parsedLastParam}`, options); + } else { + fetch('/notifications/reads', options); + } + }); + } + }, 450); +} + +function fetchNotificationsCount() { + if ( + document.getElementById('notifications-container') == null && + checkUserLoggedIn() + ) { + // Prefetch notifications page + if (instantClick) { + InstantClick.removeExpiredKeys('force'); + setTimeout(() => { + InstantClick.preload( + document.getElementById('notifications-link').href, + 'force', + ); + }, 30); + } + } +} + +function initReactions() { + setTimeout(() => { + if (document.getElementById('notifications-container')) { + let butts = document.getElementsByClassName('reaction-button'); + + for (let i = 0; i < butts.length; i++) { + const butt = butts[i]; + butt.setAttribute('aria-pressed', butt.classList.contains('reacted')); + + butt.onclick = function (event) { + event.preventDefault(); + sendHapticMessage('medium'); + const thisButt = this; + thisButt.classList.add('reacted'); + + function successCb(response) { + if (response.result === 'create') { + thisButt.classList.add('reacted'); + thisButt.setAttribute('aria-pressed', true); + } else { + thisButt.classList.remove('reacted'); + thisButt.setAttribute('aria-pressed', false); + } + } + + const formData = new FormData(); + formData.append('reactable_type', thisButt.dataset.reactableType); + formData.append('category', thisButt.dataset.category); + formData.append('reactable_id', thisButt.dataset.reactableId); + + getCsrfToken() + .then(sendFetch('reaction-creation', formData)) + .then((response) => { + if (response.status === 200) { + response.json().then(successCb); + } else { + showModalAfterError({ + response, + element: 'reaction', + action_ing: 'updating', + action_past: 'updated', + }); + } + }); + }; + } + + butts = document.getElementsByClassName('toggle-reply-form'); + + for (let i = 0; i < butts.length; i++) { + const butt = butts[i]; + + butt.onclick = function (event) { + event.preventDefault(); + const thisButt = this; + document + .getElementById(`comment-form-for-${thisButt.dataset.reactableId}`) + .classList.remove('hidden'); + thisButt.classList.add('hidden'); + thisButt.classList.remove('inline-flex'); + setTimeout(() => { + document + .getElementById( + `comment-textarea-for-${thisButt.dataset.reactableId}`, + ) + .focus(); + }, 30); + }; + } + } + }, 180); +} + +function listenForNotificationsBellClick() { + const notificationsLink = document.getElementById('notifications-link'); + if (notificationsLink) { + setTimeout(() => { + notificationsLink.onclick = function () { + document.getElementById('notifications-number').classList.add('hidden'); + }; + }, 180); + } +} + +function initFilter() { + const notificationsFilterSelect = document.getElementById( + 'notifications-filter__select', + ); + const changeNotifications = (event) => { + window.location.href = event.target.value; + }; + if (notificationsFilterSelect) { + notificationsFilterSelect.addEventListener('change', changeNotifications); + } +} + +function initPagination() { + // paginators appear after each block of HTML notifications sent by the server + const paginators = document.getElementsByClassName('notifications-paginator'); + if (paginators && paginators.length > 0) { + const paginator = paginators[paginators.length - 1]; + + if (paginator) { + window + .fetch(paginator.dataset.paginationPath, { + method: 'GET', + credentials: 'same-origin', + }) + .then((response) => { + if (response.status === 200) { + response.text().then((html) => { + const markup = html.trim(); + + if (markup) { + const container = document.getElementById('articles-list'); + + const newNotifications = document.createElement('div'); + newNotifications.innerHTML = markup; + + paginator.remove(); + container.append(newNotifications); + + initReactions(); + } else { + // no more notifications to load, we hide the load more wrapper + const button = document.getElementById('load-more-button'); + if (button) { + button.style.display = 'none'; + } + paginator.remove(); + } + + initializeSubscribeButton(); + }); + } + }); + } + } +} + +function initLoadMoreButton() { + const button = document.getElementById('load-more-button'); + if (button) { + button.addEventListener('click', initPagination); + } +} + +export function initializeNotifications() { + fetchNotificationsCount(); + markNotificationsAsRead(); + initReactions(); + listenForNotificationsBellClick(); + initFilter(); + initPagination(); + initLoadMoreButton(); +} diff --git a/app/javascript/packs/initializers/initializeSettings.js b/app/javascript/packs/initializers/initializeSettings.js new file mode 100644 index 0000000000000..d776eedf6c69c --- /dev/null +++ b/app/javascript/packs/initializers/initializeSettings.js @@ -0,0 +1,9 @@ +import { setupCopyOrgSecret } from '../../settings/copyOrgSecret'; +import { setupRssFetchTime } from '../../settings/rssFetchTime'; +import { setupMobilePageSel } from '../../settings/mobilePageSel'; + +export function initializeSettings() { + setupCopyOrgSecret(); + setupRssFetchTime(); + setupMobilePageSel(); +} diff --git a/app/javascript/packs/initializers/initializeTimeFixer.js b/app/javascript/packs/initializers/initializeTimeFixer.js new file mode 100644 index 0000000000000..6edeb61ae89b8 --- /dev/null +++ b/app/javascript/packs/initializers/initializeTimeFixer.js @@ -0,0 +1,75 @@ +function formatDateTime(options, value) { + return new Intl.DateTimeFormat('en-US', options).format(value); +} + +function convertUtcTime(utcTime) { + const time = new Date(Number(utcTime)); + const options = { + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + }; + return formatDateTime(options, time); +} + +function convertUtcDate(utcDate) { + const date = new Date(Number(utcDate)); + const options = { + month: 'short', + day: 'numeric', + }; + return formatDateTime(options, date); +} + +function convertCalEvent(utc) { + const date = new Date(Number(utc)); + const options = { + weekday: 'long', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }; + + return formatDateTime(options, date); +} + +function updateLocalDateTime(elements, convertCallback, getUtcDateTime) { + let local; + for (let i = 0; i < elements.length; i += 1) { + local = convertCallback(getUtcDateTime(elements[i])); + // eslint-disable-next-line no-param-reassign + elements[i].innerHTML = local; + } +} + +function initializeTimeFixer() { + const utcTime = document.getElementsByClassName('utc-time'); + const utcDate = document.getElementsByClassName('utc-date'); + const utc = document.getElementsByClassName('utc'); + + if (!utc) { + return; + } + + updateLocalDateTime( + utcTime, + convertUtcTime, + (element) => element.dataset.datetime, + ); + updateLocalDateTime( + utcDate, + convertUtcDate, + (element) => element.dataset.datetime, + ); + updateLocalDateTime(utc, convertCalEvent, (element) => element.innerHTML); +} + +export { + initializeTimeFixer, + updateLocalDateTime, + convertUtcDate, + convertUtcTime, + formatDateTime, + convertCalEvent, +}; diff --git a/app/javascript/packs/localizeArticleDates.js b/app/javascript/packs/localizeArticleDates.js new file mode 100644 index 0000000000000..f63f98c7fb992 --- /dev/null +++ b/app/javascript/packs/localizeArticleDates.js @@ -0,0 +1,12 @@ +/* Show article date/time according to user's locale */ +import { addLocalizedDateTimeToElementsTitles } from '../utilities/localDateTime'; + +const initializeArticleDate = () => { + const articlesDates = document.querySelectorAll( + '.crayons-story time, article time', + ); + + addLocalizedDateTimeToElementsTitles(articlesDates, 'datetime'); +}; + +initializeArticleDate(); diff --git a/app/javascript/packs/organizationDropdown.js b/app/javascript/packs/organizationDropdown.js new file mode 100644 index 0000000000000..80982ab04769e --- /dev/null +++ b/app/javascript/packs/organizationDropdown.js @@ -0,0 +1,28 @@ +import { initializeDropdown } from '@utilities/dropdownUtils'; + +function initDropdown() { + const profileDropdownDiv = document.querySelector('.profile-dropdown'); + + if (profileDropdownDiv.dataset.dropdownInitialized === 'true') { + return; + } + + if (!profileDropdownDiv) { + return; + } + + initializeDropdown({ + triggerElementId: 'organization-profile-dropdown', + dropdownContentId: 'organization-profile-dropdownmenu', + }); + + // Add actual link location (SEO doesn't like these "useless" links, so adding in here instead of in HTML) + const reportAbuseLink = profileDropdownDiv.querySelector( + '.report-abuse-link-wrapper', + ); + reportAbuseLink.innerHTML = `<a href="${reportAbuseLink.dataset.path}" class="crayons-link crayons-link--block">Report Abuse</a>`; + + profileDropdownDiv.dataset.dropdownInitialized = true; +} + +initDropdown(); diff --git a/app/javascript/packs/profileDropdown.js b/app/javascript/packs/profileDropdown.js index 55d1d408eb39b..a48d93453c291 100644 --- a/app/javascript/packs/profileDropdown.js +++ b/app/javascript/packs/profileDropdown.js @@ -1,5 +1,6 @@ import { initBlock } from '../profileDropdown/blockButton'; import { initFlag } from '../profileDropdown/flagButton'; +import { initSpam } from '../profileDropdown/spamButton'; import { initializeDropdown } from '@utilities/dropdownUtils'; /* global userData */ @@ -7,6 +8,7 @@ import { initializeDropdown } from '@utilities/dropdownUtils'; function initButtons() { initBlock(); initFlag(); + initSpam(); } function initDropdown() { diff --git a/app/javascript/packs/searchParams.js b/app/javascript/packs/searchParams.js index bdbefaa392a72..bd9b12265f4cf 100644 --- a/app/javascript/packs/searchParams.js +++ b/app/javascript/packs/searchParams.js @@ -1,6 +1,10 @@ /* global checkUserLoggedIn, showLoginModal, userData, buildArticleHTML, initializeReadingListIcons */ /* eslint no-undef: "error" */ +// This is a lightweight version of the client, which we should be fine importing regardless of whether it is set up. +// Could be optimized for optional inclusion in the future. +import algoliasearch from 'algoliasearch/lite' + function getQueryParams(qs) { qs = qs.split('+').join(' '); @@ -16,6 +20,7 @@ function getQueryParams(qs) { } const params = getQueryParams(document.location.search); +let algoliaSearchCompleted = false; function searchMain(substories, loadingHTML) { const query = filterXSS(params.q); @@ -176,6 +181,13 @@ function search(query, filters, sortBy, sortDirection) { } }); + // Run Algolia code only if the ID is live. + if (document.body.dataset.algoliaId?.length > 0 && !searchParams.toString().includes('MY_POSTS') && !algoliaSearchCompleted) { + algoliaSearch(searchParams.toString()); + algoliaSearchCompleted = true; + return; + } + fetch(`/search/feed_content?${searchParams.toString()}`, { method: 'GET', headers: { @@ -205,6 +217,46 @@ function search(query, filters, sortBy, sortDirection) { }); } +function algoliaSearch(searchParams) { + const paramsObj = getQueryParams(searchParams); + const env = document.querySelector('meta[name="environment"]').content; + const {algoliaId, algoliaSearchKey} = document.body.dataset; + const client = algoliasearch(algoliaId, algoliaSearchKey); + const indexName = paramsObj.sort_by ? `${paramsObj.class_name || 'Article'}_timestamp_${paramsObj.sort_direction}_${env}` : `${paramsObj.class_name || 'Article'}_${env}`; + const index = client.initIndex(indexName); // Hardcoded to user for now + // This is where we will add the functionality to get search results directly from index with client: + index + .search(paramsObj.search_fields, { + hitsPerPage: paramsObj.per_page, + queryType: 'prefixNone', // Disable prefix searches + page: paramsObj.page, + }) + .then(({ hits }) => { + const resultDivs = []; + const currentUser = userData(); + const currentUserId = currentUser && currentUser.id; + hits.forEach((story) => { + story.class_name = paramsObj.class_name; + story.id = story.objectID; + // Add profile_image_90 to story object from profile image if profile_image_90 is not present + resultDivs.push(buildArticleHTML(story, currentUserId)); + }); + document.getElementById('substories').innerHTML = resultDivs.join(''); + initializeReadingListIcons(); + document + .getElementById('substories') + .classList.add('search-results-loaded'); + if (hits.length === 0) { + document.getElementById('substories').innerHTML = + '<div class="p-9 align-center crayons-card">No results match that query</div>'; + } + }) + .catch(err => { + console.log('Algolia search error:') /* eslint-disable-line */ + console.log(err); /* eslint-disable-line */ + }); +} + const waitingOnSearch = setInterval(() => { if ( typeof search === 'function' && diff --git a/app/javascript/packs/subscribeButton.js b/app/javascript/packs/subscribeButton.js new file mode 100644 index 0000000000000..7028f50745e67 --- /dev/null +++ b/app/javascript/packs/subscribeButton.js @@ -0,0 +1,154 @@ +// /* global showModalAfterError*/ + +export function updateSubscribeButtonText( + button, + overrideSubscribed, + window_size, +) { + let label = ''; + let mobileLabel = ''; + if (typeof window_size == 'undefined') { + window_size = window.innerWidth; + } + + let noun = 'comments'; + const { subscription_id, subscription_config, comment_id } = button.dataset; + + let subscriptionIsActive = subscription_id != ''; + if (typeof overrideSubscribed != 'undefined') { + subscriptionIsActive = overrideSubscribed == 'subscribe'; + } + + const pressed = subscriptionIsActive; + const verb = subscriptionIsActive ? 'Subscribed' : 'Subscribe'; + + // comment_id should only be present if there's a subscription, so a button + // that initially renders as 'Subscribed-to-thread' can be a toggle until refreshed + if (comment_id && comment_id != '') { + noun = 'thread'; + } + + // Find the <span> element within the button + const spanElement = button.querySelector('span'); + + switch (subscription_config) { + case 'top_level_comments': + label = `${verb} to top-level comments`; + mobileLabel = `Top-level ${noun}`; + break; + case 'only_author_comments': + label = `${verb} to author comments`; + mobileLabel = `Author ${noun}`; + break; + default: + label = `${verb} to ${noun}`; + mobileLabel = `${noun}`.charAt(0).toUpperCase() + noun.slice(1); + } + + button.setAttribute('aria-label', label); + spanElement.innerText = window_size <= 760 ? mobileLabel : label; + button.setAttribute('aria-pressed', pressed); +} + +export function optimisticallyUpdateButtonUI(button, modeChange) { + if (typeof modeChange == 'undefined') { + modeChange = button.dataset.subscription_id ? 'unsubscribe' : 'subscribe'; + } + + if (modeChange == 'unsubscribe') { + button.classList.remove('comment-subscribed'); + updateSubscribeButtonText(button, 'unsubscribe'); + } else { + button.classList.add('comment-subscribed'); + updateSubscribeButtonText(button, 'subscribe'); + } + + return; +} + +export function determinePayloadAndEndpoint(button) { + let payload; + let endpoint; + + if (button.dataset.subscription_id != '') { + payload = { + subscription_id: button.dataset.subscription_id, + }; + endpoint = 'comment-unsubscribe'; + } else if ( + button.dataset.subscribed_to && + button.dataset.subscribed_to == 'comment' + ) { + payload = { + comment_id: button.dataset.comment_id, + }; + endpoint = 'comment-subscribe'; + } else { + payload = { + article_id: button.dataset.article_id, + subscription_config: button.dataset.subscription_config, + }; + endpoint = 'comment-subscribe'; + } + + return { + payload, + endpoint, + }; +} + +async function handleSubscribeButtonClick({ target }) { + optimisticallyUpdateButtonUI(target); + + const { payload, endpoint } = determinePayloadAndEndpoint(target); + const requestJson = JSON.stringify(payload); + + getCsrfToken() + .then(await sendFetch(endpoint, requestJson)) + .then(async (response) => { + if (response.status === 200) { + const res = await response.json(); + + if (res.destroyed) { + const matchingButtons = document.querySelectorAll( + `button[data-subscription_id='${target.dataset.subscription_id}']`, + ); + for (let i = 0; i < matchingButtons.length; i++) { + const button = matchingButtons[i]; + button.dataset.subscription_id = ''; + if (button != target) { + optimisticallyUpdateButtonUI(button, 'unsubscribe'); + } + } + } else if (res.subscription) { + target.dataset.subscription_id = res.subscription.id; + } else { + throw `Problem (un)subscribing: ${JSON.stringify(res)}`; + } + } + }); +} + +export function initializeSubscribeButton() { + const buttons = document.querySelectorAll('.subscribe-button'); + + if (buttons.length === 0) { + return; + } + + Array.from(buttons, (button) => { + if (button.wasInitialized) { + return; + } + + button.removeEventListener('click', handleSubscribeButtonClick); // Remove previous event listener + button.addEventListener('click', handleSubscribeButtonClick); + + button.wasInitialized = true; + updateSubscribeButtonText(button); + }); +} + +// Some subscribe buttons are added to the DOM dynamically. +// They will need to call this — see initializeNotifications > initPagination +initializeSubscribeButton(); diff --git a/app/javascript/packs/tagFollows.jsx b/app/javascript/packs/tagFollows.jsx new file mode 100644 index 0000000000000..24c0179e3c455 --- /dev/null +++ b/app/javascript/packs/tagFollows.jsx @@ -0,0 +1,109 @@ +import { h, render } from 'preact'; +import { Snackbar } from '../Snackbar/Snackbar'; +import { getUserDataAndCsrfToken } from '@utilities/getUserDataAndCsrfToken'; + +/* global showLoginModal */ + +// This Pack file has some dependencies for it to function accurately: +// Copy and paste the FOllow and Hide button snippets from a tag card. +// An example can be found in app/views/tags/index.html.erb +// The tag card needs to contain a class .js-tag-card elements, +// with data attributes for the tag-id and name. +// The follow button and the hide button need to contain classes +// js-follow-tag-button and/or js-hide-tag-button respectively. + +function renderPage(currentUser) { + import('../tags/TagButtonContainer') + .then(({ Tag }) => { + const tagCards = document.getElementsByClassName('js-tag-card'); + + const followedTags = JSON.parse(currentUser.followed_tags); + Array.from(tagCards).forEach((element) => { + const followedTag = followedTags.find( + (tag) => tag.id == element.dataset.tagId, + ); + const following = followedTag?.points >= 0; + const hidden = followedTag?.points < 0; + + render( + <Tag + id={element.dataset.tagId} + name={element.dataset.tagName} + isFollowing={following || hidden} + isHidden={hidden} + />, + document.getElementById(`tag-buttons-${element.dataset.tagId}`), + ); + }); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Unable to load tags', error); + }); +} + +/** + * Adds an event listener to the inner page content, to handle any and all follow button clicks with a single handler + */ +function listenForButtonClicks() { + document + .getElementById('page-content-inner') + .addEventListener('click', handleButtonClick); +} + +function handleButtonClick({ target }) { + let trigger; + + if (target.classList.contains('js-follow-tag-button')) { + trigger = 'follow_button'; + } + + if (target.classList.contains('js-hide-tag-button')) { + trigger = 'hide_button'; + } + + if (trigger) { + const trackingData = { + referring_source: 'tag', + trigger, + }; + showLoginModal(trackingData); + } +} + +function loadSnackbar() { + const snackZone = document.getElementById('snack-zone'); + + if (snackZone) { + render(<Snackbar lifespan="1" />, document.getElementById('snack-zone')); + } +} + +document.ready.then(() => { + const userStatus = document.body.getAttribute('data-user-status'); + loadSnackbar(); + + if (userStatus === 'logged-out') { + listenForButtonClicks(); + return; + } + + getUserDataAndCsrfToken() + .then(({ currentUser, csrfToken }) => { + window.currentUser = currentUser; + window.csrfToken = csrfToken; + renderPage(currentUser); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Error getting user and CSRF Token', error); + }); +}); + +Document.prototype.ready = new Promise((resolve) => { + if (document.readyState !== 'loading') { + return resolve(); + } + document.addEventListener('DOMContentLoaded', () => resolve()); + return null; +}); diff --git a/app/javascript/packs/toggleUserFlagModal.js b/app/javascript/packs/toggleUserFlagModal.js index f8ef4ad2377e1..e363491a0b3e5 100644 --- a/app/javascript/packs/toggleUserFlagModal.js +++ b/app/javascript/packs/toggleUserFlagModal.js @@ -134,8 +134,14 @@ const modalContents = new Map(); */ const getModalContents = (modalContentSelector) => { if (!modalContents.has(modalContentSelector)) { - const modalContentElement = - window.parent.document.querySelector(modalContentSelector); + let documentModal = window.parent.document; + const articleInIframe = documentModal.querySelector('.article-iframe'); + + if (articleInIframe) { + documentModal = articleInIframe.contentDocument || articleInIframe.contentWindow.document + } + + const modalContentElement = documentModal.querySelector(modalContentSelector); const modalContent = modalContentElement.innerHTML; modalContentElement.remove(); diff --git a/app/javascript/packs/userProfileSettings.js b/app/javascript/packs/userProfileSettings.js index a0ba5164b448f..9e9fbd6464edb 100644 --- a/app/javascript/packs/userProfileSettings.js +++ b/app/javascript/packs/userProfileSettings.js @@ -21,6 +21,27 @@ export function fieldCharacterLimits() { ...event.target.value, ].length; }); + + const imageInput = userSettingForm.querySelector('[id="user[profile_image]"'); + + const previewPhoto = () => { + const file = imageInput.files; + + if (!file) return; + + const fileReader = new FileReader(); + + const preview = userSettingForm.querySelector( + '#user-profile-image-preview', + ); + + fileReader.onload = function (event) { + preview.setAttribute('src', event.target.result); + }; + fileReader.readAsDataURL(file[0]); + }; + + imageInput.addEventListener('change', previewPhoto); } fieldCharacterLimits(); diff --git a/app/javascript/packs/users/profilePage.js b/app/javascript/packs/users/profilePage.js new file mode 100644 index 0000000000000..ab9581f7d8cbb --- /dev/null +++ b/app/javascript/packs/users/profilePage.js @@ -0,0 +1,35 @@ +const initializeProfileInfoToggle = () => { + const infoPanels = document.getElementsByClassName('js-user-info')[0]; + const trigger = document.getElementsByClassName('js-user-info-trigger')[0]; + const triggerWrapper = document.getElementsByClassName( + 'js-user-info-trigger-wrapper', + )[0]; + + if (trigger && infoPanels) { + trigger.addEventListener('click', () => { + triggerWrapper.classList.replace('block', 'hidden'); + infoPanels.classList.replace('hidden', 'grid'); + }); + } +}; + +const initializeProfileBadgesToggle = () => { + const badgesWrapper = document.getElementsByClassName('js-profile-badges')[0]; + const trigger = document.getElementsByClassName( + 'js-profile-badges-trigger', + )[0]; + + if (badgesWrapper && trigger) { + const badges = badgesWrapper.querySelectorAll('.js-profile-badge.hidden'); + trigger.addEventListener('click', () => { + badges.forEach((badge) => { + badge.classList.remove('hidden'); + }); + + trigger.classList.add('hidden'); + }); + } +}; + +initializeProfileInfoToggle(); +initializeProfileBadgesToggle(); diff --git a/app/javascript/previewCards/feedPreviewCards.jsx b/app/javascript/previewCards/feedPreviewCards.jsx index 2e757f08d65e6..b53c6e3516a42 100644 --- a/app/javascript/previewCards/feedPreviewCards.jsx +++ b/app/javascript/previewCards/feedPreviewCards.jsx @@ -1,5 +1,6 @@ import { h, render } from 'preact'; import { UserMetadata } from '../profilePreviewCards/UserMetadata'; +import { createRootFragment } from '../shared/preact/preact-root-fragment'; import { initializeDropdown, getDropdownRepositionListener, @@ -33,7 +34,10 @@ async function populateMissingMetadata(metadataPlaceholder) { function renderMetadata(metadata, placeholder) { const container = placeholder.parentElement; - render(<UserMetadata {...metadata} />, container, placeholder); + render( + <UserMetadata {...metadata} />, + createRootFragment(container, placeholder), + ); container .closest('.profile-preview-card__content') diff --git a/app/javascript/profileDropdown/spamButton.js b/app/javascript/profileDropdown/spamButton.js new file mode 100644 index 0000000000000..33a1c0c4aa6eb --- /dev/null +++ b/app/javascript/profileDropdown/spamButton.js @@ -0,0 +1,66 @@ +/* global userData */ +/* eslint-disable no-alert, import/order */ +import { request } from '@utilities/http'; +import { getUserDataAndCsrfToken } from '@utilities/getUserDataAndCsrfToken'; + +function addSpamButton(spamButton) { + const { profileUserId, profileUserName } = spamButton.dataset; + + let isUserSpam = spamButton.dataset.isUserSpam === 'true'; + + function toggleSpam() { + const confirm = window.confirm( + isUserSpam + ? 'Are you sure you want to remove the spam role from this user? This will make all of their posts and comments visible again, and regain their access to new posts' + : 'Are you sure you want to add the spam role to this user? This will hide all of their posts and comments and restrict their access to create new posts and comments', + ); + + if (confirm) { + request(`/users/${profileUserId}/spam`, { + method: isUserSpam ? 'DELETE' : 'PUT', + }) + .then((response) => { + if (response.ok) { + isUserSpam = !isUserSpam; + spamButton.innerHTML = isUserSpam ? `Set ${profileUserName} in Good standing` : `Mark ${profileUserName} as Spam`; + } + }) + .catch((e) => { + Honeybadger.notify( + isUserSpam ? 'Unable to remove spam role from user' : 'Unable to mark user as spam', + profileUserId, + ); + window.alert(`Something went wrong: ${e}`); + }); + } + } + + spamButton.addEventListener('click', toggleSpam); +} + +/** + * Adds a spam button visible only to admin users on profile pages. + * @function initSpam + * @returns {(void|undefined)} This function has no useable return value. + */ + +export function initSpam() { + const spamButton = document.getElementById( + 'user-profile-dropdownmenu-spam-button', + ); + + if (!spamButton) { + // button not always present when this is called + return; + } + + getUserDataAndCsrfToken().then(() => { + const user = userData(); + if (!user || !user.admin) { + spamButton.remove(); + return; + } + addSpamButton(spamButton); + }); +} +/* eslint-enable no-alert */ diff --git a/app/javascript/profilePreviewCards/MinimalProfilePreviewCard.jsx b/app/javascript/profilePreviewCards/MinimalProfilePreviewCard.jsx index 09a834fe0ebfb..8b664db781109 100644 --- a/app/javascript/profilePreviewCards/MinimalProfilePreviewCard.jsx +++ b/app/javascript/profilePreviewCards/MinimalProfilePreviewCard.jsx @@ -8,6 +8,7 @@ export const MinimalProfilePreviewCard = ({ name, profileImage, userId, + subscriber, }) => ( <div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"> <button @@ -16,7 +17,7 @@ export const MinimalProfilePreviewCard = ({ class="profile-preview-card__trigger fs-s p-1 crayons-btn crayons-btn--ghost -ml-1 -my-2" aria-label={`${name} profile details`} > - {name} + {name} {subscriber === 'true' ? <img class='subscription-icon' src={document.body.dataset.subscriptionIcon} alt='Subscriber' /> : ''} </button> <div diff --git a/app/javascript/readingList/components/__tests__/ItemListItem.test.jsx b/app/javascript/readingList/components/__tests__/ItemListItem.test.jsx index a3bce6ac6912a..701ca7d4d89fa 100644 --- a/app/javascript/readingList/components/__tests__/ItemListItem.test.jsx +++ b/app/javascript/readingList/components/__tests__/ItemListItem.test.jsx @@ -31,7 +31,7 @@ describe('<ItemListItem />', () => { it('renders the title', () => { const { queryByText } = render(<ItemListItem item={getItem()} />); - expect(queryByText(/Title/i)).toBeDefined(); + expect(queryByText(/Title/i)).toExist(); }); it('renders the path', () => { @@ -45,7 +45,7 @@ describe('<ItemListItem />', () => { it('renders a published date', () => { const { queryByText } = render(<ItemListItem item={getItem()} />); - expect(queryByText(/Jun 29/i)).toBeDefined(); + expect(queryByText(/Jun 29/i)).toExist(); }); it('renders a profile_image', () => { @@ -62,7 +62,7 @@ describe('<ItemListItem />', () => { const { queryByText } = render(<ItemListItem item={item} />); - expect(queryByText(/1 min read/i)).toBeDefined(); + expect(queryByText(/1 min read/i)).toExist(); }); it('renders with readingtime of 1 min if reading time is null.', () => { @@ -71,7 +71,7 @@ describe('<ItemListItem />', () => { const { queryByText } = render(<ItemListItem item={item} />); - expect(queryByText(/1 min read/i)).toBeDefined(); + expect(queryByText(/1 min read/i)).toExist(); }); it('renders correct readingtime.', () => { @@ -80,7 +80,7 @@ describe('<ItemListItem />', () => { const { queryByText } = render(<ItemListItem item={item} />); - expect(queryByText(/10 min read/i)).toBeDefined(); + expect(queryByText(/10 min read/i)).toExist(); }); it('renders without any tags if the tags array is empty.', () => { diff --git a/app/javascript/readingList/components/__tests__/ItemListItemArchiveButton.test.jsx b/app/javascript/readingList/components/__tests__/ItemListItemArchiveButton.test.jsx index 37a1b4d95ada5..3c2528bd8f822 100644 --- a/app/javascript/readingList/components/__tests__/ItemListItemArchiveButton.test.jsx +++ b/app/javascript/readingList/components/__tests__/ItemListItemArchiveButton.test.jsx @@ -16,6 +16,6 @@ describe('<ItemListItemArchiveButton />', () => { <ItemListItemArchiveButton text="archive" />, ); - expect(queryByText(/archive/i)).toBeDefined(); + expect(queryByText(/archive/i)).toExist(); }); }); diff --git a/app/javascript/readingList/readingList.jsx b/app/javascript/readingList/readingList.jsx index 7f8acffe30ff6..478e4b5ae404f 100644 --- a/app/javascript/readingList/readingList.jsx +++ b/app/javascript/readingList/readingList.jsx @@ -7,6 +7,7 @@ import { performInitialSearch, search, selectTag, + checkForPersistedTag, clearSelectedTags, } from '../searchableItemList/searchableItemList'; import { addSnackbarItem } from '../Snackbar'; @@ -69,12 +70,23 @@ export class ReadingList extends Component { this.clearSelectedTags = clearSelectedTags.bind(this); } - componentDidMount() { + async componentDidMount() { const { statusView } = this.state; - this.performInitialSearch({ + await this.performInitialSearch({ searchOptions: { status: `${statusView}` }, }); + + const persistedAvailableTag = checkForPersistedTag(this.state.availableTags); + if (persistedAvailableTag) { + this.selectTag({ + target: { + value: persistedAvailableTag, + skipPushState: true, + }, + preventDefault(){}, + }); + } } toggleStatusView = (event) => { diff --git a/app/javascript/responseTemplates/responseTemplates.js b/app/javascript/responseTemplates/responseTemplates.js index fd56b2f9e5f70..eb5b911adcb92 100644 --- a/app/javascript/responseTemplates/responseTemplates.js +++ b/app/javascript/responseTemplates/responseTemplates.js @@ -169,7 +169,6 @@ function fetchResponseTemplates(formId, onTemplateSelected) { }) .then((response) => response.json()) .then((response) => { - form.querySelector('img.loading-img').classList.toggle('hidden'); let revealed; const topLevelData = document.getElementById('response-templates-data'); @@ -225,7 +224,6 @@ function copyData(responsesContainer) { } function loadData(form, onTemplateSelected) { - form.querySelector('img.loading-img').classList.toggle('hidden'); fetchResponseTemplates(form.id, onTemplateSelected); } diff --git a/app/javascript/searchableItemList/searchableItemList.js b/app/javascript/searchableItemList/searchableItemList.js index b768bace0e81e..1ced9c8ecf5d0 100644 --- a/app/javascript/searchableItemList/searchableItemList.js +++ b/app/javascript/searchableItemList/searchableItemList.js @@ -18,8 +18,9 @@ export function onSearchBoxType(event) { export function selectTag(event) { event.preventDefault(); - const { value, dataset } = event.target; - const selectedTag = value ?? dataset.tag; + const { value, dataset, skipPushState } = event.target; + const selectedTagOrAll = value ?? dataset.tag; + const selectedTag = selectedTagOrAll?.match(/all tags/i) ? null : selectedTagOrAll; const component = this; const { query, statusView } = component.state; @@ -29,6 +30,12 @@ export function selectTag(event) { statusView, appendItems: false, }); + + // persist the selected tag in query params + if (!skipPushState) { + const newQueryParams = selectedTag ? `?selectedTag=${selectedTag}` : ''; + window.history.pushState(null, null, `/readinglist${newQueryParams}`); + } } export function clearSelectedTags(event) { @@ -41,7 +48,7 @@ export function clearSelectedTags(event) { } // Perform the initial search -export function performInitialSearch({ searchOptions = {} }) { +export async function performInitialSearch({ searchOptions = {} }) { const component = this; const { hitsPerPage } = component.state; const dataHash = { page: 0, per_page: hitsPerPage }; @@ -135,3 +142,13 @@ export function loadNextPage() { appendItems: true, }); } + +export function checkForPersistedTag(availableTags) { + // credit: https://stackoverflow.com/a/9870540 + const params = (new URL(window.location)).searchParams + const selectedTag = params.get('selectedTag'); + + if (availableTags?.includes(selectedTag)) return selectedTag; + return null; +} + diff --git a/app/javascript/settings/__tests__/copyOrgSecret.test.js b/app/javascript/settings/__tests__/copyOrgSecret.test.js new file mode 100644 index 0000000000000..956393aed0921 --- /dev/null +++ b/app/javascript/settings/__tests__/copyOrgSecret.test.js @@ -0,0 +1,45 @@ +import { setupCopyOrgSecret, copyToClipboardListener } from '../copyOrgSecret'; + +describe('OrgSecretCopy Tests', () => { + let copyToClipboardMock, valueToCopy; + + const getCopyBtn = () => + document.getElementById('settings-org-secret-copy-btn'); + + beforeAll(async () => { + valueToCopy = 'abc123'; + + document.body.innerHTML = ` + <input type="text" id="settings-org-secret" value="${valueToCopy}" readonly> + <button id="settings-org-secret-copy-btn"></button> + <div id="copy-text-announcer" class="hidden"></div> + `; + }); + + beforeEach(() => { + // Mock window.Forem.Runtime.copyToClipboard + copyToClipboardMock = jest.fn().mockResolvedValue({}); + global.window.Forem = { + Runtime: { + copyToClipboard: copyToClipboardMock, + }, + }; + }); + + it('attaches listener to button', () => { + const spyButton = jest.spyOn(getCopyBtn(), 'addEventListener'); + + setupCopyOrgSecret(); + expect(spyButton).toHaveBeenCalledWith('click', copyToClipboardListener); + }); + + it('after button is clicked, copyToClipboard called + announcer shown', async () => { + getCopyBtn().click(); + + expect(copyToClipboardMock).toHaveBeenCalledWith(valueToCopy); + + await Promise.resolve(); + const announcer = document.getElementById('copy-text-announcer'); + expect(announcer.classList.contains('hidden')).toBe(false); + }); +}); diff --git a/app/javascript/settings/__tests__/mobilePageSel.test.js b/app/javascript/settings/__tests__/mobilePageSel.test.js new file mode 100644 index 0000000000000..dcd8facc39b0a --- /dev/null +++ b/app/javascript/settings/__tests__/mobilePageSel.test.js @@ -0,0 +1,17 @@ +import { setupMobilePageSel, mobilePageSelListener } from '../mobilePageSel'; + +describe('MobilePageSel Tests', () => { + beforeAll(async () => { + document.body.innerHTML = '<select id="mobile-page-selector" />'; + }); + + it('attaches onchange listener to mobile page selector', () => { + const spySel = jest.spyOn( + document.getElementById('mobile-page-selector'), + 'addEventListener', + ); + + setupMobilePageSel(); + expect(spySel).toHaveBeenCalledWith('change', mobilePageSelListener); + }); +}); diff --git a/app/javascript/settings/__tests__/rssFetchTime.test.js b/app/javascript/settings/__tests__/rssFetchTime.test.js new file mode 100644 index 0000000000000..a422da8459ccc --- /dev/null +++ b/app/javascript/settings/__tests__/rssFetchTime.test.js @@ -0,0 +1,18 @@ +jest.mock('@utilities/localDateTime', () => ({ + timestampToLocalDateTime: jest.fn(), +})); + +import { setupRssFetchTime } from '../rssFetchTime'; +import { timestampToLocalDateTime } from '@utilities/localDateTime'; + +describe('RSSFetchTime Tests', () => { + beforeAll(async () => { + document.body.innerHTML = + '<time id="rss-fetch-time" datetime="2023-07-10T20:02:16Z"></time>'; + }); + + it('timestampToLocalDateTime is called', () => { + setupRssFetchTime(); + expect(timestampToLocalDateTime).toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/settings/copyOrgSecret.js b/app/javascript/settings/copyOrgSecret.js new file mode 100644 index 0000000000000..5c81761b446b9 --- /dev/null +++ b/app/javascript/settings/copyOrgSecret.js @@ -0,0 +1,16 @@ +export function copyToClipboardListener() { + const settingsOrgSecretInput = document.getElementById('settings-org-secret'); + if (settingsOrgSecretInput === null) return; + + const { value } = settingsOrgSecretInput; + return window.Forem.Runtime.copyToClipboard(value).then(() => { + // Show the confirmation message + document.getElementById('copy-text-announcer').classList.remove('hidden'); + }); +} + +export function setupCopyOrgSecret() { + document + .getElementById('settings-org-secret-copy-btn') + ?.addEventListener('click', copyToClipboardListener); +} diff --git a/app/javascript/settings/mobilePageSel.js b/app/javascript/settings/mobilePageSel.js new file mode 100644 index 0000000000000..f7475c954705f --- /dev/null +++ b/app/javascript/settings/mobilePageSel.js @@ -0,0 +1,11 @@ +export function mobilePageSelListener(event) { + const url = event.target.value; + InstantClick.preload(url); + InstantClick.display(url); +} + +export function setupMobilePageSel() { + document + .getElementById('mobile-page-selector') + ?.addEventListener('change', mobilePageSelListener); +} diff --git a/app/javascript/settings/rssFetchTime.js b/app/javascript/settings/rssFetchTime.js new file mode 100644 index 0000000000000..307184f44000b --- /dev/null +++ b/app/javascript/settings/rssFetchTime.js @@ -0,0 +1,22 @@ +import { timestampToLocalDateTime } from '@utilities/localDateTime'; + +export function setupRssFetchTime() { + // shows RSS fetch time in local time + const timeNode = document.getElementById('rss-fetch-time'); + if (timeNode) { + const timeStamp = timeNode.getAttribute('datetime'); + const timeOptions = { + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }; + + timeNode.textContent = timestampToLocalDateTime( + timeStamp, + navigator.language, + timeOptions, + ); + } +} diff --git a/app/javascript/shared/components/UserStore.js b/app/javascript/shared/components/UserStore.js new file mode 100644 index 0000000000000..aa2cd6fa2d008 --- /dev/null +++ b/app/javascript/shared/components/UserStore.js @@ -0,0 +1,42 @@ +export class UserStore { + constructor(users = []) { + this.users = users; + } + + static async fetch(url) { + try { + const res = await window.fetch(url); + return new UserStore(await res.json()); + } catch (error) { + Honeybadger.notify(error); + } + } + + matchingIds(arrayOfIds) { + const allUsers = this.users; + const someUsers = arrayOfIds.reduce((array, idString) => { + const aUser = allUsers.find((user) => user.id == idString); + if (typeof aUser != 'undefined') { + array.push(aUser); + } + return array; + }, []); + return someUsers; + } + + search(term, options) { + options ||= {}; + const { except } = options; + const allUsers = this.users; + const results = []; + for (const aUser of allUsers) { + if ( + aUser.id != except && + (aUser.name.search(term) >= 0 || aUser.username.search(term) >= 0) + ) { + results.push(aUser); + } + } + return results; + } +} diff --git a/app/javascript/shared/components/UsernameInput.jsx b/app/javascript/shared/components/UsernameInput.jsx new file mode 100644 index 0000000000000..b22fbf34c73bd --- /dev/null +++ b/app/javascript/shared/components/UsernameInput.jsx @@ -0,0 +1,49 @@ +import { h } from 'preact'; +import PropTypes from 'prop-types'; +import { MultiSelectAutocomplete } from '@crayons/MultiSelectAutocomplete/MultiSelectAutocomplete'; + +/** + * UsernameInput — produces a field that can autocomplete usernames + * + * @param {Function} fetchSuggestions Callback to sync selections to article form state + * @param {string} defaultValue Comma separated list of any currently user IDs + */ +export const UsernameInput = ({ + fetchSuggestions, + defaultValue, + inputId, + labelText, + placeholder, + maxSelections, + handleSelectionsChanged, +}) => { + const onSelectionsChanged = function (selections) { + const ids = selections.map((item) => item.id).join(', '); + handleSelectionsChanged?.(ids); + }; + + return ( + <MultiSelectAutocomplete + allowUserDefinedSelections={false} + showLabel={false} + border={true} + inputId={inputId} + labelText={labelText} + placeholder={placeholder} + maxSelections={maxSelections} + defaultValue={defaultValue} + fetchSuggestions={fetchSuggestions} + onSelectionsChanged={onSelectionsChanged} + /> + ); +}; + +UsernameInput.propTypes = { + fetchSuggestions: PropTypes.func.isRequired, + defaultValue: PropTypes.string, + inputId: PropTypes.string, + labelText: PropTypes.string, + placeholder: PropTypes.string, + maxSelections: PropTypes.string, + handleSelectionsChanged: PropTypes.func.isRequired, +}; diff --git a/app/javascript/shared/components/__tests__/UserStore.test.js b/app/javascript/shared/components/__tests__/UserStore.test.js new file mode 100644 index 0000000000000..29f9dff46feb4 --- /dev/null +++ b/app/javascript/shared/components/__tests__/UserStore.test.js @@ -0,0 +1,68 @@ +import fetch from 'jest-fetch-mock'; +import { UserStore } from '../UserStore'; + +global.fetch = fetch; + +function fakeUsers() { + return JSON.stringify([ + { name: 'Alice', username: 'alice', id: 1 }, + { name: 'Bob', username: 'bob', id: 2 }, + { name: 'Charlie', username: 'charlie', id: 3 }, + { name: 'Almost Alice', username: 'almostalice', id: 4 }, + ]); +} + +describe('UserStore', () => { + beforeEach(() => { + fetch.resetMocks(); + fetch.mockResponse(fakeUsers()); + }); + + test('initializes with an empty user list', () => { + const subject = new UserStore(); + expect(subject.users).toStrictEqual([]); + }); + + test('initializes with a user list', () => { + const users = [{ name: 'Bob', username: 'bob', id: 2 }]; + const subject = new UserStore(users); + expect(subject.users).toStrictEqual(users); + }); + + test('fetch from a given url', async () => { + await UserStore.fetch('/path/to/the/users'); + expect(fetch).toHaveBeenCalledWith('/path/to/the/users'); + }); + + test('return a sub-set of users matching given IDs', async () => { + const subject = await UserStore.fetch('/path/to/the/users'); + expect(subject.matchingIds(['1', '4'])).toStrictEqual([ + { name: 'Alice', username: 'alice', id: 1 }, + { name: 'Almost Alice', username: 'almostalice', id: 4 }, + ]); + expect(subject.matchingIds(['2', '3'])).toStrictEqual([ + { name: 'Bob', username: 'bob', id: 2 }, + { name: 'Charlie', username: 'charlie', id: 3 }, + ]); + }); + + test('return a sub-set of users matching search term', async () => { + const subject = await UserStore.fetch('/path/to/the/users'); + expect(subject.search('alice')).toStrictEqual([ + { name: 'Alice', username: 'alice', id: 1 }, + { name: 'Almost Alice', username: 'almostalice', id: 4 }, + ]); + expect(subject.search('stal')).toStrictEqual([ + { name: 'Almost Alice', username: 'almostalice', id: 4 }, + ]); + expect(subject.search('david')).toStrictEqual([]); + }); + + test('search with an exception', async () => { + const subject = await UserStore.fetch('/path/to/the/users'); + expect(subject.search('a', { except: '3' })).toStrictEqual([ + { name: 'Alice', username: 'alice', id: 1 }, + { name: 'Almost Alice', username: 'almostalice', id: 4 }, + ]); + }); +}); diff --git a/app/javascript/shared/components/__tests__/UsernameInput.test.js b/app/javascript/shared/components/__tests__/UsernameInput.test.js new file mode 100644 index 0000000000000..122a943385848 --- /dev/null +++ b/app/javascript/shared/components/__tests__/UsernameInput.test.js @@ -0,0 +1,53 @@ +import { h } from 'preact'; +import { render } from '@testing-library/preact'; +import { userEvent } from '@testing-library/user-event'; + +import '@testing-library/jest-dom'; + +import { UsernameInput } from '../UsernameInput'; + +jest.mock('@utilities/debounceAction', () => ({ + debounceAction: fn => fn +})); + +function fakeUsers() { + return [ + { name: 'Alice', username: 'alice', id: 1 }, + { name: 'Bob', username: 'bob', id: 2 }, + { name: 'Charlie', username: 'charlie', id: 3 }, + { name: 'Almost Alice', username: 'almostalice', id: 4 }, + ]; +} + +describe('<UsernameInput />', () => { + it('renders the default component', () => { + const { container } = render( + <UsernameInput + labelText="Example label" + fetchSuggestions={() => {}} + handleSelectionsChanged={() => {}} + />, + ); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it('calls handleSelectionsChanged with user IDs', async () => { + const fakeHandler = jest.fn(); + + const { getByLabelText, queryByRole } = render( + <UsernameInput + labelText="Enter username" + fetchSuggestions={fakeUsers} + handleSelectionsChanged={fakeHandler} + />, + ); + + const input = getByLabelText('Enter username'); + input.focus(); + await userEvent.type(input, 'Bob,'); + await userEvent.type(input, 'Charlie,'); + + expect(queryByRole('button', { name: 'Edit example' })).toBeNull(); + expect(fakeHandler).toHaveBeenCalledWith('2, 3'); + }); +}); diff --git a/app/javascript/shared/components/__tests__/__snapshots__/UsernameInput.test.js.snap b/app/javascript/shared/components/__tests__/__snapshots__/UsernameInput.test.js.snap new file mode 100644 index 0000000000000..fbace283b7ef8 --- /dev/null +++ b/app/javascript/shared/components/__tests__/__snapshots__/UsernameInput.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`<UsernameInput /> renders the default component 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"screen-reader-only\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"self-center\\"><input autocomplete=\\"off\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add...\\" class=\\"c-autocomplete--multi__input\\"></li></ul></div></div>"`; diff --git a/app/javascript/shared/components/__tests__/focusTrap.test.jsx b/app/javascript/shared/components/__tests__/focusTrap.test.jsx index 1cb8e9a0c01bc..8d17b98cbf281 100644 --- a/app/javascript/shared/components/__tests__/focusTrap.test.jsx +++ b/app/javascript/shared/components/__tests__/focusTrap.test.jsx @@ -1,7 +1,7 @@ import { h } from 'preact'; import { render, waitFor } from '@testing-library/preact'; import '@testing-library/jest-dom'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import { FocusTrap } from '../focusTrap'; diff --git a/app/javascript/shared/components/defaultSelectionTemplate.jsx b/app/javascript/shared/components/defaultSelectionTemplate.jsx index 779be1c27118f..02c505db57f09 100644 --- a/app/javascript/shared/components/defaultSelectionTemplate.jsx +++ b/app/javascript/shared/components/defaultSelectionTemplate.jsx @@ -1,7 +1,7 @@ -import { h } from 'preact'; +import { Fragment, h } from 'preact'; import PropTypes from 'prop-types'; import { Icon, ButtonNew as Button } from '@crayons'; -import { Close } from '@images/x.svg'; +import Close from '@images/x.svg'; /** * Responsible for the layout of a selected item in the crayons autocomplete and multi input components diff --git a/app/javascript/shared/preact/package.json b/app/javascript/shared/preact/package.json new file mode 100644 index 0000000000000..67f205e7b0194 --- /dev/null +++ b/app/javascript/shared/preact/package.json @@ -0,0 +1,7 @@ +{ + "name": "preact-root-fragment", + "version": "0.2.0", + "main": "./preact-root-fragment.js", + "module": "./preact-root-fragment.js", + "type": "module" +} diff --git a/app/javascript/shared/preact/preact-root-fragment.js b/app/javascript/shared/preact/preact-root-fragment.js new file mode 100644 index 0000000000000..2faeb87cd68e3 --- /dev/null +++ b/app/javascript/shared/preact/preact-root-fragment.js @@ -0,0 +1,39 @@ +/** + * A Preact 11+ implementation of the `replaceNode` parameter from Preact 10. + * + * This creates a "Persistent Fragment" (a fake DOM element) containing one or more + * DOM nodes, which can then be passed as the `parent` argument to Preact's `render()` method. + * Source: https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c + */ +export function createRootFragment(parent, replaceNode) { + if (replaceNode) { + replaceNode = Array.isArray(replaceNode) ? replaceNode : [replaceNode]; + } else { + replaceNode = [parent]; + parent = parent.parentNode; + } + + const s = replaceNode[replaceNode.length - 1].nextSibling; + + const rootFragment = { + nodeType: 1, + parentNode: parent, + firstChild: replaceNode[0], + childNodes: replaceNode, + insertBefore: (c, r) => { + parent.insertBefore(c, r || s); + return c; + }, + appendChild: (c) => { + parent.insertBefore(c, s); + return c; + }, + removeChild: (c) => { + parent.removeChild(c); + return c; + }, + }; + + parent.__k = rootFragment; + return rootFragment; +} diff --git a/app/javascript/shared/preact/preact-root-fragment.test.js b/app/javascript/shared/preact/preact-root-fragment.test.js new file mode 100644 index 0000000000000..7155375fe4292 --- /dev/null +++ b/app/javascript/shared/preact/preact-root-fragment.test.js @@ -0,0 +1,59 @@ +import { h } from 'preact'; +import '@testing-library/jest-dom'; + +import { createRootFragment } from './preact-root-fragment'; + +describe('createRootFragment', () => { + it('has a child element as replace node', () => { + const fragment = createRootFragment( + <div className="parent" />, + <article>text</article>, + ); + + expect(fragment.firstChild.type).toBe('article'); + }); + + it('handle multiple nodes', () => { + const fragment = createRootFragment(<div className="parent" />, [ + <div id="app1" key="1" />, + <div id="app2" key="2" />, + ]); + + expect(fragment.firstChild.props.id).toBe('app1'); + expect(fragment.childNodes.length).toBe(2); + }); + + it('adds fragment to parent context', () => { + const parent = <div className="parent" />; + const placeholder = <div className="appXYZ" />; + + const fragment = createRootFragment(parent, placeholder); + + expect(parent.__k.firstChild).toBe(fragment.firstChild); + expect(parent.__k.childNodes.length).toBeGreaterThan(0); + }); + + it('insertBefore on parent fragment', () => { + const parent = document.createElement('div'); + parent.className = '#app'; + const child = document.createElement('main'); + + const fragment = createRootFragment(parent, child); + + const span = document.createElement('span'); + + fragment.insertBefore(span, child.parentNode); + + expect(parent.children[0].tagName.toLowerCase()).toBe('span'); + }); + + it('appendChild on parent fragment', () => { + const parent = document.createElement('div'); + const fragment = createRootFragment(parent, <main id="#main" />); + const p = document.createElement('p'); + + fragment.appendChild(p); + + expect(parent.childNodes[0].nodeName.toLowerCase()).toBe('p'); + }); +}); diff --git a/app/javascript/sidebar-widget/__tests__/sidebarUser.test.jsx b/app/javascript/sidebar-widget/__tests__/sidebarUser.test.jsx index fd924bc2fb72d..a48e887f26d34 100644 --- a/app/javascript/sidebar-widget/__tests__/sidebarUser.test.jsx +++ b/app/javascript/sidebar-widget/__tests__/sidebarUser.test.jsx @@ -83,7 +83,7 @@ describe('<SidebarUser />', () => { />, ); - expect(queryByText(/Following/i)).toBeDefined(); + expect(queryByText(/Following/i)).toExist(); }); it('shows if the user can be followed', () => { @@ -99,7 +99,7 @@ describe('<SidebarUser />', () => { />, ); - expect(queryByText(/follow/i)).toBeDefined(); + expect(queryByText(/follow/i)).toExist(); }); }); }); diff --git a/app/javascript/tags/TagButtonContainer.jsx b/app/javascript/tags/TagButtonContainer.jsx new file mode 100644 index 0000000000000..0fe8bef85ae9e --- /dev/null +++ b/app/javascript/tags/TagButtonContainer.jsx @@ -0,0 +1,142 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import PropTypes from 'prop-types'; +import { addSnackbarItem } from '../Snackbar'; + +/* global browserStoreCache */ + +/** + * Renders the updated buttons component for a given tag card + * @param {number} props.id The id of the tag + * @param {string} props.name The name of the tag + * @param {boolean} props.isFollowing Whether the user is following the tag + * @param {string} props.isHidden Whether the tag is hidden + * + * @returns Updates the given Tag buttons (Follow and Hide) with the correct labels, buttons and actions. + */ +export const Tag = ({ id, name, isFollowing, isHidden }) => { + const [following, setFollowing] = useState(isFollowing); + const [hidden, setHidden] = useState(isHidden); + + let followingButton; + + const toggleFollowButton = () => { + const updatedFollowState = !following; + + postFollowItem({ + following: updatedFollowState, + hidden, + }).then((response) => { + if (response.ok) { + updateItem( + null, + updatedFollowState, + `You have ${following ? 'un' : ''}followed ${name}.`, + ); + return; + } + + addSnackbarItem({ + message: `An error has occurred.`, + addCloseButton: true, + }); + }); + }; + + const toggleHideButton = () => { + // if the tag's new state will be hidden (clicked on the hide button) then we we set it to following. + // if the tags new state is to be unhidden (clicked on the unhide button) then we set it to unfollow. + const updatedHiddenState = !hidden; + const updatedFollowState = updatedHiddenState; + + postFollowItem({ + hidden: updatedHiddenState, + following: updatedFollowState, + }).then((response) => { + if (response.ok) { + updateItem( + updatedHiddenState, + updatedFollowState, + `You have ${hidden ? 'un' : ''}hidden ${name}.`, + ); + return; + } + + addSnackbarItem({ + message: `An error has occurred.`, + addCloseButton: true, + }); + }); + }; + + const updateItem = (updatedHiddenState, updatedFollowState, message) => { + if (updatedHiddenState !== null) { + setHidden(updatedHiddenState); + } + setFollowing(updatedFollowState); + browserStoreCache('remove'); + addSnackbarItem({ + message, + addCloseButton: true, + }); + return; + }; + + const postFollowItem = ({ following, hidden }) => { + return fetch('/follows', { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + followable_type: 'Tag', + followable_id: id, + verb: `${following ? '' : 'un'}follow`, + explicit_points: hidden ? -1 : 1, + }), + credentials: 'same-origin', + }); + }; + + const hideButtonLabel = hidden ? 'Unhide' : 'Hide'; + const followButtonLabel = following ? 'Following' : 'Follow'; + + if (!hidden) { + followingButton = ( + <button + onClick={toggleFollowButton} + className={`${ + following + ? 'crayons-btn crayons-btn--outlined' + : 'c-btn c-btn--primary' + }`} + aria-pressed={following} + aria-label={`${followButtonLabel} tag: ${name}`} + > + {followButtonLabel} + </button> + ); + } + + return ( + <div className="flex gap-2"> + {followingButton} + <button + onClick={toggleHideButton} + className={`c-btn ${hidden ? 'c-btn--primary c-btn--destructive' : ''}`} + aria-label={`${hideButtonLabel} tag: ${name}`} + > + {hideButtonLabel} + </button> + </div> + ); +}; + +Tag.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + following: PropTypes.bool, + hidden: PropTypes.bool, +}; diff --git a/app/javascript/utilities/__tests__/billboardInteractivity.test.js b/app/javascript/utilities/__tests__/billboardInteractivity.test.js new file mode 100644 index 0000000000000..63ab7ee4c42c8 --- /dev/null +++ b/app/javascript/utilities/__tests__/billboardInteractivity.test.js @@ -0,0 +1,52 @@ +// Import the function to test and any necessary parts +import { setupBillboardInteractivity } from '@utilities/billboardInteractivity'; + +describe('billboard close functionality', () => { + beforeEach(() => { + // Setup a simple DOM structure that includes only the elements needed for the close functionality + document.body.innerHTML = ` + <div class="another-element"></div> + <div class="js-billboard popover-billboard" style="display: block;" data-dismissal-sku="WHATUP"> + <button id="sponsorship-close-trigger-1"></button> + </div> + `; + }); + + it('hides billboard on close button click', () => { + setupBillboardInteractivity(); + + // Simulate clicking the close button + const closeButton = document.querySelector('#sponsorship-close-trigger-1'); + closeButton.click(); + + // Assert the billboard is hidden + const billboard = document.querySelector('.js-billboard'); + expect(billboard.style.display).toBe('none'); + }); + + it('hides billboard when clicking outside of it', () => { + setupBillboardInteractivity(); + + // Simulate a click outside the billboard + const anotherElement = document.querySelector('.another-element'); + anotherElement.click(); + + // Assert the billboard is hidden + const billboard = document.querySelector('.js-billboard'); + expect(billboard.style.display).toBe('none'); + }); + + it('adds dismissal sku to local storage when dismissing a billboard', () => { + setupBillboardInteractivity(); + + // Simulate clicking the close button + const closeButton = document.querySelector('#sponsorship-close-trigger-1'); + closeButton.click(); + + // Assert the dismissal sku is added to local storage + const dismissalSkus = JSON.parse( + localStorage.getItem('dismissal_skus_triggered'), + ); + expect(dismissalSkus).toEqual(['WHATUP']); + }); +}); diff --git a/app/javascript/utilities/__tests__/checkUserLoggedIn.test.js b/app/javascript/utilities/__tests__/checkUserLoggedIn.test.js new file mode 100644 index 0000000000000..c3652eba02062 --- /dev/null +++ b/app/javascript/utilities/__tests__/checkUserLoggedIn.test.js @@ -0,0 +1,14 @@ +import { checkUserLoggedIn } from '@utilities/checkUserLoggedIn'; + +describe('CheckUserLoggedIn Utility', () => { + it('should return false if no body', () => { + const userLoggedIn = checkUserLoggedIn(); + expect(userLoggedIn).toEqual(false); + }); + + it('should return true if user has the logged in attribute', () => { + document.body.setAttribute('data-user-status', 'logged-in'); + const userLoggedIn = checkUserLoggedIn(); + expect(userLoggedIn).toEqual(true); + }); +}); diff --git a/app/javascript/utilities/__tests__/codeFullscreenModeSwitcher.test.js b/app/javascript/utilities/__tests__/codeFullscreenModeSwitcher.test.js new file mode 100644 index 0000000000000..492f81d6edd92 --- /dev/null +++ b/app/javascript/utilities/__tests__/codeFullscreenModeSwitcher.test.js @@ -0,0 +1,100 @@ +import { + addFullScreenModeControl, + getFullScreenModeStatus, + onPressEscape, + onPopstate, +} from '@utilities/codeFullscreenModeSwitcher'; + +describe('CodeFullScreenModeSwitcher Utility', () => { + const getFullScreenElements = () => + ['.js-fullscreen-code.is-open', '.js-code-highlight.is-fullscreen'].map( + (selector) => document.body.querySelector(selector), + ); + + const testFullScreenElements = ({ exists }) => { + for (const element of getFullScreenElements()) { + if (exists) expect(element).not.toBeNull(); + else expect(element).toBeNull(); + } + }; + + const testNonFullScreen = () => { + expect(getFullScreenModeStatus()).toBe(false); + testFullScreenElements({ exists: false }); + expect(document.body.style.overflow).toBe(''); + }; + + const testFullScreen = () => { + expect(getFullScreenModeStatus()).toBe(true); + testFullScreenElements({ exists: true }); + expect(document.body.style.overflow).toBe('hidden'); + }; + + const getEnterFullScreenButtons = () => + document.getElementsByClassName('js-fullscreen-code-action'); + + beforeAll(() => { + global.scrollTo = jest.fn(); + + document.body.innerHTML = ` + <div class="js-code-highlight"> + <div class="js-fullscreen-code-action"><div> + </div> + <div class="js-fullscreen-code"></div> + `; + addFullScreenModeControl(getEnterFullScreenButtons()); + }); + + // Assertions are called within the `testNonFullScreen` function. + // eslint-disable-next-line jest/expect-expect + it('starts in non-fullscreen mode', testNonFullScreen); + + it('enters fullscreen mode on click', () => { + const goFullScreenButtons = getEnterFullScreenButtons(); + const spyDoc = jest.spyOn(document.body, 'addEventListener'); + const spyWindow = jest.spyOn(window, 'addEventListener'); + + goFullScreenButtons[0].click(); + + testFullScreen(); + expect(spyDoc).toHaveBeenCalledWith('keyup', onPressEscape); + expect(spyWindow).toHaveBeenCalledWith('popstate', onPopstate); + }); + + it('exits fullscreen mode on Escape key', () => { + const spyDocRemove = jest.spyOn(document.body, 'removeEventListener'); + const spyWindowRemove = jest.spyOn(window, 'removeEventListener'); + + testFullScreen(); + document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + testNonFullScreen(); + + expect(spyDocRemove).toHaveBeenCalledWith('keyup', onPressEscape); + expect(spyWindowRemove).toHaveBeenCalledWith('popstate', onPopstate); + }); + + it('re-enters fullscreen mode on click', () => { + const goFullScreenButtons = getEnterFullScreenButtons(); + const spyDoc = jest.spyOn(document.body, 'addEventListener'); + const spyWindow = jest.spyOn(window, 'addEventListener'); + + testNonFullScreen(); + goFullScreenButtons[0].click(); + testFullScreen(); + + expect(spyDoc).toHaveBeenCalledWith('keyup', onPressEscape); + expect(spyWindow).toHaveBeenCalledWith('popstate', onPopstate); + }); + + it('exits fullscreen mode on popstate event', () => { + const spyDocRemove = jest.spyOn(document.body, 'removeEventListener'); + const spyWindowRemove = jest.spyOn(window, 'removeEventListener'); + + testFullScreen(); + window.dispatchEvent(new PopStateEvent('popstate', { state: { key: '' } })); + testNonFullScreen(); + + expect(spyDocRemove).toHaveBeenCalledWith('keyup', onPressEscape); + expect(spyWindowRemove).toHaveBeenCalledWith('popstate', onPopstate); + }); +}); diff --git a/app/javascript/utilities/__tests__/dragAndDrop.test.js b/app/javascript/utilities/__tests__/dragAndDrop.test.js index 0b1f1f582900f..c5ce95dcb7595 100644 --- a/app/javascript/utilities/__tests__/dragAndDrop.test.js +++ b/app/javascript/utilities/__tests__/dragAndDrop.test.js @@ -49,7 +49,7 @@ describe('drag and drop for components', () => { result.current.setElement(document.createElement('textarea')); }); - expect(HTMLElement.prototype.addEventListener).toHaveBeenCalledTimes(4); + expect(HTMLElement.prototype.addEventListener).toHaveBeenCalledTimes(5); expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(2); }); @@ -78,7 +78,7 @@ describe('drag and drop for components', () => { 2, ); expect(HTMLElement.prototype.removeEventListener).toHaveBeenCalledTimes( - 4, + 5, ); }); }); @@ -115,7 +115,7 @@ describe('drag and drop for components', () => { ); expect(HTMLDocument.prototype.addEventListener).toBeCalledTimes(2); - expect(HTMLElement.prototype.addEventListener).toHaveBeenCalledTimes(4); + expect(HTMLElement.prototype.addEventListener).toHaveBeenCalledTimes(5); }); it('should not attach drag and drop events', async () => { @@ -151,7 +151,7 @@ describe('drag and drop for components', () => { unmount(); expect(HTMLDocument.prototype.removeEventListener).toBeCalledTimes(2); expect(HTMLElement.prototype.removeEventListener).toHaveBeenCalledTimes( - 4, + 5, ); }); diff --git a/app/javascript/utilities/__tests__/getUserDataAndCsrfToken.test.js b/app/javascript/utilities/__tests__/getUserDataAndCsrfToken.test.js index a2aebed8b1c92..795a0f95432c4 100644 --- a/app/javascript/utilities/__tests__/getUserDataAndCsrfToken.test.js +++ b/app/javascript/utilities/__tests__/getUserDataAndCsrfToken.test.js @@ -1,4 +1,7 @@ -import { getUserDataAndCsrfToken } from '../getUserDataAndCsrfToken'; +import { + getUserDataAndCsrfToken, + getUserDataAndCsrfTokenSafely, +} from '../getUserDataAndCsrfToken'; const ERROR_MESSAGE = "Couldn't find user data on page."; @@ -54,3 +57,16 @@ describe('getUserDataAndCsrfToken', () => { }); }); }); + +describe('getUserDataAndCsrfTokenSafely', () => { + afterEach(() => { + document.head.innerHTML = ''; + document.body.removeAttribute('data-user'); + }); + + test('should not reject if no user or csrf token found.', async () => { + await expect(getUserDataAndCsrfTokenSafely(document)).resolves.toEqual( + expect.anything(), + ); + }); +}); diff --git a/app/javascript/utilities/__tests__/insertInArrayIf.test.js b/app/javascript/utilities/__tests__/insertInArrayIf.test.js new file mode 100644 index 0000000000000..c01a62c88d56f --- /dev/null +++ b/app/javascript/utilities/__tests__/insertInArrayIf.test.js @@ -0,0 +1,12 @@ +import { insertInArrayIf } from '@utilities/insertInArrayIf'; + +describe('insertInArrayIf Utility', () => { + it('should return insert into the array based on what the condition evaluates to', () => { + const trueCondition = true; + const falseCondition = false; + const object = { a: 1, b: 1 }; + + expect(insertInArrayIf(trueCondition, object)).toEqual([object]); + expect(insertInArrayIf(falseCondition, object)).toEqual([]); + }); +}); diff --git a/app/javascript/utilities/__tests__/localDateTime.test.js b/app/javascript/utilities/__tests__/localDateTime.test.js index 371834855c66c..dee899c3630f6 100644 --- a/app/javascript/utilities/__tests__/localDateTime.test.js +++ b/app/javascript/utilities/__tests__/localDateTime.test.js @@ -1,26 +1,41 @@ -import {timestampToLocalDateTime, addLocalizedDateTimeToElementsTitles } from '@utilities/localDateTime'; +import { + timestampToLocalDateTime, + addLocalizedDateTimeToElementsTitles, +} from '@utilities/localDateTime'; describe('LocalDateTime Utilities', () => { it('should return empty string when no timestamp', () => { - const localTime = timestampToLocalDateTime(null, null, null) + const localTime = timestampToLocalDateTime(null, null, null); expect(localTime).toEqual(''); }); it('should return readable date string', () => { - const localTime = timestampToLocalDateTime('2019-05-03T16:02:50.908Z', 'default', {}) + const localTime = timestampToLocalDateTime( + '2019-05-03T16:02:50.908Z', + 'en', + {}, + ); expect(localTime).toEqual('5/3/2019'); }); it('should return formatted year when year option added', () => { - const localTime = timestampToLocalDateTime('2019-05-03T16:02:50.908Z', 'default', {year: '2-digit'}) + const localTime = timestampToLocalDateTime( + '2019-05-03T16:02:50.908Z', + 'en', + { year: '2-digit' }, + ); expect(localTime).toEqual('19'); }); it('should add datetime attribute to element', () => { - document.body.setAttribute('datetime', 2222) - addLocalizedDateTimeToElementsTitles(document.querySelectorAll("body"), 'datetime') - // eslint-disable-next-line no-prototype-builtins - expect(document.querySelector('body').attributes.hasOwnProperty('datetime')).toBe(true); + document.body.setAttribute('datetime', 2222); + addLocalizedDateTimeToElementsTitles( + document.querySelectorAll('body'), + 'datetime', + ); + expect( + // eslint-disable-next-line no-prototype-builtins + document.querySelector('body').attributes.hasOwnProperty('datetime'), + ).toBe(true); }); }); - \ No newline at end of file diff --git a/app/javascript/utilities/__tests__/sendHapticMessage.test.js b/app/javascript/utilities/__tests__/sendHapticMessage.test.js new file mode 100644 index 0000000000000..1ec8b8974617b --- /dev/null +++ b/app/javascript/utilities/__tests__/sendHapticMessage.test.js @@ -0,0 +1,24 @@ +import { sendHapticMessage } from '../sendHapticMessage'; + +describe('SendHapticMessage Utility', () => { + it('should call postMessage', async () => { + const mockPostMessage = jest.fn(); + global.window.webkit = { + messageHandlers: { + haptic: { + postMessage: mockPostMessage, + }, + }, + }; + await sendHapticMessage('sample message'); + + expect(mockPostMessage).toHaveBeenCalled(); + }); + + it('should log to console otherwise', async () => { + global.window = {}; + global.console.log = jest.fn(); + await sendHapticMessage('sample message'); + expect(global.console.log).not.toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/utilities/__tests__/showUserAlertModal.test.js b/app/javascript/utilities/__tests__/showUserAlertModal.test.js new file mode 100644 index 0000000000000..818ca3e6c8a78 --- /dev/null +++ b/app/javascript/utilities/__tests__/showUserAlertModal.test.js @@ -0,0 +1,73 @@ +import 'isomorphic-fetch'; +import { + getModalHtml, + showModalAfterError, +} from '../../utilities/showUserAlertModal'; + +describe('ShowUserAlert Utility', () => { + beforeEach(() => { + const mockShowModal = jest.fn(); + global.window.Forem = { showModal: mockShowModal }; + }); + + it('should return modal html', () => { + const modalHtml = getModalHtml('Sample text', 'Sample Confirm Text'); + expect(modalHtml).toContain('Sample text'); + }); + + test('shows rate limit modal if response status is 429', async () => { + const response = new Response('', { status: 429 }); + const showRateLimitModal = jest.fn(); + const showUserAlertModal = jest.fn(); + + await showModalAfterError({ + response, + element: 'post', + action_ing: 'creating', + action_past: 'created', + timeframe: '5 minutes', + showRateLimitModal, + showUserAlertModal, + }); + + expect(showUserAlertModal).not.toHaveBeenCalled(); + }); + + test('shows user alert modal if response status is not 429', async () => { + const response = new Response( + JSON.stringify({ error: 'Something went wrong' }), + ); + const showRateLimitModal = jest.fn(); + const showUserAlertModal = jest.fn(); + + await showModalAfterError({ + response, + element: 'post', + action_ing: 'creating', + action_past: 'created', + timeframe: '5 minutes', + showRateLimitModal, + showUserAlertModal, + }); + + expect(showRateLimitModal).not.toHaveBeenCalled(); + }); + + test('shows user alert modal if response cannot be parsed as JSON', async () => { + const response = new Response('Something went wrong', { status: 500 }); + const showRateLimitModal = jest.fn(); + const showUserAlertModal = jest.fn(); + + await showModalAfterError({ + response, + element: 'post', + action_ing: 'creating', + action_past: 'created', + timeframe: '5 minutes', + showRateLimitModal, + showUserAlertModal, + }); + + expect(showRateLimitModal).not.toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/utilities/billboardInteractivity.jsx b/app/javascript/utilities/billboardInteractivity.jsx new file mode 100644 index 0000000000000..bb8599df4664a --- /dev/null +++ b/app/javascript/utilities/billboardInteractivity.jsx @@ -0,0 +1,85 @@ +import { initializeDropdown } from './dropdownUtils'; + +export function setupBillboardInteractivity() { + const sponsorshipDropdownButtons = document.querySelectorAll( + 'button[id^=sponsorship-dropdown-trigger-]', + ); + if (sponsorshipDropdownButtons.length) { + sponsorshipDropdownButtons.forEach((sponsorshipDropdownButton) => { + amendBillboardStyle(sponsorshipDropdownButton); + + const dropdownContentId = + sponsorshipDropdownButton.getAttribute('aria-controls'); + if ( + sponsorshipDropdownButton && + sponsorshipDropdownButton.dataset.initialized !== 'true' + ) { + initializeDropdown({ + triggerElementId: sponsorshipDropdownButton.id, + dropdownContentId, + }); + + sponsorshipDropdownButton.dataset.initialized = 'true'; + } + + const popoverParent = + sponsorshipDropdownButton.closest('.popover-billboard'); + if ( + popoverParent && + sponsorshipDropdownButton.getBoundingClientRect().top > + window.innerHeight / 2 + ) { + popoverParent.classList.add('popover-billboard--menuopenupwards'); + } + }); + } + const sponsorshipCloseButtons = document.querySelectorAll( + 'button[id^=sponsorship-close-trigger-]', + ); + if (sponsorshipCloseButtons.length) { + sponsorshipCloseButtons.forEach((sponsorshipCloseButton) => { + sponsorshipCloseButton.addEventListener('click', () => { + dismissBillboard(sponsorshipCloseButton); + }); + if (sponsorshipCloseButton.closest('.popover-billboard')) { + document.addEventListener('click', (event) => { + // If the event target is the article header and .popover-billboard does not have display none, don't follow that link + if (event.target.closest('.crayons-article__header') && document.querySelector('.popover-billboard').style.display !== 'none'){ + event.preventDefault(); + } + if (!event.target.closest('.js-billboard')) { + dismissBillboard(sponsorshipCloseButton); + } + }); + } + }); + } +} + +/** + * If the billboard container height is less than 220px, then we revert the overflow-y property + given by the billboard class so that the dropdown does not scroll within the container + */ +function amendBillboardStyle(sponsorshipDropdownButton) { + if (sponsorshipDropdownButton.closest('.js-billboard').clientHeight < 220) { + sponsorshipDropdownButton.closest('.js-billboard').style.overflowY = + 'revert'; + } +} + +function dismissBillboard(sponsorshipCloseButton) { + const sku = + sponsorshipCloseButton.closest('.js-billboard').dataset.dismissalSku; + sponsorshipCloseButton.closest('.js-billboard').style.display = 'none'; + if (localStorage && sku && sku.length > 0) { + const skuArray = + JSON.parse(localStorage.getItem('dismissal_skus_triggered')) || []; + if (!skuArray.includes(sku)) { + skuArray.push(sku); + localStorage.setItem( + 'dismissal_skus_triggered', + JSON.stringify(skuArray), + ); + } + } +} diff --git a/app/javascript/utilities/checkUserLoggedIn.js b/app/javascript/utilities/checkUserLoggedIn.js new file mode 100644 index 0000000000000..f61a01c297465 --- /dev/null +++ b/app/javascript/utilities/checkUserLoggedIn.js @@ -0,0 +1,8 @@ +export function checkUserLoggedIn() { + const { body } = document; + if (!body) { + return false; + } + + return body.getAttribute('data-user-status') === 'logged-in'; +} diff --git a/app/javascript/utilities/codeFullscreenModeSwitcher.js b/app/javascript/utilities/codeFullscreenModeSwitcher.js index 14d6a3526b0f5..f4cbee18beadd 100644 --- a/app/javascript/utilities/codeFullscreenModeSwitcher.js +++ b/app/javascript/utilities/codeFullscreenModeSwitcher.js @@ -1,9 +1,11 @@ let isFullScreenModeCodeOn = false; let screenScroll = 0; -const fullScreenWindow = - document.getElementsByClassName('js-fullscreen-code')[0]; const { body } = document; +export function getFullScreenModeStatus() { + return isFullScreenModeCodeOn; +} + function setAfterFullScreenScrollPosition() { window.scrollTo(0, screenScroll); } @@ -12,7 +14,7 @@ function getBeforeFullScreenScrollPosition() { screenScroll = window.scrollY; } -function onPressEscape(event) { +export function onPressEscape(event) { if (event.key == 'Escape') { fullScreenModeControl(event); } @@ -26,6 +28,18 @@ function listenToKeyboardForEscape(listen) { } } +export function onPopstate() { + fullScreenModeControl(); +} + +function listenToWindowForPopstate(listen) { + if (listen) { + window.addEventListener('popstate', onPopstate); + } else { + window.removeEventListener('popstate', onPopstate); + } +} + function toggleOverflowForDocument(overflow) { if (overflow) { body.style.overflow = 'hidden'; @@ -51,7 +65,9 @@ function removeFullScreenModeControl(elements) { } function fullScreenModeControl(event) { - const codeBlock = event.currentTarget.closest('.js-code-highlight') + const fullScreenWindow = + document.getElementsByClassName('js-fullscreen-code')[0]; + const codeBlock = event?.currentTarget.closest('.js-code-highlight') ? event.currentTarget.closest('.js-code-highlight').cloneNode(true) : null; const codeBlockControls = codeBlock @@ -62,6 +78,7 @@ function fullScreenModeControl(event) { toggleOverflowForDocument(false); setAfterFullScreenScrollPosition(); listenToKeyboardForEscape(false); + listenToWindowForPopstate(false); removeFullScreenModeControl(codeBlockControls); fullScreenWindow.classList.remove('is-open'); @@ -72,6 +89,8 @@ function fullScreenModeControl(event) { toggleOverflowForDocument(true); getBeforeFullScreenScrollPosition(); listenToKeyboardForEscape(true); + listenToWindowForPopstate(true); + codeBlock.classList.add('is-fullscreen'); fullScreenWindow.appendChild(codeBlock); fullScreenWindow.classList.add('is-open'); diff --git a/app/javascript/utilities/debounceAction.js b/app/javascript/utilities/debounceAction.js index c58e504bc776d..00ec8f5f299c3 100644 --- a/app/javascript/utilities/debounceAction.js +++ b/app/javascript/utilities/debounceAction.js @@ -9,14 +9,14 @@ import debounce from 'lodash.debounce'; * * * @param {Function} action - The function that should be wrapped with `debounce`. - * @param {Number} [time=300] - The number of milliseconds to wait. + * @param {Number} [time=200] - The number of milliseconds to wait. * @param {Object} [config={ leading: false }] - Any configuration for the debounce function. * * @returns {Function} A function wrapped in `debounce`. */ export function debounceAction( action, - { time = 300, config = { leading: false } } = {}, + { time = 200, config = { leading: false } } = {}, ) { const configs = { ...config }; return debounce(action, time, configs); diff --git a/app/javascript/utilities/document_ready.js b/app/javascript/utilities/document_ready.js new file mode 100644 index 0000000000000..8986658f24c7f --- /dev/null +++ b/app/javascript/utilities/document_ready.js @@ -0,0 +1,7 @@ +Document.prototype.ready = new Promise((resolve) => { + if (document.readyState !== 'loading') { + return resolve(); + } + document.addEventListener('DOMContentLoaded', () => resolve()); + return null; +}); diff --git a/app/javascript/utilities/dragAndDrop.js b/app/javascript/utilities/dragAndDrop.js index f74b1bea95221..69d245654c08d 100644 --- a/app/javascript/utilities/dragAndDrop.js +++ b/app/javascript/utilities/dragAndDrop.js @@ -43,6 +43,7 @@ export function useDragAndDrop({ onDragOver, onDragExit, onDrop }) { element.addEventListener('dragover', onDragOver); element.addEventListener('dragexit', onDragExit); element.addEventListener('dragleave', onDragExit); + element.addEventListener('dragend', onDragExit); element.addEventListener('drop', onDrop); return () => { @@ -52,6 +53,7 @@ export function useDragAndDrop({ onDragOver, onDragExit, onDrop }) { element.removeEventListener('dragover', onDragOver); element.removeEventListener('dragexit', onDragExit); element.removeEventListener('dragleave', onDragExit); + element.removeEventListener('dragend', onDragExit); element.removeEventListener('drop', onDrop); }; }, [element, onDragOver, onDragExit, onDrop]); diff --git a/app/javascript/utilities/dropdownUtils.js b/app/javascript/utilities/dropdownUtils.js index ce2fbb472d1c6..3ad7240f0fd46 100644 --- a/app/javascript/utilities/dropdownUtils.js +++ b/app/javascript/utilities/dropdownUtils.js @@ -129,7 +129,7 @@ export const initializeDropdown = ({ const triggerButton = document.getElementById(triggerElementId); const dropdownContent = document.getElementById(dropdownContentId); - if (!triggerButton || !dropdownContent) { + if ((!triggerButton || !dropdownContent) || triggerButton.dataset.dropdownInitialized === 'true') { // The required props haven't been provided, do nothing return; } @@ -138,6 +138,7 @@ export const initializeDropdown = ({ triggerButton.setAttribute('aria-expanded', 'false'); triggerButton.setAttribute('aria-controls', dropdownContentId); triggerButton.setAttribute('aria-haspopup', 'true'); + triggerButton.setAttribute('data-dropdown-initialized', 'true'); const keyUpListener = ({ key }) => { if (key === 'Escape') { diff --git a/app/javascript/utilities/getUserDataAndCsrfToken.js b/app/javascript/utilities/getUserDataAndCsrfToken.js index 7f4cfc6194a66..fa16eb6e6c527 100644 --- a/app/javascript/utilities/getUserDataAndCsrfToken.js +++ b/app/javascript/utilities/getUserDataAndCsrfToken.js @@ -4,13 +4,23 @@ export function getCsrfToken() { return element !== null ? element.content : undefined; } -const getWaitOnUserDataHandler = ({ resolve, reject, waitTime = 20 }) => { +const getWaitOnUserDataHandler = ({ + resolve, + reject, + safe = false, + waitTime = 20, +}) => { let totalTimeWaiting = 0; return function waitingOnUserData() { if (totalTimeWaiting === 3000) { - reject(new Error("Couldn't find user data on page.")); - return; + if (!safe) { + reject(new Error("Couldn't find user data on page.")); + return; + } + resolve({ user, csrfToken }); + return; + } const csrfToken = getCsrfToken(document); @@ -28,6 +38,13 @@ const getWaitOnUserDataHandler = ({ resolve, reject, waitTime = 20 }) => { }; }; +export function getUserDataAndCsrfTokenSafely() { + return new Promise((resolve, reject) => { + const safe = true; + getWaitOnUserDataHandler({ resolve, reject, safe })(); + }); +} + export function getUserDataAndCsrfToken() { return new Promise((resolve, reject) => { getWaitOnUserDataHandler({ resolve, reject })(); diff --git a/app/javascript/utilities/insertInArrayIf.jsx b/app/javascript/utilities/insertInArrayIf.jsx new file mode 100644 index 0000000000000..3f9c527ebb872 --- /dev/null +++ b/app/javascript/utilities/insertInArrayIf.jsx @@ -0,0 +1,3 @@ +export function insertInArrayIf(condition, ...elements) { + return condition ? elements : []; +} diff --git a/app/javascript/utilities/locale.js b/app/javascript/utilities/locale.js index 9e8bc7a06e0b0..ea8134e7e9b27 100644 --- a/app/javascript/utilities/locale.js +++ b/app/javascript/utilities/locale.js @@ -12,6 +12,6 @@ const { locale: userLocale } = document.body.dataset; if (userLocale) { i18n.locale = userLocale; } -export function locale(term) { - return i18n.t(term); +export function locale(term, params = {}) { + return i18n.t(term, params); } diff --git a/app/javascript/utilities/sendHapticMessage.js b/app/javascript/utilities/sendHapticMessage.js new file mode 100644 index 0000000000000..7110c2cbed315 --- /dev/null +++ b/app/javascript/utilities/sendHapticMessage.js @@ -0,0 +1,14 @@ +export function sendHapticMessage(message) { + try { + if ( + window && + window.webkit && + window.webkit.messageHandlers && + window.webkit.messageHandlers.haptic + ) { + window.webkit.messageHandlers.haptic.postMessage(message); + } + } catch (err) { + console.log(err.message); // eslint-disable-line no-console + } +} diff --git a/app/assets/javascripts/utilities/showUserAlertModal.js b/app/javascript/utilities/showUserAlertModal.js similarity index 88% rename from app/assets/javascripts/utilities/showUserAlertModal.js rename to app/javascript/utilities/showUserAlertModal.js index 614aaeede1508..322301f184cdf 100644 --- a/app/assets/javascripts/utilities/showUserAlertModal.js +++ b/app/javascript/utilities/showUserAlertModal.js @@ -18,7 +18,7 @@ const modalId = 'user-alert-modal'; * @example * showUserAlertModal('Warning', 'You must wait', 'OK', '/faq/why-must-i-wait', 'Why must I wait?'); */ -function showUserAlertModal(title, text, confirm_text) { +export function showUserAlertModal(title, text, confirm_text) { buildModalDiv(text, confirm_text); window.Forem.showModal({ title, @@ -26,6 +26,7 @@ function showUserAlertModal(title, text, confirm_text) { overlay: true, }); } + /** * Displays a user rate limit alert modal letting the user know what they did that exceeded a rate limit, * and gives them links to explain why they must wait @@ -45,13 +46,13 @@ function showRateLimitModal({ action_past, timeframe = 'a moment', }) { - let rateLimitText = buildRateLimitText({ + const rateLimitText = buildRateLimitText({ element, action_ing, action_past, timeframe, }); - let rateLimitLink = '/faq'; + const rateLimitLink = '/faq'; showUserAlertModal( `Wait ${timeframe}...`, rateLimitText, @@ -60,6 +61,7 @@ function showRateLimitModal({ 'Why do I have to wait?', ); } + /** * Displays the corresponding modal after an error. * @@ -73,7 +75,7 @@ function showRateLimitModal({ * @example * showModalAfterError(response, 'made a comment', 'making another comment', 'a moment'); */ -function showModalAfterError({ +export function showModalAfterError({ response, element, action_ing, @@ -82,19 +84,23 @@ function showModalAfterError({ }) { response .json() - .then(function parseError(errorResponse) { + .then((errorResponse) => { if (response.status === 429) { - showRateLimitModal({ element, action_ing, action_past, timeframe }); + showRateLimitModal({ + element, + action_ing, + action_past, + timeframe, + }); } else { showUserAlertModal( `Error ${action_ing} ${element}`, - `Your ${element} could not be ${action_past} due to an error: ` + - errorResponse.error, + `Your ${element} could not be ${action_past} due to an error: ${errorResponse.error}`, 'OK', ); } }) - .catch(function parseError(error) { + .catch(() => { showUserAlertModal( `Error ${action_ing} ${element}`, `Your ${element} could not be ${action_past} due to a server error`, @@ -103,6 +109,28 @@ function showModalAfterError({ }); } +/** + * Constructs wording for rate limit modals + * + * @private + * @function buildRateLimitText + * + * @param {string} element Description of the element that throw the error + * @param {string} action_ing The -ing form of the action taken by the user + * @param {string} action_past The past tense of the action taken by the user + * @param {string} timeframe Description of the time that we need to wait + * + * @returns {string} Formatted body text for a rate limit modal + */ +export function buildRateLimitText({ + element, + action_ing, + action_past, + timeframe, +}) { + return `Since you recently ${action_past} a ${element}, you’ll need to wait ${timeframe} before ${action_ing} another ${element}.`; +} + /** * HTML template for modal * @@ -114,7 +142,7 @@ function showModalAfterError({ * * @returns {string} HTML for the modal */ -const getModalHtml = (text, confirm_text) => ` +export const getModalHtml = (text, confirm_text) => ` <div id="${modalId}" hidden> <div class="flex flex-col"> <p class="color-base-70"> @@ -127,23 +155,6 @@ const getModalHtml = (text, confirm_text) => ` </div> `; -/** - * Constructs wording for rate limit modals - * - * @private - * @function buildRateLimitText - * - * @param {string} element Description of the element that throw the error - * @param {string} action_ing The -ing form of the action taken by the user - * @param {string} action_past The past tense of the action taken by the user - * @param {string} timeframe Description of the time that we need to wait - * - * @returns {string} Formatted body text for a rate limit modal - */ -function buildRateLimitText({ element, action_ing, action_past, timeframe }) { - return `Since you recently ${action_past} a ${element}, you’ll need to wait ${timeframe} before ${action_ing} another ${element}.`; -} - /** * Checks for the alert modal, and if it's not present builds and inserts it in the DOM * @@ -177,8 +188,8 @@ function buildModalDiv(text, confirm_text) { * * @returns {Element} DOM node of alert modal with formatted text */ -function getModal(text, confirm_text) { - let wrapper = document.createElement('div'); +export function getModal(text, confirm_text) { + const wrapper = document.createElement('div'); wrapper.innerHTML = getModalHtml(text, confirm_text); return wrapper; } diff --git a/app/javascript/utilities/videoPlayback.js b/app/javascript/utilities/videoPlayback.js index df025df4c420e..6df23afa8ec89 100644 --- a/app/javascript/utilities/videoPlayback.js +++ b/app/javascript/utilities/videoPlayback.js @@ -130,7 +130,7 @@ export function initializeVideoPlayback() { videoPlayerEvent(false); break; case 'tick': - currentTime = message.currentTime; + currentTime = message.currentTime; // eslint-disable-line prefer-destructuring break; default: console.log('Unrecognized message: ', message); // eslint-disable-line no-console diff --git a/app/lib/black_box.rb b/app/lib/black_box.rb index 68b351f1300ba..2925168ee671d 100644 --- a/app/lib/black_box.rb +++ b/app/lib/black_box.rb @@ -10,6 +10,7 @@ def article_hotness_score(article) today_bonus = usable_date > 26.hours.ago ? 795 : 0 two_day_bonus = usable_date > 48.hours.ago ? 830 : 0 four_day_bonus = usable_date > 96.hours.ago ? 930 : 0 + featured_bonus = article.featured ? 200 : 0 if usable_date < 4.days.ago reaction_points /= 2 # Older posts should fade end @@ -24,7 +25,7 @@ def article_hotness_score(article) ( article_hotness + reaction_points + recency_bonus + super_recent_bonus + - super_super_recent_bonus + today_bonus + two_day_bonus + four_day_bonus + super_super_recent_bonus + today_bonus + two_day_bonus + four_day_bonus + featured_bonus ) end diff --git a/app/lib/constants/role.rb b/app/lib/constants/role.rb index 2edebe207e238..04a04179b9afd 100644 --- a/app/lib/constants/role.rb +++ b/app/lib/constants/role.rb @@ -3,7 +3,10 @@ module Role BASE_ROLES_LABELS_TO_WHERE_CLAUSE = { "Warned" => { name: "warned", resource_type: nil }, "Comment Suspended" => { name: "comment_suspended", resource_type: nil }, + "Limited" => { name: "limited", resource_type: nil }, "Suspended" => { name: "suspended", resource_type: nil }, + "Spam" => { name: "spam", resource_type: nil }, + "Base Subscriber" => { name: "base_subscriber", resource_type: nil }, # This "role" is a weird amalgamation of multiple roles. "Good standing" => :good_standing, "Trusted" => { name: "trusted", resource_type: nil } @@ -21,7 +24,7 @@ module Role "Resource Admin: Broadcast" => { name: "single_resource_admin", resource_type: "Broadcast" }, "Resource Admin: Comment" => { name: "single_resource_admin", resource_type: "Comment" }, "Resource Admin: Config" => { name: "single_resource_admin", resource_type: "Config" }, - "Resource Admin: DisplayAd" => { name: "single_resource_admin", resource_type: "DisplayAd" }, + "Resource Admin: Billboard" => { name: "single_resource_admin", resource_type: "Billboard" }, "Resource Admin: DataUpdateScript" => { name: "single_resource_admin", resource_type: "DataUpdateScript" }, "Resource Admin: FeedbackMessage" => { name: "single_resource_admin", resource_type: "FeedbackMessage" }, "Resource Admin: HtmlVariant" => { name: "single_resource_admin", resource_type: "HtmlVariant" }, diff --git a/app/lib/constants/settings.rb b/app/lib/constants/settings.rb index 3e8dda9bf87e2..0776d46415a5d 100644 --- a/app/lib/constants/settings.rb +++ b/app/lib/constants/settings.rb @@ -5,7 +5,6 @@ module Settings Customization Notifications Account - Billing Organization Extensions ].freeze diff --git a/app/lib/constants/settings/authentication.rb b/app/lib/constants/settings/authentication.rb index fec2411fd26e5..f1b0608f59257 100644 --- a/app/lib/constants/settings/authentication.rb +++ b/app/lib/constants/settings/authentication.rb @@ -66,6 +66,10 @@ def self.details description: I18n.t("lib.constants.settings.authentication.invite_only.description"), placeholder: "" }, + new_user_status: { + description: I18n.t("lib.constants.settings.authentication.new_user_status.description"), + placeholder: I18n.t("lib.constants.settings.authentication.new_user_status.placeholder") + }, recaptcha_site_key: { description: I18n.t("lib.constants.settings.authentication.recaptcha_site.description"), placeholder: I18n.t("lib.constants.settings.authentication.recaptcha_site.placeholder") diff --git a/app/lib/constants/settings/general.rb b/app/lib/constants/settings/general.rb index 2c9ad7fb03cc7..51e15dc1317d0 100644 --- a/app/lib/constants/settings/general.rb +++ b/app/lib/constants/settings/general.rb @@ -8,6 +8,9 @@ def self.details ahoy_tracking: { description: I18n.t("lib.constants.settings.general.ahoy_tracking.description") }, + billboard_enabled_countries: { + description: I18n.t("lib.constants.settings.general.billboard_enabled_countries.description") + }, contact_email: { description: I18n.t("lib.constants.settings.general.contact_email.description"), placeholder: "hello@example.com" @@ -42,6 +45,14 @@ def self.details description: I18n.t("lib.constants.settings.general.ga_analytics_4.description"), placeholder: "" }, + cookie_banner_user_context: { + description: I18n.t("lib.constants.settings.general.cookie_banner_user_context.description"), + placeholder: "off" + }, + coolie_banner_platform_context: { + description: I18n.t("lib.constants.settings.general.coolie_banner_platform_context.description"), + placeholder: "off" + }, health_check_token: { description: I18n.t("lib.constants.settings.general.health.description"), placeholder: I18n.t("lib.constants.settings.general.health.placeholder") @@ -86,13 +97,21 @@ def self.details description: "", placeholder: I18n.t("lib.constants.settings.general.meta_keywords.description") }, - onboarding_background_image: { - description: I18n.t("lib.constants.settings.general.onboarding.description"), - placeholder: IMAGE_PLACEHOLDER + onboarding_newsletter_content: { + description: I18n.t("lib.constants.settings.general.onboarding_newsletter_content.description"), + placeholder: I18n.t("lib.constants.settings.general.onboarding_newsletter_content.placeholder") }, - payment_pointer: { - description: I18n.t("lib.constants.settings.general.payment.description"), - placeholder: "$pay.somethinglikethis.co/value" + onboarding_newsletter_opt_in_head: { + description: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_head.description"), + placeholder: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_head.placeholder") + }, + onboarding_newsletter_opt_in_subhead: { + description: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_subhead.description"), + placeholder: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_subhead.placeholder") + }, + geos_with_allowed_default_email_opt_in: { + description: I18n.t("lib.constants.settings.general.geos_with_allowed_default_email_opt_in.description"), + placeholder: I18n.t("lib.constants.settings.general.geos_with_allowed_default_email_opt_in.placeholder") }, periodic_email_digest: { description: I18n.t("lib.constants.settings.general.periodic.description"), @@ -114,13 +133,6 @@ def self.details description: I18n.t("lib.constants.settings.general.tags.description"), placeholder: I18n.t("lib.constants.settings.general.tags.placeholder") }, - suggested_users: { - description: I18n.t("lib.constants.settings.general.users.description"), - placeholder: I18n.t("lib.constants.settings.general.users.placeholder") - }, - prefer_manual_suggested_users: { - description: I18n.t("lib.constants.settings.general.prefer_manual.description") - }, twitter_hashtag: { description: I18n.t("lib.constants.settings.general.hashtag.description"), placeholder: I18n.t("lib.constants.settings.general.hashtag.placeholder") diff --git a/app/lib/constants/settings/user_experience.rb b/app/lib/constants/settings/user_experience.rb index a093406c2ba38..ee04adb2bf2bb 100644 --- a/app/lib/constants/settings/user_experience.rb +++ b/app/lib/constants/settings/user_experience.rb @@ -17,6 +17,14 @@ def self.details description: I18n.t("lib.constants.settings.user_experience.feed_style.description"), placeholder: I18n.t("lib.constants.settings.user_experience.feed_style.placeholder") }, + cover_image_height: { + description: I18n.t("lib.constants.settings.user_experience.cover_image_height.description"), + placeholder: I18n.t("lib.constants.settings.user_experience.cover_image_height.placeholder") + }, + cover_image_fit: { + description: I18n.t("lib.constants.settings.user_experience.cover_image_fit.description"), + placeholder: I18n.t("lib.constants.settings.user_experience.cover_image_fit.placeholder") + }, home_feed_minimum_score: { description: I18n.t("lib.constants.settings.user_experience.home_feed.description"), placeholder: "0" @@ -25,6 +33,10 @@ def self.details description: I18n.t("lib.constants.settings.user_experience.index_minimum_score.description"), placeholder: "0" }, + index_minimum_date: { + description: I18n.t("lib.constants.settings.user_experience.index_minimum_date.description"), + placeholder: "1500000000" + }, primary_brand_color_hex: { description: I18n.t("lib.constants.settings.user_experience.primary_hex.description"), placeholder: "#0a0a0a" @@ -32,6 +44,14 @@ def self.details tag_feed_minimum_score: { description: I18n.t("lib.constants.settings.user_experience.tag_feed.description"), placeholder: "0" + }, + head_content: { + description: I18n.t("lib.constants.settings.user_experience.head_content.description"), + placeholder: I18n.t("lib.constants.settings.user_experience.head_content.placeholder") + }, + bottom_of_body_content: { + description: I18n.t("lib.constants.settings.user_experience.bottom_of_body_content.description"), + placeholder: I18n.t("lib.constants.settings.user_experience.bottom_of_body_content.placeholder") } } end diff --git a/app/lib/constants/user_details.rb b/app/lib/constants/user_details.rb index a16cdc659ff55..96add92e52922 100644 --- a/app/lib/constants/user_details.rb +++ b/app/lib/constants/user_details.rb @@ -6,6 +6,8 @@ module UserDetails Emails Reports Flags + Articles + Comments UnpublishLogs ].freeze end diff --git a/app/lib/html_css_to_image.rb b/app/lib/html_css_to_image.rb index d570889c56d2e..1132b62fa0f8e 100644 --- a/app/lib/html_css_to_image.rb +++ b/app/lib/html_css_to_image.rb @@ -5,6 +5,8 @@ module HtmlCssToImage CACHE_EXPIRATION = 6.weeks def self.url(html:, css: nil, google_fonts: nil) + return fallback_image if ApplicationConfig["HCTI_API_USER_ID"].blank? || ApplicationConfig["HCTI_API_KEY"].blank? + image = HTTParty.post("https://hcti.io/v1/image", body: { html: html, css: css, google_fonts: google_fonts }, basic_auth: AUTH) diff --git a/app/lib/middlewares/set_cookie_domain.rb b/app/lib/middlewares/set_cookie_domain.rb index c1719930e6f39..4db572b983075 100644 --- a/app/lib/middlewares/set_cookie_domain.rb +++ b/app/lib/middlewares/set_cookie_domain.rb @@ -1,6 +1,6 @@ module Middlewares - # Since we must explicitly set the cookie domain in session_store before SiteConfig is available, - # this ensures we properly set the cookie to SiteConfig.app_domain at runtime. + # Since we must explicitly set the cookie domain in session_store before Settings::General is available, + # this ensures we properly set the cookie to Settings::General.app_domain at runtime. class SetCookieDomain def initialize(app) @app = app diff --git a/app/lib/redcarpet/render/html_rouge.rb b/app/lib/redcarpet/render/html_rouge.rb index bcc45edf432ef..c6ba389bffc67 100644 --- a/app/lib/redcarpet/render/html_rouge.rb +++ b/app/lib/redcarpet/render/html_rouge.rb @@ -12,23 +12,45 @@ def block_code(code, language) super(code, language.to_s.downcase) end - def link(link, _title, content) - # Probably not the best fix but it does it's job of preventing - # a nested links. - return if %r{<a\s.+/a>}.match?(content) - - link_attributes = "" - @options[:link_attributes]&.each do |attribute, value| - link_attributes += %( #{attribute}="#{value}") + def image(link, title, alt_text) + # Check if the URL is an image and process accordingly + if %r{\Ahttps?://}.match?(link) + modified_url = MediaStore.find_by(original_url: link)&.output_url || link + title_attr = title ? %( title="#{title}") : "" + alt_text_attr = alt_text ? %( alt="#{alt_text}") : "" + %(<img src="#{modified_url}"#{title_attr}#{alt_text_attr}/>) + else + title_attr = title ? %( title="#{title}") : "" + alt_text_attr = alt_text ? %( alt="#{alt_text}") : "" + %(<img src="#{link}"#{title_attr}#{alt_text_attr}/>) end - if (%r{https?://\S+}.match? link) || link.nil? - %(<a href="#{link}"#{link_attributes}>#{content}</a>) - elsif /\.{1}/.match? link - %(<a href="//#{link}"#{link_attributes}>#{content}</a>) - elsif link.start_with?("#") - %(<a href="#{link}"#{link_attributes}>#{content}</a>) + end + + def link(link, _title, content) # rubocop:disable Metrics/PerceivedComplexity + # Regex to capture src, alt, and title attributes from an img tag + if content&.include?("<img") && (doc = Nokogiri::HTML(content)) + image_url = doc.at_css("img")["src"] + alt_text = doc.at_css("img")["alt"] || nil + title = doc.at_css("img")["title"] || nil + modified_content = image(image_url, title, alt_text) # Call your image method with title and alt text + %(<a href="#{link}">#{modified_content}</a>) else - %(<a href="#{app_protocol}#{app_domain}#{link}"#{link_attributes}>#{content}</a>) + # Proceed with normal link rendering if no image is detected + return if %r{<a\s.+/a>}.match?(content) + + link_attributes = "" + @options[:link_attributes]&.each do |attribute, value| + link_attributes += %( #{attribute}="#{value}") + end + if (%r{https?://\S+}.match? link) || link.nil? + %(<a href="#{link}"#{link_attributes}>#{content}</a>) + elsif /\.{1}/.match? link + %(<a href="//#{link}"#{link_attributes}>#{content}</a>) + elsif link.start_with?("#") + %(<a href="#{link}"#{link_attributes}>#{content}</a>) + else + %(<a href="#{app_protocol}#{app_domain}#{link}"#{link_attributes}>#{content}</a>) + end end end diff --git a/app/lib/seeder.rb b/app/lib/seeder.rb index 485ec2865d7cd..5262c21335bd8 100644 --- a/app/lib/seeder.rb +++ b/app/lib/seeder.rb @@ -26,6 +26,10 @@ def create_if_none(klass, count = nil) if klass.none? message = ["Creating", count, plural].compact.join(" ") + if klass.respond_to?(:algolia_search) && Settings::General.algolia_search_enabled? + puts " Algolia search enabled, clearing index for #{klass}..." + klass.clear_index! + end puts " #{@counter}. #{message}." yield else diff --git a/app/liquid_tags/card_tag.rb b/app/liquid_tags/card_tag.rb new file mode 100644 index 0000000000000..daf9f2e085fc6 --- /dev/null +++ b/app/liquid_tags/card_tag.rb @@ -0,0 +1,15 @@ +class CardTag < Liquid::Block + include ActionView::Helpers::SanitizeHelper + + PARTIAL = "liquids/card".freeze + def render(_context) + ApplicationController.render( + partial: PARTIAL, + locals: { + content: super + }, + ) + end +end + +Liquid::Template.register_tag("card", CardTag) diff --git a/app/liquid_tags/codepen_tag.rb b/app/liquid_tags/codepen_tag.rb index c7f3b019920e6..2535343aa735a 100644 --- a/app/liquid_tags/codepen_tag.rb +++ b/app/liquid_tags/codepen_tag.rb @@ -2,7 +2,7 @@ class CodepenTag < LiquidTagBase PARTIAL = "liquids/codepen".freeze # rubocop:disable Layout/LineLength REGISTRY_REGEXP = - %r{\A(http|https)://(codepen\.io|codepen\.io/team)/[a-zA-Z0-9_\-]{1,30}/(pen|embed)(/preview)?/([a-zA-Z0-9]{5,32})/{0,1}\z} + %r{\A(http|https)://(codepen\.io|codepen\.io/team)/[a-zA-Z0-9_-]{1,30}/(pen|embed)(/preview)?/([a-zA-Z0-9]{5,32})/{0,1}\z} # rubocop:enable Layout/LineLength def initialize(_tag_name, link, _parse_context) diff --git a/app/liquid_tags/cta_tag.rb b/app/liquid_tags/cta_tag.rb new file mode 100644 index 0000000000000..64ff90d538127 --- /dev/null +++ b/app/liquid_tags/cta_tag.rb @@ -0,0 +1,36 @@ +class CtaTag < Liquid::Block + include ActionView::Helpers::SanitizeHelper + + PARTIAL = "liquids/cta".freeze + # at some point we may want to pass in options to dictate which type of CTA the user wants to use, + # i.e. secondary, primary, branded. This sets the scene for it without actually providing that option now. + TYPE_OPTIONS = %w[branded].freeze + DESCRIPTION_LENGTH = 128 + + def initialize(_tag_name, options, _parse_context) + super + @link = strip_tags(options.strip) + end + + def render(_context) + content = Nokogiri::HTML.parse(super) + + ApplicationController.render( + partial: PARTIAL, + locals: { + link: @link, + description: sanitized_description(content), + type: TYPE_OPTIONS.first + }, + ) + end + + private + + def sanitized_description(content) + stripped_description = strip_tags(content.xpath("//html/body").inner_html).delete("\n").strip + stripped_description.truncate(DESCRIPTION_LENGTH) + end +end + +Liquid::Template.register_tag("cta", CtaTag) diff --git a/app/liquid_tags/details_tag.rb b/app/liquid_tags/details_tag.rb index 6c377b1540603..19ea1cac182cd 100644 --- a/app/liquid_tags/details_tag.rb +++ b/app/liquid_tags/details_tag.rb @@ -12,8 +12,7 @@ def render(_context) content = Nokogiri::HTML.parse(super) parsed_content = sanitize( content.xpath("//html/body").inner_html, - tags: MarkdownProcessor::AllowedTags::RENDERED_MARKDOWN_SCRUBBER, - attributes: MarkdownProcessor::AllowedAttributes::RENDERED_MARKDOWN_SCRUBBER, + scrubber: RenderedMarkdownScrubber.new, ) ApplicationController.render( diff --git a/app/liquid_tags/dotnet_fiddle_tag.rb b/app/liquid_tags/dotnet_fiddle_tag.rb index 1f724119ff423..0191d7c35cbd8 100644 --- a/app/liquid_tags/dotnet_fiddle_tag.rb +++ b/app/liquid_tags/dotnet_fiddle_tag.rb @@ -2,7 +2,7 @@ class DotnetFiddleTag < LiquidTagBase PARTIAL = "liquids/dotnetfiddle".freeze - REGISTRY_REGEXP = %r{https://dotnetfiddle\.net(?:/Widget)?/(?<id>[\w\-]+)} + REGISTRY_REGEXP = %r{https://dotnetfiddle\.net(?:/Widget)?/(?<id>[\w-]+)} def initialize(_tag_name, link, _parse_context) super diff --git a/app/liquid_tags/forem_tag.rb b/app/liquid_tags/forem_tag.rb index f7e29ddff870c..a66fc3fc14ba4 100644 --- a/app/liquid_tags/forem_tag.rb +++ b/app/liquid_tags/forem_tag.rb @@ -1,5 +1,5 @@ module ForemTag - REGISTRY_REGEXP = %r{#{Regexp.escape(URL.url)}/\b([\w\-]+)?} + REGISTRY_REGEXP = %r{#{Regexp.escape(URL.url)}/\b([\w-]+)?} USER_ORG_REGEXP = %r{#{URL.url}/(?<name>[\w-]+)/?$} POST_PODCAST_REGEXP = %r{#{URL.url}/(?<podcast>[\w-]+)/[\w-]+/?} COMBINED_REGEXP = [USER_ORG_REGEXP, POST_PODCAST_REGEXP].freeze diff --git a/app/liquid_tags/glitch_tag.rb b/app/liquid_tags/glitch_tag.rb index 8411fa4c120af..fe87b7ec9b83b 100644 --- a/app/liquid_tags/glitch_tag.rb +++ b/app/liquid_tags/glitch_tag.rb @@ -3,8 +3,8 @@ class GlitchTag < LiquidTagBase PARTIAL = "liquids/glitch".freeze - REGISTRY_REGEXP = %r{https://(?:(?<subdomain>[\w\-]{1,110})\.)?glitch(?:\.me|\.com)(?:/edit/#!/)?(?<slug>[\w\-]{1,110})?(?<params>\?.*)?} - ID_REGEXP = /\A(?:^~)?(?<slug>[\w\-]{1,110})\Z/ + REGISTRY_REGEXP = %r{https://(?:(?<subdomain>[\w-]{1,110})\.)?glitch(?:\.me|\.com)(?:/edit/#!/)?(?<slug>[\w-]{1,110})?(?<params>\?.*)?} + ID_REGEXP = /\A(?:^~)?(?<slug>[\w-]{1,110})\Z/ REGEXP_OPTIONS = [REGISTRY_REGEXP, ID_REGEXP].freeze # last part of PATH_REGEX handles line & character numbers that may appear at path end PATH_REGEX = %r{path=(?<path>[\w/\-.]*)[\d:]*} diff --git a/app/liquid_tags/js_fiddle_tag.rb b/app/liquid_tags/js_fiddle_tag.rb index 0bf98a7dd777d..e2070e97074da 100644 --- a/app/liquid_tags/js_fiddle_tag.rb +++ b/app/liquid_tags/js_fiddle_tag.rb @@ -36,7 +36,7 @@ def parse_options(input) raise StandardError, I18n.t("liquid_tags.js_fiddle_tag.invalid_options") end - validated_options.length.zero? ? "" : validated_options.join(",").concat("/") + validated_options.empty? ? "" : validated_options.join(",").concat("/") end def parse_link(link) diff --git a/app/liquid_tags/podcast_tag.rb b/app/liquid_tags/podcast_tag.rb index 4e55b53311495..5c09a2aceaf8c 100644 --- a/app/liquid_tags/podcast_tag.rb +++ b/app/liquid_tags/podcast_tag.rb @@ -22,6 +22,10 @@ class PodcastTag < LiquidTagBase rss: "https://temenos.com/globalassets/img/marketplace/temenos/rss/rss.png" }.freeze + def self.script + SCRIPT + end + def initialize(_tag_name, link, _parse_context) super @episode = fetch_podcast(link) @@ -38,10 +42,6 @@ def render(_context) ) end - def self.script - SCRIPT - end - private def fetch_podcast(link) diff --git a/app/liquid_tags/poll_tag.rb b/app/liquid_tags/poll_tag.rb index fc51dcd8a42dd..b1746a2f9d59e 100644 --- a/app/liquid_tags/poll_tag.rb +++ b/app/liquid_tags/poll_tag.rb @@ -2,16 +2,10 @@ class PollTag < LiquidTagBase PARTIAL = "liquids/poll".freeze VALID_CONTEXTS = %w[Article].freeze - # @see LiquidTagBase.user_authorization_method_name for discussion - def self.user_authorization_method_name - :any_admin? - end - VALID_ROLES = %i[ admin super_admin ].freeze - SCRIPT = <<~JAVASCRIPT.freeze if (document.head.querySelector('meta[name="user-signed-in"][content="true"]')) { function displayPollResults(json) { @@ -106,6 +100,15 @@ def self.user_authorization_method_name } JAVASCRIPT + # @see LiquidTagBase.user_authorization_method_name for discussion + def self.user_authorization_method_name + :any_admin? + end + + def self.script + SCRIPT + end + def initialize(_tag_name, id_code, _parse_context) super @poll = Poll.find(id_code) @@ -119,10 +122,6 @@ def render(_context) }, ) end - - def self.script - SCRIPT - end end Liquid::Template.register_tag("poll", PollTag) diff --git a/app/liquid_tags/replit_tag.rb b/app/liquid_tags/replit_tag.rb index 36441c4f71f99..6a7533bf91dbc 100644 --- a/app/liquid_tags/replit_tag.rb +++ b/app/liquid_tags/replit_tag.rb @@ -1,7 +1,7 @@ class ReplitTag < LiquidTagBase PARTIAL = "liquids/replit".freeze - REGISTRY_REGEXP = %r{https?://replit\.com/(?<address>@\w{2,15}/[a-zA-Z0-9\-]{0,60})(?:#[\w.]+)?} - VALID_ADDRESS = %r{(?<address>@\w{2,15}/[a-zA-Z0-9\-]{0,60})(?:#[\w.]+)?} + REGISTRY_REGEXP = %r{https?://replit\.com/(?<address>@\w{2,15}/[a-zA-Z0-9-]{0,60})(?:#[\w.]+)?} + VALID_ADDRESS = %r{(?<address>@\w{2,15}/[a-zA-Z0-9-]{0,60})(?:#[\w.]+)?} REGEXP_OPTIONS = [REGISTRY_REGEXP, VALID_ADDRESS].freeze def initialize(_tag_name, input, _parse_context) diff --git a/app/liquid_tags/runkit_tag.rb b/app/liquid_tags/runkit_tag.rb index fc3fec6456bfc..e7ce7a4e64642 100644 --- a/app/liquid_tags/runkit_tag.rb +++ b/app/liquid_tags/runkit_tag.rb @@ -41,7 +41,7 @@ class RunkitTag < Liquid::Block } var wrapperContent = targets[i].textContent; - if (/^(\<iframe src)/.test(wrapperContent) === false) { + if (/^(<iframe src)/.test(wrapperContent) === false) { if (targets[i].children.length > 0) { var preamble = targets[i].children[0].textContent; var content = targets[i].children[1].textContent; @@ -66,6 +66,10 @@ class RunkitTag < Liquid::Block activateRunkitTags(); JAVASCRIPT + def self.script + SCRIPT + end + def initialize(_tag_name, markup, _parse_context) super @preamble = sanitized_preamble(markup) @@ -83,10 +87,6 @@ def render(context) ) end - def self.script - SCRIPT - end - def sanitized_preamble(markup) raise StandardError, I18n.t("liquid_tags.runkit_tag.runkit_tag_is_invalid") if markup.include? "\">" diff --git a/app/liquid_tags/soundcloud_tag.rb b/app/liquid_tags/soundcloud_tag.rb index e1b7008288ec8..29f8478ca5dd1 100644 --- a/app/liquid_tags/soundcloud_tag.rb +++ b/app/liquid_tags/soundcloud_tag.rb @@ -32,7 +32,7 @@ def sanitize_link(link) end def valid_link?(link) - (link =~ %r{\Ahttps://soundcloud\.com/([a-zA-Z0-9_\-]){3,25}/(sets/)?([a-zA-Z0-9_\-]){3,255}\Z}) + (link =~ %r{\Ahttps://soundcloud\.com/([a-zA-Z0-9_-]){3,25}/(sets/)?([a-zA-Z0-9_-]){3,255}\Z}) &.zero? end diff --git a/app/liquid_tags/stackblitz_tag.rb b/app/liquid_tags/stackblitz_tag.rb index 05b800d1b8be3..28d94e9ecdb7a 100644 --- a/app/liquid_tags/stackblitz_tag.rb +++ b/app/liquid_tags/stackblitz_tag.rb @@ -1,10 +1,10 @@ class StackblitzTag < LiquidTagBase PARTIAL = "liquids/stackblitz".freeze - REGISTRY_REGEXP = %r{https://stackblitz\.com/edit/(?<id>[\w\-]{,60})(?<params>\?.*)?} - ID_REGEXP = /\A(?<id>[\w\-]{,60})\Z/ + REGISTRY_REGEXP = %r{https://stackblitz\.com/edit/(?<id>[\w-]{,60})(?<params>\?.*)?} + ID_REGEXP = /\A(?<id>[\w-]{,60})\Z/ REGEXP_OPTIONS = [REGISTRY_REGEXP, ID_REGEXP].freeze # rubocop:disable Layout/LineLength - PARAM_REGEXP = /\A(view=(preview|editor|both))|(file=(.*))|(embed=1)|(hideExplorer=1)|(hideNavigation=1)|(theme=(default|light|dark))|(ctl=1)|(devtoolsheight=\d)\Z/ + PARAM_REGEXP = /\A(view=(preview|editor|both))|(file=(.*))|(embed=1)|(hideExplorer=1)|(hideNavigation=1)|(theme=(default|light|dark))|(ctl=1)|(devtoolsheight=\d)|(hidedevtools=1)|(initialpath=(.*))|(showSidebar=1)|(terminalHeight=\d)|(startScript=(.*))\Z/ # rubocop:enable Layout/LineLength def initialize(_tag_name, input, _parse_context) @@ -34,12 +34,12 @@ def parsed_input(input) match = pattern_match_for(id, REGEXP_OPTIONS) raise StandardError, I18n.t("liquid_tags.stackblitz_tag.invalid_stackblitz_id") unless match - return [match[:id], nil] unless params_present?(match) || params + return [match[:id], nil] unless url_params(match) || params - build_link_with_params(match[:id], (params_present?(match) || params)) + build_link_with_params(match[:id], (url_params(match) || params)) end - def params_present?(match) + def url_params(match) return unless match.names.include?("params") match[:params].delete("?") diff --git a/app/liquid_tags/stackery_tag.rb b/app/liquid_tags/stackery_tag.rb index 86c97a5c5a674..24875d6e5fe8d 100644 --- a/app/liquid_tags/stackery_tag.rb +++ b/app/liquid_tags/stackery_tag.rb @@ -1,7 +1,7 @@ class StackeryTag < LiquidTagBase PARTIAL = "liquids/stackery".freeze REGISTRY_REGEXP = %r{https://app\.stackery\.io/editor/design(?<params>\?.*)?} - PARAM_REGEXP = /\A(owner=[\w\-]+)|(repo=[\w\-]+)|(file=.*)|(ref=.*)\Z/ + PARAM_REGEXP = /\A(owner=[\w-]+)|(repo=[\w-]+)|(file=.*)|(ref=.*)\Z/ def initialize(_tag_name, input, _parse_context) super diff --git a/app/liquid_tags/stackexchange_tag.rb b/app/liquid_tags/stackexchange_tag.rb index 212c17513f2be..50cc155bec635 100644 --- a/app/liquid_tags/stackexchange_tag.rb +++ b/app/liquid_tags/stackexchange_tag.rb @@ -4,7 +4,7 @@ class StackexchangeTag < LiquidTagBase ID_REGEXP = /\A(?<id>\d{1,20})\Z/ SITE_REGEXP = /(?<subdomain>\b[a-zA-Z]+\b)/ REGEXP_OPTIONS = [REGISTRY_REGEXP, ID_REGEXP, SITE_REGEXP].freeze - STACKOVERFLOW_REGEXP = %r{https://stackoverflow\.com/(q|a|questions)/\d{1,20}(?:/[\w\-]+)?} + STACKOVERFLOW_REGEXP = %r{https://stackoverflow\.com/(q|a|questions)/\d{1,20}(?:/[\w-]+)?} API_URL = "https://api.stackexchange.com/2.3/".freeze # Filter codes come from the example tools in the docs. For example: https://api.stackexchange.com/docs/posts-by-ids FILTERS = { @@ -88,7 +88,7 @@ def get_data(input) def handle_response_error(response, input) raise StandardError, "Calling StackExchange API failed: #{response&.error_message}" unless response.ok? - return unless response["items"].length.zero? + return unless response["items"].empty? raise StandardError, I18n.t("liquid_tags.stackexchange_tag.post_not_found", tag: tag_name, input: input) end diff --git a/app/liquid_tags/tweet_tag.rb b/app/liquid_tags/tweet_tag.rb index 819d6840f71cb..80f3f4f420e5d 100644 --- a/app/liquid_tags/tweet_tag.rb +++ b/app/liquid_tags/tweet_tag.rb @@ -1,11 +1,31 @@ class TweetTag < LiquidTagBase - include ActionView::Helpers::AssetTagHelper PARTIAL = "liquids/tweet".freeze - REGISTRY_REGEXP = %r{https://twitter\.com/\w{1,15}/status/(?<id>\d{10,20})} + REGISTRY_REGEXP = %r{https://(?:twitter\.com|x\.com)/\w{1,15}/status/(?<id>\d{10,20})} VALID_ID_REGEXP = /\A(?<id>\d{10,20})\Z/ REGEXP_OPTIONS = [REGISTRY_REGEXP, VALID_ID_REGEXP].freeze SCRIPT = <<~JAVASCRIPT.freeze + // Listen for resize events and match them to the iframe + window.addEventListener('message', function(event) { + if (event.origin.startsWith('https://platform.twitter.com')) { + var iframes = document.getElementsByTagName('iframe'); + for (var i = 0; i < iframes.length; i++) { + if (event.source === iframes[i].contentWindow) { // iframes which match the event + var iframe = iframes[i]; + var data = event.data['twttr.embed']; + if (data && data['method'] === 'twttr.private.resize' && data['params'] && data['params']['0']) { + iframe.style.height = data['params']['0']['height'] + 0.5 + 'px'; + iframe.style.minHeight = data['params']['0']['height'] + 0.5 + 'px'; + iframe.style.width = data['params']['0']['width'] + 'px !important'; + } + break; + } + } + } + }, false); + + // Legacy support: We have shifted up how we render tweets, but still need to render + // the old way for old embed. This could eventually be removed. var videoPreviews = document.getElementsByClassName("ltag__twitter-tweet__media__video-wrapper"); [].forEach.call(videoPreviews, function(el) { el.onclick = function(e) { @@ -27,28 +47,25 @@ class TweetTag < LiquidTagBase }); JAVASCRIPT + def self.script + SCRIPT + end + def initialize(_tag_name, id, _parse_context) super - @id = parse_id_or_url(strip_tags(id)) - @tweet = Tweet.find_or_fetch(@id) - @twitter_logo = ActionController::Base.helpers.asset_path("twitter.svg") + input = CGI.unescape_html(strip_tags(id)) + @id = parse_id_or_url(input) end def render(_context) ApplicationController.render( partial: PARTIAL, locals: { - tweet: @tweet, id: @id, - twitter_logo: @twitter_logo }, ) end - def self.script - SCRIPT - end - private def parse_id_or_url(input) diff --git a/app/liquid_tags/unified_embed/tag.rb b/app/liquid_tags/unified_embed/tag.rb index e082162800992..e0fd115c8c670 100644 --- a/app/liquid_tags/unified_embed/tag.rb +++ b/app/liquid_tags/unified_embed/tag.rb @@ -44,13 +44,24 @@ def self.new(tag_name, input, parse_context) def self.validate_link(input:, retries: MAX_REDIRECTION_COUNT, method: Net::HTTP::Head) uri = URI.parse(input.split.first) + return input if uri.host == "twitter.com" || uri.host == "x.com" # Twitter sends a forbidden like to codepen below + http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true if http.port == 443 req = method.new(uri.request_uri) req["User-Agent"] = "#{safe_user_agent} (#{URL.url})" + response = http.request(req) + # This might be a temporary hack we can remove in the future. For some + # reason, CodePen sometimes sends a 403 on initial request, it's likely a + # misconfigured CloudFlare on their end, but making the request a second + # time seems to fix it. + if uri.host == "codepen.io" && response.is_a?(Net::HTTPForbidden) + response = http.request(req) + end + case response when Net::HTTPSuccess input diff --git a/app/liquid_tags/user_subscription_tag.rb b/app/liquid_tags/user_subscription_tag.rb index 4d8fc2247638b..7ae7af1237abd 100644 --- a/app/liquid_tags/user_subscription_tag.rb +++ b/app/liquid_tags/user_subscription_tag.rb @@ -1,16 +1,17 @@ class UserSubscriptionTag < LiquidTagBase PARTIAL = "liquids/user_subscription".freeze VALID_CONTEXTS = %w[Article].freeze - # @see LiquidTagBase.user_authorization_method_name for discussion - def self.user_authorization_method_name - :user_subscription_tag_available? - end VALID_ROLES = [ :admin, [:restricted_liquid_tag, LiquidTags::UserSubscriptionTag], :super_admin, ].freeze + # @see LiquidTagBase.user_authorization_method_name for discussion + def self.user_authorization_method_name + :user_subscription_tag_available? + end + def initialize(_tag_name, cta_text, parse_context) super @cta_text = cta_text.strip diff --git a/app/mailers/custom_mailer.rb b/app/mailers/custom_mailer.rb new file mode 100644 index 0000000000000..58a9ae3a020e6 --- /dev/null +++ b/app/mailers/custom_mailer.rb @@ -0,0 +1,27 @@ +class CustomMailer < ApplicationMailer + default from: -> { email_from(I18n.t("mailers.custom_mailer.from")) } + + has_history extra: lambda { + { + email_id: params[:email_id] + } + }, only: :custom_email + + + def custom_email + @user = params[:user] + @content = Email.replace_merge_tags(params[:content], @user) + @subject = Email.replace_merge_tags(params[:subject], @user) + @unsubscribe = generate_unsubscribe_token(@user.id, :email_newsletter) + @from_topic = Email.find_by(id: params[:email_id])&.default_from_name_based_on_type + + # set sendgrid category in the header using smtp api + # https://docs.sendgrid.com/for-developers/sending-email/building-an-x-smtpapi-header + if ForemInstance.sendgrid_enabled? + smtpapi_header = { category: "#{params[:type_of] || "Custom"} Email" }.to_json + headers["X-SMTPAPI"] = smtpapi_header + end + + mail(to: @user.email, subject: @subject, from: email_from(@from_topic)) + end +end diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb index 077382c2b8403..2280c0c472c43 100644 --- a/app/mailers/devise_mailer.rb +++ b/app/mailers/devise_mailer.rb @@ -1,4 +1,7 @@ class DeviseMailer < Devise::Mailer + include Rails.application.routes.url_helpers + self.mailer_name = 'devise/mailer' + default reply_to: proc { ForemInstance.reply_to_email_address } include Deliverable @@ -10,4 +13,20 @@ def use_settings_general_values "#{Settings::Community.community_name} <#{ForemInstance.from_email_address}>" ActionMailer::Base.default_url_options[:host] = Settings::General.app_domain end + + # Existing custom methods + # rubocop:disable Style/OptionHash + def invitation_instructions(record, token, opts = {}) + @message = opts[:custom_invite_message] + @footnote = opts[:custom_invite_footnote] + headers = { subject: opts[:custom_invite_subject].presence || "Invitation Instructions" } + super(record, token, opts.merge(headers)) + end + # rubocop:enable Style/OptionHash + + def confirmation_instructions(record, token, opts = {}) + @name = record.name + opts[:subject] = "#{@name}, confirm your #{Settings::Community.community_name} account" + super + end end diff --git a/app/mailers/digest_mailer.rb b/app/mailers/digest_mailer.rb index ed4c6118dd132..08c1c9bf588f8 100644 --- a/app/mailers/digest_mailer.rb +++ b/app/mailers/digest_mailer.rb @@ -4,16 +4,30 @@ class DigestMailer < ApplicationMailer def digest_email @user = params[:user] @articles = params[:articles] + @billboards = params[:billboards] @unsubscribe = generate_unsubscribe_token(@user.id, :email_digest_periodic) subject = generate_title + + # set sendgrid category in the header using smtp api + # https://docs.sendgrid.com/for-developers/sending-email/building-an-x-smtpapi-header + if ForemInstance.sendgrid_enabled? + smtpapi_header = { category: "Digest Email" }.to_json + headers["X-SMTPAPI"] = smtpapi_header + end + mail(to: @user.email, subject: subject) end private def generate_title - "#{adjusted_title(@articles.first)} + #{@articles.size - 1} #{email_end_phrase} #{random_emoji}" + # Winner of digest_title_03_11 + if ForemInstance.dev_to? + "#{@articles.first.title} | DEV Digest" + else + @articles.first.title + end end def adjusted_title(article) diff --git a/app/mailers/notify_mailer.rb b/app/mailers/notify_mailer.rb index b3d0b59178a46..ca561984171c7 100644 --- a/app/mailers/notify_mailer.rb +++ b/app/mailers/notify_mailer.rb @@ -8,13 +8,25 @@ class NotifyMailer < ApplicationMailer def new_reply_email @comment = params[:comment] + sanitized_comment = ApplicationController.helpers.sanitize(@comment.processed_html, + scrubber: CommentEmailScrubber.new) + @truncated_comment = ApplicationController.helpers.truncate(sanitized_comment, length: 500, separator: " ", + omission: "...", escape: false) + @user = @comment.parent_user + return if @user.email.blank? return if RateLimitChecker.new.limit_by_email_recipient_address(@user.email) @unsubscribe = generate_unsubscribe_token(@user.id, :email_comment_notifications) + # Don't send the email if there's no visible contents + # Placed here to allow the preview to continue to work + return if @truncated_comment.blank? + mail(to: @user.email, subject: I18n.t("mailers.notify_mailer.new_reply", name: @comment.user.name, type: @comment.parent_type)) + rescue StandardError => e + Honeybadger.notify(e) end def new_follower_email @@ -139,6 +151,14 @@ def trusted_role_email mail(to: @user.email, subject: subject) end + def base_subscriber_role_email + @user = params[:user] + + subject = I18n.t("mailers.notify_mailer.base_subscriber", + community: Settings::Community.community_name) + mail(to: @user.email, subject: subject) + end + def subjects { new_follower_email: I18n.t("mailers.notify_mailer.new_follower", diff --git a/app/mailers/verification_mailer.rb b/app/mailers/verification_mailer.rb index 5aa6386df3dc2..de98e6e689306 100644 --- a/app/mailers/verification_mailer.rb +++ b/app/mailers/verification_mailer.rb @@ -13,4 +13,14 @@ def account_ownership_verification_email subject: I18n.t("mailers.verification_mailer.verify_ownership", community: Settings::Community.community_name)) end + + def magic_link + @user = User.find(params[:user_id]) + mail( + to: @user.email, + subject: "Sign in to #{Settings::Community.community_name} with a magic link", + from: "#{Settings::Community.community_name} <#{ForemInstance.from_email_address}>", + reply_to: ForemInstance.reply_to_email_address + ) + end end diff --git a/app/models/ab_experiment/goal_conversion_handler.rb b/app/models/ab_experiment/goal_conversion_handler.rb index 492a8da0e4f1a..5565326a1d455 100644 --- a/app/models/ab_experiment/goal_conversion_handler.rb +++ b/app/models/ab_experiment/goal_conversion_handler.rb @@ -13,6 +13,7 @@ class GoalConversionHandler USER_CREATES_PAGEVIEW_GOAL = "user_creates_pageview".freeze USER_CREATES_COMMENT_GOAL = "user_creates_comment".freeze USER_CREATES_ARTICLE_REACTION_GOAL = "user_creates_article_reaction".freeze + USER_CREATES_EMAIL_FEED_EVENT_GOAL = "user_creates_email_feed_event".freeze def self.call(...) new(...).call @@ -56,7 +57,7 @@ def convert(experiment:, experiment_start_date:) when USER_CREATES_ARTICLE_REACTION_GOAL convert_reaction_goal(experiment: experiment, experiment_start_date: experiment_start_date) else - field_test_converted(experiment, participant: user, goal: goal) # base single comment goal. + field_test_converted(experiment, participant: user, goal: goal) # When there is only a single goal. end end diff --git a/app/models/admin_menu.rb b/app/models/admin_menu.rb index d2d6ef431b01b..05ac87d58e4f0 100644 --- a/app/models/admin_menu.rb +++ b/app/models/admin_menu.rb @@ -8,6 +8,7 @@ class AdminMenu item(name: "members", controller: "users"), item(name: "invited members", controller: "invitations"), item(name: "gdpr actions", controller: "gdpr_delete_requests"), + item(name: "bulk assign role", controller: "bulk_assign_role"), ] scope :content_manager, "dashboard-line", [ @@ -21,12 +22,12 @@ class AdminMenu item(name: "organizations"), item(name: "podcasts"), item(name: "tags"), + item(name: "emails"), ] scope :customization, "tools-line", [ item(name: "config"), - item(name: "html variants", controller: "html_variants"), - item(name: "display ads"), + item(name: "billboards"), item(name: "navigation links"), item(name: "pages"), item(name: "profile fields"), @@ -39,7 +40,7 @@ class AdminMenu scope :moderation, "mod", [ item(name: "reports"), item(name: "mods"), - item(name: "moderator actions ads", controller: "moderator_actions"), + item(name: "moderator actions", controller: "moderator_actions"), item(name: "privileged reactions"), ] diff --git a/app/models/ahoy/visit.rb b/app/models/ahoy/visit.rb index 9833ce8810097..01302b228a099 100644 --- a/app/models/ahoy/visit.rb +++ b/app/models/ahoy/visit.rb @@ -7,5 +7,6 @@ class Visit < ApplicationRecord has_many :events, class_name: "Ahoy::Event", dependent: :destroy belongs_to :user, optional: true + belongs_to :user_visit_context, optional: true end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 66ce77a4b53b3..ed05f75abe1d9 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -26,25 +26,6 @@ def self.estimated_count count end - # Decorate object with appropriate decorator - def decorate - self.class.decorator_class.new(self) - end - - def decorated? - false - end - - # In our view objects, we often ask "What's this object's class's name?" - # - # We can either first check "Are you decorated?" If so, ask for the decorated object's class - # name. Or we can add a helper method for that very thing. - # - # @return [String] - def class_name - self.class.name - end - # Decorate collection with appropriate decorator def self.decorate decorator_class.decorate_collection(all) @@ -81,6 +62,67 @@ def self.with_statement_timeout(duration, connection: self.connection) connection.execute "SET statement_timeout = #{milliseconds}" end + # ActiveRecord's `find_each` method allows you to work with a large collection of records + # in batches, but strictly only orders those batches by IDs in ascending order. + # Any other specified order is either ignored or raises an error (depending on configuration). + # This method allows performing batch queries of arbitrary order. + # + # @param batch_size [Integer] Batch size limit + # @yieldparam [self] + # @return [Enumerator<self>] if no block is given + # + # @see https://api.rubyonrails.org/v7.0.4.2/classes/ActiveRecord/Batches.html#method-i-find_each + def self.find_each_respecting_scope(batch_size: 1000, &block) + load_in_batches = Enumerator.new do |e| + in_batches_respecting_scope(batch_size: batch_size) do |batch| + batch.each { |record| e.yield record } + end + end + + return load_in_batches unless block + + load_in_batches.each(&block) + end + + def self.in_batches_respecting_scope(batch_size: 1000) + relation = self + + # Without a specified order, the sorting of PostgreSQL's query results is undefined behaviour + relation = relation.order(id: :asc) if all.arel.orders.blank? + all_ids = relation.ids.to_a + + # `where` and `order` are unnecessary as we already know the exact records we need (and in what order) + # `limit` and `offset` would conflict with the manual batching + batch_relation = relation.unscope(:where, :order, :limit, :offset) + # We're loading in batches to reduce memory usage; if the results get cached anyway, that defeats the purpose + batch_relation.skip_query_cache! + + all_ids.in_groups_of(batch_size, false) do |ids| + records = batch_relation.where(id: ids).index_by(&:id) + # Avoid yielding nil if e.g. record has been deleted since loading IDs + yield ids.filter_map { |id| records[id] } + end + end + + # Decorate object with appropriate decorator + def decorate + self.class.decorator_class.new(self) + end + + def decorated? + false + end + + # In our view objects, we often ask "What's this object's class's name?" + # + # We can either first check "Are you decorated?" If so, ask for the decorated object's class + # name. Or we can add a helper method for that very thing. + # + # @return [String] + def class_name + self.class.name + end + def errors_as_sentence errors.full_messages.to_sentence end diff --git a/app/models/article.rb b/app/models/article.rb index a442c4a73b8da..e78fc6804a70e 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -5,6 +5,7 @@ class Article < ApplicationRecord include Taggable include UserSubscriptionSourceable include PgSearch::Model + include AlgoliaSearchable acts_as_taggable_on :tags resourcify @@ -31,6 +32,7 @@ class Article < ApplicationRecord # move it to the services where the create/update takes place to avoid using hacks attr_accessor :publish_under_org, :admin_update attr_writer :series + attr_accessor :body_url delegate :name, to: :user, prefix: true delegate :username, to: :user, prefix: true @@ -47,23 +49,33 @@ class Article < ApplicationRecord # The date that we began limiting the number of user mentions in an article. MAX_USER_MENTION_LIVE_AT = Time.utc(2021, 4, 7).freeze - PROHIBITED_UNICODE_CHARACTERS_REGEX = /[\u202a-\u202e]/ # BIDI embedding controls + # all BIDI control marks (a part of them are expected to be removed during #normalize_title, but still) + BIDI_CONTROL_CHARACTERS = /[\u061C\u200E\u200F\u202a-\u202e\u2066-\u2069]/ MAX_TAG_LIST_SIZE = 4 - # Filter out anything that isn't a word, space, punctuation mark, or - # recognized emoji. + # Filter out anything that isn't a word, space, punctuation mark, + # recognized emoji, and other auxiliary marks. # See: https://github.com/forem/forem/pull/16787#issuecomment-1062044359 + # + # NOTE: try not to use hyphen (- U+002D) in comments inside regex, + # otherwise it may break parser randomly. + # Use underscore or Unicode hyphen (‐ U+2010) instead. # rubocop:disable Lint/DuplicateRegexpCharacterClassElement TITLE_CHARACTERS_ALLOWED = /[^ [:word:] [:space:] [:punct:] - \u00a3 # GBP symbol + \p{Sc} # All currency symbols \u00a9 # Copyright symbol \u00ae # Registered trademark symbol + \u061c # BIDI: Arabic letter mark + \u180e # Mongolian vowel separator + \u200c # Zero‐width non‐joiner, for complex scripts \u200d # Zero-width joiner, for multipart emojis such as family - \u203c # !! emoji + \u200e-\u200f # BIDI: LTR and RTL mark (standalone) + \u202c-\u202e # BIDI: POP, LTR, and RTL override + \u2066-\u2069 # BIDI: LTR, RTL, FSI, and POP isolate \u20e3 # Combining enclosing keycap \u2122 # Trademark symbol \u2139 # Information symbol @@ -73,15 +85,26 @@ class Article < ApplicationRecord \u231b # Hourglass emoji \u2328 # Keyboard emoji \u23cf # Eject symbol - \u23e9-\u23f3 # Various VCR-actions emoji and clocks + \u23e9-\u23f3 # Various VCR‐actions emoji and clocks \u23f8-\u23fa # More VCR emoji \u24c2 # Blue circle with a white M in it \u25aa # Black box \u25ab # White box - \u25b6 # VCR-style play emoji - \u25c0 # VCR-style play backwards emoji + \u25b6 # VCR‐style play emoji + \u25c0 # VCR‐style play backwards emoji \u25fb-\u25fe # More black and white squares \u2600-\u273f # Weather, zodiac, coffee, hazmat, cards, music, other misc emoji + \u2744 # Snowflake emoji + \u2747 # Sparkle emoji + \u274c # Cross mark + \u274e # Cross mark box + \u2753-\u2755 # Big red and white ? emoji, big white ! emoji + \u2757 # Big red ! emoji + \u2763-\u2764 # Heart ! and heart emoji + \u2795-\u2797 # Math operator emoji + \u27a1 # Right arrow + \u27b0 # One loop + \u27bf # Two loops \u2934 # Curved arrow pointing up to the right \u2935 # Curved arrow pointing down to the right \u2b00-\u2bff # More arrows, geometric shapes @@ -89,7 +112,6 @@ class Article < ApplicationRecord \u303d # Either a line chart plummeting or the letter M, not sure \u3297 # Circled Ideograph Congratulation \u3299 # Circled Ideograph Secret - \u20ac # Euro symbol (€) \u{1f000}-\u{1ffff} # More common emoji ]+/m # rubocop:enable Lint/DuplicateRegexpCharacterClassElement @@ -98,13 +120,19 @@ def self.unique_url_error I18n.t("models.article.unique_url", email: ForemInstance.contact_email) end + enum type_of: { + full_post: 0, + status: 1, +} + has_one :discussion_lock, dependent: :delete has_many :mentions, as: :mentionable, inverse_of: :mentionable, dependent: :delete_all has_many :comments, as: :commentable, inverse_of: :commentable, dependent: :nullify has_many :context_notifications, as: :context, inverse_of: :context, dependent: :delete_all - has_many :context_notifications_published, -> { where(context_notifications: { action: "Published" }) }, + has_many :context_notifications_published, -> { where(context_notifications_published: { action: "Published" }) }, as: :context, inverse_of: :context, class_name: "ContextNotification" + has_many :feed_events, dependent: :delete_all has_many :notification_subscriptions, as: :notifiable, inverse_of: :notifiable, dependent: :delete_all has_many :notifications, as: :notifiable, inverse_of: :notifiable, dependent: :delete_all has_many :page_views, dependent: :delete_all @@ -115,6 +143,7 @@ def self.unique_url_error # `dependent: :destroy` because in RatingVote we're relying on # counter_culture to do some additional tallies has_many :rating_votes, dependent: :destroy + has_many :tag_adjustments has_many :top_comments, lambda { where(comments: { score: 11.. }, ancestry: nil, hidden_by_commentable_user: false, deleted: false) @@ -124,12 +153,47 @@ def self.unique_url_error inverse_of: :commentable, class_name: "Comment" + has_many :more_inclusive_top_comments, + lambda { + where(comments: { score: 5.. }, ancestry: nil, hidden_by_commentable_user: false, deleted: false) + .order("comments.score" => :desc) + }, + as: :commentable, + inverse_of: :commentable, + class_name: "Comment" + + has_many :recent_good_comments, + lambda { + where(comments: { score: 8.. }, ancestry: nil, hidden_by_commentable_user: false, deleted: false) + .order("comments.created_at" => :desc) + }, + as: :commentable, + inverse_of: :commentable, + class_name: "Comment" + + has_many :more_inclusive_recent_good_comments, + lambda { + where(comments: { score: 5.. }, ancestry: nil, hidden_by_commentable_user: false, deleted: false) + .order("comments.created_at" => :desc) + }, + as: :commentable, + inverse_of: :commentable, + class_name: "Comment" + + has_many :most_inclusive_recent_good_comments, + lambda { + where(comments: { score: 3.. }, ancestry: nil, hidden_by_commentable_user: false, deleted: false) + .order("comments.created_at" => :desc) + }, + as: :commentable, + inverse_of: :commentable, + class_name: "Comment" + validates :body_markdown, bytesize: { maximum: 800.kilobytes, too_long: proc { I18n.t("models.article.is_too_long") } } validates :body_markdown, length: { minimum: 0, allow_nil: false } - validates :body_markdown, uniqueness: { scope: %i[user_id title] } validates :cached_tag_list, length: { maximum: 126 } validates :canonical_url, uniqueness: { allow_nil: true, scope: :published, message: unique_url_error }, @@ -149,7 +213,6 @@ def self.unique_url_error validates :reactions_count, presence: true validates :slug, presence: { if: :published? }, format: /\A[0-9a-z\-_]*\z/ validates :slug, uniqueness: { scope: :user_id } - validates :title, presence: true, length: { maximum: 128 } validates :user_subscriptions_count, presence: true validates :video, url: { allow_blank: true, schemes: %w[https http] } validates :video_closed_caption_track_url, url: { allow_blank: true, schemes: ["https"] } @@ -157,9 +220,14 @@ def self.unique_url_error validates :video_source_url, url: { allow_blank: true, schemes: ["https"] } validates :video_state, inclusion: { in: %w[PROGRESSING COMPLETED] }, allow_nil: true validates :video_thumbnail_url, url: { allow_blank: true, schemes: %w[https http] } + validates :clickbait_score, numericality: { greater_than_or_equal_to: 0.0, less_than_or_equal_to: 1.0 } + validates :max_score, numericality: { greater_than_or_equal_to: 0 } validate :future_or_current_published_at, on: :create validate :correct_published_at?, on: :update, unless: :admin_update + validate :title_length_based_on_type_of + validate :title_unique_for_user_past_five_minutes + validate :restrict_attributes_with_status_types validate :canonical_url_must_not_have_spaces validate :validate_collection_permission validate :validate_tag @@ -169,21 +237,26 @@ def self.unique_url_error validate :validate_co_authors_must_not_be_the_same, unless: -> { co_author_ids.blank? } validate :validate_co_authors_exist, unless: -> { co_author_ids.blank? } + before_validation :set_markdown_from_body_url, if: :body_url? before_validation :evaluate_markdown, :create_slug, :set_published_date - before_validation :remove_prohibited_unicode_characters before_validation :normalize_title + before_validation :replace_blank_title_for_status + before_validation :remove_prohibited_unicode_characters + before_validation :remove_invalid_published_at before_save :set_cached_entities before_save :set_all_dates before_save :calculate_base_scores before_save :fetch_video_duration before_save :set_caches + before_save :detect_language before_create :create_password before_destroy :before_destroy_actions, prepend: true after_save :create_conditional_autovomits after_save :bust_cache after_save :collection_cleanup + after_save :generate_social_image after_update_commit :update_notifications, if: proc { |article| article.notifications.any? && !article.saved_changes.empty? @@ -265,6 +338,9 @@ def self.unique_url_error } scope :unpublished, -> { where(published: false) } + scope :full_posts, -> { where(type_of: :full_post) } + scope :statuses, -> { where(type_of: :status) } + scope :not_authored_by, ->(user_id) { where.not(user_id: user_id) } # [@jeremyf] For approved articles is there always an assumption of @@ -301,18 +377,18 @@ def self.unique_url_error :video, :user_id, :organization_id, :video_source_url, :video_code, :video_thumbnail_url, :video_closed_caption_track_url, :experience_level_rating, :experience_level_rating_distribution, :cached_user, :cached_organization, - :published_at, :crossposted_at, :description, :reading_time, :video_duration_in_seconds, - :last_comment_at) + :published_at, :crossposted_at, :description, :reading_time, :video_duration_in_seconds, :score, + :last_comment_at, :main_image_height, :type_of, :edited_at, :processed_html) } scope :limited_columns_internal_select, lambda { select(:path, :title, :id, :featured, :approved, :published, :comments_count, :public_reactions_count, :cached_tag_list, - :main_image, :main_image_background_hex_color, :updated_at, + :main_image, :main_image_background_hex_color, :updated_at, :max_score, :video, :user_id, :organization_id, :video_source_url, :video_code, :video_thumbnail_url, :video_closed_caption_track_url, :social_image, - :published_from_feed, :crossposted_at, :published_at, :created_at, - :body_markdown, :email_digest_eligible, :processed_html, :co_author_ids) + :published_from_feed, :crossposted_at, :published_at, :created_at, :edited_at, + :body_markdown, :email_digest_eligible, :processed_html, :co_author_ids, :score, :type_of) } scope :sorting, lambda { |value| @@ -366,6 +442,16 @@ def self.unique_url_error scope :eager_load_serialized_data, -> { includes(:user, :organization, :tags) } + scope :above_average, lambda { + order(:score).where("score >= ?", average_score) + } + + def self.average_score + Rails.cache.fetch("article_average_score", expires_in: 1.day) do + unscoped { where(score: 0..).average(:score) } || 0.0 + end + end + def self.seo_boostable(tag = nil, time_ago = 18.days.ago) # Time ago sometimes returns this phrase instead of a date time_ago = 5.days.ago if time_ago == "latest" @@ -401,6 +487,17 @@ def self.search_optimized(tag = nil) end end + def processed_html_final + # This is a final non-database-driven step to adjust processed html + # It is sort of a hack to avoid having to reprocess all articles + # It is currently only for this one cloudflare domain change + # It is duplicated across article, bullboard and comment where it is most needed + # In the future this could be made more customizable. For now it's just this one thing. + return processed_html if ApplicationConfig["PRIOR_CLOUDFLARE_IMAGES_DOMAIN"].blank? || ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"].blank? + + processed_html.gsub(ApplicationConfig["PRIOR_CLOUDFLARE_IMAGES_DOMAIN"], ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"]) + end + def scheduled? published_at? && published_at.future? end @@ -445,22 +542,7 @@ def current_state_path end def has_frontmatter? - if FeatureFlag.enabled?(:consistent_rendering, FeatureFlag::Actor[user]) - processed_content.has_front_matter? - else - original_has_frontmatter? - end - end - - def original_has_frontmatter? - fixed_body_markdown = MarkdownProcessor::Fixer::FixAll.call(body_markdown) - begin - parsed = FrontMatterParser::Parser.new(:md).call(fixed_body_markdown) - parsed.front_matter["title"].present? - rescue Psych::SyntaxError, Psych::DisallowedClass - # if frontmatter is invalid, still render editor with errors instead of 500ing - true - end + processed_content.has_front_matter? end def class_name @@ -536,13 +618,24 @@ def video_duration_in_minutes end def update_score - self.score = reactions.sum(:points) + Reaction.where(reactable_id: user_id, reactable_type: "User").sum(:points) + base_subscriber_adjustment = user.base_subscriber? ? Settings::UserExperience.index_minimum_score : 0 + spam_adjustment = user.spam? ? -500 : 0 + negative_reaction_adjustment = Reaction.where(reactable_id: user_id, reactable_type: "User").sum(:points) + self.score = reactions.sum(:points) + spam_adjustment + negative_reaction_adjustment + base_subscriber_adjustment + accepted_max = [max_score, user&.max_score.to_i].min + accepted_max = [max_score, user&.max_score.to_i].max if accepted_max.zero? + self.score = accepted_max if accepted_max.positive? && accepted_max < score + update_columns(score: score, privileged_users_reaction_points_sum: reactions.privileged_category.sum(:points), comment_score: comments.sum(:score), hotness_score: BlackBox.article_hotness_score(self)) end + def co_author_ids_list + co_author_ids.join(", ") + end + def co_author_ids_list=(list_of_co_author_ids) self.co_author_ids = list_of_co_author_ids.split(",").map(&:strip) end @@ -566,20 +659,65 @@ def followers followers.uniq.compact end + def body_url? + body_url.present? # Returns true if body_url is not nil or an empty string + end + def skip_indexing? # should the article be skipped indexed by crawlers? # true if unpublished, or spammy, - # or low score, not featured, and from a user with no comments + # or low score, and not featured !published || - (score < Settings::UserExperience.index_minimum_score && - user.comments_count < 1 && - !featured) || - published_at.to_i < 1_500_000_000 || + (score < Settings::UserExperience.index_minimum_score && !featured) || + published_at.to_i < Settings::UserExperience.index_minimum_date.to_i || score < -1 end + def skip_indexing_reason + return "unpublished" unless published + return "negative_score" if score < -1 + return "below_minimum_score" if score < Settings::UserExperience.index_minimum_score && !featured + return "below_minimum_date" if published_at.to_i < Settings::UserExperience.index_minimum_date.to_i + + "unknown" + end + + def privileged_reaction_counts + @privileged_reaction_counts ||= reactions.privileged_category.group(:category).count + end + + def ordered_tag_adjustments + tag_adjustments.includes(:user).order(:created_at).reverse + end + + def async_score_calc + return if !published? || destroyed? + + Articles::ScoreCalcWorker.perform_async(id) + end + + def evaluate_and_update_column_from_markdown + content_renderer = processed_content + return unless content_renderer + + result = content_renderer.process_article + self.update_column(:processed_html, result.processed_html) + end + + def body_preview + return unless type_of == "status" + + processed_html_final + end + private + def set_markdown_from_body_url + return unless body_url.present? + + self.body_markdown = "{% embed #{body_url} %}" + end + def collection_cleanup # Should only check to cleanup if Article was removed from collection return unless saved_change_to_collection_id? && collection_id.nil? @@ -591,6 +729,12 @@ def collection_cleanup collection.destroy end + def detect_language + return unless title_changed? || body_markdown_changed? + + self.language = Languages::Detection.call("#{title}. #{body_text}") + end + def search_score comments_score = (comments_count * 3).to_i partial_score = (comments_score + (public_reactions_count.to_i * 300 * user.reputation_modifier * score.to_i)) @@ -635,22 +779,58 @@ def processed_content @processed_content = ContentRenderer.new(body_markdown, source: self, user: user) end - def evaluate_markdown - if FeatureFlag.enabled?(:consistent_rendering, FeatureFlag::Actor[user]) - extracted_evaluate_markdown - else - original_evaluate_markdown + def title_length_based_on_type_of + max_length = case type_of + when "full_post" + 128 + when "status" + 256 + else + 128 # Default length if type_of is nil or another value + end + if title.blank? + errors.add(:title, "can't be blank") + elsif title.to_s.length > max_length + errors.add(:title, "is too long (maximum is #{max_length} characters for #{type_of})") + end + end + + def replace_blank_title_for_status + # Get content within H2 tags via regex + self.title = "[Boost]" if title.blank? && type_of == "status" + end + + def restrict_attributes_with_status_types + # Return early if this is already saved and the body_markdown hasn't changed + return if persisted? && !body_markdown_changed? + + # For now, there is no body allowed for status types + if type_of == "status" && body_url.blank? && (body_markdown.present? || main_image.present? || collection_id.present?) + errors.add(:body_markdown, "is not allowed for status types") end end - def extracted_evaluate_markdown + def title_unique_for_user_past_five_minutes + # Validates that the user did not create an article with the same title in the last five minutes + return unless user_id && title + return unless new_record? + + if Article.where(user_id: user_id, title: title).where("created_at > ?", 5.minutes.ago).exists? + errors.add(:title, "has already been used in the last five minutes") + end + end + + + def evaluate_markdown content_renderer = processed_content return unless content_renderer - self.processed_html = content_renderer.process(calculate_reading_time: true) - self.reading_time = content_renderer.reading_time + result = content_renderer.process_article + + self.processed_html = result.processed_html + self.reading_time = result.reading_time - front_matter = content_renderer.front_matter + front_matter = result.front_matter if front_matter.any? evaluate_front_matter(front_matter) @@ -663,36 +843,12 @@ def extracted_evaluate_markdown errors.add(:base, ErrorMessages::Clean.call(e.message)) end - def original_evaluate_markdown - fixed_body_markdown = MarkdownProcessor::Fixer::FixAll.call(body_markdown || "") - parsed = FrontMatterParser::Parser.new(:md).call(fixed_body_markdown) - parsed_markdown = MarkdownProcessor::Parser.new(parsed.content, source: self, user: user) - self.reading_time = parsed_markdown.calculate_reading_time - self.processed_html = parsed_markdown.finalize - - if parsed.front_matter.any? - evaluate_front_matter(parsed.front_matter) - elsif tag_list.any? - set_tag_list(tag_list) - end - - self.description = processed_description if description.blank? - rescue StandardError => e - errors.add(:base, ErrorMessages::Clean.call(e.message)) - end - def set_tag_list(tags) self.tag_list = [] # overwrite any existing tag with those from the front matter tag_list.add(tags, parse: true) self.tag_list = tag_list.map { |tag| Tag.find_preferred_alias_for(tag) } end - def async_score_calc - return if !published? || destroyed? - - Articles::ScoreCalcWorker.perform_async(id) - end - def fetch_video_duration if video.present? && video_duration_in_seconds.zero? url = video_source_url.gsub(".m3u8", "1351620000001-200015_hls_v4.m3u8") @@ -763,10 +919,20 @@ def parse_date(date) published_at || date || Time.current end + # When an article is saved, it ensures that the tags that were adjusted by moderators and admins + # remain adjusted. We do not allow the author to add or remove tags that were previously added or + # removed by moderators and admins. + # + # This method is called before validation, so that the tag_list can be validated. + # + # @return [String] an array of tag names. def validate_tag - # remove adjusted tags - remove_tag_adjustments_from_tag_list - add_tag_adjustments_to_tag_list + distinct_tag_adjustments = TagAdjustment.where(article_id: id, status: "committed") + .select('DISTINCT ON ("tag_id") *') + .order(:tag_id, updated_at: :desc, id: :desc) + + remove_tag_adjustments_from_tag_list(distinct_tag_adjustments) + add_tag_adjustments_to_tag_list(distinct_tag_adjustments) # check there are not too many tags return errors.add(:tag_list, I18n.t("models.article.too_many_tags")) if tag_list.size > MAX_TAG_LIST_SIZE @@ -774,14 +940,13 @@ def validate_tag validate_tag_name(tag_list) end - def remove_tag_adjustments_from_tag_list - tags_to_remove = TagAdjustment.where(article_id: id, adjustment_type: "removal", - status: "committed").pluck(:tag_name) + def remove_tag_adjustments_from_tag_list(distinct_adjustments) + tags_to_remove = distinct_adjustments.select { |adj| adj.adjustment_type == "removal" }.pluck(:tag_name) tag_list.remove(tags_to_remove, parse: true) if tags_to_remove.present? end - def add_tag_adjustments_to_tag_list - tags_to_add = TagAdjustment.where(article_id: id, adjustment_type: "addition", status: "committed").pluck(:tag_name) + def add_tag_adjustments_to_tag_list(distinct_adjustments) + tags_to_add = distinct_adjustments.select { |adj| adj.adjustment_type == "addition" }.pluck(:tag_name) return if tags_to_add.blank? tag_list.add(tags_to_add, parse: true) @@ -825,13 +990,13 @@ def validate_co_authors_exist def future_or_current_published_at # allow published_at in the future or within 15 minutes in the past - return if !published || published_at > 15.minutes.ago + return if !published || published_at.blank? || published_at > 15.minutes.ago errors.add(:published_at, I18n.t("models.article.future_or_current_published_at")) end def correct_published_at? - return unless changes["published_at"] + return true unless changes["published_at"] # for drafts (that were never published before) or scheduled articles # => allow future or current dates, or no published_at @@ -922,7 +1087,8 @@ def set_nth_published_at end def title_to_slug - "#{Sterile.sluggerize(title)}-#{rand(100_000).to_s(26)}" + truncated_title = title.size > 100 ? title[0..100].split[0...-1].join(" ") : title + "#{Sterile.sluggerize(truncated_title)}-#{rand(100_000).to_s(26)}" end def touch_actor_latest_article_updated_at(destroying: false) @@ -941,6 +1107,15 @@ def bust_cache(destroying: false) touch_actor_latest_article_updated_at(destroying: destroying) end + def generate_social_image + return if main_image.present? + + change = saved_change_to_attribute?(:title) || saved_change_to_attribute?(:published_at) + return unless (change || social_image.blank?) && published + + Images::SocialImageWorker.perform_async(id, self.class.name) + end + def calculate_base_scores self.hotness_score = 1000 if hotness_score.blank? end @@ -958,15 +1133,24 @@ def touch_collection end def enrich_image_attributes - return unless saved_change_to_attribute?(:processed_html) + return unless saved_change_to_attribute?(:processed_html) || saved_change_to_attribute?(:main_image) ::Articles::EnrichImageAttributesWorker.perform_async(id) end def remove_prohibited_unicode_characters - return unless title&.match?(PROHIBITED_UNICODE_CHARACTERS_REGEX) + return unless title&.match?(BIDI_CONTROL_CHARACTERS) + + bidi_stripped = title.gsub(BIDI_CONTROL_CHARACTERS, "") + self.title = bidi_stripped if bidi_stripped.blank? # title only contains BIDI characters = blank title + end + + # Sometimes published_at is set to a date *way way too far in the future*, likely a parsing mistake. Let's nullify. + # Do this instead of invlidating the record, because we want to allow the user to fix the date and publish as needed. + def remove_invalid_published_at + return if published_at.blank? - self.title = title.gsub(PROHIBITED_UNICODE_CHARACTERS_REGEX, "") + self.published_at = nil if published_at > 5.years.from_now end def record_field_test_event diff --git a/app/models/articles/cached_entity.rb b/app/models/articles/cached_entity.rb index 0a080f7dc49db..302627bea32a8 100644 --- a/app/models/articles/cached_entity.rb +++ b/app/models/articles/cached_entity.rb @@ -1,6 +1,6 @@ module Articles # NOTE: articles cache either users or organizations, but they have the same attributes. - CachedEntity = Struct.new(:name, :username, :slug, :profile_image_90, :profile_image_url) do + CachedEntity = Struct.new(:name, :username, :slug, :profile_image_90, :profile_image_url, :cached_base_subscriber?) do include Images::Profile.for(:profile_image_url) def self.from_object(object) @@ -10,6 +10,7 @@ def self.from_object(object) object.respond_to?(:slug) ? object.slug : object.username, object.profile_image_90, object.profile_image_url, + object.cached_base_subscriber?, ) end end diff --git a/app/models/articles/feeds.rb b/app/models/articles/feeds.rb index 4d1ee464d4e36..bd0e3afaf0c4f 100644 --- a/app/models/articles/feeds.rb +++ b/app/models/articles/feeds.rb @@ -12,7 +12,8 @@ module Feeds # into an administrative setting. Hence, I want to keep it # a scalar to ease the implementation details of the admin # setting. - NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS = 18 + NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS = + (ApplicationConfig["NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS"] || 18).to_i DEFAULT_USER_EXPERIENCE_LEVEL = 5 DEFAULT_NEGATIVE_REACTION_THRESHOLD = -10 @@ -47,7 +48,9 @@ def self.oldest_published_at_to_consider_for(user:, days_since_published: DEFAUL time_of_second_latest_page_view = user&.page_views&.second_to_last&.created_at return days_since_published.days.ago unless time_of_second_latest_page_view - time_of_second_latest_page_view - NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS.hours + # If they have a page view *well in the past*, let's go max 18 days look back. + time_of_second_latest_page_view = 18.days.ago if time_of_second_latest_page_view < 18.days.ago + time_of_second_latest_page_view - number_of_hours_to_offset_users_latest_article_views.hours end # Get the properly configured feed for the given user (and other parameters). @@ -59,7 +62,7 @@ def self.oldest_published_at_to_consider_for(user:, days_since_published: DEFAUL # @param tag [NilClass, String] not used but carried forward for interface conformance # # @return [Articles::Feeds::VariantQuery] - def self.feed_for(controller:, user:, number_of_articles:, page:, tag:) + def self.feed_for(controller:, user:, number_of_articles:, page:, tag:, type_of: "discover") variant = AbExperiment.get_feed_variant_for(controller: controller, user: user) VariantQuery.build_for( @@ -68,6 +71,7 @@ def self.feed_for(controller:, user:, number_of_articles:, page:, tag:) number_of_articles: number_of_articles, page: page, tag: tag, + type_of: type_of ) end @@ -78,6 +82,10 @@ def self.lever_catalog LEVER_CATALOG end + def self.number_of_hours_to_offset_users_latest_article_views + (ApplicationConfig["NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS"] || 18).to_i + end + # rubocop:disable Metrics/BlockLength # The available levers for this forem instance. LEVER_CATALOG = LeverCatalogBuilder.new do @@ -89,6 +97,39 @@ def self.lever_catalog label: "Order by conflating a random number and the score (see forem/forem#16128)", order_by_fragment: "article_relevancies.randomized_value " \ "^ (1.0 / greatest(articles.score, 0.1)) DESC") + order_by_lever(:final_order_by_feed_success_score, + label: "Order by feed success score", + order_by_fragment: "articles.feed_success_score DESC") + order_by_lever(:final_order_by_feed_success_score_minus_clickbait_score, + label: "Order by feed success score minus clickbait score", + order_by_fragment: "articles.feed_success_score - articles.clickbait_score DESC") + order_by_lever(:final_order_by_feed_success_score_minus_half_of_clickbait_score, + label: "Order by feed success score minus half of clickbait score", + order_by_fragment: "articles.feed_success_score - (articles.clickbait_score / 2) DESC") + order_by_lever(:final_order_by_feed_success_score_minus_one_tenth_of_clickbait_score, + label: "Order by feed success score minus one tenth of clickbait score", + order_by_fragment: "articles.feed_success_score - (articles.clickbait_score / 10) DESC") + order_by_lever(:final_order_by_feed_success_score_minus_clickbait_score_with_randomness, + label: "Order by feed success score minus clickbait score with a randomization factor", + order_by_fragment: + "(articles.feed_success_score - articles.clickbait_score) * + article_relevancies.randomized_value DESC") + order_by_lever(:final_order_by_feed_success_score_minus_half_of_clickbait_score_with_small_randomness, + label: "Order by feed success score minus half of clickbait score with a randomization factor", + order_by_fragment: + "(articles.feed_success_score - (articles.clickbait_score / 2)) + + (article_relevancies.randomized_value / 3) DESC") + order_by_lever(:final_order_by_feed_success_score_and_primary_score, + label: "Order by feed success score and primary score", + order_by_fragment: "((articles.feed_success_score + 0.01) * (articles.score / 10)) DESC") + + order_by_lever(:final_order_by_feed_success_score_and_log_of_primary_score, + label: "Order by feed success score and log of primary score", + order_by_fragment: "((feed_success_score + 0.01) * LOG(GREATEST(score, 1))) DESC") + + order_by_lever(:final_order_by_feed_success_score_and_log_of_comment_score, + label: "Order by feed success score and log of comment_score", + order_by_fragment: "((feed_success_score + 0.01) * LOG(GREATEST(comment_score, 1))) DESC") order_by_lever(:published_at_with_randomization_favoring_public_reactions, label: "Favor recent articles with more reactions, " \ @@ -340,7 +381,40 @@ def self.lever_catalog user_required: false, select_fragment: "articles.score", group_by_fragment: "articles.score") + + relevancy_lever(:language_match, + label: "Weight to give based on whether the language matches any of the user's languages", + range: "[0..1]", # 0 for no match, 1 for match + user_required: true, + select_fragment: "CASE + WHEN COUNT(user_languages.language) = 0 THEN 0 + WHEN articles.language = ANY(array_agg(user_languages.language)) THEN 1 + ELSE 0 + END", + joins_fragments: ["LEFT OUTER JOIN user_languages + ON user_languages.user_id = :user_id"], + group_by_fragment: "articles.language") + + relevancy_lever(:recommended_articles_match, + label: "Weight to give based on whether the article is in the first non-expired recommendations", + range: "[0..1]", # 0 for no match, 1 for match + user_required: true, + select_fragment: "CASE + WHEN COUNT(first_matching_list.id) = 0 THEN 0 + WHEN articles.id = ANY(array_agg(first_matching_list.article_ids) + FILTER (WHERE first_matching_list.article_ids IS NOT NULL)) THEN 1 + ELSE 0 + END", + joins_fragments: ["LEFT OUTER JOIN + (SELECT * FROM recommended_articles_lists + WHERE expires_at > CURRENT_TIMESTAMP + AND placement_area = 0 + ORDER BY created_at ASC + LIMIT 1) AS first_matching_list + ON first_matching_list.user_id = :user_id"], + group_by_fragment: "articles.id") end + private_constant :LEVER_CATALOG # rubocop:enable Metrics/BlockLength end diff --git a/app/models/async_info.rb b/app/models/async_info.rb index b2f548b4834f9..6aa880928762c 100644 --- a/app/models/async_info.rb +++ b/app/models/async_info.rb @@ -5,6 +5,7 @@ # @see UserDecorator # @see ApplicationPolicy class AsyncInfo + include FieldTest::Helpers # @api public # # Generate a Hash of the relevant user data. @@ -46,7 +47,7 @@ def to_hash trusted: user.trusted?, moderator_for_tags: user.moderator_for_tags, config_body_class: user.config_body_class, - feed_style: feed_style_preference, + feed_style: feed_style_preference_variable(user), created_at: user.created_at, admin: user.any_admin?, policies: [ @@ -75,4 +76,11 @@ def visible?(record:, query:) rescue Pundit::NotAuthorizedError false end + + def feed_style_preference_variable(user) + # TODO: Let users set their own feed style preference + # Currently only at app level + + feed_style_preference + end end diff --git a/app/models/audience_segment.rb b/app/models/audience_segment.rb new file mode 100644 index 0000000000000..e4d6a402852f9 --- /dev/null +++ b/app/models/audience_segment.rb @@ -0,0 +1,82 @@ +class AudienceSegment < ApplicationRecord + # enum does not like names that start with "not_" + enum type_of: { + manual: 0, # never matches anyone, used in test factory + trusted: 1, + posted: 2, + no_posts_yet: 3, + dark_theme: 4, + light_theme: 5, + no_experience: 6, + experience1: 7, + experience2: 8, + experience3: 9, + experience4: 10, + experience5: 11 + } + + has_many :segmented_users, dependent: :destroy + has_many :users, through: :segmented_users + has_many :emails, dependent: :nullify + + after_validation :persist_recently_active_users, unless: :manual? + + QUERIES = { + manual: ->(scope = User) { scope.where(id: nil) }, + trusted: ->(scope = User) { scope.with_role(:trusted) }, + no_posts_yet: ->(scope = User) { scope.where(articles_count: 0) }, + posted: ->(scope = User) { scope.where("articles_count > 0") }, + dark_theme: ->(scope = User) { scope.where(id: Users::Setting.dark_theme.select(:user_id)) }, + light_theme: ->(scope = User) { scope.where(id: Users::Setting.light_theme.select(:user_id)) }, + experience1: ->(scope = User) { scope.with_experience_level(1) }, + # see SettingsHelper#user_experience_levels + experience2: ->(scope = User) { scope.with_experience_level(3) }, + experience3: ->(scope = User) { scope.with_experience_level(5) }, + experience4: ->(scope = User) { scope.with_experience_level(8) }, + experience5: ->(scope = User) { scope.with_experience_level(10) }, + no_experience: ->(scope = User) { scope.with_experience_level(nil) } + }.freeze + + def self.scoped_users_in_segment(segment_type, scope: nil) + query_for_segment(segment_type)&.call(scope) || [] + end + + def self.all_users_in_segment(segment_type, scope: User) + query_for_segment(segment_type)&.call(scope) || [] + end + + def self.recently_active_users_in_segment(segment_type, scope: User.recently_active) + scoped_users_in_segment(segment_type, scope: scope) + end + + def self.query_for_segment(segment_type) + QUERIES[segment_type.to_sym] + end + + def self.human_readable_description_for(type) + I18n.t("models.#{model_name.i18n_key}.type_ofs.#{type}") + end + + def self.including_user_counts + left_joins(:segmented_users) + .select("audience_segments.*, COUNT(segmented_users.id) AS user_count") + .group("audience_segments.id") + end + + def persist_recently_active_users + self.users = self.class.recently_active_users_in_segment(type_of) + end + + def all_users_in_segment + return users if manual? + + self.class.all_users_in_segment(type_of) + end + + def includes?(user_or_id) + user_id = user_or_id.respond_to?(:id) ? user_or_id.id : user_or_id + all_users_in_segment.exists?(user_id) + end + + alias refresh! save! +end diff --git a/app/models/badge.rb b/app/models/badge.rb index e6dd452b825a0..a4655b56fbdf8 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -10,6 +10,7 @@ class Badge < ApplicationRecord validates :description, presence: true validates :slug, presence: true validates :title, presence: true, uniqueness: true + validates :allow_multiple_awards, inclusion: { in: [true, false] } before_validation :generate_slug diff --git a/app/models/badge_achievement.rb b/app/models/badge_achievement.rb index 810b2b6d25b48..53cfbd4cbf434 100644 --- a/app/models/badge_achievement.rb +++ b/app/models/badge_achievement.rb @@ -14,7 +14,7 @@ class BadgeAchievement < ApplicationRecord counter_culture :user, column_name: "badge_achievements_count" - validates :badge_id, uniqueness: { scope: :user_id } + validates :badge_id, uniqueness: { scope: :user_id, if: :single_award_badge? } before_validation :render_rewarding_context_message_html after_create :award_credits @@ -53,4 +53,8 @@ def award_credits Credit.add_to(user, badge.credits_awarded) end + + def single_award_badge? + badge&.allow_multiple_awards == false + end end diff --git a/app/models/billboard.rb b/app/models/billboard.rb new file mode 100644 index 0000000000000..7699dc02fd12f --- /dev/null +++ b/app/models/billboard.rb @@ -0,0 +1,413 @@ +class Billboard < ApplicationRecord + include Taggable + acts_as_taggable_on :tags + resourcify + belongs_to :creator, class_name: "User", optional: true + belongs_to :audience_segment, optional: true + belongs_to :page, optional: true + + ALLOWED_PLACEMENT_AREAS = %w[sidebar_left + sidebar_left_2 + sidebar_right + sidebar_right_second + sidebar_right_third + feed_first + feed_second + feed_third + home_hero + footer + page_fixed_bottom + post_fixed_bottom + post_body_bottom + post_sidebar + post_comments + post_comments_mid + digest_first + digest_second].freeze + ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE = ["Sidebar Left (First Position)", + "Sidebar Left (Second Position)", + "Sidebar Right (Home first position)", + "Sidebar Right (Home second position)", + "Sidebar Right (Home third position)", + "Home Feed First", + "Home Feed Second", + "Home Feed Third", + "Home Hero", + "Footer", + "Fixed Bottom (Page)", + "Fixed Bottom (Individual Post)", + "Below the post body", + "Sidebar Right (Individual Post)", + "Below the comment section", + "Midway through the comment section", + "Digest Email First", + "Digest Email Second"].freeze + + HOME_FEED_PLACEMENTS = %w[feed_first feed_second feed_third].freeze + + COLOR_HEX_REGEXP = /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/ + + MAX_TAG_LIST_SIZE = 25 + POST_WIDTH = 775 + SIDEBAR_WIDTH = 350 + LOW_IMPRESSION_COUNT = 1_000 + RANDOM_RANGE_MAX_FALLBACK = 5 + NEW_AND_PRIORITY_RANGE_MAX_FALLBACK = 35 + NEW_ONLY_RANGE_MAX_FALLBACK = 40 + + attribute :target_geolocations, :geolocation_array + enum display_to: { all: 0, logged_in: 1, logged_out: 2 }, _prefix: true + enum type_of: { in_house: 0, community: 1, external: 2 } + enum render_mode: { forem_markdown: 0, raw: 1 } + enum template: { authorship_box: 0, plain: 1 } + enum :special_behavior, { nothing: 0, delayed: 1 } + enum :browser_context, { all_browsers: 0, desktop: 1, mobile_web: 2, mobile_in_app: 3 } + + belongs_to :organization, optional: true + has_many :billboard_events, foreign_key: :display_ad_id, inverse_of: :billboard, dependent: :destroy + + validates :placement_area, presence: true, + inclusion: { in: ALLOWED_PLACEMENT_AREAS } + validates :body_markdown, presence: true + validates :organization, presence: true, if: :community? + validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10_000 } + validates :audience_segment_type, + inclusion: { in: AudienceSegment.type_ofs }, + allow_blank: true + validates :color, format: COLOR_HEX_REGEXP, allow_blank: true + validate :valid_audience_segment_match, + :validate_in_house_hero_ads, + :valid_manual_audience_segment, + :validate_tag, + :validate_geolocations + + before_save :process_markdown + after_save :generate_billboard_name + after_save :refresh_audience_segment, if: :should_refresh_audience_segment? + after_save :update_links_with_bb_param + + scope :approved_and_published, -> { where(approved: true, published: true) } + + scope :search_ads, lambda { |term| + where "name ILIKE :search OR processed_html ILIKE :search OR placement_area ILIKE :search", + search: "%#{term}%" + } + + scope :seldom_seen, ->(area) { where("impressions_count < ?", low_impression_count(area)).or(where(priority: true)) } + scope :new_only, ->(area) { where("impressions_count < ?", low_impression_count(area)) } + + self.table_name = "display_ads" + + def self.for_display(area:, user_signed_in:, user_id: nil, article: nil, user_tags: nil, + location: nil, cookies_allowed: false, page_id: nil, user_agent: nil, + role_names: nil) + permit_adjacent = article ? article.permit_adjacent_sponsors? : true + + billboards_for_display = Billboards::FilteredAdsQuery.call( + billboards: self, + area: area, + user_signed_in: user_signed_in, + article_id: article&.id, + article_tags: article&.cached_tag_list_array || [], + organization_id: article&.organization_id, + permit_adjacent_sponsors: permit_adjacent, + user_id: user_id, + page_id: page_id, + user_tags: user_tags, + location: location, + cookies_allowed: cookies_allowed, + user_agent: user_agent, + role_names: role_names, + ) + + case rand(99) # output integer from 0-99 + when (0..random_range_max(area)) # smallest range, 5% + # We are always showing more of the good stuff — but we are also always testing the system to give any a chance to + # rise to the top. 5 out of every 100 times we show an ad (5%), it is totally random. This gives "not yet + # evaluated" stuff a chance to get some engagement and start showing up more. If it doesn't get engagement, it + # stays in this area. + billboards_for_display.sample + when (random_range_max(area)..new_and_priority_range_max(area)) # medium range, 30% + # Here we sample from only billboards with fewer than 1000 impressions (with a fallback + # if there are none of those, causing an extra query, but that shouldn't happen very often). + relation = billboards_for_display.seldom_seen(area) + weighted_random_selection(relation, article&.id) || billboards_for_display.sample + when (new_and_priority_range_max(area)..new_only_range_max(area)) # 5% by default + # Here we sample from only billboards with fewer than 1000 impressions (with a fallback + billboards_for_display.new_only(area).sample || billboards_for_display.limit(rand(1..15)).sample + else # large range, 65% + + # Ads that get engagement have a higher "success rate", and among this category, we sample from the top 15 that + # meet that criteria. Within those 15 top "success rates" likely to be clicked, there is a weighting towards the + # top ranked outcome as well, and a steady decline over the next 15 — that's because it's not "Here are the top 15 + # pick one randomly", it is actually "Let's cut off the query at a random limit between 1 and 15 and sample from + # that". So basically the "limit" logic will result in 15 sets, and then we sample randomly from there. The + # "first ranked" ad will show up in all 15 sets, where as 15 will only show in 1 of the 15. + billboards_for_display.limit(rand(1..15)).sample + end + end + + def self.weighted_random_selection(relation, target_article_id = nil) + base_query = relation.to_sql + random_val = rand.to_f + if FeatureFlag.enabled?(:article_id_adjusted_weight) + condition = target_article_id.blank? ? "FALSE" : "#{target_article_id} = ANY(preferred_article_ids)" + query = <<-SQL + WITH base AS (#{base_query}), + weighted AS ( + SELECT *, + CASE + WHEN #{condition} THEN weight * 10 + ELSE weight + END AS adjusted_weight, + SUM(CASE + WHEN #{condition} THEN weight * 10 + ELSE weight + END) OVER () AS total_weight, + SUM(CASE + WHEN #{condition} THEN weight * 10 + ELSE weight + END) OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_weight + FROM base + ) + SELECT *, running_weight, ? * total_weight AS random_value FROM weighted + WHERE running_weight >= ? * total_weight + ORDER BY running_weight ASC + LIMIT 1 + SQL + else + query = <<-SQL + WITH base AS (#{base_query}), + weighted AS ( + SELECT *, weight, + SUM(weight) OVER () AS total_weight, + SUM(weight) OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_weight + FROM base + ) + SELECT *, running_weight, ? * total_weight AS random_value FROM weighted + WHERE running_weight >= ? * total_weight + ORDER BY running_weight ASC + LIMIT 1 + SQL + end + relation.find_by_sql([query, random_val, random_val]).first + end + + # Temporary ENV configs, to eventually be replaced by permanent configurations + # once we determine what the appropriate long-term config approach is. + + def self.low_impression_count(placement_area) + ApplicationConfig["LOW_IMPRESSION_COUNT_FOR_#{placement_area.upcase}"] || + ApplicationConfig["LOW_IMPRESSION_COUNT"] || + LOW_IMPRESSION_COUNT + end + + def self.random_range_max(placement_area) + selected_number = ApplicationConfig["SELDOM_SEEN_MIN_FOR_#{placement_area.upcase}"] || + ApplicationConfig["SELDOM_SEEN_MIN"] || + RANDOM_RANGE_MAX_FALLBACK + selected_number.to_i + end + + def self.new_and_priority_range_max(placement_area) + selected_number = ApplicationConfig["SELDOM_SEEN_MAX_FOR_#{placement_area.upcase}"] || + ApplicationConfig["SELDOM_SEEN_MAX"] || + NEW_AND_PRIORITY_RANGE_MAX_FALLBACK + selected_number.to_i + end + + def self.new_only_range_max(placement_area) + selected_number = ApplicationConfig["NEW_ONLY_MAX_FOR_#{placement_area.upcase}"] || + ApplicationConfig["NEW_ONLY_MAX"] || + NEW_ONLY_RANGE_MAX_FALLBACK + selected_number.to_i + end + + def processed_html_final + # This is a final non-database-driven step to adjust processed html + # It is sort of a hack to avoid having to reprocess all articles + # It is currently only for this one cloudflare domain change + # It is duplicated across article, bullboard and comment where it is most needed + # In the future this could be made more customizable. For now it's just this one thing. + return processed_html if ApplicationConfig["PRIOR_CLOUDFLARE_IMAGES_DOMAIN"].blank? || ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"].blank? + + processed_html.gsub(ApplicationConfig["PRIOR_CLOUDFLARE_IMAGES_DOMAIN"], ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"]) + end + + def type_of_display + type_of.gsub("external", "partner") + end + + def human_readable_placement_area + ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE[ALLOWED_PLACEMENT_AREAS.find_index(placement_area)] + end + + def validate_tag + # check there are not too many tags + return errors.add(:tag_list, I18n.t("models.article.too_many_tags")) if tag_list.size > MAX_TAG_LIST_SIZE + + validate_tag_name(tag_list) + end + + def validate_geolocations + target_geolocations.each do |geo| + unless geo.valid?(:targeting) + errors.add(:target_geolocations, I18n.t("models.billboard.invalid_location", location: geo.to_iso3166)) + end + end + end + + def validate_in_house_hero_ads + return unless placement_area == "home_hero" && type_of != "in_house" + + errors.add(:type_of, "must be in_house if billboard is a Home Hero") + end + + def audience_segment_type + @audience_segment_type ||= audience_segment&.type_of + end + + def audience_segment_type=(type) + errors.delete(:audience_segment_type) + @audience_segment_type = type + end + + # This needs to correspond with Rails built-in method signature + # rubocop:disable Style/OptionHash + def as_json(options = {}) + overrides = { + "audience_segment_type" => audience_segment_type, + "tag_list" => cached_tag_list, + "exclude_article_ids" => exclude_article_ids.join(","), + "target_geolocations" => target_geolocations.map(&:to_iso3166) + } + super(options.merge(except: %i[tags tag_list target_geolocations])).merge(overrides) + end + # rubocop:enable Style/OptionHash + + # exclude_article_ids and preferred_article_ids are integer arrays, web inputs are comma-separated strings + # ActiveRecord normalizes these in a bad way, so we are intervening + def exclude_article_ids=(input) + adjusted_input = input.is_a?(String) ? input.split(",") : input + adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i } + write_attribute :exclude_article_ids, (adjusted_input || []) + end + + def preferred_article_ids=(input) + adjusted_input = input.is_a?(String) ? input.split(",") : input + adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i } + write_attribute :preferred_article_ids, (adjusted_input || []) + end + + def exclude_role_names=(input) + adjusted_input = input.is_a?(String) ? input.split(",") : input + write_attribute :exclude_role_names, (adjusted_input || []) + end + + def target_role_names=(input) + adjusted_input = input.is_a?(String) ? input.split(",") : input + write_attribute :target_role_names, (adjusted_input || []) + end + + def style_string + return "" if color.blank? + + if placement_area.include?("fixed_") + "border-top: calc(9px + 0.5vw) solid #{color}" + else + "border: 5px solid #{color}" + end + end + + def update_links_with_bb_param + # Parse the processed_html with Nokogiri + full_html = "<html><head></head><body>#{processed_html}</body></html>" + doc = Nokogiri::HTML(full_html) + # Iterate over all the <a> tags + doc.css("a").each do |link| + href = link["href"] + next unless href.present? && href.start_with?("http", "/") + + uri = URI.parse(href) + existing_params = URI.decode_www_form(uri.query || "") + # Check if 'bb' parameter exists and update it or append if not exists + bb_param_index = existing_params.find_index { |param| param[0] == "bb" } + if bb_param_index + existing_params[bb_param_index][1] = id.to_s # Update existing 'bb' parameter + else + existing_params << ["bb", id.to_s] # Append new 'bb' parameter + end + uri.query = URI.encode_www_form(existing_params) + link["href"] = uri.to_s + end + + # Extract and save only the inner HTML of the body + modified_html = doc.at("body").inner_html + + modified_html.gsub!(/href="([^"]*)&([^"]*)"/, 'href="\1&\2"') + + # Early return if the new HTML is the same as the old one + return if modified_html == processed_html + + # Update the processed_html column with the new HTML + update_column(:processed_html, modified_html) + end + + private + + def generate_billboard_name + return unless name.nil? + + self.name = "Billboard #{id}" + save! + end + + def process_markdown + return unless body_markdown_changed? + + if render_mode == "forem_markdown" + extracted_process_markdown + else # raw + self.processed_html = Html::Parser.new(body_markdown) + .prefix_all_images(width: 880, quality: 100, synchronous_detail_detection: true).html + end + end + + def extracted_process_markdown + renderer = ContentRenderer.new(body_markdown || "", source: self) + self.processed_html = renderer.process(prefix_images_options: { width: prefix_width, + quality: 100, + synchronous_detail_detection: true }).processed_html + self.processed_html = processed_html.delete("\n") + end + + def prefix_width + placement_area.include?("sidebar") ? SIDEBAR_WIDTH : POST_WIDTH + end + + def refresh_audience_segment + AudienceSegmentRefreshWorker.perform_async(audience_segment_id) + end + + def should_refresh_audience_segment? + change_relevant_to_audience = saved_change_to_approved? || + saved_change_to_published? || + saved_change_to_audience_segment_id? + + change_relevant_to_audience && + audience_segment && + audience_segment.updated_at < 1.day.ago + end + + def valid_audience_segment_match + return if audience_segment.blank? || audience_segment_type.blank? + + errors.add(:audience_segment_type) if audience_segment.type_of.to_s != audience_segment_type.to_s + end + + def valid_manual_audience_segment + return if audience_segment_type != "manual" + + errors.add(:audience_segment_type) if audience_segment.blank? + end +end diff --git a/app/models/billboard_event.rb b/app/models/billboard_event.rb new file mode 100644 index 0000000000000..1df79b678e825 --- /dev/null +++ b/app/models/billboard_event.rb @@ -0,0 +1,50 @@ +# @note When we destroy the related user, it's using dependent: +# :delete for the relationship. That means no before/after +# destroy callbacks will be called on this object. +class BillboardEvent < ApplicationRecord + belongs_to :billboard, class_name: "Billboard", foreign_key: :display_ad_id, inverse_of: :billboard_events + belongs_to :user, optional: true + # We also have an article_id param, but not belongs_to because it is not indexed and not designed to be + # consistently referenced within the application. + + validate :unique_on_user_if_type_of_conversion_category, on: :create + validate :only_recent_registrations, on: :create + + self.table_name = "display_ad_events" + + alias_attribute :billboard_id, :display_ad_id + + CATEGORY_IMPRESSION = "impression".freeze + CATEGORY_CLICK = "click".freeze + CATEGORY_SIGNUP = "signup".freeze + CATEGORY_CONVERSION = "conversion".freeze + VALID_CATEGORIES = [CATEGORY_CLICK, CATEGORY_IMPRESSION, CATEGORY_SIGNUP, CATEGORY_CONVERSION].freeze + + CONTEXT_TYPE_HOME = "home".freeze + CONTEXT_TYPE_ARTICLE = "article".freeze + CONTEXT_TYPE_EMAIL = "email".freeze + VALID_CONTEXT_TYPES = [CONTEXT_TYPE_HOME, CONTEXT_TYPE_ARTICLE, CONTEXT_TYPE_EMAIL].freeze + + validates :category, inclusion: { in: VALID_CATEGORIES } + validates :context_type, inclusion: { in: VALID_CONTEXT_TYPES } + + scope :impressions, -> { where(category: CATEGORY_IMPRESSION) } + scope :clicks, -> { where(category: CATEGORY_CLICK) } + scope :signups, -> { where(category: CATEGORY_SIGNUP) } + scope :conversions, -> { where(category: CATEGORY_CONVERSION) } + scope :all_conversion_types, -> { where(category: [CATEGORY_SIGNUP, CATEGORY_CONVERSION]) } + + def unique_on_user_if_type_of_conversion_category + return unless [CATEGORY_SIGNUP, CATEGORY_CONVERSION].include?(category) && user_id.present? + return unless self.class.exists?(user_id: user_id, category: category) + + errors.add(:user_id, "has already converted") + end + + def only_recent_registrations + return unless category == CATEGORY_SIGNUP && user_id.present? + return unless user.registered_at < 1.day.ago + + errors.add(:user_id, "is not a recent registration") + end +end diff --git a/app/models/campaign.rb b/app/models/campaign.rb index 3c7065d101ad7..07a0cc9790bdd 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -3,12 +3,6 @@ class Campaign include Singleton - # Ruby's singleton exposes the instance via a method of the same name, but we - # prefer a friendlier name. - def self.current - instance - end - METHODS = %w[ articles_expiry_time articles_require_approval? @@ -20,6 +14,11 @@ def self.current sidebar_image url ].freeze + # Ruby's singleton exposes the instance via a method of the same name, but we + # prefer a friendlier name. + def self.current + instance + end delegate(*METHODS, to: Settings::Campaign) diff --git a/app/models/comment.rb b/app/models/comment.rb index e5a3782e9f546..30bd5e4050539 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,11 +4,15 @@ class Comment < ApplicationRecord include PgSearch::Model include Reactable + include AlgoliaSearchable BODY_MARKDOWN_SIZE_RANGE = (1..25_000) COMMENTABLE_TYPES = %w[Article PodcastEpisode].freeze + LOW_QUALITY_THRESHOLD = -75 + HIDE_THRESHOLD = -400 # hide comments below this threshold + VALID_SORT_OPTIONS = %w[top latest oldest].freeze URI_REGEXP = %r{ @@ -65,7 +69,6 @@ class Comment < ApplicationRecord after_create_commit :record_field_test_event after_create_commit :send_email_notification, if: :should_send_email_notification? - after_create_commit :send_to_moderator after_commit :calculate_score, on: %i[create update] @@ -85,17 +88,10 @@ class Comment < ApplicationRecord } scope :eager_load_serialized_data, -> { includes(:user, :commentable) } + scope :good_quality, -> { where("score > ?", LOW_QUALITY_THRESHOLD) } alias touch_by_reaction save - def self.tree_for(commentable, limit = 0, order = nil) - commentable.comments - .includes(user: %i[setting profile]) - .arrange(order: build_sort_query(order)) - .to_a[0..limit - 1] - .to_h - end - def self.title_deleted I18n.t("models.comment.deleted") end @@ -104,19 +100,12 @@ def self.title_hidden I18n.t("models.comment.hidden") end - def self.build_comment(params, &blk) - includes(user: :profile).new(params, &blk) + def self.title_image_only + I18n.t("models.comment.image_only") end - def self.build_sort_query(order) - case order - when "latest" - "created_at DESC" - when "oldest" - "created_at ASC" - else - "score DESC" - end + def self.build_comment(params, &blk) + includes(user: :profile).new(params, &blk) end def search_id @@ -160,6 +149,8 @@ def title(length = 80) return self.class.title_hidden if hidden_by_commentable_user text = ActionController::Base.helpers.strip_tags(processed_html).strip + return self.class.title_image_only if only_contains_image?(text) + truncated_text = ActionController::Base.helpers.truncate(text, length: length).gsub("'", "'").gsub("&", "&") Nokogiri::HTML.fragment(truncated_text).text # unescapes all HTML entities end @@ -181,14 +172,35 @@ def remove_notifications end def safe_processed_html - processed_html.html_safe # rubocop:disable Rails/OutputSafety + processed_html_final.html_safe # rubocop:disable Rails/OutputSafety end def root_exists? ancestry && Comment.exists?(id: ancestry) end - private_class_method :build_sort_query + def by_staff_account? + user == User.staff_account + end + + def privileged_reaction_counts + @privileged_reaction_counts ||= reactions.privileged_category.group(:category).count + end + + def calculate_score + Comments::CalculateScoreWorker.perform_async(id) + end + + def processed_html_final + # This is a final non-database-driven step to adjust processed html + # It is sort of a hack to avoid having to reprocess all articles + # It is currently only for this one cloudflare domain change + # It is duplicated across article, bullboard and comment where it is most needed + # In the future this could be made more customizable. For now it's just this one thing. + return processed_html if ApplicationConfig["PRIOR_CLOUDFLARE_IMAGES_DOMAIN"].blank? || ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"].blank? + + processed_html.gsub(ApplicationConfig["PRIOR_CLOUDFLARE_IMAGES_DOMAIN"], ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"]) + end private @@ -215,40 +227,16 @@ def send_to_moderator end def evaluate_markdown - if FeatureFlag.enabled?(:consistent_rendering, FeatureFlag::Actor[user]) - extracted_evaluate_markdown - else - original_evaluate_markdown - end - end - - def processed_content - return @processed_content if @processed_content && !body_markdown_changed? return unless user - @processed_content = ContentRenderer.new(body_markdown, source: self, user: user) - end - - def extracted_evaluate_markdown - content_renderer = processed_content - - return unless content_renderer - - self.processed_html = content_renderer.process(link_attributes: { rel: "nofollow" }) + renderer = ContentRenderer.new(body_markdown, source: self, user: user) + self.processed_html = renderer.process(link_attributes: { rel: "nofollow" }).processed_html wrap_timestamps_if_video_present! if commentable shorten_urls! rescue ContentRenderer::ContentParsingError => e errors.add(:base, ErrorMessages::Clean.call(e.message)) end - def original_evaluate_markdown - fixed_body_markdown = MarkdownProcessor::Fixer::FixForComment.call(body_markdown) - parsed_markdown = MarkdownProcessor::Parser.new(fixed_body_markdown, source: self, user: user) - self.processed_html = parsed_markdown.finalize(link_attributes: { rel: "nofollow" }) - wrap_timestamps_if_video_present! if commentable - shorten_urls! - end - def adjust_comment_parent_based_on_depth self.parent_id = parent.descendant_ids.last if parent_exists? && (parent.depth > 1 && parent.has_children?) end @@ -276,10 +264,6 @@ def shorten_urls! self.processed_html = doc.to_html.html_safe # rubocop:disable Rails/OutputSafety end - def calculate_score - Comments::CalculateScoreWorker.perform_async(id) - end - def after_create_checks create_id_code touch_user @@ -328,9 +312,8 @@ def send_email_notification end def synchronous_spam_score_check - return unless Settings::RateLimit.trigger_spam_for?(text: [title, body_markdown].join("\n")) - - self.score = -1 # ensure notification is not sent if possibly spammy + self.score = -3 if user.registered_at > 48.hours.ago && body_markdown.include?("http") + self.score = -5 if Settings::RateLimit.trigger_spam_for?(text: [title, body_markdown].join("\n")) end def create_conditional_autovomits @@ -343,6 +326,7 @@ def should_send_email_notification? parent_user != user && parent_user.notification_setting.email_comment_notifications && parent_user.email && + user&.badge_achievements_count&.positive? && parent_or_root_article.receive_notifications end @@ -397,4 +381,9 @@ def notify_slack_channel_about_warned_users def parent_exists? parent_id && Comment.exists?(id: parent_id) end + + def only_contains_image?(stripped_text) + # If stripped text is blank and processed html has <img> tags, then it's an image-only comment + stripped_text.blank? && processed_html.include?("<img") + end end diff --git a/app/models/concerns/algolia_searchable.rb b/app/models/concerns/algolia_searchable.rb new file mode 100644 index 0000000000000..a53a08836823c --- /dev/null +++ b/app/models/concerns/algolia_searchable.rb @@ -0,0 +1,14 @@ +module AlgoliaSearchable + extend ActiveSupport::Concern + + DEFAULT_ALGOLIA_SETTINGS = { + per_environment: true, + disable_indexing: -> { Settings::General.algolia_search_enabled? == false }, + enqueue: :trigger_sidekiq_worker + }.freeze + + included do + include AlgoliaSearch + public_send :include, "AlgoliaSearchable::Searchable#{name}".constantize + end +end diff --git a/app/models/concerns/algolia_searchable/searchable_article.rb b/app/models/concerns/algolia_searchable/searchable_article.rb new file mode 100644 index 0000000000000..299b414bbae07 --- /dev/null +++ b/app/models/concerns/algolia_searchable/searchable_article.rb @@ -0,0 +1,46 @@ +module AlgoliaSearchable + module SearchableArticle + extend ActiveSupport::Concern + + included do + include AlgoliaSearch + + algoliasearch(**DEFAULT_ALGOLIA_SETTINGS, if: :indexable) do + attribute :user do + { name: user.name, + username: user.username, + profile_image: user.profile_image_90, + id: user.id, + profile_image_90: user.profile_image_90 } + end + + searchableAttributes %w[title tag_list body user] + + attribute :title, :tag_list, :reading_time, :score, :featured, :comments_count, + :positive_reactions_count, :path, :main_image, :user_id, :public_reactions_count, + :public_reaction_categories + + add_attribute(:published_at) { published_at.to_i } + add_attribute(:readable_publish_date) { readable_publish_date } + add_attribute(:body) { processed_html.first(1000) } + add_attribute(:timestamp) { published_at.to_i } + add_replica("Article_timestamp_desc", per_environment: true) { customRanking ["desc(timestamp)"] } + add_replica("Article_timestamp_asc", per_environment: true) { customRanking ["asc(timestamp)"] } + end + end + + class_methods do + def trigger_sidekiq_worker(record, delete) + AlgoliaSearch::SearchIndexWorker.perform_async(record.class.name, record.id, delete) + end + end + + def indexable + published && score.positive? + end + + def indexable_changed? + published_changed? || score_changed? + end + end +end diff --git a/app/models/concerns/algolia_searchable/searchable_comment.rb b/app/models/concerns/algolia_searchable/searchable_comment.rb new file mode 100644 index 0000000000000..f5cdac826b988 --- /dev/null +++ b/app/models/concerns/algolia_searchable/searchable_comment.rb @@ -0,0 +1,41 @@ +module AlgoliaSearchable + module SearchableComment + extend ActiveSupport::Concern + + included do + include AlgoliaSearch + + algoliasearch(**DEFAULT_ALGOLIA_SETTINGS, unless: :bad_comment?) do + attribute :commentable_id, :commentable_type, :path, :parent_id, :title + attribute :body do + title + end + + attribute :published_at do + readable_publish_date + end + + attribute :user do + { name: user.name, + username: user.username, + profile_image: user.profile_image_90, + profile_image_90: user.profile_image_90 } + end + + add_attribute(:timestamp) { created_at.to_i } + add_replica("Comment_timestamp_desc", per_environment: true) { customRanking ["desc(timestamp)"] } + add_replica("Comment_timestamp_asc", per_environment: true) { customRanking ["asc(timestamp)"] } + end + end + + class_methods do + def trigger_sidekiq_worker(record, delete) + AlgoliaSearch::SearchIndexWorker.perform_async(record.class.name, record.id, delete) + end + end + + def bad_comment? + score.negative? + end + end +end diff --git a/app/models/concerns/algolia_searchable/searchable_organization.rb b/app/models/concerns/algolia_searchable/searchable_organization.rb new file mode 100644 index 0000000000000..378aec166ebf7 --- /dev/null +++ b/app/models/concerns/algolia_searchable/searchable_organization.rb @@ -0,0 +1,24 @@ +module AlgoliaSearchable + module SearchableOrganization + extend ActiveSupport::Concern + + included do + algoliasearch(**DEFAULT_ALGOLIA_SETTINGS) do + attribute :name, :tag_line, :summary, :slug + attribute :profile_image do + { url: profile_image_90 } + end + + add_attribute(:timestamp) { created_at.to_i } + add_replica("Organization_timestamp_desc", per_environment: true) { customRanking ["desc(timestamp)"] } + add_replica("Organization_timestamp_asc", per_environment: true) { customRanking ["asc(timestamp)"] } + end + end + + class_methods do + def trigger_sidekiq_worker(record, delete) + AlgoliaSearch::SearchIndexWorker.perform_async(record.class.name, record.id, delete) + end + end + end +end diff --git a/app/models/concerns/algolia_searchable/searchable_podcast_episode.rb b/app/models/concerns/algolia_searchable/searchable_podcast_episode.rb new file mode 100644 index 0000000000000..03986a425286d --- /dev/null +++ b/app/models/concerns/algolia_searchable/searchable_podcast_episode.rb @@ -0,0 +1,29 @@ +module AlgoliaSearchable + module SearchablePodcastEpisode + extend ActiveSupport::Concern + + included do + include AlgoliaSearch + + algoliasearch(**DEFAULT_ALGOLIA_SETTINGS, if: :published) do + attribute :title, :summary, :path + attribute :podcast_name do + podcast.title + end + attribute :podcast_image do + profile_image_url + end + + add_attribute(:timestamp) { published_at.to_i } + add_replica("PodcastEpisode_timestamp_desc", per_environment: true) { customRanking ["desc(timestamp)"] } + add_replica("PodcastEpisode_timestamp_asc", per_environment: true) { customRanking ["asc(timestamp)"] } + end + end + + class_methods do + def trigger_sidekiq_worker(record, delete) + AlgoliaSearch::SearchIndexWorker.perform_async(record.class.name, record.id, delete) + end + end + end +end diff --git a/app/models/concerns/algolia_searchable/searchable_tag.rb b/app/models/concerns/algolia_searchable/searchable_tag.rb new file mode 100644 index 0000000000000..a367478917925 --- /dev/null +++ b/app/models/concerns/algolia_searchable/searchable_tag.rb @@ -0,0 +1,36 @@ +module AlgoliaSearchable + module SearchableTag + extend ActiveSupport::Concern + + included do + include AlgoliaSearch + + algoliasearch(**DEFAULT_ALGOLIA_SETTINGS) do + attribute :name, :pretty_name, :short_summary, :hotness_score, :supported, :rules_html, :bg_color_hex + + attribute :badge do + { badge_image: { + url: if badge&.badge_image_url + ApplicationController.helpers.optimized_image_url(badge&.badge_image_url, width: 64) + end + } } + end + + add_attribute(:timestamp) { created_at.to_i } + + customRanking ["desc(hotness_score)"] + + add_replica("Tag_timestamp_desc", per_environment: true) { customRanking ["desc(timestamp)"] } + add_replica("Tag_timestamp_asc", per_environment: true) { customRanking ["asc(timestamp)"] } + + attributesForFaceting ["searchable(supported)"] + end + end + + class_methods do + def trigger_sidekiq_worker(record, delete) + AlgoliaSearch::SearchIndexWorker.perform_async(record.class.name, record.id, delete) + end + end + end +end diff --git a/app/models/concerns/algolia_searchable/searchable_user.rb b/app/models/concerns/algolia_searchable/searchable_user.rb new file mode 100644 index 0000000000000..1c36ee8b0a3a6 --- /dev/null +++ b/app/models/concerns/algolia_searchable/searchable_user.rb @@ -0,0 +1,29 @@ +module AlgoliaSearchable + module SearchableUser + extend ActiveSupport::Concern + + included do + algoliasearch(**DEFAULT_ALGOLIA_SETTINGS, unless: :bad_actor?) do + attribute :name, :username, :badge_achievements_count + + add_attribute(:profile_image) { { url: profile_image_90 } } + # add_attribute(:profile_image_90) { profile_image_90 } + add_attribute(:timestamp) { registered_at.to_i } + add_replica("User_timestamp_desc", per_environment: true) { customRanking ["desc(timestamp)"] } + add_replica("User_timestamp_asc", per_environment: true) { customRanking ["asc(timestamp)"] } + add_replica("User_badge_achievements_count_desc", + per_environment: true) { customRanking ["desc(badge_achievements_count)"] } + end + end + + class_methods do + def trigger_sidekiq_worker(record, delete) + AlgoliaSearch::SearchIndexWorker.perform_async(record.class.name, record.id, delete) + end + end + + def bad_actor? + score.negative? || banished? || spam_or_suspended? + end + end +end diff --git a/app/models/concerns/reactable.rb b/app/models/concerns/reactable.rb index 6611d6857c7da..2758aba280d90 100644 --- a/app/models/concerns/reactable.rb +++ b/app/models/concerns/reactable.rb @@ -3,13 +3,24 @@ module Reactable included do has_many :reactions, as: :reactable, inverse_of: :reactable, dependent: :destroy + has_many :distinct_reaction_categories, -> { order(:category).merge(Reaction.distinct_categories) }, + as: :reactable, + inverse_of: :reactable, + dependent: nil, + class_name: "Reaction" end def sync_reactions_count update_column(:public_reactions_count, reactions.public_category.size) end - def reaction_categories - reactions.distinct(:category).pluck(:category) + def public_reaction_categories + @public_reaction_categories ||= begin + # .map is intentional below - .pluck would break eager-loaded association! + reacted = distinct_reaction_categories.map(&:category) + ReactionCategory.for_view.select do |reaction_type| + reacted.include?(reaction_type.slug.to_s) + end + end end end diff --git a/app/models/concerns/unique_across_models.rb b/app/models/concerns/unique_across_models.rb new file mode 100644 index 0000000000000..fe24e8fb7b8db --- /dev/null +++ b/app/models/concerns/unique_across_models.rb @@ -0,0 +1,8 @@ +# extend to add :unique_across_models, which validates a slug or name across +# all "slug-like" models via CrossModelSlug +module UniqueAcrossModels + def unique_across_models(attribute, **options) + validates attribute, presence: true + validates attribute, cross_model_slug: true, **options, if: :"#{attribute}_changed?" + end +end diff --git a/app/models/cross_model_slug.rb b/app/models/cross_model_slug.rb new file mode 100644 index 0000000000000..d4747f60dd437 --- /dev/null +++ b/app/models/cross_model_slug.rb @@ -0,0 +1,35 @@ +# +# We have a simple top-level route equivalent to `/slug`. Because of this, +# we want to verify that newly created records don't overlap with a previously- +# defined `slug` -- in other words, slug values should be unique across all +# relevant models. Except also, models might use "username" instead of "slug". +# +# "Slug-like" models are all included in a cross-model-uniqueness check. An +# impacted models are checked for the existence of a record matching a given +# value. Additionally, we have some special cases (eg, sitemap) that we want to +# apply across all registered models. +# +class CrossModelSlug + MODELS = { + "User" => :username, + "Page" => :slug, + "Podcast" => :slug, + "Organization" => :slug + }.freeze + + class << self + def exists?(value) + # Presence check is likely redundant, but is **much** cheaper than the + # cross-model check + return false if value.blank? + + value = value.downcase + + return true if value.include?("sitemap-") # https://github.com/forem/forem/pull/6704 + + MODELS.detect do |class_name, attribute| + class_name.constantize.exists?({ attribute => value }) + end + end + end +end diff --git a/app/models/display_ad.rb b/app/models/display_ad.rb deleted file mode 100644 index d4918c246479a..0000000000000 --- a/app/models/display_ad.rb +++ /dev/null @@ -1,89 +0,0 @@ -class DisplayAd < ApplicationRecord - include Taggable - acts_as_taggable_on :tags - resourcify - belongs_to :creator, class_name: "User", optional: true - - ALLOWED_PLACEMENT_AREAS = %w[sidebar_left sidebar_left_2 sidebar_right post_sidebar post_comments].freeze - ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE = ["Sidebar Left (First Position)", - "Sidebar Left (Second Position)", - "Sidebar Right (Home)", - "Sidebar Right (Individual Post)", - "Below the comment section"].freeze - - MAX_TAG_LIST_SIZE = 10 - POST_WIDTH = 775 - SIDEBAR_WIDTH = 350 - - enum display_to: { all: 0, logged_in: 1, logged_out: 2 }, _prefix: true - enum type_of: { in_house: 0, community: 1, external: 2 } - - belongs_to :organization, optional: true - has_many :display_ad_events, dependent: :destroy - - validates :placement_area, presence: true, - inclusion: { in: ALLOWED_PLACEMENT_AREAS } - validates :body_markdown, presence: true - validate :validate_tag - before_save :process_markdown - after_save :generate_display_ad_name - - scope :approved_and_published, -> { where(approved: true, published: true) } - - scope :search_ads, lambda { |term| - where "name ILIKE :search OR processed_html ILIKE :search OR placement_area ILIKE :search", - search: "%#{term}%" - } - - def self.for_display(area, user_signed_in, article_tags = []) - DisplayAds::FilteredAdsQuery.call( - display_ads: self, - area: area, - user_signed_in: user_signed_in, - article_tags: article_tags, - ) - end - - def human_readable_placement_area - ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE[ALLOWED_PLACEMENT_AREAS.find_index(placement_area)] - end - - def validate_tag - # check there are not too many tags - return errors.add(:tag_list, I18n.t("models.article.too_many_tags")) if tag_list.size > MAX_TAG_LIST_SIZE - - validate_tag_name(tag_list) - end - - # This needs to correspond with Rails built-in method signature - # rubocop:disable Style/OptionHash - def as_json(options = {}) - super(options.merge(except: %i[tags tag_list])).merge("tag_list" => cached_tag_list) - end - # rubocop:enable Style/OptionHash - - private - - def generate_display_ad_name - return unless name.nil? - - self.name = "Display Ad #{id}" - save! - end - - def process_markdown - renderer = Redcarpet::Render::HTMLRouge.new(hard_wrap: true, filter_html: false) - markdown = Redcarpet::Markdown.new(renderer, Constants::Redcarpet::CONFIG) - initial_html = markdown.render(body_markdown) - stripped_html = ActionController::Base.helpers.sanitize initial_html, - tags: MarkdownProcessor::AllowedTags::DISPLAY_AD, - attributes: MarkdownProcessor::AllowedAttributes::DISPLAY_AD - html = stripped_html.delete("\n") - self.processed_html = Html::Parser.new(html) - .prefix_all_images(prefix_width, synchronous_detail_detection: true).html - end - - def prefix_width - placement_area.to_s == "post_comments" ? POST_WIDTH : SIDEBAR_WIDTH - end -end diff --git a/app/models/display_ad_event.rb b/app/models/display_ad_event.rb deleted file mode 100644 index 6fd04de0f189c..0000000000000 --- a/app/models/display_ad_event.rb +++ /dev/null @@ -1,20 +0,0 @@ -# @note When we destroy the related user, it's using dependent: -# :delete for the relationship. That means no before/after -# destroy callbacks will be called on this object. -class DisplayAdEvent < ApplicationRecord - belongs_to :display_ad - belongs_to :user, optional: true - - CATEGORY_IMPRESSION = "impression".freeze - CATEGORY_CLICK = "click".freeze - VALID_CATEGORIES = [CATEGORY_CLICK, CATEGORY_IMPRESSION].freeze - - CONTEXT_TYPE_HOME = "home".freeze - VALID_CONTEXT_TYPES = [CONTEXT_TYPE_HOME].freeze - - validates :category, inclusion: { in: VALID_CATEGORIES } - validates :context_type, inclusion: { in: VALID_CONTEXT_TYPES } - - scope :impressions, -> { where(category: CATEGORY_IMPRESSION) } - scope :clicks, -> { where(category: CATEGORY_CLICK) } -end diff --git a/app/models/email.rb b/app/models/email.rb new file mode 100644 index 0000000000000..547bb0dfc07d5 --- /dev/null +++ b/app/models/email.rb @@ -0,0 +1,89 @@ +class Email < ApplicationRecord + belongs_to :audience_segment, optional: true + has_many :email_messages + + after_save :deliver_to_users + + validates :subject, presence: true + validates :body, presence: true + + enum type_of: { one_off: 0, newsletter: 1, onboarding_drip: 2 } + enum status: { draft: 0, active: 1, delivered: 2 } # Not implemented yet anywhere + + BATCH_SIZE = Rails.env.production? ? 1000 : 10 + + attr_accessor :test_email_addresses + + def self.replace_merge_tags(content, user) + return content unless user + + # Define the mapping of merge tags to user attributes + merge_tags = { + "name" => user.name, + "username" => user.username, + "email" => user.email + } + + # Replace merge tags in the content + content.gsub(/\*\|(\w+)\|\*/) do |match| + tag = Regexp.last_match(1).downcase + merge_tags[tag] || match # Leave the tag untouched if not found + end + end + + def bg_color + case status + when "draft" + # soft yellow hex + "#fff9c0" + when "active" + # soft green hex + "#d4f7dc" + when "delivered" + # soft blue hex + "#d4e7f7" + end + end + + def default_from_name_based_on_type + case type_of + when "one_off" + "" + when "newsletter" + "Newsletter" + when "onboarding_drip" + "Onboarding" + end + end + + def deliver_to_test_emails(addresses_string) + addresses_string ||= test_email_addresses + return if addresses_string.blank? + + email_array = addresses_string.gsub(/\s+/, "").split(",") + users_batch = User.where(email: email_array) + return if users_batch.empty? + + Emails::BatchCustomSendWorker.perform_async(users_batch.map(&:id), subject, body, type_of, id) + end + + def deliver_to_users + return if type_of == "onboarding_drip" + return if status != "active" + + user_scope = if audience_segment + audience_segment.users.registered.joins(:notification_setting) + .where(notification_setting: { email_newsletter: true }) + .where.not(email: "") + else + User.registered.joins(:notification_setting) + .where(notification_setting: { email_newsletter: true }) + .where.not(email: "") + end + user_scope.find_in_batches(batch_size: BATCH_SIZE) do |users_batch| + Emails::BatchCustomSendWorker.perform_async(users_batch.map(&:id), subject, body, type_of, id) + end + + self.update_columns(status: "delivered") + end +end diff --git a/app/models/email_message.rb b/app/models/email_message.rb index a51f4174f861e..cd028091f188b 100644 --- a/app/models/email_message.rb +++ b/app/models/email_message.rb @@ -1,15 +1,6 @@ class EmailMessage < Ahoy::Message belongs_to :feedback_message, optional: true - - def html_content - return "" if content.nil? - - html_index = content.index("<html") - return content if html_index.nil? - - closing_html_index = content.index("</html>") + 7 - content[html_index..closing_html_index] - end + belongs_to :email, optional: true def self.find_for_reports(feedback_message_ids) select(:to, :subject, :content, :utm_campaign, :feedback_message_id) @@ -34,4 +25,14 @@ def self.fast_destroy_old_retained_email_deliveries(destroy_before_timestamp = 3 BulkSqlDelete.delete_in_batches(email_sql) end + + def html_content + return "" if content.nil? + + html_index = content.index("<html") + return content if html_index.nil? + + closing_html_index = content.index("</html>") + 7 + content[html_index..closing_html_index] + end end diff --git a/app/models/feed_event.rb b/app/models/feed_event.rb new file mode 100644 index 0000000000000..a7236aad16c35 --- /dev/null +++ b/app/models/feed_event.rb @@ -0,0 +1,118 @@ +class FeedEvent < ApplicationRecord + # These are "optional" mostly so that we can perform validated bulk inserts + # without triggering article/user validation. + # Since there are database-level constraints, it's fine to skip the automatic + # Rails-side association validation (which causes an N+1 query). + belongs_to :article, optional: true + belongs_to :user, optional: true + + after_save :update_article_counters_and_scores + after_create_commit :record_field_test_event + + enum category: { + impression: 0, + click: 1, + reaction: 2, + comment: 3, + extended_pageview: 4 + } + + CONTEXT_TYPE_HOME = "home".freeze + CONTEXT_TYPE_SEARCH = "search".freeze + CONTEXT_TYPE_TAG = "tag".freeze + CONTEXT_TYPE_EMAIL = "email".freeze + VALID_CONTEXT_TYPES = [ + CONTEXT_TYPE_HOME, + CONTEXT_TYPE_SEARCH, + CONTEXT_TYPE_TAG, + CONTEXT_TYPE_EMAIL, + ].freeze + DEFAULT_TIMEBOX = 5.minutes.freeze + + REACTION_SCORE_MULTIPLIER = 6 + COMMENT_SCORE_MULTIPLIER = 12 + + validates :article_position, numericality: { only_integer: true, greater_than: 0 } + validates :context_type, inclusion: { in: VALID_CONTEXT_TYPES }, presence: true + # Since we have disabled association validation, this is handy to filter basic bad data + validates :article_id, presence: true, numericality: { only_integer: true } + validates :user_id, numericality: { only_integer: true }, allow_nil: true + + def self.record_journey_for(user, article:, category:) + return unless %i[reaction comment extended_pageview].include?(category) + + last_click = where(user: user, category: :click).last + return unless last_click&.article_id == article.id + + create_with(last_click.slice(:article_position, :context_type)) + .find_or_create_by( + category: category, + user: user, + article: article, + ) + end + + def self.bulk_update_counters_by_article_id(article_ids) + unique_article_ids = article_ids.uniq + update_counters_for_articles(unique_article_ids) + end + + def self.update_counters_for_articles(article_ids) + article_ids.each do |article_id| + update_single_article_counters(article_id) + end + end + + def self.update_single_article_counters(article_id) + ThrottledCall.perform("article_feed_success_score_#{article_id}", throttle_for: 5.minutes) do + impressions = FeedEvent.where(article_id: article_id, category: "impression") + return if impressions.empty? + + clicks = FeedEvent.where(article_id: article_id, category: "click") + reactions = FeedEvent.where(article_id: article_id, category: "reaction") + comments = FeedEvent.where(article_id: article_id, category: "comment") + pageviews = FeedEvent.where(article_id: article_id, category: "extended_pageview") + + # Count the distinct users for impressions and each event type + distinct_impressions_users = impressions.distinct.pluck(:user_id) + distinct_clicks_users = clicks.distinct.pluck(:user_id) + distinct_reactions_users = reactions.distinct.pluck(:user_id) + distinct_comments_users = comments.distinct.pluck(:user_id) + distinct_pageviews_users = pageviews.distinct.pluck(:user_id) + + # Calculate score based on distinct users + reactions_score = distinct_reactions_users.size * REACTION_SCORE_MULTIPLIER + clicks_score = distinct_clicks_users.size # 1x multiplier for clicks + comments_score = distinct_comments_users.size * COMMENT_SCORE_MULTIPLIER + pageviews_score = distinct_pageviews_users.size # 1x multiplier for extended pageviews + + score = (clicks_score + pageviews_score + reactions_score + comments_score).to_f / distinct_impressions_users.size + + # Update the article counters + Article.where(id: article_id).update_all( + feed_success_score: score, + feed_clicks_count: clicks.size, + feed_impressions_count: impressions.size, + ) + end + end + + private + + def update_article_counters_and_scores + return unless article + + self.class.update_single_article_counters(article_id) + end + + # @see AbExperiment::GoalConversionHandler + def record_field_test_event + return if FieldTest.config["experiments"].nil? + return if category.to_s == "impression" + return unless user_id + return unless context_type == CONTEXT_TYPE_EMAIL # We are only doing this for email at the moment + + Users::RecordFieldTestEventWorker + .perform_async(user_id, AbExperiment::GoalConversionHandler::USER_CREATES_EMAIL_FEED_EVENT_GOAL) + end +end diff --git a/app/models/follow.rb b/app/models/follow.rb index c13f23eb54690..670b7de83a6d0 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -27,6 +27,17 @@ class Follow < ApplicationRecord scope :follower_podcast, ->(id) { where(follower_id: id, followable_type: "Podcast") } scope :follower_tag, ->(id) { where(follower_id: id, followable_type: "ActsAsTaggableOn::Tag") } + # Follows from users who don't have suspended or spam role + scope :non_suspended, lambda { |followable_type, followable_id| + joins("INNER JOIN users ON users.id = follows.follower_id") + .joins("LEFT JOIN users_roles ON users_roles.user_id = users.id") + .joins("LEFT JOIN roles ON roles.id = users_roles.role_id") + .where(followable_type: followable_type, followable_id: followable_id) + .where("follows.follower_type = 'User'") + .where("roles.name NOT IN (?) OR roles.name IS NULL", %w[suspended spam]) + .distinct + } + counter_culture :follower, column_name: proc { |follow| COUNTER_CULTURE_COLUMN_NAME_BY_TYPE[follow.followable_type] }, column_names: COUNTER_CULTURE_COLUMNS_NAMES before_save :calculate_points @@ -53,6 +64,7 @@ def touch_follower end def send_email_notification + return if follower&.badge_achievements_count&.zero? # Only email if follower is active enough to have received badge return unless followable.instance_of?(User) && followable.email? Follows::SendEmailNotificationWorker.perform_async(id) diff --git a/app/models/geolocation.rb b/app/models/geolocation.rb new file mode 100644 index 0000000000000..2b5603763df8b --- /dev/null +++ b/app/models/geolocation.rb @@ -0,0 +1,102 @@ +require Rails.root.join("lib/ISO3166/country") # so the class definition has access to extensions + +class Geolocation + class ArrayType < ActiveRecord::Type::Value + # Since an array can be modified directly, this module flags any field using this type as internally + # mutable (and provides different implementations of methods used internally by ActiveRecord to mark + # fields as changed/unchanged) + include ActiveModel::Type::Helpers::Mutable + + def cast(codes) + return [] if codes.blank? + + # Allows setting comma-separated string + codes = codes.split(/[\s,]+/) unless codes.is_a?(Array) + + codes.map { |code| Geolocation.from_iso3166(code) } + end + + def serialize(geo_locations) + PG::TextEncoder::Array.new.encode(cast(geo_locations).map(&:to_ltree)) + end + + def deserialize(geo_ltrees) + PG::TextDecoder::Array.new.decode(geo_ltrees).map { |geo_ltree| Geolocation.from_ltree(geo_ltree) } + end + end + + include ActiveModel::Validations + + FEATURE_FLAG = :billboard_location_targeting + ISO3166_SEPARATOR = "-".freeze + LTREE_SEPARATOR = ".".freeze + DEFAULT_ENABLED_COUNTRIES = { + ISO3166::Country.code_from_name("United States") => :with_regions, + ISO3166::Country.code_from_name("Canada") => :with_regions + }.freeze + + attr_reader :country_code, :region_code + + def self.from_iso3166(iso_3166) + parse(iso_3166, separator: ISO3166_SEPARATOR) + end + + def self.from_ltree(ltree) + parse(ltree, separator: LTREE_SEPARATOR) + end + + def self.parse(code, separator:) + return if code.blank? + return code if code.is_a?(Geolocation) + + country, region = code.split(separator) + + new(country, region) + end + + def initialize(country_code, region_code = nil) + @country_code = country_code + @region_code = region_code + end + + validates :country_code, inclusion: { + in: ->(_) { Settings::General.billboard_enabled_countries.keys } + } + validates :region_code, inclusion: { + in: ->(geolocation) { ISO3166::Country.region_codes_if_exists(geolocation.country_code) } + }, allow_nil: true + validate :valid_region_for_targeting, on: :targeting + + def ==(other) + country_code == other.country_code && region_code == other.region_code + end + + def to_iso3166 + [country_code, region_code].compact.join("-") + end + + def to_ltree + [country_code, region_code].compact.join(".") + end + + def to_sql_query_clause(column_name = :target_geolocations) + return unless valid? + + lquery = country_code + # Match region if specified + lquery += ".#{region_code}{,}" if region_code + + "'#{lquery}' ~ #{column_name}" + end + + def valid_region_for_targeting + return if region_code.nil? || + Settings::General.billboard_enabled_countries[country_code] == :with_regions + + errors.add(:region_code, "was provided on a country with region targeting disabled") + end + + def errors_as_sentence + errors.full_messages.to_sentence + end +end diff --git a/app/models/html_variant.rb b/app/models/html_variant.rb index 715085cb3e1c6..4d7bd428208f1 100644 --- a/app/models/html_variant.rb +++ b/app/models/html_variant.rb @@ -1,6 +1,4 @@ class HtmlVariant < ApplicationRecord - self.ignored_columns = %w[success_rate].freeze - resourcify GROUP_NAMES = %w[article_show_below_article_cta badge_landing_page campaign].freeze diff --git a/app/models/listing.rb b/app/models/listing.rb index ce7d764da1896..6d9de6c0d9bd6 100644 --- a/app/models/listing.rb +++ b/app/models/listing.rb @@ -2,7 +2,6 @@ class Listing < ApplicationRecord # We used to use both "classified listing" and "listing" throughout the app. # We standardized on the latter, but keeping the table name was easier. self.table_name = "classified_listings" - self.ignored_columns = %w[contact_via_connect].freeze include PgSearch::Model @@ -120,7 +119,7 @@ def modify_inputs temp_tags = tag_list self.tag_list = [] # overwrite any existing tag with those from the front matter tag_list.add(temp_tags, parser: ActsAsTaggableOn::TagParser) - self.body_markdown = body_markdown.to_s.gsub(/\r\n/, "\n") + self.body_markdown = body_markdown.to_s.gsub("\r\n", "\n") end def restrict_markdown_input diff --git a/app/models/media_store.rb b/app/models/media_store.rb new file mode 100644 index 0000000000000..d804bce6146d1 --- /dev/null +++ b/app/models/media_store.rb @@ -0,0 +1,15 @@ +class MediaStore < ApplicationRecord + # media_type enum + enum media_type: { image: 0, video: 1, audio: 2 } + + before_validation :set_output_url_if_needed + + private + + def set_output_url_if_needed + return if output_url.present? + + uploader = ArticleImageUploader.new + self.output_url = uploader.upload_from_url(original_url) + end +end diff --git a/app/models/navigation_link.rb b/app/models/navigation_link.rb index 97f648fe87a43..0cd47d7fe070d 100644 --- a/app/models/navigation_link.rb +++ b/app/models/navigation_link.rb @@ -1,5 +1,5 @@ class NavigationLink < ApplicationRecord - SVG_REGEXP = /<svg .*>/im + SVG_REGEXP = /\A<svg .*>[\s]*\z/im before_validation :allow_relative_url, if: :url? before_save :strip_local_hostname, if: :url? diff --git a/app/models/note.rb b/app/models/note.rb index f44a264496f3e..3857c4583ae39 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -6,4 +6,8 @@ class Note < ApplicationRecord belongs_to :noteable, polymorphic: true, touch: true validates :content, :reason, presence: true + + def self.find_for_reports(feedback_message_ids) + includes(:author).where(noteable_id: feedback_message_ids).order(:created_at) + end end diff --git a/app/models/notification.rb b/app/models/notification.rb index c065d8148de94..cf7316a7b1213 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -32,7 +32,12 @@ def send_new_follower_notification(follow, is_read: false) return if follow.followable_type == "User" && UserBlock.blocking?(follow.followable_id, follow.follower_id) follow_data = Notifications::NewFollower::FollowData.coerce(follow).to_h - Notifications::NewFollowerWorker.perform_async(follow_data, is_read) + follower = User.find_by(id: follow.follower_id) + if follower.registered_at > 48.hours.ago # Delay the job 60 minutes to check for spam users if new user + Notifications::NewFollowerWorker.perform_in(1.hour, follow_data, is_read) + else + Notifications::NewFollowerWorker.perform_async(follow_data, is_read) + end end def send_new_follower_notification_without_delay(follow, is_read: false) @@ -44,7 +49,7 @@ def send_new_follower_notification_without_delay(follow, is_read: false) end def send_to_mentioned_users_and_followers(notifiable, _action = nil) - return unless notifiable.is_a?(Article) && notifiable.published? + return unless notifiable.is_a?(Article) && notifiable.published? && notifiable.type_of == "full_post" # We need to create associated mentions inline because they need to exist _before_ creating any # other Article-related notifications. This ensures that users will not receive a second notification for the @@ -101,11 +106,13 @@ def send_welcome_notification(receiver_id, broadcast_id) end def send_moderation_notification(notifiable) - # TODO: make this work for articles in the future. only works for comments right now - return unless notifiable.commentable - return if UserBlock.blocking?(notifiable.commentable.user_id, notifiable.user_id) + return unless [Comment, Article].include?(notifiable.class) - Notifications::CreateRoundRobinModerationNotificationsWorker.perform_async(notifiable.id) + if notifiable.instance_of?(Comment) && UserBlock.blocking?(notifiable.commentable.user_id, notifiable.user_id) + return + end + + Notifications::CreateRoundRobinModerationNotificationsWorker.perform_async(notifiable.id, notifiable.class.to_s) end def send_tag_adjustment_notification(tag_adjustment) diff --git a/app/models/notification_subscription.rb b/app/models/notification_subscription.rb index ff58f0ee879c9..5f36c24643315 100644 --- a/app/models/notification_subscription.rb +++ b/app/models/notification_subscription.rb @@ -13,6 +13,15 @@ class NotificationSubscription < ApplicationRecord validates :notifiable_type, presence: true, inclusion: { in: %w[Comment Article] } validates :user_id, uniqueness: { scope: %i[notifiable_type notifiable_id] } + def self.for_notifiable(notifiable = nil, notifiable_type: nil, notifiable_id: nil) + notifiable_type ||= notifiable&.class&.polymorphic_name + notifiable_id ||= notifiable&.id + + return none if !notifiable_type || !notifiable_id + + where(notifiable_type: notifiable_type, notifiable_id: notifiable_id) + end + class << self # @param notifiable [Comment, Article] # diff --git a/app/models/organization.rb b/app/models/organization.rb index 606bd7b28c316..94bfdab301549 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -1,11 +1,13 @@ class Organization < ApplicationRecord include CloudinaryHelper + include PgSearch::Model + include AlgoliaSearchable include Images::Profile.for(:profile_image_url) + extend UniqueAcrossModels COLOR_HEX_REGEXP = /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/ INTEGER_REGEXP = /\A\d+\z/ - SLUG_REGEXP = /\A[a-zA-Z0-9\-_]+\z/ acts_as_followable @@ -17,14 +19,17 @@ class Organization < ApplicationRecord before_save :generate_secret after_save :bust_cache + after_save :generate_social_images after_update_commit :conditionally_update_articles after_destroy_commit :bust_cache + pg_search_scope :search_organizations, against: :name + has_many :articles, dependent: :nullify has_many :collections, dependent: :nullify has_many :credits, dependent: :restrict_with_error - has_many :display_ads, dependent: :destroy + has_many :billboards, class_name: "Billboard", dependent: :destroy has_many :listings, dependent: :destroy has_many :notifications, dependent: :delete_all has_many :organization_memberships, dependent: :delete_all @@ -42,14 +47,11 @@ class Organization < ApplicationRecord validates :cta_button_url, length: { maximum: 150 }, url: { allow_blank: true, no_local: true } validates :github_username, length: { maximum: 50 } validates :location, :email, length: { maximum: 64 } - validates :name, :summary, :url, :profile_image, presence: true + validates :name, :profile_image, presence: true validates :name, length: { maximum: 50 } validates :proof, length: { maximum: 1500 } validates :secret, length: { is: 100 }, allow_nil: true validates :secret, uniqueness: true - validates :slug, exclusion: { in: ReservedWords.all, message: :reserved_word } - validates :slug, format: { with: SLUG_REGEXP }, length: { in: 2..30 } - validates :slug, presence: true, uniqueness: { case_sensitive: false } validates :spent_credits_count, presence: true validates :summary, length: { maximum: 250 } validates :tag_line, length: { maximum: 60 } @@ -59,17 +61,23 @@ class Organization < ApplicationRecord validates :unspent_credits_count, presence: true validates :url, length: { maximum: 200 }, url: { allow_blank: true, no_local: true } - validates :slug, unique_cross_model_slug: true, if: :slug_changed? + unique_across_models :slug, length: { in: 2..30 } mount_uploader :profile_image, ProfileImageUploader - mount_uploader :nav_image, ProfileImageUploader - mount_uploader :dark_nav_image, ProfileImageUploader alias_attribute :username, :slug alias_attribute :old_username, :old_slug alias_attribute :old_old_username, :old_old_slug alias_attribute :website_url, :url + def self.simple_name_match(query) + scope = order(:name) + query&.strip! + return scope if query.blank? + + scope.where("name ILIKE ?", "%#{query}%") + end + def self.integer_only I18n.t("models.organization.integer_only") end @@ -114,14 +122,29 @@ def destroyable? organization_memberships.count == 1 && articles.count.zero? && credits.count.zero? end + def public_articles_count + articles.published.count + end + # NOTE: We use Organization and User objects interchangeably. Since the former # don't have profiles we return self instead. def profile self end + def cached_base_subscriber? + false + end + private + def generate_social_images + change = saved_change_to_attribute?(:name) || saved_change_to_attribute?(:profile_image) + return unless change && articles.published.size.positive? + + Images::SocialImageWorker.perform_async(id, self.class.name) + end + def evaluate_markdown self.cta_processed_html = MarkdownProcessor::Parser.new(cta_body_markdown).evaluate_limited_markdown end diff --git a/app/models/organization_membership.rb b/app/models/organization_membership.rb index a0ba6055a6eee..bfceec769c3aa 100644 --- a/app/models/organization_membership.rb +++ b/app/models/organization_membership.rb @@ -14,6 +14,8 @@ class OrganizationMembership < ApplicationRecord after_create :update_user_organization_info_updated_at after_destroy :update_user_organization_info_updated_at + after_commit :bust_cache + scope :admin, -> { where(type_of_user: "admin") } scope :member, -> { where(type_of_user: %w[admin member]) } @@ -23,4 +25,10 @@ class OrganizationMembership < ApplicationRecord def update_user_organization_info_updated_at user.touch(:organization_info_updated_at) end + + private + + def bust_cache + BustCachePathWorker.perform_async(organization.path.to_s) + end end diff --git a/app/models/page.rb b/app/models/page.rb index d657b4afc8217..47e5b9952a62e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -1,17 +1,19 @@ class Page < ApplicationRecord - TEMPLATE_OPTIONS = %w[contained full_within_layout nav_bar_included json].freeze + extend UniqueAcrossModels + TEMPLATE_OPTIONS = %w[contained full_within_layout nav_bar_included json css txt].freeze TERMS_SLUG = "terms".freeze CODE_OF_CONDUCT_SLUG = "code-of-conduct".freeze PRIVACY_SLUG = "privacy".freeze + has_many :billboards, dependent: :nullify + validates :title, presence: true validates :description, presence: true - validates :slug, presence: true, format: /\A[0-9a-z\-_]*\z/ validates :template, inclusion: { in: TEMPLATE_OPTIONS } validate :body_present - validates :slug, unique_cross_model_slug: true, if: :slug_changed? - validates :slug, uniqueness: true + + unique_across_models :slug before_validation :set_default_template before_save :evaluate_markdown @@ -80,7 +82,7 @@ def set_default_template end def body_present - return unless body_markdown.blank? && body_html.blank? && body_json.blank? + return unless body_markdown.blank? && body_html.blank? && body_json.blank? && body_css.blank? errors.add(:body_markdown, I18n.t("models.page.body_must_exist")) end diff --git a/app/models/podcast.rb b/app/models/podcast.rb index 65d04389d46d3..edad43d80fb3b 100644 --- a/app/models/podcast.rb +++ b/app/models/podcast.rb @@ -17,13 +17,9 @@ class Podcast < ApplicationRecord validates :main_color_hex, :title, :feed_url, :image, presence: true validates :main_color_hex, format: /\A([a-fA-F]|[0-9]){6}\Z/ validates :feed_url, uniqueness: true, url: { schemes: %w[https http] } - validates :slug, - presence: true, - uniqueness: true, - format: { with: /\A[a-zA-Z0-9\-_]+\Z/ }, - exclusion: { in: ReservedWords.all, message: I18n.t("models.podcast.slug_is_reserved") } - validates :slug, unique_cross_model_slug: true, if: :slug_changed? + extend UniqueAcrossModels + unique_across_models :slug after_save :bust_cache diff --git a/app/models/podcast_episode.rb b/app/models/podcast_episode.rb index 27414504d75d6..1f9641ffe0c67 100644 --- a/app/models/podcast_episode.rb +++ b/app/models/podcast_episode.rb @@ -1,5 +1,6 @@ class PodcastEpisode < ApplicationRecord include PgSearch::Model + include AlgoliaSearchable acts_as_taggable diff --git a/app/models/reaction.rb b/app/models/reaction.rb index a5c72e96618b8..81e32b04de69e 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -7,9 +7,7 @@ class Reaction < ApplicationRecord counter_culture :reactable, column_name: proc { |model| - # After FeatureFlag :multiple_reactions, this could change to: - # ReactionCategory[model.category].visible_to_public? - public_reaction_types.include?(model.category.to_s) ? "public_reactions_count" : "reactions_count" + ReactionCategory[model.category].visible_to_public? ? "public_reactions_count" : "reactions_count" } counter_culture :user @@ -39,6 +37,18 @@ class Reaction < ApplicationRecord scope :unarchived, -> { where.not(status: "archived") } scope :from_user, ->(user) { where(user: user) } scope :readinglist_for_user, ->(user) { readinglist.unarchived.only_articles.from_user(user) } + scope :distinct_categories, -> { select("distinct(reactions.category) as category, reactable_id, reactable_type") } + scope :live_reactable, lambda { + joins("LEFT JOIN articles ON reactions.reactable_id = articles.id AND reactions.reactable_type = 'Article'") + .joins("LEFT JOIN users ON reactions.reactable_id = users.id AND reactions.reactable_type = 'User'") + .where(" + CASE + WHEN reactions.reactable_type = 'Article' THEN articles.published = TRUE + WHEN reactions.reactable_type = 'User' THEN users.username NOT LIKE 'spam_%' + ELSE TRUE + END + ") + } validates :category, inclusion: { in: ReactionCategory.all_slugs.map(&:to_s) } validates :reactable_type, inclusion: { in: REACTABLE_TYPES } @@ -79,18 +89,7 @@ def cached_any_reactions_for?(reactable, user, category) end def public_reaction_types - if FeatureFlag.enabled?(:multiple_reactions) - reaction_types = ReactionCategory.public.map(&:to_s) - ["readinglist"] - else - # used to include "readinglist" but that's not correct now, even without the feature flag - # we aren't going to re-process these, they will gradually correct over time - reaction_types = %w[like] - unless FeatureFlag.enabled?(:replace_unicorn_with_jump_to_comments) - reaction_types << "unicorn" - end - end - - reaction_types + @public_reaction_types ||= ReactionCategory.public.map(&:to_s) - ["readinglist"] end def for_analytics @@ -151,6 +150,7 @@ def contradictory_mod_reactions(category:, reactable_id:, reactable_type:, user: # - reaction is negative # - receiver is the same user as the one who reacted # - reaction status is marked invalid + # - reaction is not in a category that should be notified def skip_notification_for?(_receiver) reactor_id = case reactable when User @@ -159,7 +159,7 @@ def skip_notification_for?(_receiver) reactable.user_id end - (status == "invalid") || points.negative? || (user_id == reactor_id) + !should_notify? || (status == "invalid") || points.negative? || (user_id == reactor_id) end def reaction_on_organization_article? @@ -176,6 +176,14 @@ def reaction_category ReactionCategory[category.to_sym] end + def readable_date + if created_at.year == Time.current.year + I18n.l(created_at, format: :short) + else + I18n.l(created_at, format: :short_with_yy) + end + end + private def update_reactable @@ -194,14 +202,6 @@ def bust_reactable_cache_without_delay Reactions::BustReactableCacheWorker.new.perform(id) end - def reading_time - reactable.reading_time if category == "readinglist" - end - - def viewable_by - user_id - end - def assign_points self.points = CalculateReactionPoints.call(self) end @@ -214,7 +214,7 @@ def permissions end def negative_reaction_from_untrusted_user? - return if user&.any_admin? || user&.id == Settings::General.mascot_user_id + return false if user&.any_admin? || user&.id == Settings::General.mascot_user_id negative? && !user.trusted? end @@ -236,4 +236,8 @@ def record_field_test_event Users::RecordFieldTestEventWorker .perform_async(user_id, AbExperiment::GoalConversionHandler::USER_CREATES_ARTICLE_REACTION_GOAL) end + + def should_notify? + ReactionCategory.notifiable.include?(category.to_sym) + end end diff --git a/app/models/reaction_category.rb b/app/models/reaction_category.rb index 608d40f0336ef..3f89ee9e902e4 100644 --- a/app/models/reaction_category.rb +++ b/app/models/reaction_category.rb @@ -21,6 +21,10 @@ def public list.sort_by(&:position).filter_map { |category| category.slug if category.visible_to_public? } end + def notifiable + public + end + def privileged list.filter_map { |category| category.slug if category.privileged? } end @@ -68,4 +72,13 @@ def negative? def visible_to_public? !privileged? && published? end + + def as_json(_options = nil) + { + slug: slug, + name: name, + icon: icon, + position: position + } + end end diff --git a/app/models/recommended_articles_list.rb b/app/models/recommended_articles_list.rb new file mode 100644 index 0000000000000..f11215988cd06 --- /dev/null +++ b/app/models/recommended_articles_list.rb @@ -0,0 +1,22 @@ +class RecommendedArticlesList < ApplicationRecord + belongs_to :user + validates :name, presence: true, length: { maximum: 120 } + + enum placement_area: { main_feed: 0 } # Only main feed for now, could be used in Digest, trending, etc. + + scope :active, -> { where("expires_at > ?", Time.current) } + + before_save :set_default_values + + def set_default_values + self.expires_at = 1.day.from_now if expires_at.nil? + end + + # exclude_article_ids is an integer array, web inputs are comma-separated strings + # ActiveRecord normalizes these in a bad way, so we are intervening + def article_ids=(input) + adjusted_input = input.is_a?(String) ? input.split(",") : input + adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i } + write_attribute :article_ids, (adjusted_input || []) + end +end diff --git a/app/models/role.rb b/app/models/role.rb index 75aed34654441..5e32f4f3ca8cc 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -4,7 +4,6 @@ class Role < ApplicationRecord codeland_admin comment_suspended creator - mod_relations_admin super_moderator podcast_admin restricted_liquid_tag @@ -12,13 +11,21 @@ class Role < ApplicationRecord super_admin support_admin suspended + spam tag_moderator tech_admin trusted warned - workshop_pass + limited + base_subscriber ].freeze + ROLES.each do |role| + define_method(:"#{role}?") do + name == role + end + end + has_and_belongs_to_many :users, join_table: :users_roles # rubocop:disable Rails/HasAndBelongsToMany belongs_to :resource, @@ -41,4 +48,14 @@ def resource_name Tag.find(resource_id).name end + + def name_labelize + if single_resource_admin? + Constants::Role::SPECIAL_ROLES_LABELS_TO_WHERE_CLAUSE.detect do |_k, v| + v[:name] == "single_resource_admin" && v[:resource_type] == resource_type + end&.first || name + else + name + end + end end diff --git a/app/models/segmented_user.rb b/app/models/segmented_user.rb new file mode 100644 index 0000000000000..696f9f59a9493 --- /dev/null +++ b/app/models/segmented_user.rb @@ -0,0 +1,4 @@ +class SegmentedUser < ApplicationRecord + belongs_to :audience_segment + belongs_to :user +end diff --git a/app/models/settings/authentication.rb b/app/models/settings/authentication.rb index 46d5eff17f94c..25dccb7e26876 100644 --- a/app/models/settings/authentication.rb +++ b/app/models/settings/authentication.rb @@ -2,6 +2,8 @@ module Settings class Authentication < Base self.table_name = :settings_authentications + NEW_USER_STATUSES = %w[good_standing limited].freeze + setting :allow_email_password_login, type: :boolean, default: true setting :allow_email_password_registration, type: :boolean, default: false setting :allowed_registration_email_domains, type: :array, default: %w[], validates: { @@ -24,6 +26,9 @@ class Authentication < Base setting :google_oauth2_key, type: :string setting :google_oauth2_secret, type: :string setting :invite_only_mode, type: :boolean, default: false + setting :new_user_status, type: :string, default: "good_standing", validates: { + inclusion: { in: NEW_USER_STATUSES } + } setting :providers, type: :array, default: %w[] setting :require_captcha_for_email_password_registration, type: :boolean, default: false setting :twitter_key, type: :string, default: ApplicationConfig["TWITTER_KEY"] @@ -57,5 +62,9 @@ def self.acceptable_domain?(domain:) false end + + def self.limit_new_users? + new_user_status == "limited" + end end end diff --git a/app/models/settings/base.rb b/app/models/settings/base.rb index 39468e74ab6d9..7c71548ef609d 100644 --- a/app/models/settings/base.rb +++ b/app/models/settings/base.rb @@ -73,20 +73,29 @@ def define_setting(key, default: nil, type: :string, separator: nil, validates: if result.nil? # we don't want to accidentally do this for "false" result ||= default.is_a?(Proc) ? default.call : default end - result = __send__(:convert_string_to_value_type, type, result, separator: separator) + + read_as_type = type == :markdown ? :string : type + result = __send__(:convert_string_to_value_type, read_as_type, result, separator: separator) result end # Setter - define_singleton_method("#{key}=") do |value| + define_singleton_method(:"#{key}=") do |value| var_name = key record = find_by(var: var_name) || new(var: var_name) - value = __send__(:convert_string_to_value_type, type, value, separator: separator) - record.value = value - record.save! + if type == :markdown + processed = __send__(:convert_string_to_value_type, type, value) + record.value = value + record.save! + __send__(:"#{key}_processed_html=", processed) + else + value = __send__(:convert_string_to_value_type, type, value, separator: separator) + record.value = value + record.save! + end value end @@ -102,7 +111,7 @@ def define_setting(key, default: nil, type: :string, separator: nil, validates: return unless type == :boolean # Predicate method for booleans - define_singleton_method("#{key}?") { __send__(key) } + define_singleton_method(:"#{key}?") { __send__(key) } end def convert_string_to_value_type(type, value, separator: nil) @@ -127,6 +136,8 @@ def convert_string_to_value_type(type, value, separator: nil) value.to_f when :big_decimal value.to_d + when :markdown + ContentRenderer.new(value).process.processed_html else value end @@ -153,7 +164,7 @@ def all_settings # get the setting's value, YAML decoded def value - YAML.load(self[:value]) if self[:value].present? # rubocop:disable Security/YAMLLoad + YAML.unsafe_load(self[:value]) if self[:value].present? end # set the settings's value, YAML encoded diff --git a/app/models/settings/general.rb b/app/models/settings/general.rb index 4d8c1a426f66b..31ece0b74d268 100644 --- a/app/models/settings/general.rb +++ b/app/models/settings/general.rb @@ -1,6 +1,12 @@ module Settings class General < Base + BANNER_USER_CONFIGS = %w[off logged_out_only all].freeze + BANNER_PLATFORM_CONFIGS = %w[off all all_web desktop_web mobile_web mobile_app].freeze + self.table_name = "site_configs" + SOCIAL_MEDIA_SERVICES = %w[ + twitter facebook github instagram twitch mastodon + ].freeze # Forem Team # [forem-fix] Remove channel name from Settings::General @@ -21,9 +27,16 @@ class General < Base setting :contact_email, type: :string, default: ApplicationConfig["DEFAULT_EMAIL"] setting :periodic_email_digest, type: :integer, default: 2 - # Google Analytics Tracking ID, e.g. UA-71991000-1 + # Analytics and tracking setting :ga_tracking_id, type: :string, default: ApplicationConfig["GA_TRACKING_ID"] setting :ga_analytics_4_id, type: :string, default: ApplicationConfig["GA_ANALYTICS_4_ID"] + setting :ga_api_secret, type: :string, default: ApplicationConfig["GA_API_SECRET"] + setting :cookie_banner_user_context, type: :string, default: "off", validates: { + inclusion: { in: BANNER_USER_CONFIGS } + } + setting :coolie_banner_platform_context, type: :string, default: "off", validates: { + inclusion: { in: BANNER_PLATFORM_CONFIGS } + } # Ahoy Tracking setting :ahoy_tracking, type: :boolean, default: false @@ -42,6 +55,7 @@ class General < Base setting :original_logo, type: :string setting :resized_logo, type: :string + setting :resized_logo_aspect_ratio, type: :string setting :enable_video_upload, type: :boolean, default: false @@ -66,9 +80,12 @@ class General < Base } # Monetization - setting :payment_pointer, type: :string setting :stripe_api_key, type: :string, default: ApplicationConfig["STRIPE_SECRET_KEY"] setting :stripe_publishable_key, type: :string, default: ApplicationConfig["STRIPE_PUBLISHABLE_KEY"] + # Billboard-related. Not sure this is the best place for it, but it's a start. + setting :billboard_enabled_countries, type: :hash, default: Geolocation::DEFAULT_ENABLED_COUNTRIES, validates: { + enabled_countries_hash: true + } # Newsletter # <https://mailchimp.com/developer/> @@ -81,10 +98,7 @@ class General < Base setting :mailchimp_incoming_webhook_secret, type: :string, default: "" # Onboarding - setting :onboarding_background_image, type: :string, validates: { url: true, unless: -> { value.blank? } } setting :suggested_tags, type: :array, default: %w[] - setting :suggested_users, type: :array, default: %w[] - setting :prefer_manual_suggested_users, type: :boolean, default: false # Social Media setting :social_media_handles, type: :hash, default: { @@ -98,6 +112,7 @@ class General < Base setting :twitter_hashtag, type: :string # Tags + setting :display_sidebar_active_discussions, type: :boolean, default: true setting :sidebar_tags, type: :array, default: %w[] # Broadcast @@ -118,5 +133,38 @@ class General < Base setting :feed_pinned_article_id, type: :integer, validates: { existing_published_article_id: true, allow_nil: true } + + # Onboarding newsletter + setting :onboarding_newsletter_content, type: :markdown + setting :onboarding_newsletter_content_processed_html + setting :onboarding_newsletter_opt_in_head + setting :onboarding_newsletter_opt_in_subhead + + setting :geos_with_allowed_default_email_opt_in, type: :array, default: %w[] + + setting :default_content_language, type: :string, default: "en", + validates: { inclusion: Languages::Detection.codes } + + # Algolia + setting :algolia_application_id, type: :string, default: ApplicationConfig["ALGOLIA_APPLICATION_ID"] + setting :algolia_api_key, type: :string, default: ApplicationConfig["ALGOLIA_API_KEY"] + setting :algolia_search_only_api_key, type: :string, default: ApplicationConfig["ALGOLIA_SEARCH_ONLY_API_KEY"] + setting :display_algolia_branding, type: :boolean, default: ApplicationConfig["ALGOLIA_DISPLAY_BRANDING"] == "true" + + def self.algolia_search_enabled? + algolia_application_id.present? && algolia_search_only_api_key.present? && algolia_api_key.present? + end + + def self.custom_newsletter_configured? + onboarding_newsletter_content_processed_html.present? && + onboarding_newsletter_opt_in_head.present? && + onboarding_newsletter_opt_in_subhead.present? + end + + def self.social_media_services + SOCIAL_MEDIA_SERVICES.index_with do |name| + social_media_handles[name] + end + end end end diff --git a/app/models/settings/user_experience.rb b/app/models/settings/user_experience.rb index 334d5817d506c..4949f2fad06e1 100644 --- a/app/models/settings/user_experience.rb +++ b/app/models/settings/user_experience.rb @@ -6,6 +6,7 @@ class UserExperience < Base HEX_COLOR_REGEX = /\A#(\h{6}|\h{3})\z/ FEED_STRATEGIES = %w[basic large_forem_experimental].freeze FEED_STYLES = %w[basic rich compact].freeze + COVER_IMAGE_FITS = %w[crop limit].freeze # The default font for all users that have not chosen a custom font yet setting :default_font, type: :string, default: "sans_serif" @@ -18,6 +19,7 @@ class UserExperience < Base } setting :home_feed_minimum_score, type: :integer, default: 0 setting :index_minimum_score, type: :integer, default: 0 + setting :index_minimum_date, type: :integer, default: 1_500_000_000 setting :primary_brand_color_hex, type: :string, default: "#3b49df", validates: { format: { with: HEX_COLOR_REGEX, @@ -25,11 +27,26 @@ class UserExperience < Base }, color_contrast: true } + + # cover images + setting :cover_image_height, type: :integer, default: 420 + setting :cover_image_fit, type: :string, default: "crop", validates: { + inclusion: { in: COVER_IMAGE_FITS } + } + # a non-public forem will redirect all unauthenticated pages to the registration page. # a public forem could have more fine-grained authentication (listings ar private etc.) in future setting :public, type: :boolean, default: true setting :tag_feed_minimum_score, type: :integer, default: 0 setting :default_locale, type: :string, default: "en" setting :display_in_directory, type: :boolean, default: true + setting :award_tag_minimum_score, type: :integer, default: 100 + + # Mobile App + setting :show_mobile_app_banner, type: :boolean, default: true + + # Head and footer content + setting :head_content, type: :string, default: "" + setting :bottom_of_body_content, type: :string, default: "" end end diff --git a/app/models/tag.rb b/app/models/tag.rb index 42204bfd614a3..5a69a828d7b3b 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -13,8 +13,6 @@ # @note models with `acts_as_taggable_on` declarations (e.g., Article and Listing) # @see https://developers.forem.com/technical-overview/architecture/#tags for more discussion class Tag < ActsAsTaggableOn::Tag - self.ignored_columns = %w[mod_chat_channel_id].freeze - attr_accessor :tag_moderator_id, :remove_moderator_id acts_as_followable @@ -23,6 +21,7 @@ class Tag < ActsAsTaggableOn::Tag # This model doesn't inherit from ApplicationRecord so this has to be included include Purgeable include PgSearch::Model + include AlgoliaSearchable # @note Even though we have a data migration script (see further # comments below), as of <2022-01-04 Tue> we had 5 tags where @@ -33,11 +32,12 @@ class Tag < ActsAsTaggableOn::Tag include StringAttributeCleaner.nullify_blanks_for(:alias_for) ALLOWED_CATEGORIES = %w[uncategorized language library tool site_mechanic location subcommunity].freeze HEX_COLOR_REGEXP = /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/ + ATTRIBUTES_FOR_SERIALIZATION = %i[id name bg_color_hex text_color_hex short_summary badge_id].freeze belongs_to :badge, optional: true has_many :articles, through: :taggings, source: :taggable, source_type: "Article" - has_many :display_ads, through: :taggings, source: :taggable, source_type: "DisplayAd" + has_many :billboards, class_name: "Billboard", through: :taggings, source: :taggable, source_type: "Billboard" mount_uploader :profile_image, ProfileImageUploader mount_uploader :social_image, ProfileImageUploader @@ -56,6 +56,7 @@ class Tag < ActsAsTaggableOn::Tag before_save :calculate_hotness_score before_save :mark_as_updated + after_save :update_suggested_tags, if: :saved_change_to_suggested? after_commit :bust_cache # @note Even though we have a data migration script (see further @@ -74,6 +75,8 @@ class Tag < ActsAsTaggableOn::Tag # this scope we have a name. scope :direct, -> { where(alias_for: [nil, ""]) } + scope :select_attributes_for_serialization, -> { select(ATTRIBUTES_FOR_SERIALIZATION) } + pg_search_scope :search_by_name, against: :name, using: { tsearch: { prefix: true } } @@ -81,25 +84,7 @@ class Tag < ActsAsTaggableOn::Tag scope :eager_load_serialized_data, -> {} scope :supported, -> { where(supported: true) } - # @return [String] - # - # @see ApplicationRecord#class_name - def class_name - self.class.name - end - - # possible social previews templates for articles with a particular tag - def self.social_preview_templates - Rails.root.join("app/views/social_previews/articles").children.map { |ch| File.basename(ch, ".html.erb") } - end - - def submission_template_customized(param_0 = nil) - submission_template&.gsub("PARAM_0", param_0) - end - - def tag_moderator_ids - User.with_role(:tag_moderator, self).order(id: :asc).ids - end + scope :suggested_for_onboarding, -> { where(suggested: true) } def self.valid_categories ALLOWED_CATEGORIES @@ -116,14 +101,6 @@ def self.find_preferred_alias_for(word) find_by(name: word.downcase)&.alias_for.presence || word.downcase end - def validate_name - errors.add(:name, I18n.t("errors.messages.too_long", count: 30)) if name.length > 30 - # [:alnum:] is not used here because it supports diacritical characters. - # If we decide to allow diacritics in the future, we should replace the - # following regex with [:alnum:]. - errors.add(:name, I18n.t("errors.messages.contains_prohibited_characters")) unless name.match?(/\A[[:alnum:]]+\z/i) - end - # While this non-end user facing flag is "in play", our goal is to say that when it's "false" # we'll preserve existing behavior. And when true, we're testing out new behavior. This way we # can push up changes and refactor towards improvements without unleashing a large pull request @@ -141,19 +118,6 @@ def self.favor_accessible_name_for_tag_label? FeatureFlag.enabled?(:favor_accessible_name_for_tag_label) end - # @note In the future we envision always favoring pretty name over the given name. - # - # @todo When we "rollout this feature" remove the guard clause and adjust the corresponding spec. - def accessible_name - return name unless self.class.favor_accessible_name_for_tag_label? - - pretty_name.presence || name - end - - def errors_as_sentence - errors.full_messages.to_sentence - end - # @param follower [#id, #class_name] An object who's class "acts_as_follower" (e.g. a User). # # @return [ActiveRecord::Relation<Tag>] with the "points" attribute and limited field selection @@ -200,6 +164,61 @@ def self.followed_tags_for(follower:) .order(hotness_score: :desc) end + # returns the tags that are followed by the user + # @param user [User] the user to check + # @param points [Range] the range of explicit points to check for (default is 0 (including 0) to infinity) + # @return [ActiveRecord::Relation<Tag>] the tags followed by the user + def self.followed_by(user, explicit_points = (0...)) + joins("INNER JOIN follows ON tags.id = follows.followable_id") + .where(follows: { follower_id: user.id, followable_type: "ActsAsTaggableOn::Tag", + explicit_points: explicit_points }) + .order("follows.points DESC") + end + + # returns the tags that are anti-followed (hidden) by the user. It uses the followed_by method + # and passes in a range of -infinity to 0 (excluding 0). + # @param user [User] the user to check + # @return [ActiveRecord::Relation<Tag>] the tags anti-followed by the user (for hidden tags) + def self.antifollowed_by(user) + followed_by(user, (...0)) + end + + # @return [String] + # + # @see ApplicationRecord#class_name + def class_name + self.class.name + end + + def submission_template_customized(param_0 = nil) + submission_template&.gsub("PARAM_0", param_0) + end + + def tag_moderator_ids + User.with_role(:tag_moderator, self).order(id: :asc).ids + end + + def validate_name + errors.add(:name, I18n.t("errors.messages.too_long", count: 30)) if name.length > 30 + # [:alnum:] is not used here because it supports diacritical characters. + # If we decide to allow diacritics in the future, we should replace the + # following regex with [:alnum:]. + errors.add(:name, I18n.t("errors.messages.contains_prohibited_characters")) unless name.match?(/\A[[:alnum:]]+\z/i) + end + + # @note In the future we envision always favoring pretty name over the given name. + # + # @todo When we "rollout this feature" remove the guard clause and adjust the corresponding spec. + def accessible_name + return name unless self.class.favor_accessible_name_for_tag_label? + + pretty_name.presence || name + end + + def errors_as_sentence + errors.full_messages.to_sentence + end + # What's going on here? There are times where we want our "Tag" object to have a "points" # attribute; for example when we want to render the tags a user is following and the points we've # calculated for that following. (Yes that is a short-circuit and we could perhaps make a more @@ -214,7 +233,7 @@ def self.followed_tags_for(follower:) # @see Tag#explicit_points # @see Tag#implicit_points def points - (attributes["points"] || @points || 0) + attributes["points"] || @points || 0 end # @!attribute [rw] explicit_points @@ -293,4 +312,8 @@ def pound_it def mark_as_updated self.updated_at = Time.current # Acts-as-taggable didn't come with this by default end + + def update_suggested_tags + Settings::General.suggested_tags = Tag.suggested_for_onboarding.pluck(:name).join(",") + end end diff --git a/app/models/tag_adjustment.rb b/app/models/tag_adjustment.rb index aec115d09a00b..9b1d928d5ac49 100644 --- a/app/models/tag_adjustment.rb +++ b/app/models/tag_adjustment.rb @@ -1,6 +1,5 @@ class TagAdjustment < ApplicationRecord - validates :tag_name, presence: true, - uniqueness: { scope: :article_id, message: I18n.t("models.tag_adjustment.unique") } + validates :tag_name, presence: true validates :adjustment_type, inclusion: { in: %w[removal addition] }, presence: true validates :status, inclusion: { in: %w[committed pending committed_and_resolvable resolved] }, presence: true has_many :notifications, as: :notifiable, inverse_of: :notifiable, dependent: :delete_all diff --git a/app/models/tweet.rb b/app/models/tweet.rb index 17c41dfa2b28b..743b68a6a24e7 100644 --- a/app/models/tweet.rb +++ b/app/models/tweet.rb @@ -58,11 +58,7 @@ def retrieve_and_save_tweet(status_id) end def create_tweet_from_api_status(status) - status = if status.retweeted_status.present? - TwitterClient::Client.status(status.retweeted_status.id.to_s) - else - status - end + status = TwitterClient::Client.status(status.retweeted_status.id.to_s) if status.retweeted_status.present? params = { twitter_id_code: status.id.to_s } tweet = Tweet.find_by(params) || new(params) diff --git a/app/models/user.rb b/app/models/user.rb index e945f5e4fdcf7..0a3130af87fe2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,10 +1,11 @@ class User < ApplicationRecord resourcify - rolify + rolify after_add: :update_user_roles_cache, after_remove: :update_user_roles_cache include CloudinaryHelper include Images::Profile.for(:profile_image_url) + include AlgoliaSearchable # NOTE: we are using an inline module to keep profile related things together. concerning :Profiles do @@ -18,26 +19,24 @@ class User < ApplicationRecord attr_accessor :_skip_creating_profile # All new users should automatically have a profile - after_create_commit -> { Profile.create(user: self) }, unless: :_skip_creating_profile + after_create_commit unless: :_skip_creating_profile do + Profile.find_or_create_by(user: self) + rescue ActiveRecord::RecordNotUnique + Rails.logger.warn("Profile already exists for user #{id}") + end end end include StringAttributeCleaner.nullify_blanks_for(:email) + extend UniqueAcrossModels USERNAME_MAX_LENGTH = 30 - USERNAME_REGEXP = /\A[a-zA-Z0-9_]+\z/ - # follow the syntax in https://interledger.org/rfcs/0026-payment-pointers/#payment-pointer-syntax - PAYMENT_POINTER_REGEXP = %r{ - \A # start - \$ # starts with a dollar sign - ([a-zA-Z0-9\-.])+ # matches the hostname (ex ilp.uphold.com) - (/[\x20-\x7F]+)? # optional forward slash and identifier with printable ASCII characters - \z - }x + + RECENTLY_ACTIVE_LIMIT = 10_000 attr_accessor :scholar_email, :new_note, :note_for_current_role, :user_status, :merge_user_id, :add_credits, :remove_credits, :add_org_credits, :remove_org_credits, :ip_address, - :current_password + :current_password, :custom_invite_subject, :custom_invite_message, :custom_invite_footnote acts_as_followable acts_as_follower @@ -68,9 +67,10 @@ class User < ApplicationRecord has_many :created_podcasts, class_name: "Podcast", foreign_key: :creator_id, inverse_of: :creator, dependent: :nullify has_many :credits, dependent: :destroy has_many :discussion_locks, dependent: :delete_all, inverse_of: :locking_user, foreign_key: :locking_user_id - has_many :display_ad_events, dependent: :nullify + has_many :billboard_events, dependent: :nullify has_many :email_authorizations, dependent: :delete_all has_many :email_messages, class_name: "Ahoy::Message", dependent: :destroy + has_many :feed_events, dependent: :nullify has_many :field_test_memberships, class_name: "FieldTest::Membership", as: :participant, dependent: :destroy # Consider that we might be able to use dependent: :delete_all as the GithubRepo busts the user cache has_many :github_repos, dependent: :destroy @@ -95,6 +95,9 @@ class User < ApplicationRecord has_many :poll_skips, dependent: :delete_all has_many :poll_votes, dependent: :delete_all has_many :profile_pins, as: :profile, inverse_of: :profile, dependent: :delete_all + has_many :segmented_users, dependent: :destroy + has_many :audience_segments, through: :segmented_users + has_many :recommended_articles_lists, dependent: :destroy # we keep rating votes as they belong to the article, not to the user who viewed it has_many :rating_votes, dependent: :nullify @@ -110,6 +113,9 @@ class User < ApplicationRecord has_many :subscribers, through: :source_authored_user_subscriptions, dependent: :destroy has_many :tweets, dependent: :nullify has_many :devices, dependent: :delete_all + # languages that user undestands + has_many :languages, class_name: "UserLanguage", inverse_of: :user, dependent: :delete_all + has_many :user_visit_contexts, dependent: :delete_all mount_uploader :profile_image, ProfileImageUploader @@ -127,23 +133,17 @@ class User < ApplicationRecord validates :following_orgs_count, presence: true validates :following_tags_count, presence: true validates :following_users_count, presence: true - validates :name, length: { in: 1..100 } + validates :name, length: { in: 1..100 }, presence: true validates :password, length: { in: 8..100 }, allow_nil: true - validates :payment_pointer, format: PAYMENT_POINTER_REGEXP, allow_blank: true validates :rating_votes_count, presence: true validates :reactions_count, presence: true validates :sign_in_count, presence: true validates :spent_credits_count, presence: true validates :subscribed_to_user_subscriptions_count, presence: true validates :unspent_credits_count, presence: true - validates :username, length: { in: 2..USERNAME_MAX_LENGTH }, format: USERNAME_REGEXP - validates :username, presence: true, exclusion: { - in: ReservedWords.all, - message: proc { I18n.t("models.user.username_is_reserved") } - } - validates :username, uniqueness: { case_sensitive: false, message: lambda do |_obj, data| - I18n.t("models.user.is_taken", username: (data[:value])) - end }, if: :username_changed? + validates :max_score, numericality: { greater_than_or_equal_to: 0 } + validates :reputation_modifier, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 5 }, + presence: true # add validators for provider related usernames Authentication::Providers.username_fields.each do |username_field| @@ -158,7 +158,9 @@ class User < ApplicationRecord end validate :non_banished_username, :username_changed? - validates :username, unique_cross_model_slug: true, if: :username_changed? + + unique_across_models :username, length: { in: 2..USERNAME_MAX_LENGTH } + validate :can_send_confirmation_email validate :update_rate_limit # NOTE: when updating the password on a Devise enabled model, the :encrypted_password @@ -206,21 +208,50 @@ class User < ApplicationRecord ), ) } + + scope :with_experience_level, lambda { |level = nil| + includes(:setting).where("users_settings.experience_level": level) + } + + scope :recently_active, lambda { |active_limit = RECENTLY_ACTIVE_LIMIT| + order(updated_at: :desc).limit(active_limit) + } + + scope :above_average, lambda { + where( + articles_count: average_articles_count.., + comments_count: average_comments_count.., + ) + } + before_validation :downcase_email # make sure usernames are not empty, to be able to use the database unique index before_validation :set_username - before_validation :strip_payment_pointer before_create :create_users_settings_and_notification_settings_records + after_update :refresh_auto_audience_segments before_destroy :remove_from_mailchimp_newsletters, prepend: true before_destroy :destroy_follows, prepend: true after_create_commit :send_welcome_notification after_save :create_conditional_autovomits + after_save :generate_social_images after_commit :subscribe_to_mailchimp_newsletter after_commit :bust_cache + def self.average_articles_count + Rails.cache.fetch("established_user_article_count", expires_in: 1.day) do + unscoped { where(articles_count: 1..).average(:articles_count) || average(:articles_count) } || 0.0 + end + end + + def self.average_comments_count + Rails.cache.fetch("established_user_comment_count", expires_in: 1.day) do + unscoped { where(comments_count: 1..).average(:comments_count) || average(:comments_count) } || 0.0 + end + end + def self.staff_account find_by(id: Settings::Community.staff_user_id) end @@ -229,6 +260,10 @@ def self.mascot_account find_by(id: Settings::General.mascot_user_id) end + def good_standing_followers_count + Follow.non_suspended("User", id).count + end + def tag_line profile.summary end @@ -246,6 +281,21 @@ def set_remember_fields self.remember_created_at ||= Time.now.utc end + def set_initial_roles! + # Avoid overwriting roles for users who already exist but are e.g. logging in + # through a new identity provider + return unless valid? && previously_new_record? + + if Settings::General.waiting_on_first_user + add_role(:creator) + add_role(:super_admin) + add_role(:trusted) + elsif Settings::Authentication.limit_new_users? + add_role(:limited) + # Otherwise just leave the new user in good standing + end + end + def calculate_score # User score is used to mitigate spam by reducing visibility of flagged users # It can generally be used as a baseline for affecting certain functionality which @@ -259,7 +309,9 @@ def calculate_score # mass re-calculation is needed. user_reaction_points = Reaction.user_vomits.where(reactable_id: id).sum(:points) calculated_score = (badge_achievements_count * 10) + user_reaction_points + calculated_score -= 500 if spam? update_column(:score, calculated_score) + AlgoliaSearch::SearchIndexWorker.perform_async(self.class.name, id, false) end def path @@ -281,6 +333,20 @@ def followed_articles .union(Article.where(user_id: cached_following_users_ids)) end + def cached_followed_tag_names_or_recent_tags + followed_tags = cached_followed_tag_names + return followed_tags if followed_tags.any? + + ### pluck cached_tag_list for articles with most recent page views. Page views have a user_id and article_id + ### cached_tag_list is a comma-separated string of tag names on the article + + cached_recent_pageview_article_ids = page_views.order("created_at DESC").limit(6).pluck(:article_id) + tags = Article.where(id: cached_recent_pageview_article_ids).pluck(:cached_tag_list) + .map { |list| list.to_s.split(", ") } + .flatten.uniq.reject(&:empty?) + tags + %w[career productivity ai git] # These are highly DEV-specific. Should be refactored later to be config'd + end + def cached_following_users_ids cache_key = "user-#{id}-#{last_followed_at}-#{following_users_count}/following_users_ids" Rails.cache.fetch(cache_key, expires_in: 12.hours) do @@ -296,7 +362,7 @@ def cached_following_organizations_ids end def cached_following_podcasts_ids - cache_key = "user-#{id}-#{last_followed_at}/following_podcasts_ids" + cache_key = "#{cache_key_with_version}/following_podcasts_ids" Rails.cache.fetch(cache_key, expires_in: 12.hours) do Follow.follower_podcast(id).pluck(:followable_id) end @@ -310,6 +376,17 @@ def cached_reading_list_article_ids end end + def cached_role_names + cache_key = "user-#{id}/role_names" + Rails.cache.fetch(cache_key, expires_in: 200.hours) do + roles.pluck(:name) + end + end + + def cached_base_subscriber? + cached_role_names.include?("base_subscriber") + end + def processed_website_url profile.website_url.to_s.strip if profile.website_url.present? end @@ -318,34 +395,24 @@ def remember_me true end - # @todo Move the Query logic into Tag. It represents User understanding the inner working of Tag. def cached_followed_tag_names - cache_name = "user-#{id}-#{following_tags_count}-#{last_followed_at&.rfc3339}/followed_tag_names" + cache_name = "user-#{id}-#{following_tags_count}-#{last_followed_at&.rfc3339}-x/followed_tag_names" Rails.cache.fetch(cache_name, expires_in: 24.hours) do - Tag.where( - id: Follow.where( - follower_id: id, - followable_type: "ActsAsTaggableOn::Tag", - points: 1.., - ).select(:followable_id), - ).pluck(:name) + Tag.followed_by(self).pluck(:name) end end - # @todo Move the Query logic into Tag. It represents User understanding the inner working of Tag. def cached_antifollowed_tag_names cache_name = "user-#{id}-#{following_tags_count}-#{last_followed_at&.rfc3339}/antifollowed_tag_names" Rails.cache.fetch(cache_name, expires_in: 24.hours) do - Tag.where( - id: Follow.where( - follower_id: id, - followable_type: "ActsAsTaggableOn::Tag", - points: ...1, - ).select(:followable_id), - ).pluck(:name) + Tag.antifollowed_by(self).pluck(:name) end end + def refresh_auto_audience_segments + SegmentedUserRefreshWorker.perform_async(id) + end + ############################################################################## # # Heads Up: Start Authorization Refactor @@ -399,6 +466,7 @@ def authorizer :auditable?, :banished?, :comment_suspended?, + :limited?, :creator?, :has_trusted_role?, :super_moderator?, @@ -408,15 +476,19 @@ def authorizer :super_admin?, :support_admin?, :suspended?, + :spam?, + :spam_or_suspended?, :tag_moderator?, :tech_admin?, :trusted?, :user_subscription_tag_available?, :vomited_on?, :warned?, - :workshop_eligible?, + :base_subscriber?, to: :authorizer, ) + alias suspended suspended? + alias spam spam? ############################################################################## # # End Authorization Refactor @@ -546,6 +618,26 @@ def last_activity last_moderation_notification, last_notification_activity].compact.max end + def currently_following_tags + Tag.followed_by(self) + end + + def has_no_published_content? + articles.published.empty? && comments_count.zero? + end + + def send_magic_link! + # Generate random string + self.sign_in_token = SecureRandom.hex(20) + self.sign_in_token_sent_at = Time.now.utc + if self.save + VerificationMailer.with(user_id: id).magic_link.deliver_now + else + errors.add(:email, "Error sending magic link") + Rails.logger.error("Error sending magic link for user #{id}") + end + end + protected # Send emails asynchronously @@ -557,6 +649,13 @@ def send_devise_notification(notification, *args) private + def generate_social_images + change = saved_change_to_attribute?(:name) || saved_change_to_attribute?(:profile_image) + return unless change && articles.published.size.positive? + + Images::SocialImageWorker.perform_async(id, self.class.name) + end + def create_users_settings_and_notification_settings_records self.setting = Users::Setting.create self.notification_setting = Users::NotificationSetting.create @@ -569,33 +668,19 @@ def send_welcome_notification end def set_username - set_temp_username if username.blank? - self.username = username&.downcase + self.username = username&.downcase.presence || generate_username end - # @todo Should we do something to ensure that we don't create a username that violates our - # USERNAME_MAX_LENGTH constant? - # - # @see USERNAME_MAX_LENGTH - def set_temp_username - self.username = if temp_name_exists? - "#{temp_username}_#{rand(100)}" - else - temp_username - end + def auth_provider_usernames + attributes + .with_indifferent_access + .slice(*Authentication::Providers.username_fields) + .values.compact || [] end - def temp_name_exists? - User.exists?(username: temp_username) || Organization.exists?(slug: temp_username) - end - - def temp_username - Authentication::Providers.username_fields.each do |username_field| - value = public_send(username_field) - next if value.blank? - - return value.downcase.gsub(/[^0-9a-z_]/i, "").delete(" ") - end + def generate_username + Users::UsernameGenerator + .call(auth_provider_usernames) end def downcase_email @@ -640,11 +725,15 @@ def password_matches_confirmation errors.add(:password, I18n.t("models.user.password_not_matched")) end - def strip_payment_pointer - self.payment_pointer = payment_pointer.strip if payment_pointer - end - def confirmation_required? ForemInstance.smtp_enabled? end + + def update_user_roles_cache(...) + authorizer.clear_cache + Rails.cache.delete("user-#{id}/has_trusted_role") + Rails.cache.delete("user-#{id}/role_names") + refresh_auto_audience_segments + trusted? + end end diff --git a/app/models/user_language.rb b/app/models/user_language.rb new file mode 100644 index 0000000000000..42e7e90151b4b --- /dev/null +++ b/app/models/user_language.rb @@ -0,0 +1,5 @@ +class UserLanguage < ApplicationRecord + belongs_to :user, inverse_of: :languages + + validates :language, inclusion: { in: Languages::Detection.codes }, presence: true +end diff --git a/app/models/user_visit_context.rb b/app/models/user_visit_context.rb new file mode 100644 index 0000000000000..630ab28518e79 --- /dev/null +++ b/app/models/user_visit_context.rb @@ -0,0 +1,27 @@ +class UserVisitContext < ApplicationRecord + belongs_to :user + has_many :ahoy_visits, class_name: "Ahoy::Visit", dependent: :nullify + + after_create :set_user_language + + def set_user_language + # When we detect a new user context, we automatically + # add languages with a weight of greater than 0.7 + languages = accept_language.split(",") + + languages.map! do |lang| + lang, q = lang.split(";q=") + [lang[0..1], (q || "1").to_f] + end + + filtered_languages = languages + .select { |_, q| q >= 0.7 } + .uniq { |lang, _| lang } + + filtered_languages.map(&:first).each do |lang| + UserLanguage.where(user_id: user_id, language: lang).first_or_create + end + rescue StandardError => e + Rails.logger.error(e) + end +end diff --git a/app/models/users/notification_setting.rb b/app/models/users/notification_setting.rb index ab73f495b97b0..aa6f75f03f18b 100644 --- a/app/models/users/notification_setting.rb +++ b/app/models/users/notification_setting.rb @@ -4,7 +4,6 @@ module Users # destroy callbacks will be called on this object. class NotificationSetting < ApplicationRecord self.table_name_prefix = "users_" - self.ignored_columns += %w[email_connect_messages] belongs_to :user, touch: true diff --git a/app/models/users/setting.rb b/app/models/users/setting.rb index be21c85e4fbcd..3e1570bdb8ae9 100644 --- a/app/models/users/setting.rb +++ b/app/models/users/setting.rb @@ -24,19 +24,27 @@ class Setting < ApplicationRecord format: { with: HEX_COLOR_REGEXP, message: I18n.t("models.users.setting.invalid_hex") }, allow_nil: true - validates :experience_level, numericality: { less_than_or_equal_to: 10 }, allow_blank: true + validates :experience_level, numericality: { in: 1..10 }, allow_blank: true validates :feed_referential_link, inclusion: { in: [true, false] } validates :feed_url, length: { maximum: 500 }, allow_nil: true validates :inbox_guidelines, length: { maximum: 250 }, allow_nil: true + validates :content_preferences_input, length: { maximum: 1250 }, allow_nil: true validate :validate_feed_url, if: :feed_url_changed? + before_update :update_content_preferences_updated_at_if_changed + after_update :refresh_auto_audience_segments + def resolved_font_name config_font.gsub("default", Settings::UserExperience.default_font) end private + def refresh_auto_audience_segments + user.refresh_auto_audience_segments + end + def validate_feed_url return if feed_url.blank? @@ -46,5 +54,11 @@ def validate_feed_url rescue StandardError => e errors.add(:feed_url, e.message) end + + def update_content_preferences_updated_at_if_changed + return unless content_preferences_input.present? && content_preferences_input_changed? + + self.content_preferences_updated_at = Time.current + end end end diff --git a/app/models/users/suspended_username.rb b/app/models/users/suspended_username.rb index 89908a99cf7c7..30b71dd397f53 100644 --- a/app/models/users/suspended_username.rb +++ b/app/models/users/suspended_username.rb @@ -8,11 +8,12 @@ def self.hash_username(username) Digest::SHA256.hexdigest(username) end + # suspended or assigned spam role def self.previously_suspended?(username) where(username_hash: hash_username(username)).any? end - # Convenience method for easily adding a suspended user + # Convenience method for easily adding a suspended/spam user def self.create_from_user(user) create!(username_hash: hash_username(user.username)) end diff --git a/app/policies/api_secret_policy.rb b/app/policies/api_secret_policy.rb index 7d9b6c68a9560..0c6e7aead2e33 100644 --- a/app/policies/api_secret_policy.rb +++ b/app/policies/api_secret_policy.rb @@ -1,6 +1,6 @@ class ApiSecretPolicy < ApplicationPolicy def create? - !user_suspended? + !user.spam_or_suspended? end def destroy? diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 5eb1dc1122102..a6317897218e5 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -70,7 +70,7 @@ class UserRequiredError < NotAuthorizedError def self.require_user_in_good_standing!(user:) require_user!(user: user) - return true unless user.suspended? + return true unless user.spam_or_suspended? raise ApplicationPolicy::UserSuspendedError, I18n.t("policies.application_policy.your_account_is_suspended") end @@ -201,12 +201,6 @@ def resolve delegate :super_moderator?, :super_admin?, :any_admin?, :suspended?, to: :user, prefix: true - alias minimal_admin? user_any_admin? - deprecate minimal_admin?: "Deprecating #{self}#minimal_admin?, use #{self}#user_any_admin?" - - alias user_admin? user_super_admin? - deprecate minimal_admin?: "Deprecating #{self}#user_admin?, use #{self}#user_super_admin?" - def user_trusted? user.has_trusted_role? end diff --git a/app/policies/article_policy.rb b/app/policies/article_policy.rb index 41c17f5bbe830..a80d2fce263e9 100644 --- a/app/policies/article_policy.rb +++ b/app/policies/article_policy.rb @@ -81,7 +81,6 @@ def self.scope_users_authorized_to_action(users_scope:, action:) # address the at present fundamental assumption regarding "Policies are for authorizing when # you have a user, otherwise let the controller decide." # - # rubocop:disable Lint/MissingSuper # # @see even Rubocop thinks this is a bad idea. But the short-cut gets me unstuck. I hope there's # enough breadcrumbs to undo this short-cut. @@ -89,7 +88,6 @@ def initialize(user, record) @user = user @record = record end - # rubocop:enable Lint/MissingSuper def feed? true @@ -172,21 +170,7 @@ def allow_tag_adjustment? def tag_moderator_eligible? tag_ids_moderated_by_user = Tag.with_role(:tag_moderator, @user).ids - return false unless tag_ids_moderated_by_user.size.positive? - - adjustments = TagAdjustment.where(article_id: @record.id) - has_room_for_tags = @record.tag_list.size < MAX_TAG_LIST_SIZE - # ensures that mods cannot adjust an already-adjusted tag - # "zero?" because intersection has just one integer (0 or 1) - has_no_relevant_adjustments = adjustments.pluck(:tag_id).intersection(tag_ids_moderated_by_user).size.zero? - - # tag_mod can add their moderated tags - return true if has_room_for_tags && has_no_relevant_adjustments - - authorized_to_adjust = @record.tags.ids.intersection(tag_ids_moderated_by_user).size.positive? - - # tag_mod can remove their moderated tags - !has_room_for_tags && has_no_relevant_adjustments && authorized_to_adjust + tag_ids_moderated_by_user.size.positive? end def destroy? diff --git a/app/policies/authorizer.rb b/app/policies/authorizer.rb index d0f90950335c9..cf3ff98959cd1 100644 --- a/app/policies/authorizer.rb +++ b/app/policies/authorizer.rb @@ -71,6 +71,10 @@ def comment_suspended? has_role?(:comment_suspended) end + def limited? + has_role?(:limited) + end + def creator? has_role?(:creator) end @@ -129,6 +133,18 @@ def suspended? has_role?(:suspended) end + def spam? + has_role?(:spam) + end + + def base_subscriber? + has_role?(:base_subscriber) + end + + def spam_or_suspended? + has_any_role?(:spam, :suspended) + end + def tag_moderator?(tag: nil) # Note a fan of "peeking" into the roles table, which in a way # circumvents the rolify gem. But this was the past implementation. @@ -161,8 +177,8 @@ def warned? has_role?(:warned) end - def workshop_eligible? - has_any_role?(:workshop_pass) + def clear_cache + remove_instance_variable(:@trusted) if defined? @trusted end private diff --git a/app/policies/comment_policy.rb b/app/policies/comment_policy.rb index b7b483260c2db..0db69b55e6b4e 100644 --- a/app/policies/comment_policy.rb +++ b/app/policies/comment_policy.rb @@ -1,6 +1,6 @@ class CommentPolicy < ApplicationPolicy def edit? - return false if user_suspended? + return false if user.spam_or_suspended? user_author? end @@ -10,7 +10,7 @@ def destroy? end def create? - !user_suspended? && !user.comment_suspended? + !user.spam_or_suspended? && !user.comment_suspended? end alias new? create? @@ -25,6 +25,14 @@ def preview? true end + def subscribe? + true + end + + def unsubscribe? + true + end + def moderate? return true if user.trusted? @@ -32,12 +40,11 @@ def moderate? end def moderator_create? - # NOTE: Here, when we say "moderator", we mean "tag_moderator" - user_moderator? || user_any_admin? + Authorizer.for(user: user).accesses_mod_response_templates? end def hide? - user_commentable_author? + user_commentable_author? && !record.by_staff_account? end alias unhide? hide? @@ -54,6 +61,14 @@ def permitted_attributes_for_preview %i[body_markdown] end + def permitted_attributes_for_subscribe + %i[subscription_id comment_id article_id] + end + + def permitted_attributes_for_unsubscribe + %i[subscription_id] + end + def permitted_attributes_for_create %i[body_markdown commentable_id commentable_type parent_id] end diff --git a/app/policies/discussion_lock_policy.rb b/app/policies/discussion_lock_policy.rb index 1445fe3053a8a..8c084eaa5d336 100644 --- a/app/policies/discussion_lock_policy.rb +++ b/app/policies/discussion_lock_policy.rb @@ -2,7 +2,7 @@ class DiscussionLockPolicy < ApplicationPolicy PERMITTED_ATTRIBUTES = %i[article_id notes reason].freeze def create? - (user_author? || user_any_admin?) && !user_suspended? + (user_author? || user_any_admin?) && !user.spam_or_suspended? end alias destroy? create? diff --git a/app/policies/github_repo_policy.rb b/app/policies/github_repo_policy.rb index 59f8e3ef0f397..52f18dc9998a7 100644 --- a/app/policies/github_repo_policy.rb +++ b/app/policies/github_repo_policy.rb @@ -1,6 +1,6 @@ class GithubRepoPolicy < ApplicationPolicy def index? - !user_suspended? && user.authenticated_through?(:github) + !user.spam_or_suspended? && user.authenticated_through?(:github) end alias update_or_create? index? diff --git a/app/policies/html_variant_policy.rb b/app/policies/html_variant_policy.rb index 28a013368feda..22d25ab979f49 100644 --- a/app/policies/html_variant_policy.rb +++ b/app/policies/html_variant_policy.rb @@ -3,17 +3,17 @@ def index? user_any_admin? end - alias show? minimal_admin? + alias show? user_any_admin? - alias edit? minimal_admin? + alias edit? user_any_admin? - alias update? minimal_admin? + alias update? user_any_admin? - alias new? minimal_admin? + alias new? user_any_admin? - alias create? minimal_admin? + alias create? user_any_admin? - alias destroy? minimal_admin? + alias destroy? user_any_admin? def permitted_attributes %i[html name published approved target_tag group] diff --git a/app/policies/image_upload_policy.rb b/app/policies/image_upload_policy.rb index 73734411987eb..bfa99e305e4da 100644 --- a/app/policies/image_upload_policy.rb +++ b/app/policies/image_upload_policy.rb @@ -1,5 +1,5 @@ class ImageUploadPolicy < ApplicationPolicy def create? - !user_suspended? + !user.spam_or_suspended? end end diff --git a/app/policies/message_policy.rb b/app/policies/message_policy.rb index 05d95c2e22657..aeb73205abf12 100644 --- a/app/policies/message_policy.rb +++ b/app/policies/message_policy.rb @@ -1,6 +1,6 @@ class MessagePolicy < ApplicationPolicy def create? - !user_suspended? + !user.spam_or_suspended? end def destroy? diff --git a/app/policies/organization_policy.rb b/app/policies/organization_policy.rb index ddbb2815e3151..3e8186eacb0b9 100644 --- a/app/policies/organization_policy.rb +++ b/app/policies/organization_policy.rb @@ -1,6 +1,6 @@ class OrganizationPolicy < ApplicationPolicy def create? - !user.suspended? + !user.spam_or_suspended? end def update? @@ -8,7 +8,7 @@ def update? end def destroy? - user.org_admin?(record) && record.destroyable? + user.super_admin? || (user.org_admin?(record) && record.destroyable?) end def leave_org? diff --git a/app/policies/rating_vote_policy.rb b/app/policies/rating_vote_policy.rb index 008aa91a55c2a..020ea256060b8 100644 --- a/app/policies/rating_vote_policy.rb +++ b/app/policies/rating_vote_policy.rb @@ -1,6 +1,6 @@ class RatingVotePolicy < ApplicationPolicy def create? - !user_suspended? + !user.spam_or_suspended? end def permitted_attributes diff --git a/app/policies/response_template_policy.rb b/app/policies/response_template_policy.rb index f91c505e27da1..69c4efcfbf938 100644 --- a/app/policies/response_template_policy.rb +++ b/app/policies/response_template_policy.rb @@ -20,7 +20,7 @@ def admin_index? end def moderator_index? - user_moderator? + user_moderator? || user_trusted? end alias create? index? @@ -30,9 +30,10 @@ def admin_create? end # comes from comments_controller - def moderator_create? - user_moderator? && mod_comment? + def use_template_for_moderator_comment? + mod_comment? && (user_moderator? || user_trusted?) end + alias moderator_create? use_template_for_moderator_comment? def modify? if user_owner? @@ -46,7 +47,7 @@ def modify? alias destroy? modify? def permitted_attributes_for_create - if user_trusted? + if user_moderator? PERMITTED_ATTRIBUTES + [:type_of] else PERMITTED_ATTRIBUTES diff --git a/app/policies/role_policy.rb b/app/policies/role_policy.rb new file mode 100644 index 0000000000000..2831386d4eed2 --- /dev/null +++ b/app/policies/role_policy.rb @@ -0,0 +1,9 @@ +class RolePolicy < ApplicationPolicy + def remove_role? + if user.super_admin? + true + else + user.admin? && !record.super_admin? + end + end +end diff --git a/app/policies/stripe_active_card_policy.rb b/app/policies/stripe_active_card_policy.rb index ace29be702e95..7ec5fe19169dc 100644 --- a/app/policies/stripe_active_card_policy.rb +++ b/app/policies/stripe_active_card_policy.rb @@ -1,6 +1,6 @@ class StripeActiveCardPolicy < ApplicationPolicy def create? - !user_suspended? + !user.spam_or_suspended? end alias update? create? diff --git a/app/policies/stripe_subscription_policy.rb b/app/policies/stripe_subscription_policy.rb index a1be09c5c76a2..1dda424863e7d 100644 --- a/app/policies/stripe_subscription_policy.rb +++ b/app/policies/stripe_subscription_policy.rb @@ -1,6 +1,6 @@ class StripeSubscriptionPolicy < ApplicationPolicy def create? - !user_suspended? + !user.spam_or_suspended? end alias update? create? diff --git a/app/policies/user_block_policy.rb b/app/policies/user_block_policy.rb index 4e9e4c77d30be..8516b4542cfe7 100644 --- a/app/policies/user_block_policy.rb +++ b/app/policies/user_block_policy.rb @@ -1,6 +1,6 @@ class UserBlockPolicy < ApplicationPolicy def create? - !user_suspended? + !user.spam_or_suspended? end alias destroy? create? diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index e013fc3d9b132..520fb0977ac9b 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -39,7 +39,6 @@ class UserPolicy < ApplicationPolicy name password password_confirmation - payment_pointer permit_adjacent_sponsors profile_image text_color_hex @@ -66,7 +65,7 @@ def onboarding_update? alias onboarding_notifications_checkbox_update? onboarding_update? def update? - edit? && !user_suspended? + edit? && !user.spam_or_suspended? end alias destroy? edit? @@ -78,7 +77,7 @@ def update? alias request_destroy? edit? def join_org? - !user_suspended? + !user.spam_or_suspended? end def leave_org? @@ -94,10 +93,13 @@ def elevated_user? end alias toggle_suspension_status? elevated_user? + alias manage_user_roles? elevated_user? alias unpublish_all_articles? elevated_user? + alias search_by_email? elevated_user? + alias toggle_spam? elevated_user? def moderation_routes? - (user.has_trusted_role? || elevated_user?) && !user.suspended? + (user.has_trusted_role? || elevated_user?) && !user.spam_or_suspended? end def permitted_attributes diff --git a/app/queries/admin/moderators_query.rb b/app/queries/admin/moderators_query.rb index de3fdc8ebc7de..f311a99ce36cb 100644 --- a/app/queries/admin/moderators_query.rb +++ b/app/queries/admin/moderators_query.rb @@ -4,7 +4,7 @@ class ModeratorsQuery state: :trusted }.with_indifferent_access.freeze - POTENTIAL_ROLE_NAMES = %i[comment_suspended suspended trusted warned].freeze + POTENTIAL_ROLE_NAMES = %i[comment_suspended limited suspended trusted warned].freeze def self.call(relation: User.all, options: {}) options = DEFAULT_OPTIONS.merge(options) diff --git a/app/queries/admin/users_query.rb b/app/queries/admin/users_query.rb index 063b987d2df91..92d2e7452ca59 100644 --- a/app/queries/admin/users_query.rb +++ b/app/queries/admin/users_query.rb @@ -21,19 +21,23 @@ def self.find(identifier, relation: User) # @param search [String, nil] # @param roles [Array<String>, nil] # @param statuses [Array<String>, nil] + # @param ids [Array<Integer>, nil] # @param organizations [Array<String>, nil] # @param joining_start [String, nil] # @param joining_end [String, nil] # @param date_format [String] + # @param limit [Integer, nil] def self.call(relation: User.registered, role: nil, search: nil, roles: [], organizations: [], statuses: [], + ids: [], joining_start: nil, joining_end: nil, - date_format: "DD/MM/YYYY") + date_format: "DD/MM/YYYY", + limit: nil) # We are at an interstitial moment where we are exposing both the role and roles param. We # need to favor one or the other. if role.presence @@ -53,6 +57,9 @@ def self.call(relation: User.registered, end relation = search_relation(relation, search) if search.presence + relation = filter_ids(relation, ids) if ids.presence + relation = relation.limit(limit.to_i) if limit.to_i.positive? + relation.distinct.order(created_at: :desc) end @@ -60,6 +67,10 @@ def self.search_relation(relation, search) relation.where(SEARCH_CLAUSE, search: "%#{search.strip}%") end + def self.filter_ids(relation, ids) + relation.where(id: ids) + end + def self.filter_joining_date(relation:, joining_start:, joining_end:, date_format:) ui_formats_to_parse_format = { "DD/MM/YYYY" => "%d/%m/%Y", diff --git a/app/queries/articles/active_threads_query.rb b/app/queries/articles/active_threads_query.rb index e061a0a817fa0..79337c06bc9f2 100644 --- a/app/queries/articles/active_threads_query.rb +++ b/app/queries/articles/active_threads_query.rb @@ -7,8 +7,6 @@ class ActiveThreadsQuery count: 10 }.with_indifferent_access.freeze - MINIMUM_SCORE = -4 - # Get the "plucked" attribute information for the article thread. # # @param relation [ActiveRecord::Relation] the original Article scope @@ -28,19 +26,20 @@ class ActiveThreadsQuery # @see `./app/views/articles/_widget_list_item.html.erb` for the # importance of maintaining position of these parameters. def self.call(relation: Article.published, **options) + minimum_score = Settings::UserExperience.home_feed_minimum_score.to_i options = DEFAULT_OPTIONS.merge(options) tags, time_ago, count = options.values_at(:tags, :time_ago, :count) relation = relation.limit(count) relation = relation.cached_tagged_with(tags) relation = if time_ago == "latest" - relation = relation.where(score: MINIMUM_SCORE..).presence || relation + relation = relation.where(score: minimum_score..).presence || relation relation.order(published_at: :desc) elsif time_ago - relation = relation.where(published_at: time_ago.., score: MINIMUM_SCORE..).presence || relation + relation = relation.where(published_at: time_ago.., score: minimum_score..).presence || relation relation.order(comments_count: :desc) else - relation = relation.where(published_at: 3.days.ago.., score: MINIMUM_SCORE..).presence || relation + relation = relation.where(published_at: 3.days.ago.., score: minimum_score..).presence || relation relation.order("last_comment_at DESC NULLS LAST") end relation.pluck(:path, :title, :comments_count, :created_at) diff --git a/app/queries/articles/api_search_query.rb b/app/queries/articles/api_search_query.rb new file mode 100644 index 0000000000000..92b0f41b2be2e --- /dev/null +++ b/app/queries/articles/api_search_query.rb @@ -0,0 +1,53 @@ +module Articles + class ApiSearchQuery + DEFAULT_PER_PAGE = 30 + + def self.call(...) + new(...).call + end + + def initialize(params) + @q = params[:q] + @top = params[:top] + @page = params[:page].to_i + @per_page = [(params[:per_page] || DEFAULT_PER_PAGE).to_i, per_page_max].min + end + + def call + @articles = published_articles_with_users_and_organizations + + if q.present? + @articles = query_articles + end + + if top.present? + @articles = top_articles.order(public_reactions_count: :desc) + end + + @articles.page(page).per(per_page || DEFAULT_PER_PAGE) + end + + private + + attr_reader :q, :top, :page, :per_page + + def per_page_max + (ApplicationConfig["API_PER_PAGE_MAX"] || 1000).to_i + end + + def query_articles + @articles.search_articles(q) + end + + def top_articles + @articles.where("published_at > ?", top.to_i.days.ago) + end + + def published_articles_with_users_and_organizations + Article.published + .includes([{ user: :profile }, :organization]) + .where("score >= ?", Settings::UserExperience.index_minimum_score) + .order(hotness_score: :desc) + end + end +end diff --git a/app/queries/audit_log/unpublish_alls_query.rb b/app/queries/audit_log/unpublish_alls_query.rb index e9f32f92a888a..32aaf6201fca2 100644 --- a/app/queries/audit_log/unpublish_alls_query.rb +++ b/app/queries/audit_log/unpublish_alls_query.rb @@ -2,14 +2,20 @@ class AuditLog class UnpublishAllsQuery Result = Struct.new(:exists?, :audit_log, :target_articles, :target_comments, keyword_init: true) + def self.call(...) + new(...).call + end + def initialize(user_id) @user_id = user_id @target_articles = [] @target_comments = [] end - def self.call(...) - new(...).call + def exists? + exists = AuditLog.where(slug: %w[api_user_unpublish unpublish_all_articles]) + .where("data @> '{\"target_user_id\": ?}'", user_id).present? + Result.new(exists?: exists) end def call diff --git a/app/queries/billboards/filtered_ads_query.rb b/app/queries/billboards/filtered_ads_query.rb new file mode 100644 index 0000000000000..4ae6d3226eed3 --- /dev/null +++ b/app/queries/billboards/filtered_ads_query.rb @@ -0,0 +1,177 @@ +module Billboards + class FilteredAdsQuery + include BillboardHelper + def self.call(...) + new(...).call + end + + # @param area [String] the site area where the ad is visible + # @param user_signed_in [Boolean] whether or not the visitor is signed-in + # @param billboards [Billboard] can be a filtered scope or Arel relationship + # @param location [Geolocation|String] the visitor's geographic location + def initialize(area:, user_signed_in:, organization_id: nil, article_tags: [], page_id: nil, + permit_adjacent_sponsors: true, article_id: nil, billboards: Billboard, + user_id: nil, user_tags: nil, location: nil, cookies_allowed: false, user_agent: nil, + role_names: nil) + @filtered_billboards = billboards.includes([:organization]) + @area = area + @user_signed_in = user_signed_in + @user_id = user_signed_in ? user_id : nil + @page_id = page_id + @organization_id = organization_id + @article_tags = article_tags + @article_id = article_id + @permit_adjacent_sponsors = permit_adjacent_sponsors + @user_tags = user_tags + @user_agent = user_agent + @location = Geolocation.from_iso3166(location) + @cookies_allowed = cookies_allowed + @role_names = role_names + end + + def call + @filtered_billboards = approved_and_published_ads + @filtered_billboards = placement_area_ads + @filtered_billboards = browser_context_ads if @user_agent.present? + @filtered_billboards = page_ads if @page_id.present? + @filtered_billboards = cookies_allowed_ads unless @cookies_allowed + + if @article_id.present? + if @article_tags.any? + @filtered_billboards = tagged_ads(@article_tags).or(untagged_ads) + end + + if @article_tags.blank? + @filtered_billboards = untagged_ads + end + + @filtered_billboards = unexcluded_article_ads + end + + if @user_tags.present? && @user_tags.any? + @filtered_billboards = tagged_ads(@user_tags).or(untagged_ads) + end + + # We apply the condition feed_targeted_tag_placement? because we only want to filter by + # untagged ads on the home feed area placements. We do not want to have any side effects happen + # on the article page or anywhere else. + if @user_tags.blank? && feed_targeted_tag_placement?(@area) + @filtered_billboards = untagged_ads + end + + @filtered_billboards = user_targeting_ads + @filtered_billboards = role_filtered_ads if @user_signed_in + + @filtered_billboards = if @user_signed_in + authenticated_ads(%w[all logged_in]) + else + authenticated_ads(%w[all logged_out]) + end + + if FeatureFlag.enabled?(Geolocation::FEATURE_FLAG) + @filtered_billboards = location_targeted_ads + end + + # type_of filter needs to be applied as near to the end as possible + # as it checks if any type-matching ads exist (this will apply all/any + # filters applied up to this point, thus near the end is best) + @filtered_billboards = type_of_ads + + @filtered_billboards = @filtered_billboards.order(success_rate: :desc) + end + + private + + def approved_and_published_ads + @filtered_billboards.approved_and_published + end + + def placement_area_ads + @filtered_billboards.where(placement_area: @area) + end + + def browser_context_ads + case @user_agent + when /DEV-Native-ios|DEV-Native-android|ForemWebView/ + @filtered_billboards.where(browser_context: %i[all_browsers mobile_in_app]) + when /Mobile|iPhone|Android/ + @filtered_billboards.where(browser_context: %i[all_browsers mobile_web]) + when /Windows|Macintosh|Mac OS X|Linux/ + @filtered_billboards.where(browser_context: %i[all_browsers desktop]) + else + @filtered_billboards + end + end + + def page_ads + @filtered_billboards.where(page_id: @page_id) + end + + def tagged_ads(tag_type) + @filtered_billboards.cached_tagged_with_any(tag_type) + end + + def untagged_ads + @filtered_billboards.where(cached_tag_list: "") + end + + def unexcluded_article_ads + @filtered_billboards.where("NOT (:id = ANY(exclude_article_ids))", id: @article_id) + end + + def authenticated_ads(display_auth_audience) + @filtered_billboards.where(display_to: display_auth_audience) + end + + def cookies_allowed_ads + @filtered_billboards.where(requires_cookies: false) + end + + def user_targeting_ads + if @user_id + segment_ids = SegmentedUser.where(user_id: @user_id).pluck(:audience_segment_id) + @filtered_billboards.where("audience_segment_id IS NULL OR audience_segment_id IN (?)", segment_ids) + else + @filtered_billboards.where(audience_segment_id: nil) + end + end + + def role_filtered_ads + @filtered_billboards.where( + "(cardinality(target_role_names) = 0 OR target_role_names && ARRAY[:role_names]::varchar[]) + AND (cardinality(exclude_role_names) = 0 OR NOT exclude_role_names && ARRAY[:role_names]::varchar[])", + role_names: @role_names, + ) + end + + def location_targeted_ads + geo_query = "cardinality(target_geolocations) = 0" # Empty array + if @location&.valid? + geo_query += " OR (#{@location.to_sql_query_clause})" + end + + @filtered_billboards.where(geo_query) + end + + def type_of_ads + # If this is an organization article and community-type ads exist, show them + if @organization_id.present? + community = @filtered_billboards.where(type_of: Billboard.type_ofs[:community], + organization_id: @organization_id) + return community if community.any? + end + + types_matching = [] + + # Always match in-house-type ads + types_matching << :in_house + + # If the author or current_user has opted out of seeing adjacent sponsors, do not show them + if @permit_adjacent_sponsors + types_matching << :external + end + + @filtered_billboards.where(type_of: Billboard.type_ofs.slice(*types_matching).values) + end + end +end diff --git a/app/queries/comments/count.rb b/app/queries/comments/count.rb new file mode 100644 index 0000000000000..cceead096359d --- /dev/null +++ b/app/queries/comments/count.rb @@ -0,0 +1,36 @@ +# find comments count for an article based on our display rules for signed in users +# the count includes both comments displayed as usual (text), comments displayed as "deleted" or "hidden by post author" +# the count doesn't include comments not displayed at all (childless comments with score below HIDE_THRESHOLD) + +module Comments + class Count + def self.call(...) + new(...).call + end + + def initialize(article, recalculate: false) + @article = article + @recalculate = recalculate + end + + def call + if recalculate || !article.displayed_comments_count? + # comments that are not displayed at all (not even a "comment deleted" message): + # with the score below hiding threshold and w/o children + count_sql = "SELECT COUNT(id) FROM comments c1 WHERE score < ? AND commentable_id = ? " \ + "AND commentable_type = ? AND NOT EXISTS " \ + "(SELECT 1 FROM comments c2 WHERE c2.ancestry LIKE CONCAT('%/', c1.id::varchar(255)) " \ + "OR c2.ancestry = c1.id::varchar(255))" + san_count_sql = Comment.sanitize_sql([count_sql, Comment::HIDE_THRESHOLD, @article.id, "Article"]) + hidden_comments_cnt = Comment.count_by_sql(san_count_sql) + displayed_comments_count = article.comments.count - hidden_comments_cnt + article.update_column(:displayed_comments_count, displayed_comments_count) + end + article.displayed_comments_count + end + + private + + attr_reader :article, :recalculate + end +end diff --git a/app/queries/comments/tree.rb b/app/queries/comments/tree.rb new file mode 100644 index 0000000000000..434677a1782fe --- /dev/null +++ b/app/queries/comments/tree.rb @@ -0,0 +1,34 @@ +module Comments + module Tree + module_function + + def for_commentable(commentable, limit: 0, order: nil, include_negative: false) + collection = commentable.comments + .includes(user: %i[setting profile]) + .arrange(order: build_sort_query(order)) + .to_a[0..limit - 1] + .to_h + collection.reject! { |comment| comment.score.negative? } unless include_negative + collection + end + + def for_root_comment(root_comment, include_negative: false) + sub_comments = root_comment.subtree.includes(user: %i[setting profile]).arrange[root_comment] + sub_comments.reject! { |comment| comment.score.negative? } unless include_negative + { root_comment => sub_comments } + end + + def build_sort_query(order) + case order + when "latest" + "created_at DESC" + when "oldest" + "created_at ASC" + else + "score DESC" + end + end + + private_class_method :build_sort_query + end +end diff --git a/app/queries/display_ads/filtered_ads_query.rb b/app/queries/display_ads/filtered_ads_query.rb deleted file mode 100644 index 78c88f2519ce6..0000000000000 --- a/app/queries/display_ads/filtered_ads_query.rb +++ /dev/null @@ -1,79 +0,0 @@ -module DisplayAds - class FilteredAdsQuery - def self.call(...) - new(...).call - end - - def initialize(display_ads:, area:, user_signed_in:, article_tags: []) - @filtered_display_ads = display_ads - @area = area - @user_signed_in = user_signed_in - @article_tags = article_tags - end - - def call - @filtered_display_ads = approved_and_published_ads - @filtered_display_ads = placement_area_ads - - if @article_tags.any? - @filtered_display_ads = tagged_post_comment_ads - end - - if @article_tags.blank? - @filtered_display_ads = untagged_post_comment_ads - end - - @filtered_display_ads = if @user_signed_in - authenticated_ads(%w[all logged_in]) - else - authenticated_ads(%w[all logged_out]) - end - - @filtered_display_ads = @filtered_display_ads.order(success_rate: :desc) - @filtered_display_ads = sample_ads - end - - private - - def approved_and_published_ads - @filtered_display_ads.approved_and_published - end - - def placement_area_ads - @filtered_display_ads.where(placement_area: @area) - end - - def tagged_post_comment_ads - display_ads_with_targeted_article_tags = @filtered_display_ads.cached_tagged_with_any(@article_tags) - untagged_post_comment_ads.or(display_ads_with_targeted_article_tags) - end - - def untagged_post_comment_ads - @filtered_display_ads.where(cached_tag_list: "") - end - - def authenticated_ads(display_auth_audience) - @filtered_display_ads.where(display_to: display_auth_audience) - end - - # Business Logic Context: - # We are always showing more of the good stuff — but we are also always testing the system to give any a chance to - # rise to the top. 1 out of every 8 times we show an ad (12.5%), it is totally random. This gives "not yet - # evaluated" stuff a chance to get some engagement and start showing up more. If it doesn't get engagement, it - # stays in this area. - - # Ads that get engagement have a higher "success rate", and among this category, we sample from the top 15 that - # meet that criteria. Within those 15 top "success rates" likely to be clicked, there is a weighting towards the - # top ranked outcome as well, and a steady decline over the next 15 — that's because it's not "Here are the top 15 - # pick one randomly", it is actually "Let's cut off the query at a random limit between 1 and 15 and sample from - # that". So basically the "limit" logic will result in 15 sets, and then we sample randomly from there. The - # "first ranked" ad will show up in all 15 sets, where as 15 will only show in 1 of the 15. - def sample_ads - if rand(8) == 1 - @filtered_display_ads.sample - else - @filtered_display_ads.limit(rand(1..15)).sample - end - end - end -end diff --git a/app/queries/homepage/articles_query.rb b/app/queries/homepage/articles_query.rb index ae2fc2537fbc9..818f1ab3a54bc 100644 --- a/app/queries/homepage/articles_query.rb +++ b/app/queries/homepage/articles_query.rb @@ -4,6 +4,7 @@ class ArticlesQuery cached_tag_list comments_count crossposted_at + displayed_comments_count id organization_id path @@ -32,18 +33,21 @@ def initialize( user_id: nil, organization_id: nil, tags: [], + hidden_tags: [], sort_by: nil, sort_direction: nil, page: 0, per_page: DEFAULT_PER_PAGE ) @relation = Article.published.select(*ATTRIBUTES) + .includes(:distinct_reaction_categories) @approved = approved @published_at = published_at @user_id = user_id @organization_id = organization_id @tags = tags.presence || [] + @hidden_tags = hidden_tags.presence || [] @sort_by = sort_by @sort_direction = sort_direction || DEFAULT_SORT_DIRECTION @@ -58,15 +62,19 @@ def call private - attr_reader :relation, :approved, :published_at, :user_id, :organization_id, :tags, :sort_by, :sort_direction, - :page, :per_page + attr_reader :relation, :approved, :published_at, :user_id, :organization_id, :tags, :hidden_tags, + :sort_by, :sort_direction, :page, :per_page def filter + @relation = @relation.full_posts @relation = @relation.where(approved: approved) unless approved.nil? @relation = @relation.where(published_at: published_at) if published_at.present? @relation = @relation.where(user_id: user_id) if user_id.present? @relation = @relation.where(organization_id: organization_id) if organization_id.present? @relation = @relation.cached_tagged_with_any(tags) if tags.any? + @relation = @relation.not_cached_tagged_with_any(hidden_tags) if hidden_tags.any? + @relation = @relation.includes(:distinct_reaction_categories) + @relation = @relation.where("score >= 0") # Never return negative score articles relation end diff --git a/app/queries/organizations/suggest_prominent.rb b/app/queries/organizations/suggest_prominent.rb new file mode 100644 index 0000000000000..af25b069fee55 --- /dev/null +++ b/app/queries/organizations/suggest_prominent.rb @@ -0,0 +1,33 @@ +module Organizations + class SuggestProminent + MAX = 5 + + def self.call(...) + new(...).suggest + end + + def initialize(user) + @user = user + end + + def suggest + return [] if tags_to_consider.empty? + + org_ids = fetch_and_pluck_org_ids + Organization.where(id: org_ids.uniq).order(Arel.sql("RANDOM()")).limit(MAX) + end + + private + + attr_reader :user + + def tags_to_consider + user.decorate.cached_followed_tag_names + end + + def fetch_and_pluck_org_ids + Article.published.cached_tagged_with_any(tags_to_consider).where.not(organization_id: nil) + .order("hotness_score DESC").limit(MAX * 2).pluck(:organization_id) + end + end +end diff --git a/app/queries/tags/suggested_for_onboarding.rb b/app/queries/tags/suggested_for_onboarding.rb new file mode 100644 index 0000000000000..1ab21e22beadd --- /dev/null +++ b/app/queries/tags/suggested_for_onboarding.rb @@ -0,0 +1,33 @@ +module Tags + class SuggestedForOnboarding + MAX = 45 + + def self.call(...) + new(...).call + end + + def initialize(...); end + + def call + return suggested_tags if suggested_tags.count >= MAX + + Tag + .where(suggested_for_onboarding_or_supported) + .order("hotness_score DESC") + .limit(MAX) + end + + private + + def suggested_tags + @suggested_tags ||= Tag.suggested_for_onboarding.order("hotness_score DESC") + end + + def suggested_for_onboarding_or_supported + builder = Tag.arel_table + supported = builder[:supported].eq(true) + suggested = builder[:suggested].eq(true) + suggested.or(supported) + end + end +end diff --git a/app/queries/users/suggest_prominent.rb b/app/queries/users/suggest_prominent.rb new file mode 100644 index 0000000000000..a0628f427f8a3 --- /dev/null +++ b/app/queries/users/suggest_prominent.rb @@ -0,0 +1,43 @@ +module Users + class SuggestProminent + RETURNING = 50 + + def self.call(user, attributes_to_select: []) + new(user, attributes_to_select: attributes_to_select).suggest + end + + def initialize(user, attributes_to_select: []) + @user = user + @attributes_to_select = attributes_to_select + end + + def suggest + User.joins(:profile).without_role(:suspended).where(id: fetch_and_pluck_user_ids.uniq) + .limit(RETURNING).select(attributes_to_select) + end + + private + + attr_reader :user, :attributes_to_select + + def tags_to_consider + user.decorate.cached_followed_tag_names + end + + def fetch_and_pluck_user_ids + filtered_articles = if tags_to_consider.any? + Article.published.cached_tagged_with_any(tags_to_consider) + else + Article.published.featured + end + user_ids = filtered_articles.order("hotness_score DESC").limit(RETURNING * 2).pluck(:user_id) - [user.id] + if user_ids.size > (RETURNING / 2) + user_ids.sample(RETURNING) + else + # This is a fallback in case we don't have enough users to return + # Will generally not be called — but maybe for brand new forems + User.order("score DESC").limit(RETURNING * 2).ids - [user.id] + end + end + end +end diff --git a/app/sanitizers/comment_email_scrubber.rb b/app/sanitizers/comment_email_scrubber.rb new file mode 100644 index 0000000000000..9a247ee0bd9e6 --- /dev/null +++ b/app/sanitizers/comment_email_scrubber.rb @@ -0,0 +1,17 @@ +class CommentEmailScrubber < Rails::Html::PermitScrubber + def initialize + super + self.tags = MarkdownProcessor::AllowedTags::EMAIL_COMMENT + self.attributes = MarkdownProcessor::AllowedAttributes::EMAIL_COMMENT + end + + def allowed_node?(node) + tags.include?(node.name) && node.children.present? + end + + # The default behavior of PermitScrubber removes the <script> tags + # but keeps the contents and this is required to fix that + def skip_node?(node) + node.name == "script" || super + end +end diff --git a/app/serializers/homepage/article_serializer.rb b/app/serializers/homepage/article_serializer.rb index 5c9d277779e63..5b3369e567f12 100644 --- a/app/serializers/homepage/article_serializer.rb +++ b/app/serializers/homepage/article_serializer.rb @@ -20,7 +20,6 @@ def self.serialized_collection_from(relation:) attributes( :class_name, :cloudinary_video_url, - :comments_count, :id, :path, :public_reactions_count, @@ -28,8 +27,13 @@ def self.serialized_collection_from(relation:) :reading_time, :title, :user_id, + :public_reaction_categories, ) + # return displayed_comments_count (excluding low score comments) if it was calculated earlier + attribute :comments_count, (lambda do |article| + article.displayed_comments_count? ? article.displayed_comments_count : article.comments_count + end) attribute :video_duration_string, &:video_duration_in_minutes attribute :published_at_int, ->(article) { article.published_at.to_i } attribute :tag_list, ->(article) { article.cached_tag_list.to_s.split(", ") } diff --git a/app/serializers/search/organization_serializer.rb b/app/serializers/search/organization_serializer.rb new file mode 100644 index 0000000000000..8a37a62423778 --- /dev/null +++ b/app/serializers/search/organization_serializer.rb @@ -0,0 +1,6 @@ +module Search + class OrganizationSerializer < ApplicationSerializer + attribute :class_name, -> { "Organization" } + attributes :id, :name, :summary, :profile_image, :twitter_username, :slug + end +end diff --git a/app/services/admin/charts_data.rb b/app/services/admin/charts_data.rb index 233c19bbc3631..c27a43fd814e1 100644 --- a/app/services/admin/charts_data.rb +++ b/app/services/admin/charts_data.rb @@ -6,7 +6,7 @@ def initialize(length = 7) def call period = (@length + 1).days.ago..1.day.ago - previous_period = (@length * 2).days.ago..@length.days.ago + previous_period = (@length * 2).days.ago..(@length + 1).days.ago grouped_posts = Article.where(published_at: period).group("DATE(published_at)").size grouped_comments = Comment.where(created_at: period).group("DATE(created_at)").size @@ -31,4 +31,4 @@ def call ] end end -end +end \ No newline at end of file diff --git a/app/services/admin/data_counts.rb b/app/services/admin/data_counts.rb index 4936b0e310aa2..17a7694f4743f 100644 --- a/app/services/admin/data_counts.rb +++ b/app/services/admin/data_counts.rb @@ -2,7 +2,6 @@ module Admin class DataCounts Response = Struct.new( :open_abuse_reports_count, - :possible_spam_users_count, :flags_count, :flags_posts_count, :flags_comments_count, @@ -18,19 +17,13 @@ def self.call open_abuse_reports_count = FeedbackMessage.open_abuse_reports.size - possible_spam_users_count = User.registered.where("length(name) > ?", 30) - .where("created_at > ?", 48.hours.ago) - .order(created_at: :desc) - .select(:username, :name, :id) - .where.not("username LIKE ?", "%spam_%") - .size - flags = Reaction .includes(:user, :reactable) - .privileged_category + .where(status: "valid") + .live_reactable + .where(category: "vomit") Response.new( open_abuse_reports_count: open_abuse_reports_count, - possible_spam_users_count: possible_spam_users_count, flags_count: flags.size, flags_posts_count: flags.where(reactable_type: "Article").size, flags_comments_count: flags.where(reactable_type: "Comment").size, diff --git a/app/services/algolia_insights_service.rb b/app/services/algolia_insights_service.rb new file mode 100644 index 0000000000000..39460fd87bf91 --- /dev/null +++ b/app/services/algolia_insights_service.rb @@ -0,0 +1,60 @@ +class AlgoliaInsightsService + include HTTParty + base_uri "https://insights.algolia.io/1" + + def initialize(application_id = nil, api_key = nil) + @application_id = application_id || Settings::General.algolia_application_id + @api_key = api_key || Settings::General.algolia_api_key + end + + def track_event(event_type, event_name, user_id, object_id, index_name, timestamp = nil) + headers = { + "X-Algolia-Application-Id" => @application_id, + "X-Algolia-API-Key" => @api_key, + "Content-Type" => "application/json" + } + payload = { + events: [ + { + eventType: event_type, + eventName: event_name, + index: index_name, + userToken: user_id.to_s, + objectIDs: [object_id.to_s], + timestamp: timestamp || (Time.now.to_i * 1000) + }, + ] + } + + response = self.class.post("/events", headers: headers, body: payload.to_json) + if response.success? + Rails.logger.debug { "Event tracked: #{response.body}" } + else + Rails.logger.debug { "Failed to track event: #{response.body}" } + end + end + + # WIP, this is for backfilling data, but not something we are doing now due to potential de-dupe problems. + def track_insights_for_article(article) + article.page_views.where.not(user_id: nil).find_each do |page_view| + track_event( + "view", + "Article Viewed", + page_view.user_id, + page_view.article_id, + "Article_#{Rails.env}", + page_view.created_at.to_i * 1000, # Adding timestamp from the page view + ) + end + article.reactions.public_category.each do |reaction| + track_event( + "conversion", + "Reaction Created", + reaction.user_id, + article.id, + "Article_#{Rails.env}", + reaction.created_at.to_i * 1000, # Adding timestamp from the reaction + ) + end + end +end diff --git a/app/services/articles/attributes.rb b/app/services/articles/attributes.rb index 74ee89676e8d2..07dff2edd9d85 100644 --- a/app/services/articles/attributes.rb +++ b/app/services/articles/attributes.rb @@ -1,8 +1,8 @@ module Articles class Attributes ATTRIBUTES = %i[archived body_markdown canonical_url description - edited_at main_image organization_id user_id published - title video_thumbnail_url published_at].freeze + edited_at main_image organization_id user_id published clickbait_score + title video_thumbnail_url published_at co_author_ids_list].freeze attr_reader :attributes, :article_user @@ -15,7 +15,7 @@ def for_update(update_edited_at: false) hash = attributes.slice(*ATTRIBUTES) # don't reset the collection when no series was passed hash[:collection] = collection if attributes.key?(:series) - hash[:tag_list] = tag_list + hash[:tag_list] = tag_list if !attributes[:tag_list].nil? || !attributes[:tags].nil? hash[:edited_at] = Time.current if update_edited_at hash[:published_at] = hash[:published_at].to_datetime if hash[:published_at] hash diff --git a/app/services/articles/builder.rb b/app/services/articles/builder.rb index 782a7b69fbe7a..a4da2e38f5980 100644 --- a/app/services/articles/builder.rb +++ b/app/services/articles/builder.rb @@ -2,6 +2,10 @@ module Articles class Builder LINE_BREAK = "\n".freeze + def self.call(...) + new(...).call + end + def initialize(user, tag, prefill) @user = user @tag = tag @@ -10,10 +14,6 @@ def initialize(user, tag, prefill) @editor_version2 = @user&.setting&.editor_version == "v2" end - def self.call(...) - new(...).call - end - # the Builder returns a pair of [article, needs_authorization?] # => `needs_authorization? can be either true or false def call diff --git a/app/services/articles/creator.rb b/app/services/articles/creator.rb index 05ab87fab6408..3b389d24e101c 100644 --- a/app/services/articles/creator.rb +++ b/app/services/articles/creator.rb @@ -1,31 +1,49 @@ module Articles class Creator - def initialize(user, article_params) - @user = user - @article_params = article_params - end - def self.call(...) new(...).call end + # @param user [User] + # @param article_params [Hash] + # @option article_params [NilClass, String] :title + # @option article_params [NilClass, String] :body_markdown + # @option article_params [NilClass, String] :main_image + # @option article_params [Boolean] :published + # @option article_params [NilClass, String] :description + # @option article_params [NilClass, String] :video_thumbnail_url + # @option article_params [NilClass, String] :canonical_url + # @option article_params [NilClass, String] :series series slug + # @option article_params [Integer, NilClass] :collection_id + # @option article_params [Boolean] :archived + # @option article_params [String<Array>] :tags + # @option article_params [NilClass, String, ActiveSupport::TimeWithZone] :published_at + def initialize(user, article_params) + @user = user + @article_params = normalize_params(article_params) + end + def call rate_limit! - article = save_article - - if article.persisted? - # Subscribe author to notifications for all comments on their article. - NotificationSubscription.create(user: user, notifiable_id: article.id, notifiable_type: "Article", - config: "all_comments") + create_article.tap do + subscribe_author if article.persisted? + refresh_auto_audience_segments if article.published? end - - article end private - attr_reader :user, :article_params + attr_reader :article, :user, :article_params + + def normalize_params(original_params) + original_params.except(:tags).tap do |params| + # convert tags from array to a string + if (tags = original_params[:tags]).present? + params[:tag_list] = tags.join(", ") + end + end + end def rate_limit! rate_limit_to_use = if user.decorate.considered_new? @@ -37,22 +55,31 @@ def rate_limit! user.rate_limiter.check_limit!(rate_limit_to_use) end - def save_article - series = article_params[:series] - tags = article_params[:tags] + def refresh_auto_audience_segments + user.refresh_auto_audience_segments + end - # convert tags from array to a string - if tags.present? - article_params.delete(:tags) - article_params[:tag_list] = tags.join(", ") + def create_article + @article = Article.create(article_params) do |article| + article.user_id = user.id + article.show_comments = true + article.collection = series if series.present? end + end + + def series + @series ||= if article_params[:series].blank? + [] + else + Collection.find_series(article_params[:series], user) + end + end - article = Article.new(article_params) - article.user_id = user.id - article.show_comments = true - article.collection = Collection.find_series(series, user) if series.present? - article.save - article + # Subscribe author to notifications for all comments on their article. + def subscribe_author + NotificationSubscription.create(user: user, + notifiable: article, + config: "all_comments") end end end diff --git a/app/services/articles/enrich_image_attributes.rb b/app/services/articles/enrich_image_attributes.rb index 9a7ea253d0380..f04e303addb64 100644 --- a/app/services/articles/enrich_image_attributes.rb +++ b/app/services/articles/enrich_image_attributes.rb @@ -16,11 +16,14 @@ module EnrichImageAttributes ].join(", ").freeze def self.call(article) + fast_image_headers = { "User-Agent" => "#{Settings::Community.community_name} (#{URL.url})" } + store_image_if_appropriate(article) parsed_html = Nokogiri::HTML.fragment(article.processed_html) + main_image_height = default_image_height # we ignore images contained in liquid tags as they are not animated images = parsed_html.css("img") - parsed_html.css(IMAGES_IN_LIQUID_TAGS_SELECTORS) - return unless images.any? + return unless images.any? || article.main_image images.each do |img| src = img.attr("src") @@ -34,12 +37,17 @@ def self.call(article) next if image.blank? - img_properties = FastImage.new(image, timeout: 10) + img_properties = FastImage.new(image, timeout: 10, http_header: fast_image_headers) img["width"], img["height"] = img_properties.size img["data-animated"] = true if img_properties.type == :gif end - article.update_columns(processed_html: parsed_html.to_html) + if article.main_image && Settings::UserExperience.cover_image_fit == "limit" + main_image_size = FastImage.size(article.main_image, timeout: 15, http_header: fast_image_headers) + main_image_height = (main_image_size[1].to_f / main_image_size[0]) * 1000 if main_image_size + end + + article.update_columns(processed_html: parsed_html.to_html, main_image_height: main_image_height) end def self.retrieve_image_from_uploader_store(src) @@ -52,5 +60,38 @@ def self.retrieve_image_from_uploader_store(src) uploader.file&.file end private_class_method :retrieve_image_from_uploader_store + + def self.default_image_height + # If FastImage times out, we don't want to fall back to the "max limit" — 300 is instead used as a safer default + # This will ultimately represent the height the image takes over *while it loads*. + # FastImage will reliably succeed. This is a fallback. + Settings::UserExperience.cover_image_fit == "limit" ? 300 : Settings::UserExperience.cover_image_height + end + private_class_method :default_image_height + + def self.store_image_if_appropriate(article) + return if ApplicationConfig["AWS_BUCKET_NAME"].blank? + + markdown_text = article.body_markdown + markdown_pattern = /!\[.*?\]\((.*?)\)/ + html_pattern = /<img.*?src=["'](.*?)["']/ + markdown_urls = markdown_text.scan(markdown_pattern).flatten + html_urls = markdown_text.scan(html_pattern).flatten + all_urls = markdown_urls + html_urls + stored_image_url = "https://#{ApplicationConfig['AWS_BUCKET_NAME']}.s3.amazonaws.com" + filtered_urls = all_urls.reject { |url| url.include?(stored_image_url) } + images_converted = [] + filtered_urls.uniq.each do |url| + store = MediaStore.where(original_url: url).first_or_create + images_converted << store.output_url if store&.created_at&.> 1.minute.ago + rescue StandardError => e + Rails.logger.error("Error storing images: #{e.message}") + end + # This just updates the processed HTML based on new state (now has media stores) + article.evaluate_and_update_column_from_markdown if images_converted.any? + rescue StandardError => e + Rails.logger.error("Error storing images: #{e.message}") + end + private_class_method :store_image_if_appropriate end end diff --git a/app/services/articles/feeds/basic.rb b/app/services/articles/feeds/basic.rb index 8b457b37c7b25..d34cfef44f587 100644 --- a/app/services/articles/feeds/basic.rb +++ b/app/services/articles/feeds/basic.rb @@ -4,7 +4,7 @@ class Basic def initialize(user: nil, number_of_articles: Article::DEFAULT_FEED_PAGINATION_WINDOW_SIZE, page: 1, tag: nil) @user = user @number_of_articles = number_of_articles - @page = page + @page = [page, 1].max @tag = tag @article_score_applicator = Articles::Feeds::ArticleScoreCalculatorForUser.new(user: @user) end @@ -14,10 +14,15 @@ def default_home_feed(**_kwargs) .order(hotness_score: :desc) .with_at_least_home_feed_minimum_score .limit(@number_of_articles) + .offset((@page - 1) * @number_of_articles) .limited_column_select.includes(top_comments: :user) + .includes(:distinct_reaction_categories) return articles unless @user articles = articles.where.not(user_id: UserBlock.cached_blocked_ids_for_blocker(@user.id)) + if (hidden_tags = @user.cached_antifollowed_tag_names).any? + articles = articles.not_cached_tagged_with_any(hidden_tags) + end articles.sort_by.with_index do |article, index| tag_score = score_followed_tags(article) user_score = score_followed_user(article) diff --git a/app/services/articles/feeds/large_forem_experimental.rb b/app/services/articles/feeds/large_forem_experimental.rb index 566a038b53bd3..55590e77aec15 100644 --- a/app/services/articles/feeds/large_forem_experimental.rb +++ b/app/services/articles/feeds/large_forem_experimental.rb @@ -79,11 +79,14 @@ def globally_hot_articles(user_signed_in, must_have_main_image: true, article_sc featured_story = featured_story_from(stories: hot_stories, must_have_main_image: must_have_main_image) new_stories = Article.published .where("score > ?", article_score_threshold) - .limited_column_select.includes(top_comments: :user).order(published_at: :desc) + .limited_column_select.includes(top_comments: :user) + .order(published_at: :desc) + .includes(:distinct_reaction_categories) .limit(rand(min_rand_limit..max_rand_limit)) hot_stories = hot_stories.to_a + new_stories.to_a else hot_stories = Article.published.limited_column_select + .includes(:distinct_reaction_categories) .page(@page).per(@number_of_articles) .with_at_least_home_feed_minimum_score .order(hotness_score: :desc) @@ -103,6 +106,7 @@ def featured_story_from(stories:, must_have_main_image:) def experimental_hot_story_grab start_time = Articles::Feeds.oldest_published_at_to_consider_for(user: @user) Article.published.limited_column_select.includes(top_comments: :user) + .includes(:distinct_reaction_categories) .where("published_at > ?", start_time) .page(@page).per(@number_of_articles) .order(score: :desc) diff --git a/app/services/articles/feeds/latest.rb b/app/services/articles/feeds/latest.rb index 04117b545ff93..b397e88dbd957 100644 --- a/app/services/articles/feeds/latest.rb +++ b/app/services/articles/feeds/latest.rb @@ -9,6 +9,7 @@ def self.call(tag: nil, number_of_articles: nil, page: 1, minimum_score: nil) Articles::Feeds::Tag.call(tag) .order(published_at: :desc) + .includes(:distinct_reaction_categories) .where("score > ?", minimum_score) .page(page) .per(number_of_articles) diff --git a/app/services/articles/feeds/tag.rb b/app/services/articles/feeds/tag.rb index 8bdd6015b25db..49eb7abd07f8c 100644 --- a/app/services/articles/feeds/tag.rb +++ b/app/services/articles/feeds/tag.rb @@ -17,6 +17,7 @@ def self.call(tag = nil, number_of_articles: Article::DEFAULT_FEED_PAGINATION_WI .published .limited_column_select .includes(top_comments: :user) + .includes(:distinct_reaction_categories) .page(page) .per(number_of_articles) end diff --git a/app/services/articles/feeds/timeframe.rb b/app/services/articles/feeds/timeframe.rb index 9c65ca2142d55..8a5bac8a681a7 100644 --- a/app/services/articles/feeds/timeframe.rb +++ b/app/services/articles/feeds/timeframe.rb @@ -1,11 +1,14 @@ module Articles module Feeds module Timeframe - def self.call(timeframe, tag: nil, number_of_articles: Article::DEFAULT_FEED_PAGINATION_WINDOW_SIZE, page: 1) + def self.call(timeframe, tag: nil, minimum_score: -20, + number_of_articles: Article::DEFAULT_FEED_PAGINATION_WINDOW_SIZE, page: 1) articles = ::Articles::Feeds::Tag.call(tag) articles .where("published_at > ?", ::Timeframe.datetime(timeframe)) + .includes(:distinct_reaction_categories) + .where("score > ?", minimum_score) .order(score: :desc) .page(page) .per(number_of_articles) diff --git a/app/services/articles/feeds/variant_query.rb b/app/services/articles/feeds/variant_query.rb index 47c172fd6aafd..d6b830656372a 100644 --- a/app/services/articles/feeds/variant_query.rb +++ b/app/services/articles/feeds/variant_query.rb @@ -10,6 +10,19 @@ module Feeds # @see config/feed-variants/README.md # @see app/models/articles/feeds/README.md class VariantQuery + Config = Struct.new( + :variant, + :description, + :levers, # Array <Articles::Feeds::RelevancyLever::Configured> + :order_by, # Articles::Feeds::OrderByLever + :max_days_since_published, + # when true, each time you call the query you will get different randomized numbers; when + # false, the resulting randomized numbers will be the same within a window of time. + :reseed_randomizer_on_each_request, + keyword_init: true, + ) do + alias_method :reseed_randomizer_on_each_request?, :reseed_randomizer_on_each_request + end # @api public # # @param variant [Symbol, #to_sym] the name of the variant query we're building. @@ -25,20 +38,6 @@ def self.build_for(variant:, assembler: VariantAssembler, **kwargs) new(config: config, **kwargs) end - Config = Struct.new( - :variant, - :description, - :levers, # Array <Articles::Feeds::RelevancyLever::Configured> - :order_by, # Articles::Feeds::OrderByLever - :max_days_since_published, - # when true, each time you call the query you will get different randomized numbers; when - # false, the resulting randomized numbers will be the same within a window of time. - :reseed_randomizer_on_each_request, - keyword_init: true, - ) do - alias_method :reseed_randomizer_on_each_request?, :reseed_randomizer_on_each_request - end - # @param config [Articles::Feeds::VariantQuery::Config] # @param user [User,NilClass] # @param number_of_articles [Integer, #to_i] @@ -48,18 +47,23 @@ def self.build_for(variant:, assembler: VariantAssembler, **kwargs) # @param seed [Number] used in the `setseed` Postgresql function to set the randomization # seed. This parameter allows the caller (and debugger) to use the same randomization # order in the queries; the hope being that this might help in any debugging. - def initialize(config:, user: nil, number_of_articles: 50, page: 1, tag: nil, seed: nil) + def initialize(config:, user: nil, number_of_articles: 50, page: 1, tag: nil, seed: nil, type_of: "discover") @user = user @number_of_articles = number_of_articles @page = page @tag = tag @config = config + @type_of = type_of @seed = randomizer_seed_for(seed: seed, user: user) oldest_published_at = Articles::Feeds.oldest_published_at_to_consider_for( user: @user, days_since_published: config.max_days_since_published, ) - @query_parameters = { oldest_published_at: oldest_published_at } + @query_parameters = { + oldest_published_at: oldest_published_at, + conditional_lookback: oldest_published_at - 12.hours, + conditional_comment_timeframe: 6.hours.ago + } configure! end @@ -87,7 +91,7 @@ def initialize(config:, user: nil, number_of_articles: 50, page: 1, tag: nil, se # puts strategy.call.to_sql # # rubocop:disable Layout/LineLength - def call(only_featured: false, must_have_main_image: false, limit: default_limit, offset: default_offset, omit_article_ids: []) + def call(only_featured: false, must_have_main_image: false, limit: default_limit, offset: default_offset, omit_article_ids: [], comments_variant: default_comments_variant) # rubocop:enable Layout/LineLength # These are the variables we'll pass to the SQL statement. @@ -131,10 +135,31 @@ def call(only_featured: false, must_have_main_image: false, limit: default_limit # This sub-query allows us to take the hard work of the hand-coded unsanitized sql and # create a sub-query that we can use to help ensure that we can use all of the ActiveRecord # goodness of scopes (e.g., limited_column_select) and eager includes. - Article.joins(join_fragment) + scope = Article.joins(join_fragment) .limited_column_select - .includes(top_comments: :user) + .includes(:distinct_reaction_categories) .order(config.order_by.to_sql) + + scope = case comments_variant + when "top_comments" + scope.includes(top_comments: :user) + when "more_inclusive_top_comments" + scope.includes(more_inclusive_top_comments: :user) + when "recent_good_comments" + scope.includes(recent_good_comments: :user) + when "more_inclusive_recent_good_comments" + scope.includes(more_inclusive_recent_good_comments: :user) + when "most_inclusive_recent_good_comments" + scope.includes(most_inclusive_recent_good_comments: :user) + else + scope.includes(top_comments: :user) # fallback default + end + + if @user.present? && (hidden_tags = @user.cached_antifollowed_tag_names).any? + scope = scope.not_cached_tagged_with_any(hidden_tags) + end + + scope end alias more_comments_minimal_weight_randomized call @@ -255,14 +280,18 @@ def sql_sub_query(limit:, offset:, omit_article_ids:, only_featured: false, must end def build_sql_with_where_clauses(only_featured:, must_have_main_image:, omit_article_ids:) - where_clauses = "articles.published = true AND articles.published_at > :oldest_published_at" - # See Articles.published scope discussion regarding the query planner + # Hardcode the values for lookback_hours and comment_hours. + where_clauses = "articles.published = true" where_clauses += " AND articles.published_at < :now" - where_clauses += " AND articles.score >= 0" # We only want positive values here. - - # Without the compact, if we have `omit_article_ids: [nil]` we - # have the following SQL clause: `articles.id NOT IN (NULL)` - # which will immediately omit EVERYTHING from the query. + where_clauses += " AND articles.score >= 0" + if @type_of == "discover" + where_clauses += " AND ((articles.published_at > :oldest_published_at) + OR (articles.published_at > :conditional_lookback + AND articles.last_comment_at > :conditional_comment_timeframe))" + elsif @user + user_ids = @user.cached_following_users_ids + @user.cached_following_organizations_ids + [0] # Adding one that will never be reached so we can have a valid SQL statement + where_clauses += " AND articles.user_id IN (#{user_ids.join(',')})" + end where_clauses += " AND articles.id NOT IN (:omit_article_ids)" unless omit_article_ids.compact.empty? where_clauses += " AND articles.featured = true" if only_featured where_clauses += " AND articles.main_image IS NOT NULL" if must_have_main_image @@ -292,9 +321,13 @@ def default_limit end def default_offset - return 0 if @page == 1 + return 0 if @page.zero? + + (@page.to_i - 1) * default_limit + end - @page.to_i - (1 * default_limit) + def default_comments_variant + "top_comments" end # We want to ensure that we're not randomizing someone's feed all the time; and instead aiming diff --git a/app/services/articles/page_view_updater.rb b/app/services/articles/page_view_updater.rb index f5a8d69df54c2..1e9ea071963d6 100644 --- a/app/services/articles/page_view_updater.rb +++ b/app/services/articles/page_view_updater.rb @@ -4,6 +4,7 @@ module Articles # @see Articles::UpdatePageViewsWorker for the sibling that's responsible for recording page # views. module PageViewUpdater + EXTENDED_PAGEVIEW_NUMBER = 60 # @param article_id [Integer] # @param user_id [Integer] # @@ -22,10 +23,13 @@ def self.call(article_id:, user_id:) page_view = PageView.order(created_at: :desc) .find_or_create_by(article_id: article_id, user_id: user_id) - return true if page_view.new_record? - page_view.update_column(:time_tracked_in_seconds, page_view.time_tracked_in_seconds + 15) + new_time_mark = page_view.time_tracked_in_seconds + 15 + page_view.update_column(:time_tracked_in_seconds, new_time_mark) + if new_time_mark == EXTENDED_PAGEVIEW_NUMBER + FeedEvent.record_journey_for(page_view.user, article: page_view.article, category: :extended_pageview) + end true end diff --git a/app/services/articles/suggest.rb b/app/services/articles/suggest.rb index 4de4fd25b9fca..5954029c5cda8 100644 --- a/app/services/articles/suggest.rb +++ b/app/services/articles/suggest.rb @@ -38,6 +38,7 @@ def call def other_suggestions(max: MAX_DEFAULT, ids_to_ignore: []) ids_to_ignore << article.id Article.published + .where("published_at > ?", 3.months.ago) .where.not(id: ids_to_ignore) .not_authored_by(article.user_id) .order(hotness_score: :desc) @@ -48,6 +49,7 @@ def other_suggestions(max: MAX_DEFAULT, ids_to_ignore: []) def suggestions_by_tag(max: MAX_DEFAULT) Article .published + .where("published_at > ?", 3.months.ago) .cached_tagged_with_any(cached_tag_list_array) .not_authored_by(article.user_id) .where(tag_suggestion_query) diff --git a/app/services/articles/updater.rb b/app/services/articles/updater.rb index 969ef3ebcc2ce..f2a85ef04dcd4 100644 --- a/app/services/articles/updater.rb +++ b/app/services/articles/updater.rb @@ -2,60 +2,87 @@ module Articles class Updater Result = Struct.new(:success, :article, keyword_init: true) + def self.call(...) + new(...).call + end + def initialize(user, article, article_params) @user = user @article = article - @article_params = article_params - end - - def self.call(...) - new(...).call + @article_params = normalize_params(article_params) end def call user.rate_limiter.check_limit!(:article_update) + success = article.update(article_params) + + if success + user.rate_limiter.track_limit_by_action(:article_update) + + remove_all_notifications if became_unpublished? + send_to_mentioned_users_and_followers if remains_published? + refresh_auto_audience_segments if became_published? + end + + Result.new(success: success, article: article.decorate) + end + + private + + attr_reader :user, :article, :article_params + + def normalize_params(original_params) + article_params = original_params.dup # updated edited time only if already published and not edited by an admin update_edited_at = article.user == user && article.published + # remove published_at values received from a user if an articles was published before (has past published_at) # published_at will remain as it was in this case article_params.delete :published_at if article.published_at && !article.scheduled? - attrs = Articles::Attributes.new(article_params, article.user) + # NOTE: It's surprising that this is article.user and not @user + Articles::Attributes.new(article_params, article.user) .for_update(update_edited_at: update_edited_at) + end - success = article.update(attrs) - if success - user.rate_limiter.track_limit_by_action(:article_update) + def refresh_auto_audience_segments + user.refresh_auto_audience_segments + end - if article.published && article.saved_change_to_published.blank? - # If the article has already been published and is only being updated, then we need to create - # mentions and send notifications to mentioned users inline via the Mentions::CreateAll service. - Mentions::CreateAll.call(article) - end - - # Remove any associated notifications if Article is unpublished - if article.saved_changes["published"] == [true, false] - Notification.remove_all_by_action_without_delay(notifiable_ids: article.id, notifiable_type: "Article", - action: "Published") - ContextNotification.delete_by(context_id: article.id, context_type: "Article", - action: "Published") - - if article.comments.exists? - Notification.remove_all(notifiable_ids: article.comments.ids, - notifiable_type: "Comment") - end - if article.mentions.exists? - Notification.remove_all(notifiable_ids: article.mentions.ids, - notifiable_type: "Mention") - end - end - end - Result.new(success: success, article: article.decorate) + def became_published? + article.published? && !article.published_previously_was end - private + def remains_published? + article.published && article.saved_change_to_published.blank? + end - attr_reader :user, :article, :article_params + def became_unpublished? + article.saved_changes["published"] == [true, false] + end + + # If the article has already been published and is only being updated, then we need to create + # mentions and send notifications to mentioned users inline via the Mentions::CreateAll service. + def send_to_mentioned_users_and_followers + Mentions::CreateAll.call(article) + end + + # Remove any associated notifications if Article is unpublished + def remove_all_notifications + Notification.remove_all_by_action_without_delay(notifiable_ids: article.id, notifiable_type: "Article", + action: "Published") + ContextNotification.delete_by(context_id: article.id, context_type: "Article", + action: "Published") + + if article.comments.exists? + Notification.remove_all(notifiable_ids: article.comments.ids, + notifiable_type: "Comment") + end + return unless article.mentions.exists? + + Notification.remove_all(notifiable_ids: article.mentions.ids, + notifiable_type: "Mention") + end end end diff --git a/app/services/authentication/authenticator.rb b/app/services/authentication/authenticator.rb index 179e41b4720c3..60c99abe23cd5 100644 --- a/app/services/authentication/authenticator.rb +++ b/app/services/authentication/authenticator.rb @@ -12,14 +12,6 @@ module Authentication # 2. update an existing user and align it to its authentication identity # 3. return the current user if a user is given (already logged in scenario) class Authenticator - # auth_payload is the payload schema, see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema - def initialize(auth_payload, current_user: nil, cta_variant: nil) - @provider = load_authentication_provider(auth_payload) - - @current_user = current_user - @cta_variant = cta_variant - end - # @api public # # @see #initialize method for parameters @@ -32,6 +24,14 @@ def self.call(...) new(...).call end + # auth_payload is the payload schema, see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema + def initialize(auth_payload, current_user: nil, cta_variant: nil) + @provider = load_authentication_provider(auth_payload) + + @current_user = current_user + @cta_variant = cta_variant + end + # @api private def call identity = Identity.build_from_omniauth(provider) @@ -51,6 +51,7 @@ def call else update_user(user) end + user.set_initial_roles! identity.user = user if identity.user_id.blank? new_identity = identity.new_record? @@ -105,11 +106,15 @@ def current_user_identity_exists? def proper_user(identity) if current_user + Rails.logger.debug { "Current user exists: #{current_user.id}" } current_user elsif identity.user + Rails.logger.debug { "Identity user found: #{identity.user.id}" } identity.user elsif provider.user_email.present? - User.find_by(email: provider.user_email) + user = User.find_by(email: provider.user_email) + Rails.logger.debug { "User found by email: #{user&.id}" } + user end end @@ -118,9 +123,9 @@ def find_or_create_user! suspended_user = Users::SuspendedUsername.previously_suspended?(username) raise ::Authentication::Errors::PreviouslySuspended if suspended_user - existing_user = User.where( + existing_user = User.find_by( provider.user_username_field => username, - ).take + ) return existing_user if existing_user User.new.tap do |user| @@ -132,7 +137,7 @@ def find_or_create_user! # The user must be saved in the database before # we assign the user to a new identity. - user.save! + user.new_record? ? user.save! : user.save # Throw excption if new record. end end @@ -148,7 +153,7 @@ def default_user_fields end def update_user(user) - return user if user.suspended? + return user if user.spam_or_suspended? user.tap do |model| model.unlock_access! if model.access_locked? diff --git a/app/services/authentication/providers/apple.rb b/app/services/authentication/providers/apple.rb index 7c5074deb9bcc..ddefe6175bdfb 100644 --- a/app/services/authentication/providers/apple.rb +++ b/app/services/authentication/providers/apple.rb @@ -7,6 +7,21 @@ class Apple < Provider TRUSTED_CALLBACK_ORIGIN = "https://appleid.apple.com".freeze CALLBACK_PATH = "/users/auth/apple/callback".freeze + def self.official_name + OFFICIAL_NAME + end + + def self.settings_url + SETTINGS_URL + end + + def self.sign_in_path(**kwargs) + ::Authentication::Paths.sign_in_path( + provider_name, + **kwargs, + ) + end + def new_user_data # Apple sends `first_name` and `last_name` as separate fields name = I18n.t("services.authentication.providers.apple.name", first: info.first_name, last: info.last_name) @@ -17,11 +32,11 @@ def new_user_data name: name } - if Rails.env.test? - user_data[:profile_image] = Settings::General.mascot_image_url - else - user_data[:profile_image] = Users::ProfileImageGenerator.call - end + user_data[:profile_image] = if Rails.env.test? + Settings::General.mascot_image_url + else + Images::ProfileImageGenerator.call + end user_data end @@ -59,21 +74,6 @@ def user_nickname end end - def self.official_name - OFFICIAL_NAME - end - - def self.settings_url - SETTINGS_URL - end - - def self.sign_in_path(**kwargs) - ::Authentication::Paths.sign_in_path( - provider_name, - **kwargs, - ) - end - protected def cleanup_payload(auth_payload) diff --git a/app/services/authentication/providers/facebook.rb b/app/services/authentication/providers/facebook.rb index 2f53876e04ace..fe37a8ec64152 100644 --- a/app/services/authentication/providers/facebook.rb +++ b/app/services/authentication/providers/facebook.rb @@ -5,12 +5,27 @@ class Facebook < Provider OFFICIAL_NAME = "Facebook".freeze SETTINGS_URL = "https://www.facebook.com/settings?tab=applications".freeze + def self.official_name + OFFICIAL_NAME + end + + def self.settings_url + SETTINGS_URL + end + + def self.sign_in_path(**kwargs) + ::Authentication::Paths.sign_in_path( + provider_name, + **kwargs, + ) + end + def new_user_data image_url = @info.image.gsub("http://", "https://") { name: @info.name, email: @info.email || "", - remote_profile_image_url: Users::SafeRemoteProfileImageUrl.call(image_url), + remote_profile_image_url: Images::SafeRemoteProfileImageUrl.call(image_url), facebook_username: user_nickname } end @@ -31,21 +46,6 @@ def user_nickname ].join("_")[0...25] end - def self.official_name - OFFICIAL_NAME - end - - def self.settings_url - SETTINGS_URL - end - - def self.sign_in_path(**kwargs) - ::Authentication::Paths.sign_in_path( - provider_name, - **kwargs, - ) - end - protected def cleanup_payload(auth_payload) diff --git a/app/services/authentication/providers/forem.rb b/app/services/authentication/providers/forem.rb index 0cf84d427cbfc..7d98edc8adb72 100644 --- a/app/services/authentication/providers/forem.rb +++ b/app/services/authentication/providers/forem.rb @@ -5,6 +5,21 @@ class Forem < Provider DOMAIN_URL = ApplicationConfig["FOREM_OAUTH_URL"] || "https://account.forem.com".freeze SETTINGS_URL = "#{DOMAIN_URL}/oauth/authorized_applications".freeze + def self.official_name + OFFICIAL_NAME + end + + def self.settings_url + SETTINGS_URL + end + + def self.sign_in_path(**kwargs) + ::Authentication::Paths.sign_in_path( + provider_name, + **kwargs, + ) + end + def new_user_data { email: info.email, @@ -24,21 +39,6 @@ def existing_user_data delegate :user_nickname, to: :info - def self.official_name - OFFICIAL_NAME - end - - def self.settings_url - SETTINGS_URL - end - - def self.sign_in_path(**kwargs) - ::Authentication::Paths.sign_in_path( - provider_name, - **kwargs, - ) - end - protected # Remove sensible data from the payload: None in this case so return as-is diff --git a/app/services/authentication/providers/github.rb b/app/services/authentication/providers/github.rb index 2cfb7c35b32eb..6168111bbdfc9 100644 --- a/app/services/authentication/providers/github.rb +++ b/app/services/authentication/providers/github.rb @@ -5,6 +5,21 @@ class Github < Provider OFFICIAL_NAME = "GitHub".freeze SETTINGS_URL = "https://github.com/settings/applications".freeze + def self.official_name + OFFICIAL_NAME + end + + def self.settings_url + SETTINGS_URL + end + + def self.sign_in_path(**kwargs) + ::Authentication::Paths.sign_in_path( + provider_name, + **kwargs, + ) + end + def new_user_data name = raw_info.name.presence || info.name @@ -12,7 +27,7 @@ def new_user_data email: info.email.to_s, github_username: info.nickname, name: name, - remote_profile_image_url: Users::SafeRemoteProfileImageUrl.call(info.image.to_s) + remote_profile_image_url: Images::SafeRemoteProfileImageUrl.call(info.image.to_s) } end @@ -22,21 +37,6 @@ def existing_user_data } end - def self.official_name - OFFICIAL_NAME - end - - def self.settings_url - SETTINGS_URL - end - - def self.sign_in_path(**kwargs) - ::Authentication::Paths.sign_in_path( - provider_name, - **kwargs, - ) - end - protected def cleanup_payload(auth_payload) diff --git a/app/services/authentication/providers/google_oauth2.rb b/app/services/authentication/providers/google_oauth2.rb index 9060378bab4a9..f96c42e1cb719 100644 --- a/app/services/authentication/providers/google_oauth2.rb +++ b/app/services/authentication/providers/google_oauth2.rb @@ -5,18 +5,33 @@ class GoogleOauth2 < Provider OFFICIAL_NAME = "Google".freeze SETTINGS_URL = "https://console.cloud.google.com/apis/credentials".freeze + def self.official_name + OFFICIAL_NAME + end + + def self.settings_url + SETTINGS_URL + end + + def self.sign_in_path(**kwargs) + ::Authentication::Paths.sign_in_path( + "google_oauth2", + **kwargs, + ) + end + def new_user_data { name: info.name, email: info.email || "", - remote_profile_image_url: Users::SafeRemoteProfileImageUrl.call(@info.image), - google_oauth2_username: user_nickname + remote_profile_image_url: Images::SafeRemoteProfileImageUrl.call(@info.image), + google_oauth2_username: user_nickname || (0...8).map { rand(65..90).chr }.join } end def existing_user_data { - google_oauth2_username: info.name + google_oauth2_username: user_nickname || (0...8).map { rand(65..90).chr }.join } end @@ -30,21 +45,6 @@ def user_nickname ].join("_")[0...25] end - def self.official_name - OFFICIAL_NAME - end - - def self.settings_url - SETTINGS_URL - end - - def self.sign_in_path(**kwargs) - ::Authentication::Paths.sign_in_path( - "google_oauth2", - **kwargs, - ) - end - protected def cleanup_payload(auth_payload) diff --git a/app/services/authentication/providers/provider.rb b/app/services/authentication/providers/provider.rb index 470aa80c1f55c..a3cceaa46a76c 100644 --- a/app/services/authentication/providers/provider.rb +++ b/app/services/authentication/providers/provider.rb @@ -5,6 +5,30 @@ class Provider delegate :email, to: :info, prefix: :user delegate :user_username_field, to: :class + def self.provider_name + name.demodulize.underscore.to_sym + end + + def self.user_username_field + "#{provider_name}_username".to_sym + end + + def self.official_name + name.demodulize + end + + def self.settings_url + raise SubclassResponsibility + end + + def self.authentication_path(**kwargs) + ::Authentication::Paths.authentication_path(provider_name, **kwargs) + end + + def self.sign_in_path(**_kwargs) + raise SubclassResponsibility + end + def initialize(auth_payload) @auth_payload = cleanup_payload(auth_payload.dup) @info = auth_payload.info @@ -33,30 +57,6 @@ def payload auth_payload end - def self.provider_name - name.demodulize.underscore.to_sym - end - - def self.user_username_field - "#{provider_name}_username".to_sym - end - - def self.official_name - name.demodulize - end - - def self.settings_url - raise SubclassResponsibility - end - - def self.authentication_path(**kwargs) - ::Authentication::Paths.authentication_path(provider_name, **kwargs) - end - - def self.sign_in_path(**_kwargs) - raise SubclassResponsibility - end - protected # Remove sensible data from the payload diff --git a/app/services/authentication/providers/twitter.rb b/app/services/authentication/providers/twitter.rb index 40b343a06b91f..2fd6b99bd590f 100644 --- a/app/services/authentication/providers/twitter.rb +++ b/app/services/authentication/providers/twitter.rb @@ -2,8 +2,27 @@ module Authentication module Providers # Twitter authentication provider, uses omniauth-twitter as backend class Twitter < Provider + OFFICIAL_NAME = "Twitter (X)".freeze SETTINGS_URL = "https://twitter.com/settings/applications".freeze + def self.settings_url + SETTINGS_URL + end + + def self.official_name + OFFICIAL_NAME + end + + def self.sign_in_path(**kwargs) + # see https://github.com/arunagw/omniauth-twitter#authentication-options + mandatory_params = { secure_image_url: true } + + ::Authentication::Paths.sign_in_path( + provider_name, + **kwargs.merge(mandatory_params), + ) + end + def new_user_data name = raw_info.name.presence || info.name remote_profile_image_url = info.image.to_s.gsub("_normal", "") @@ -11,7 +30,7 @@ def new_user_data { email: info.email.to_s, name: name, - remote_profile_image_url: Users::SafeRemoteProfileImageUrl.call(remote_profile_image_url), + remote_profile_image_url: Images::SafeRemoteProfileImageUrl.call(remote_profile_image_url), twitter_username: info.nickname } end @@ -22,20 +41,6 @@ def existing_user_data } end - def self.settings_url - SETTINGS_URL - end - - def self.sign_in_path(**kwargs) - # see https://github.com/arunagw/omniauth-twitter#authentication-options - mandatory_params = { secure_image_url: true } - - ::Authentication::Paths.sign_in_path( - provider_name, - **kwargs.merge(mandatory_params), - ) - end - protected def cleanup_payload(auth_payload) diff --git a/app/services/badges/award.rb b/app/services/badges/award.rb index afdb3d1a6a524..a1c92d478d59a 100644 --- a/app/services/badges/award.rb +++ b/app/services/badges/award.rb @@ -1,6 +1,6 @@ module Badges class Award - def self.call(user_relation, slug, message_markdown) + def self.call(user_relation, slug, message_markdown, include_default_description: true) return unless (badge_id = Badge.id_for_slug(slug)) user_relation.find_each do |user| @@ -9,6 +9,7 @@ def self.call(user_relation, slug, message_markdown) achievement = user.badge_achievements.create( badge_id: badge_id, rewarding_context_message_markdown: message_markdown, + include_default_description: include_default_description, ) user.touch if achievement.persisted? end diff --git a/app/services/badges/award_community_wellness.rb b/app/services/badges/award_community_wellness.rb index 001b09974abea..60f6d2c1c8e34 100644 --- a/app/services/badges/award_community_wellness.rb +++ b/app/services/badges/award_community_wellness.rb @@ -1,6 +1,6 @@ module Badges class AwardCommunityWellness - REWARD_STREAK_WEEKS = [1, 2, 4, 8, 16, 32].freeze + REWARD_STREAK_WEEKS = [1, 2, 4, 8, 16, 24, 32].freeze def self.call # These are the users 'eligible' to be awarded the badge diff --git a/app/services/badges/award_fab_five.rb b/app/services/badges/award_fab_five.rb index 3163b6cdb003e..c4d3b1bd30acf 100644 --- a/app/services/badges/award_fab_five.rb +++ b/app/services/badges/award_fab_five.rb @@ -2,12 +2,16 @@ module Badges class AwardFabFive BADGE_SLUG = "fab-5".freeze - def self.call(usernames, message_markdown = I18n.t("services.badges.congrats")) + def self.call(usernames, message_markdown = default_message_markdown) ::Badges::Award.call( User.where(username: usernames), BADGE_SLUG, message_markdown, ) end + + def self.default_message_markdown + I18n.t("services.badges.congrats", community: Settings::Community.community_name) + end end end diff --git a/app/services/badges/award_first_post.rb b/app/services/badges/award_first_post.rb new file mode 100644 index 0000000000000..55c9f2d71865a --- /dev/null +++ b/app/services/badges/award_first_post.rb @@ -0,0 +1,23 @@ +module Badges + class AwardFirstPost + BADGE_SLUG = "writing-debut".freeze + + def self.call + return unless (badge_id = Badge.id_for_slug(BADGE_SLUG)) + + Article.joins(:user) + .published + .where("articles.published_at > ?", 1.week.ago) + .where("articles.published_at < ?", 1.hour.ago) + .where("articles.score >= ?", 0) + .where(nth_published_by_author: 1) + .where.not(users: { id: User.with_role(:spam).or(User.with_role(:suspended)) }) + .find_each do |article| + BadgeAchievement.create( + user_id: article.user_id, + badge_id: badge_id, + ) + end + end + end +end diff --git a/app/services/badges/award_tag.rb b/app/services/badges/award_tag.rb index b992f0e0d7985..976b72527ad65 100644 --- a/app/services/badges/award_tag.rb +++ b/app/services/badges/award_tag.rb @@ -13,7 +13,7 @@ def self.call def call Tag.where.not(badge_id: nil).find_each do |tag| past_winner_user_ids = BadgeAchievement.where(badge_id: tag.badge_id).pluck(:user_id) - winning_article = Article.where("score > 100") + winning_article = Article.where("score > ?", Settings::UserExperience.award_tag_minimum_score) .published .not_authored_by(past_winner_user_ids) .order(score: :desc) diff --git a/app/services/badges/award_thumbs_up.rb b/app/services/badges/award_thumbs_up.rb new file mode 100644 index 0000000000000..57d13e33f8651 --- /dev/null +++ b/app/services/badges/award_thumbs_up.rb @@ -0,0 +1,55 @@ +module Badges + class AwardThumbsUp + THUMBS_UP_BADGES = { + 100 => "100 Thumbs Up Milestone", + 500 => "500 Thumbs Up Milestone", + 1000 => "1,000 Thumbs Up Milestone", + 5000 => "5,000 Thumbs Up Milestone", + 10_000 => "10,000 Thumbs Up Milestone" + }.freeze + + MIN_THRESHOLD = THUMBS_UP_BADGES.keys.min + + def self.call + badge_ids = fetch_badge_ids + + # Early return if any badge is not found + return if badge_ids.values.any?(&:nil?) + + user_thumbsup_counts = get_user_thumbsup_counts + + user_thumbsup_counts.each do |user_id, count| + THUMBS_UP_BADGES.each do |threshold, _| + break unless count >= threshold + + badge_id = badge_ids[threshold] + next unless badge_id + + BadgeAchievement.create( + user_id: user_id, + badge_id: badge_id, + rewarding_context_message_markdown: generate_message(threshold: threshold), + ) + end + end + end + + def self.get_user_thumbsup_counts + Reaction.where(category: "thumbsup", reactable_type: "Article") + .group(:user_id) + .having("COUNT(*) >= ?", MIN_THRESHOLD) + .order(Arel.sql("COUNT(*) DESC")) + .count + end + + def self.fetch_badge_ids + Badge.where(title: THUMBS_UP_BADGES.values).each_with_object({}) do |badge, hash| + hash[THUMBS_UP_BADGES.key(badge.title)] = badge[:id] + end + end + + def self.generate_message(threshold:) + I18n.t("services.badges.thumbs_up", count: threshold) + end + end +end diff --git a/app/services/badges/award_top_seven.rb b/app/services/badges/award_top_seven.rb index 0e7c8d9ccca73..a363c7cd30c27 100644 --- a/app/services/badges/award_top_seven.rb +++ b/app/services/badges/award_top_seven.rb @@ -2,12 +2,16 @@ module Badges class AwardTopSeven BADGE_SLUG = "top-7".freeze - def self.call(usernames, message_markdown = I18n.t("services.badges.congrats")) + def self.call(usernames, message_markdown = default_message_markdown) ::Badges::Award.call( User.where(username: usernames), BADGE_SLUG, message_markdown, ) end + + def self.default_message_markdown + I18n.t("services.badges.congrats", community: Settings::Community.community_name) + end end end diff --git a/app/services/billboard_event_rollup.rb b/app/services/billboard_event_rollup.rb new file mode 100644 index 0000000000000..c0c4a2493372e --- /dev/null +++ b/app/services/billboard_event_rollup.rb @@ -0,0 +1,113 @@ +class BillboardEventRollup + ATTRIBUTES_PRESERVED = %i[user_id display_ad_id category context_type created_at].freeze + ATTRIBUTES_DESTROYED = %i[id counts_for updated_at article_id geolocation].freeze + STATEMENT_TIMEOUT = ENV.fetch("STATEMENT_TIMEOUT_BULK_DELETE", 10_000).to_i.seconds / 1_000.to_f + + class EventAggregator + Compact = Struct.new(:events, :user_id, :display_ad_id, :category, :context_type) do + def to_h + { + user_id: user_id, + display_ad_id: display_ad_id, + category: category, + context_type: context_type, + counts_for: events.sum(&:counts_for), + created_at: events.first.created_at + } + end + end + + def initialize + @aggregator = Hash.new do |level1, user_id| + level1[user_id] = Hash.new do |level2, display_ad_id| + level2[display_ad_id] = Hash.new do |level3, category| + level3[category] = Hash.new do |level4, context_type| + level4[context_type] = [] + end + end + end + end + end + + def <<(event) + @aggregator[event.user_id][event.display_ad_id][event.category][event.context_type] << event + end + + def each + @aggregator.each_pair do |user_id, grouped_by_user| + grouped_by_user.each_pair do |display_ad_id, grouped_by_display_ad| + grouped_by_display_ad.each_pair do |category, grouped_by_category| + grouped_by_category.each_pair do |context_type, events| + next unless events.size > 1 + + yield Compact.new(events, user_id, display_ad_id, category, context_type) + end + end + end + end + end + + private + + attr_reader :aggregator + end + + def self.rollup(date, relation: BillboardEvent) + new(relation: relation).rollup(date) + end + + def initialize(relation:) + @relation = relation + end + + attr_reader :relation + + def rollup(date, batch_size: 1000) + created = [] + # Set statement_timeout for the initial query and then reset it + relation.connection.execute("SET statement_timeout = '#{STATEMENT_TIMEOUT}s'") + display_ad_ids = relation.where(created_at: date.all_day).distinct.pluck(:display_ad_id) + relation.connection.execute("RESET statement_timeout") + + display_ad_ids.each do |display_ad_id| + aggregator = EventAggregator.new + + # Each billboard is processed in its own transaction + relation.transaction(requires_new: true) do + relation.connection.execute("SET LOCAL statement_timeout = '#{STATEMENT_TIMEOUT}s'") + + relation.where(display_ad_id: display_ad_id, created_at: date.all_day).in_batches(of: batch_size) do |batch| + batch.each do |event| + aggregator << event + end + end + + aggregator.each do |compacted_events| + created << compact_records(compacted_events) + end + ensure + relation.connection.execute("RESET statement_timeout") + end + end + + created + end + + private + + def compact_records(compacted) + result = nil + + relation.transaction do + relation.connection.execute("SET LOCAL statement_timeout = '#{STATEMENT_TIMEOUT}s'") + + result = relation.create!(compacted.to_h) + + relation.where(id: compacted.events.map(&:id)).delete_all + ensure + relation.connection.execute("RESET statement_timeout") + end + + result + end +end diff --git a/app/services/calculate_reaction_points.rb b/app/services/calculate_reaction_points.rb index 92335de4dea59..d8e1a388d6dff 100644 --- a/app/services/calculate_reaction_points.rb +++ b/app/services/calculate_reaction_points.rb @@ -28,6 +28,9 @@ def calculate_points base_points = POINTS["invalid"] if status == "invalid" base_points /= POINTS["User"] if reactable_type == "User" base_points *= POINTS["confirmed"] if status == "confirmed" + if base_points.positive? && user&.base_subscriber? && reactable.respond_to?(:user_id) && reactable&.user_id != user_id + base_points += Settings::UserExperience.index_minimum_score + end unless persisted? # Actions we only want to apply upon initial creation # Author's comment reaction counts for more weight on to their own posts. (5.0 vs 1.0) diff --git a/app/services/comment_creator.rb b/app/services/comment_creator.rb index 01601bbfcda3c..f65758fbf0cd5 100644 --- a/app/services/comment_creator.rb +++ b/app/services/comment_creator.rb @@ -8,13 +8,11 @@ def self.build_comment(params, current_user:) new(params, current_user: current_user) end - # rubocop:disable Lint/MissingSuper def initialize(params, current_user:) @current_user = current_user @params = params @record = comment end - # rubocop:enable Lint/MissingSuper def save return unless record.save diff --git a/app/services/comments/calculate_score.rb b/app/services/comments/calculate_score.rb new file mode 100644 index 0000000000000..40d2ea9004cc0 --- /dev/null +++ b/app/services/comments/calculate_score.rb @@ -0,0 +1,33 @@ +module Comments + class CalculateScore + def self.call(...) + new(...).call + end + + def initialize(comment) + @comment = comment + end + + def call + score = BlackBox.comment_quality_score(comment) + score -= 500 if comment.user&.spam? + score += Settings::UserExperience.index_minimum_score if comment.user&.base_subscriber? + comment.update_columns(score: score, updated_at: Time.current) + + comment.user&.touch(:last_comment_at) + + # update commentable + commentable = comment.commentable + + commentable.touch(:last_comment_at) if commentable.respond_to?(:last_comment_at) + Comments::Count.call(commentable, recalculate: true) if commentable.is_a?(Article) + + # busting comment cache includes busting commentable cache + Comments::BustCacheWorker.new.perform(comment.id) + end + + private + + attr_reader :comment + end +end diff --git a/app/services/content_renderer.rb b/app/services/content_renderer.rb index e597c2de6dbb0..278b2fc22e1f8 100644 --- a/app/services/content_renderer.rb +++ b/app/services/content_renderer.rb @@ -1,27 +1,69 @@ +# renders markdown for Articles, Billboards, Comments, Onboarding newsletter content class ContentRenderer - class_attribute :fixer, default: MarkdownProcessor::Fixer::FixAll - class_attribute :front_matter_parser, default: FrontMatterParser::Parser.new(:md) + Result = Struct.new(:front_matter, :reading_time, :processed_html, keyword_init: true) + class_attribute :processor, default: MarkdownProcessor::Parser + class_attribute :front_matter_parser, default: FrontMatterParser::Parser.new(:md) class ContentParsingError < StandardError end - attr_reader :input, :source, :user - attr_accessor :reading_time, :front_matter - - def initialize(input, source:, user:) + # @param input [String] body_markdown to process + # @param source [optional, possibly Article, Comment, Billboard] + # @param user [User, NilClass] article's or comment's user, nil for Billboard + # @param fixer [Object] fixes the input markdown + def initialize(input, source: nil, user: nil, fixer: MarkdownProcessor::Fixer::FixAll) @input = input || "" @source = source @user = user + @fixer = fixer + end + + # @param link_attributes [Hash] options passed further to RedCarpet::Render::HTMLRouge, example: { rel: "nofollow"} + # @param prefix_images_options [Hash] options for Html::Parser#prefix_all_images + # @return [ContentRenderer::Result] + def process(link_attributes: {}, + prefix_images_options: { width: 800, synchronous_detail_detection: false }) + if prefix_images_options[:synchronous_detail_detection] && ApplicationConfig["AWS_BUCKET_NAME"].present? && FeatureFlag.enabled?(:store_images) # rubocop:disable Layout/LineLength + markdown_text = input + markdown_pattern = /!\[.*?\]\((.*?)\)/ + html_pattern = /<img.*?src=["'](.*?)["']/ + markdown_urls = markdown_text.scan(markdown_pattern).flatten + html_urls = markdown_text.scan(html_pattern).flatten + all_urls = markdown_urls + html_urls + stored_image_url = "https://#{ApplicationConfig['AWS_BUCKET_NAME']}.s3.amazonaws.com" + filtered_urls = all_urls.reject { |url| url.include?(stored_image_url) } + filtered_urls.uniq.each do |url| + MediaStore.where(original_url: url).first_or_create + rescue StandardError => e + Rails.logger.error("Error storing images: #{e.message}") + end + end + fixed = fixer.call(input) + processed = processor.new(fixed, source: source, user: user) + + processed_html = processed.finalize(link_attributes: link_attributes, + prefix_images_options: prefix_images_options) + + Result.new(front_matter: nil, processed_html: processed_html, reading_time: 0) + rescue StandardError => e + raise ContentParsingError, e.message end - def process(link_attributes: {}, calculate_reading_time: false) + # processes Article markdown, calculates reading time and finds frontmatter (if it exists) + # + # @return [ContentRenderer::Result] + def process_article fixed = fixer.call(input) parsed = front_matter_parser.call(fixed) - self.front_matter = parsed.front_matter + front_matter = parsed.front_matter processed = processor.new(parsed.content, source: source, user: user) - self.reading_time = processed.calculate_reading_time if calculate_reading_time - processed.finalize(link_attributes: link_attributes) + + reading_time = processed.calculate_reading_time + + processed_html = processed.finalize + + Result.new(front_matter: front_matter, processed_html: processed_html, reading_time: reading_time) rescue StandardError => e raise ContentParsingError, e.message end @@ -29,9 +71,13 @@ def process(link_attributes: {}, calculate_reading_time: false) def has_front_matter? fixed = fixer.call(input) parsed = front_matter_parser.call(fixed) - self.front_matter = parsed.front_matter + front_matter = parsed.front_matter front_matter.any? && front_matter["title"].present? rescue ContentRenderer::ContentParsingError true end + + private + + attr_reader :fixer, :input, :user, :source end diff --git a/app/services/credits/ledger.rb b/app/services/credits/ledger.rb index 373d0924ef14c..2119be2b273f0 100644 --- a/app/services/credits/ledger.rb +++ b/app/services/credits/ledger.rb @@ -2,14 +2,14 @@ module Credits class Ledger Item = Struct.new(:purchase, :cost, :purchased_at, keyword_init: true) - def initialize(user) - @user = user - end - def self.call(...) new(...).call end + def initialize(user) + @user = user + end + def call # build the ledger for the user ledger = { diff --git a/app/services/display_ad_event_rollup.rb b/app/services/display_ad_event_rollup.rb deleted file mode 100644 index a43c7c8f6e30f..0000000000000 --- a/app/services/display_ad_event_rollup.rb +++ /dev/null @@ -1,92 +0,0 @@ -class DisplayAdEventRollup - ATTRIBUTES_PRESERVED = %i[user_id display_ad_id category context_type created_at].freeze - ATTRIBUTES_DESTROYED = %i[id counts_for updated_at].freeze - - class EventAggregator - Compact = Struct.new(:events, :user_id, :display_ad_id, :category, :context_type) do - def to_h - super.except(:events).merge({ counts_for: events.sum(&:counts_for) }) - end - end - - def initialize - @aggregator = Hash.new do |level1, user_id| - level1[user_id] = Hash.new do |level2, display_ad_id| - level2[display_ad_id] = Hash.new do |level3, category| - level3[category] = Hash.new do |level4, context_type| - level4[context_type] = [] - end - end - end - end - end - - def <<(event) - @aggregator[event.user_id][event.display_ad_id][event.category][event.context_type] << event - end - - def each - @aggregator.each_pair do |user_id, grouped_by_user_id| - grouped_by_user_id.each_pair do |display_ad_id, grouped_by_display_ad_id| - grouped_by_display_ad_id.each_pair do |category, grouped_by_category| - grouped_by_category.each_pair do |context_type, events| - next unless events.size > 1 - - yield Compact.new(events, user_id, display_ad_id, category, context_type) - end - end - end - end - end - - private - - attr_reader :aggregator - end - - def self.rollup(date, relation: DisplayAdEvent) - new(relation: relation).rollup(date) - end - - def initialize(relation:) - @aggregator = EventAggregator.new - @relation = relation - end - - attr_reader :aggregator, :relation - - def rollup(date) - created = [] - - rows = relation.where(created_at: date.all_day) - aggregate_into_groups(rows).each do |compacted_events| - created << compact_records(date, compacted_events) - end - - created - end - - private - - def aggregate_into_groups(rows) - rows.in_batches.each_record do |event| - aggregator << event - end - - aggregator - end - - def compact_records(date, compacted) - result = nil - - relation.transaction do - result = relation.create!(compacted.to_h) do |event| - event.created_at = date - end - - relation.where(id: compacted.events).delete_all - end - - result - end -end diff --git a/app/services/edge_cache/bust.rb b/app/services/edge_cache/bust.rb index 72b7a72f7fe14..9b940770d758c 100644 --- a/app/services/edge_cache/bust.rb +++ b/app/services/edge_cache/bust.rb @@ -1,13 +1,13 @@ module EdgeCache class Bust - def initialize - @provider_class = determine_provider_class - end - def self.call(*paths) new.call(*paths) end + def initialize + @provider_class = determine_provider_class + end + def call(paths) return unless @provider_class diff --git a/app/services/edge_cache/bust_sidebar.rb b/app/services/edge_cache/bust_sidebar.rb deleted file mode 100644 index 6b0edb8ad3e8c..0000000000000 --- a/app/services/edge_cache/bust_sidebar.rb +++ /dev/null @@ -1,8 +0,0 @@ -module EdgeCache - class BustSidebar - def self.call - cache_bust = EdgeCache::Bust.new - cache_bust.call("/sidebars/home") - end - end -end diff --git a/app/services/email_digest.rb b/app/services/email_digest.rb index 6ddd2533dddcd..1a97c53a4a8b2 100644 --- a/app/services/email_digest.rb +++ b/app/services/email_digest.rb @@ -1,10 +1,10 @@ class EmailDigest - def self.send_periodic_digest_email(users = []) - new(users).send_periodic_digest_email + def self.send_periodic_digest_email(users = [], starting_id = 1, ending_id = 50_000_000) + new(users, starting_id, ending_id).send_periodic_digest_email end - def initialize(users = []) - @users = users.empty? ? get_users : users + def initialize(users = [], starting_id = 1, ending_id = 50_000_000) + @users = users.empty? ? get_users(starting_id, ending_id) : users end def send_periodic_digest_email @@ -18,15 +18,18 @@ def send_periodic_digest_email else Emails::SendUserDigestWorker.perform_async(user.id) end + rescue StandardError => e + Honeybadger.notify(e) end end end private - def get_users + def get_users(starting_id, ending_id) User.registered.joins(:notification_setting) .where(notification_setting: { email_digest_periodic: true }) .where.not(email: "") + .where("users.id >= ? AND users.id <= ?", starting_id, ending_id) end end diff --git a/app/services/email_digest_article_collector.rb b/app/services/email_digest_article_collector.rb index 0266c0512286e..94b3d64f33b17 100644 --- a/app/services/email_digest_article_collector.rb +++ b/app/services/email_digest_article_collector.rb @@ -1,7 +1,10 @@ class EmailDigestArticleCollector + include FieldTest::Helpers include Instrumentation ARTICLES_TO_SEND = "EmailDigestArticleCollector#articles_to_send".freeze + RESULTS_COUNT = 7 # Winner of digest_count_03_18 field test + CLICK_LOOKBACK = 30 def initialize(user) @user = user @@ -9,48 +12,86 @@ def initialize(user) def articles_to_send # rubocop:disable Metrics/BlockLength + order = Arel.sql("((score * ((feed_success_score * 12) + 0.1)) - (clickbait_score * 2)) DESC") instrument ARTICLES_TO_SEND, tags: { user_id: @user.id } do return [] unless should_receive_email? - articles = if user_has_followings? - experience_level_rating = (@user.setting.experience_level || 5) - experience_level_rating_min = experience_level_rating - 3.6 - experience_level_rating_max = experience_level_rating + 3.6 + articles = if @user.cached_followed_tag_names.any? + experience_level_rating = @user.setting.experience_level || 5 + experience_level_rating_min = experience_level_rating - 4 + experience_level_rating_max = experience_level_rating + 4 @user.followed_articles - .select(:title, :description, :path) + .select(:title, :description, :path, :cached_user, :cached_tag_list) .published .where("published_at > ?", cutoff_date) .where(email_digest_eligible: true) .not_authored_by(@user.id) - .where("score > ?", 12) + .where("score > ?", 8) .where("experience_level_rating > ? AND experience_level_rating < ?", experience_level_rating_min, experience_level_rating_max) - .order(score: :desc) - .limit(6) + .order(order) + .limit(RESULTS_COUNT) else - Article.select(:title, :description, :path) + tags = @user.cached_followed_tag_names_or_recent_tags + Article.select(:title, :description, :path, :cached_user, :cached_tag_list) .published .where("published_at > ?", cutoff_date) - .featured .where(email_digest_eligible: true) .not_authored_by(@user.id) - .where("score > ?", 25) - .order(score: :desc) - .limit(6) + .where("score > ?", 11) + .order(order) + .limit(RESULTS_COUNT) + .merge(Article.featured.or(Article.cached_tagged_with_any(tags))) end + # Fallback if there are not enough articles + if articles.length < 3 + articles = Article.select(:title, :description, :path, :cached_user, :cached_tag_list) + .published + .where("published_at > ?", cutoff_date) + .where(email_digest_eligible: true) + .where("score > ?", 11) + .not_authored_by(@user.id) + .order(order) + .limit(RESULTS_COUNT) + if @user.cached_antifollowed_tag_names.any? + articles = articles.not_cached_tagged_with_any(@user.cached_antifollowed_tag_names) + end + end + + # Pop second article to front if the first article is the same as the last email + if articles.any? && last_email_includes_title_in_subject?(articles.first.title) + articles = articles[1..] + [articles.first] + end + articles.length < 3 ? [] : articles end # rubocop:enable Metrics/BlockLength end - private - def should_receive_email? return true unless last_email_sent + return false if last_email_sent > 18.hours.ago + return true if last_email_clicked? - last_email_sent.before? Settings::General.periodic_email_digest.days.ago + email_sent_within_lookback_period = last_email_sent >= Settings::General.periodic_email_digest.days.ago + return false if email_sent_within_lookback_period && !recent_tracked_click? + + true + end + + private + + def recent_tracked_click? + @user.email_messages + .where(mailer: "DigestMailer#digest_email") + .where("sent_at > ?", CLICK_LOOKBACK.days.ago) + .where.not(clicked_at: nil).any? + end + + def last_email_clicked? + @user.email_messages.where(mailer: "DigestMailer#digest_email").last&.clicked_at.present? end def last_email_sent @@ -60,11 +101,17 @@ def last_email_sent .maximum(:sent_at) end + def last_email_includes_title_in_subject?(title) + @user.email_messages + .where(mailer: "DigestMailer#digest_email") + .last&.subject&.include?(title) + end + def cutoff_date - a_few_days_ago = 4.days.ago.utc + a_few_days_ago = 7.days.ago.utc return a_few_days_ago unless last_email_sent - [a_few_days_ago, last_email_sent].max + [a_few_days_ago, (last_email_sent - 18.hours)].max end def user_has_followings? diff --git a/app/services/fastly_config/snippets.rb b/app/services/fastly_config/snippets.rb index b449fa014e1c1..bde88c823a9eb 100644 --- a/app/services/fastly_config/snippets.rb +++ b/app/services/fastly_config/snippets.rb @@ -1,3 +1,14 @@ +# In general this service should be considered deprecated, as it duplicates +# infrastructure management that is better left to the Fastly Terraform +# provider. It solely exists to support the not-yet-Terraformed DEV.to +# deployment, and should be removed whenever that instance has been migrated +# to Terraform control (and, presumably, the relevant bits of Terraform open- +# sourced and documented to replace this). +# +# As such, if VCL snippets are added to the snippets directory, a Forem employee +# should replicate them in the appropriate infrastructure repository and roll +# them out to other non-DEV Forems. + module FastlyConfig class Snippets < Base FASTLY_FILES = Rails.root.join("config/fastly/snippets/*.vcl").freeze diff --git a/app/services/feature_flag.rb b/app/services/feature_flag.rb index c75b4ad88143d..e9efe718bbbaf 100644 --- a/app/services/feature_flag.rb +++ b/app/services/feature_flag.rb @@ -8,13 +8,21 @@ class << self end def flipper_id - respond_to?(:id) ? id : nil + respond_to?(:id) ? id : self end end class << self delegate :add, :disable, :enable, :enabled?, :exist?, :remove, to: Flipper + def enabled_for_user?(flag_name, user) + enabled?(flag_name, FeatureFlag::Actor[user]) + end + + def enabled_for_user_id?(flag_name, user_id) + enabled?(flag_name, FeatureFlag::Actor[user_id]) + end + # @!method FeatureFlag.enabled?(feature_flag_name, *args) # # Answers if the :feature_flag_name has been _explicitly_ **enabled**. diff --git a/app/services/feed_events/bulk_upsert.rb b/app/services/feed_events/bulk_upsert.rb new file mode 100644 index 0000000000000..47980ce685521 --- /dev/null +++ b/app/services/feed_events/bulk_upsert.rb @@ -0,0 +1,122 @@ +module FeedEvents + # Inserts a collection of feed events into the database. + # + # If there are duplicate events in the collection (i.e. having the same user, + # article, and category) only one will be inserted. + # + # If a timebox is provided and there is a duplicate event (using the criteria + # for uniqueness above) that was created within it, any matching new event will + # not be created. + # + # This avoids inflating metrics (whether by accident or deliberately). + class BulkUpsert + ATTRIBUTES_FOR_INSERT = %i[article_id user_id article_position category context_type].freeze + + def self.call(...) + new(...).call + end + + # @param feed_events_data [Array<Hash|FeedEvent>] A list of feed events attributes to upsert + # @param timebox [ActiveSupport::Duration] A time window (in minutes) within which feed events must be unique + def initialize(feed_events_data, timebox: FeedEvent::DEFAULT_TIMEBOX) + @feed_events_data = feed_events_data + @timebox = timebox + end + + def call + return if valid_events.blank? + + if valid_events.size == 1 + create_single_event! + return + end + + # It's *possible* to construct a single SQL query that does what we want + # here, (i.e. find existing records, filter out duplicates, and insert the + # resulting set) but it is more complicated than it sounds. + # For instance, using Postgres' upserting feature (`ON CONFLICT DO...`) is + # not an option because we *don't* actually want the records to be unique + # in general, just within the provided timebox, so there is no constraint + # to trigger a conflict. + # Instead we take a Rails-ish `find_or_create_by` approach: first try to + # find matching record(s), then figure out which new record(s) to insert + # application-side. Making two queries does potentially allow duplicates + # (through race conditions), but that's an acceptable margin of error and + # would be the case anyway with a single query (without locking the table) + if timebox.present? + find_existing_events_within_timebox do |event| + track_recent_event(event) + end + end + + records_to_insert = valid_events.filter_map do |event| + unless recent_event?(event) + track_recent_event(event) + ATTRIBUTES_FOR_INSERT.index_with { |attr| event[attr] } + end + end + + return if records_to_insert.blank? + + FeedEvent.insert_all(records_to_insert) + FeedEvent.bulk_update_counters_by_article_id(records_to_insert.pluck(:article_id).sample(5)) + end + + private + + attr_reader :feed_events_data, :timebox + + def valid_events + @valid_events ||= feed_events_data.filter_map do |event_data| + event = FeedEvent.new(event_data) + event if event.valid? + rescue ArgumentError + # Enums raise ArgumentError if assigned with invalid value + end + end + + def create_single_event! + event = valid_events.first + FeedEvent + .where.not("created_at > ?", timebox.ago.utc) # Only proceed if + .create_with(event.slice(:article_position, :context_type)) + .find_or_create_by(event.slice(:article_id, :user_id, :category)) + end + + def recent_events + @recent_events ||= Hash.new do |users, user_id| + users[user_id] = Hash.new do |categories, category| + categories[category] = {} # articles + end + end + end + + def recent_event?(event) + recent_events[event.user_id][event.category][event.article_id] + end + + def track_recent_event(event) + recent_events[event.user_id][event.category][event.article_id] = true + end + + def find_existing_events_within_timebox(&block) + values = valid_events.reduce([]) do |acc, event| + acc << event.article_id + # Postgres is...iffy about comparing NULL (NULL !== NULL), and chokes if it is present in a tuple comparison. + # Coalescing to 0 is fine because primary keys auto-increment from 1. + acc << (event.user_id || 0) + acc << FeedEvent.categories[event[:category]] + end + + values_clause = Array.new(valid_events.length) { "(?, ?, ?)" }.join(", ") + + FeedEvent + .where("created_at > ?", timebox.ago.utc) + .where( + "(article_id, COALESCE(user_id, 0), category) IN (#{values_clause})", + *values, + ) + .each(&block) + end + end +end diff --git a/app/services/feeds/assemble_article_markdown.rb b/app/services/feeds/assemble_article_markdown.rb index 0aebd58177da0..a520ff6618bb6 100644 --- a/app/services/feeds/assemble_article_markdown.rb +++ b/app/services/feeds/assemble_article_markdown.rb @@ -130,7 +130,7 @@ def parse_liquid_variable!(html_doc) def parse_and_translate_youtube_iframe!(html_doc) html_doc.css("iframe").each do |iframe| - next unless /youtube\.com/.match?(iframe.attributes["src"].value) + next unless iframe.attributes["src"].value.include?("youtube.com") iframe.name = "p" youtube_id = iframe.attributes["src"].value.scan(/embed%2F(.{4,11})/).flatten.first diff --git a/app/services/follows/delete_cached.rb b/app/services/follows/delete_cached.rb index 8b9b1bbb58c04..4ced3f8096771 100644 --- a/app/services/follows/delete_cached.rb +++ b/app/services/follows/delete_cached.rb @@ -13,12 +13,15 @@ def initialize(follower, followable_type, followable_id) def call return false unless follower - cache_key = "user-#{follower.id}-#{follower.updated_at.rfc3339}/is_following_#{followable_type}_#{followable_id}" Rails.cache.delete(cache_key) end private attr_accessor :follower, :followable_type, :followable_id + + def cache_key + "user-#{follower.id}-#{follower.updated_at.rfc3339}/is_following_#{followable_type}_#{followable_id}" + end end end diff --git a/app/services/ga/tracking_service.rb b/app/services/ga/tracking_service.rb new file mode 100644 index 0000000000000..a274d78560a85 --- /dev/null +++ b/app/services/ga/tracking_service.rb @@ -0,0 +1,37 @@ +module Ga + class TrackingService + include HTTParty + base_uri "https://www.google-analytics.com" + + def initialize(measurement_id, api_secret, client_id) + @measurement_id = measurement_id + @api_secret = api_secret + @client_id = client_id + end + + def track_event(event_name, params = {}) # rubocop:disable Style/OptionHash + payload = { + client_id: @client_id, + events: [ + { + name: event_name, + params: params + }, + ] + } + + # raise payload.inspect.to_s + + options = { + query: { + measurement_id: @measurement_id, + api_secret: @api_secret + }, + headers: { "Content-Type" => "application/json" }, + body: payload.to_json + } + + self.class.post("/mp/collect", options) + end + end +end diff --git a/app/services/github/oauth_client.rb b/app/services/github/oauth_client.rb index bf4f2b1b7dd7e..12044111223d4 100644 --- a/app/services/github/oauth_client.rb +++ b/app/services/github/oauth_client.rb @@ -4,6 +4,11 @@ class OauthClient APP_AUTH_CREDENTIALS = %i[client_id client_secret].freeze APP_AUTH_CREDENTIALS_PRESENT = proc { |key, value| APP_AUTH_CREDENTIALS.include?(key) && value.present? }.freeze + def self.for_user(user) + access_token = user.identities.github.select(:token).take!.token + new(access_token: access_token) + end + # @param credentials [Hash] the OAuth credentials, {client_id:, client_secret:} or {access_token:} def initialize(credentials = nil) credentials ||= { @@ -13,11 +18,6 @@ def initialize(credentials = nil) @credentials = check_credentials!(credentials) end - def self.for_user(user) - access_token = user.identities.github.select(:token).take!.token - new(access_token: access_token) - end - # Hides private credentials when printed def inspect "#<#{self.class.name}:#{object_id}>" diff --git a/app/services/homepage/fetch_articles.rb b/app/services/homepage/fetch_articles.rb index f122b3f11e7c8..894378251bba3 100644 --- a/app/services/homepage/fetch_articles.rb +++ b/app/services/homepage/fetch_articles.rb @@ -14,6 +14,7 @@ def self.call( user_id: nil, organization_id: nil, tags: [], + hidden_tags: [], sort_by: nil, sort_direction: nil, page: 0, @@ -25,6 +26,7 @@ def self.call( user_id: user_id, organization_id: organization_id, tags: tags, + hidden_tags: hidden_tags, sort_by: sort_by, sort_direction: sort_direction, page: page, diff --git a/app/services/html/image_uri.rb b/app/services/html/image_uri.rb new file mode 100644 index 0000000000000..91dc64f2a3539 --- /dev/null +++ b/app/services/html/image_uri.rb @@ -0,0 +1,43 @@ +module Html + class ImageUri + GITHUB_CAMO = { + scheme: "https", + host: "camo.githubusercontent.com" + }.freeze + + GITHUB_BADGE = { + scheme: "https", + host: "github.com", + filename: "badge.svg" + }.freeze + + attr_reader :uri, :original_source + + delegate :scheme, :host, :path, to: :uri + + def initialize(src) + @uri = URI(src) + @original_source = src + end + + def allowed? + github_camo_user_content? || github_badge? + end + + def github_badge? + scheme == GITHUB_BADGE[:scheme] && + host == GITHUB_BADGE[:host] && + filename == GITHUB_BADGE[:filename] + end + + def github_camo_user_content? + scheme == GITHUB_CAMO[:scheme] && host == GITHUB_CAMO[:host] + end + + private + + def filename + File.basename path + end + end +end diff --git a/app/services/html/parser.rb b/app/services/html/parser.rb index 323214c677b85..5257c9e908c6d 100644 --- a/app/services/html/parser.rb +++ b/app/services/html/parser.rb @@ -32,7 +32,7 @@ def remove_nested_linebreak_in_list self end - def prefix_all_images(width = 880, synchronous_detail_detection: false) + def prefix_all_images(width: 880, synchronous_detail_detection: false, quality: "auto") # wrap with Cloudinary or allow if from giphy or githubusercontent.com doc = Nokogiri::HTML.fragment(@html) @@ -43,14 +43,15 @@ def prefix_all_images(width = 880, synchronous_detail_detection: false) next if allowed_image_host?(src) if synchronous_detail_detection - img["width"], img["height"] = FastImage.size(src, timeout: 10) + header = { "User-Agent" => "#{Settings::Community.community_name} (#{URL.url})" } + img["width"], img["height"] = FastImage.size(src, timeout: 10, http_header: header) end img["loading"] = "lazy" img["src"] = if Giphy::Image.valid_url?(src) src.gsub("https://media.", "https://i.") else - img_of_size(src, width) + img_of_size(src, width, quality: quality) end end @@ -257,8 +258,8 @@ def parse_emojis private - def img_of_size(source, width = 880) - Images::Optimizer.call(source, width: width).gsub(",", "%2C") + def img_of_size(source, width = 880, quality: "auto") + Images::Optimizer.call(source, width: width, quality: quality).gsub(",", "%2C") end def all_children_are_blank?(node) @@ -270,8 +271,7 @@ def blank?(node) end def allowed_image_host?(src) - # GitHub camo image won't parse but should be safe to host direct - src.start_with?("https://camo.githubusercontent.com") + ImageUri.new(src).allowed? end def user_link_if_exists(mention) diff --git a/app/services/images/generate_social_image.rb b/app/services/images/generate_social_image.rb deleted file mode 100644 index 8bc6ba9091f75..0000000000000 --- a/app/services/images/generate_social_image.rb +++ /dev/null @@ -1,51 +0,0 @@ -module Images - class GenerateSocialImage - OPTIMIZER_OPTIONS = { - height: 400, - width: 800, - gravity: "north", - crop: "fill", - type: "url2png", - flags: nil, - quality: nil, - fetch_format: nil - }.freeze - - def self.call(resource) - new(resource).call - end - - def initialize(resource) - @resource = resource - end - - def call - if resource.class.name.include?("Article") - article_image - elsif resource.instance_of?(User) - optimize_image "/user/#{resource.id}?bust=#{resource.profile_image_url}" - elsif resource.instance_of?(Organization) - optimize_image "/organization/#{resource.id}?bust=#{resource.profile_image_url}" - elsif resource.class.name.include?("Tag") - optimize_image "/tag/#{@resource.id}?bust=#{@resource.pretty_name}" - end - end - - private - - attr_reader :resource - - def article_image - return resource.social_image if resource.social_image.present? - return resource.main_image if resource.main_image.present? - return resource.video_thumbnail_url if resource.video_thumbnail_url.present? - - path = "/article/#{resource.id}?bust=#{resource.comments_count}-#{resource.title}-#{resource.published}" - optimize_image(path) - end - - def optimize_image(path) - Images::Optimizer.call("#{URL.url}/social_previews#{path}", **OPTIMIZER_OPTIONS) - end - end -end diff --git a/app/services/images/generate_social_image_magickally.rb b/app/services/images/generate_social_image_magickally.rb new file mode 100644 index 0000000000000..ab4479534441a --- /dev/null +++ b/app/services/images/generate_social_image_magickally.rb @@ -0,0 +1,199 @@ +module Images + MEDIUM_FONT_PATH = "app/assets/fonts/Roboto-Medium.ttf".freeze + BOLD_FONT_PATH = "app/assets/fonts/Roboto-Bold.ttf".freeze + TEMPLATE_PATH = "app/assets/images/social_template.png".freeze + ROUNDED_MASK_PATH = "app/assets/images/rounded_mask.png".freeze + + class GenerateSocialImageMagickally + def self.call(resource = nil) + new(resource).call + end + + def initialize(resource) + @resource = resource + @logo_url = Settings::General.logo_png + end + + def call + if @resource.is_a?(Article) + @user = @resource.user + read_files + url = generate_magickally(title: @resource.title, + date: @resource.readable_publish_date, + author_name: @user.name, + color: @user.setting.brand_color1) + @resource.update_column(:social_image, url) + ## We only need to bust article. All else can fade naturally + EdgeCache::BustArticle.call(@resource) + elsif @resource.is_a?(User) + @user = @resource + read_files + @resource.articles.published.where(organization_id: nil, main_image: nil).find_each do |article| + url = generate_magickally(title: article.title, + date: article.readable_publish_date, + author_name: @user.name, + color: @user.setting.brand_color1) + article.update_column(:social_image, url) + end + else # Organization + @user = @resource + read_files + @resource.articles.published.where(main_image: nil).find_each do |article| + url = generate_magickally(title: article.title, + date: article.readable_publish_date, + author_name: @user.name, + color: @user.bg_color_hex) + article.update_column(:social_image, url) + end + end + rescue StandardError => e + Rails.logger.error(e) + Honeybadger.notify(e) + end + + private + + def generate_magickally(title: nil, date: nil, author_name: nil, color: nil) + result = draw_stripe(color) + result = add_logo(result) + result = add_text(result, title, date, author_name) + result = add_profile_image(result) + upload_result(result) + end + + def draw_stripe(color) + color = "#111212" if color == "#000000" # pure black has minimagick side effects + @background_image.combine_options do |c| + c.fill color + c.draw "rectangle 0,0 1000,24" # adjust width according to your image width + end + end + + def add_logo(result) + if @logo_image + # Add white stroke to the overlay image + @logo_image.combine_options do |c| + c.stroke "white" + c.strokewidth "4" + c.fill "none" + c.draw "rectangle 0,0 1000,1000" # adjust as needed based on image size + end + + # Resize the overlay image + @logo_image.resize "64x64" + + result = @background_image.composite(@logo_image) do |c| + c.compose "Over" # OverCompositeOp + c.geometry "+850+372" # move the overlay to the top left + end + end + result + end + + def add_text(result, title, date, author_name) + title = title.truncate(128) + title = wrap_text(title) + font_size = calculate_font_size(title) + + result.combine_options do |c| + escaped_title = title.gsub('"', '\\"') + c.gravity "West" # Set the origin for the text at the top left corner + c.pointsize font_size.to_s + c.draw "text 80,-39 \"#{escaped_title}\"" # Start drawing text 90 from the left and slightly north, with double quotes around the title + c.fill "black" + c.font BOLD_FONT_PATH.to_s + end + + result.combine_options do |c| + escaped_name = author_name.gsub('"', '\\"') + c.gravity "Southwest" + c.pointsize "32" + c.draw "text 156,88 \"#{escaped_name}\"" # adjust coordinates as needed + c.fill "black" + c.font MEDIUM_FONT_PATH.to_s + end + + result.combine_options do |c| + c.gravity "Southwest" + c.pointsize "26" + c.draw "text 156,60 \"#{date}\"" # adjust coordinates as needed + c.fill "#525252" + end + end + + def add_profile_image(result) + profile_image_size = "64x64" + profile_image_location = "+80+63" + # Add subtext and author image + @author_image.resize profile_image_size + result = result.composite(@author_image) do |c| + c.compose "Over" + c.gravity "Southwest" + c.geometry profile_image_location + end + + @rounded_mask.resize profile_image_size + + result.composite(@rounded_mask) do |c| + c.compose "Over" + c.gravity "Southwest" + c.geometry profile_image_location + end + end + + def upload_result(result) + tempfile = Tempfile.new(["output", ".png"]) + result.write tempfile.path + image_uploader = ArticleImageUploader.new.tap do |uploader| + uploader.store!(tempfile) + end + # Don't forget to close and unlink (delete) the tempfile after you're done with it. + tempfile.close + tempfile.unlink + + # Return the uploaded url + image_uploader.url + end + + attr_reader :resource + + def calculate_font_size(text) + text_length = text.length + + if text_length < 18 + 88 + elsif text_length < 40 + 77 + elsif text_length < 55 + 65 + elsif text_length < 70 + 60 + else + 50 + end + end + + def wrap_text(text) + line_width = if text.length < 40 + 20 + elsif text.length < 70 + 27 + else + 35 + end + text.split("\n").map do |line| + line.length > line_width ? line.gsub(/(.{1,#{line_width}})(\s+|$)/, "\\1\n").strip : line + end * "\n" + end + + def read_files + # These are files we can open once for all the images we are generating within the loop. + @background_image = MiniMagick::Image.open(TEMPLATE_PATH) + @logo_image = MiniMagick::Image.open(@logo_url) if @logo_url.present? + image = @user&.profile_image_90.to_s + author_image_url = image.start_with?("http") ? image : Images::Profile::BACKUP_LINK + @author_image = MiniMagick::Image.open(author_image_url) + @rounded_mask = MiniMagick::Image.open(ROUNDED_MASK_PATH) + end + end +end diff --git a/app/services/images/optimizer.rb b/app/services/images/optimizer.rb index 7b3ca488e2db1..bf1398956d9b3 100644 --- a/app/services/images/optimizer.rb +++ b/app/services/images/optimizer.rb @@ -5,13 +5,21 @@ def self.call(img_src, **kwargs) if imgproxy_enabled? imgproxy(img_src, **kwargs) - elsif cloudinary_enabled? + elsif cloudinary_enabled? && !cloudflare_contextually_preferred?(img_src) cloudinary(img_src, **kwargs) + elsif cloudflare_enabled? + cloudflare(img_src, **kwargs) else img_src end end + # Each service has different ways of describing image cropping. + # for the ideal croping we want. + # Cloudinary uses "fill" and "limit" + # Cloudflare uses "cover" and "scale-down" respectively + # imgproxy uses "fill" and "fit" respectively + DEFAULT_CL_OPTIONS = { type: "fetch", height: nil, @@ -23,9 +31,35 @@ def self.call(img_src, **kwargs) sign_url: true }.freeze + CLOUDFLARE_DIRECTORY = (ApplicationConfig["CLOUDFLARE_IMAGES_DIRECTORY"] || "cdn-cgi").freeze + + def self.cloudflare(img_src, **kwargs) + template = Addressable::Template.new("https://{domain}/{directory}/image/{options*}/{src}") + fit = kwargs[:crop] == "crop" ? "cover" : "scale-down" + template.expand( + domain: ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"], + directory: CLOUDFLARE_DIRECTORY, + options: { + width: kwargs[:width], + height: kwargs[:height], + fit: fit, + gravity: "auto", + format: "auto" + }, + src: extract_suffix_url(img_src), + ).to_s + end + def self.cloudinary(img_src, **kwargs) options = DEFAULT_CL_OPTIONS.merge(kwargs).compact_blank - + imagga = kwargs[:crop] == "crop" && ApplicationConfig["CROP_WITH_IMAGGA_SCALE"].present? && !kwargs[:never_imagga] + options[:crop] = if imagga + "imagga_scale" # Legacy setting if admin imagga_scale set + elsif kwargs[:crop] == "crop" + "fill" + else + "limit" + end if img_src&.include?(".gif") options[:quality] = 66 end @@ -38,7 +72,8 @@ def self.cloudinary(img_src, **kwargs) width: nil, max_bytes: 500_000, # Keep everything under half of one MB. auto_rotate: true, - resizing_type: nil + gravity: "sm", + resizing_type: "fit" }.freeze def self.imgproxy(img_src, **kwargs) @@ -49,12 +84,15 @@ def self.imgproxy(img_src, **kwargs) end def self.translate_cloudinary_options(options) - if options[:crop] == "fill" - options[:resizing_type] = "fill" - end + options[:resizing_type] = if options[:crop] == "crop" + "fill" + else + "fit" + end options[:crop] = nil options[:fetch_format] = nil + options[:never_imagga] = nil options end @@ -68,6 +106,10 @@ def self.cloudinary_enabled? config.cloud_name.present? && config.api_key.present? && config.api_secret.present? end + def self.cloudflare_enabled? + ApplicationConfig["CLOUDFLARE_IMAGES_DOMAIN"].present? + end + def self.get_imgproxy_endpoint if Rails.env.production? # Use /images with the same domain on Production as @@ -81,5 +123,26 @@ def self.get_imgproxy_endpoint ApplicationConfig["IMGPROXY_ENDPOINT"] || "http://localhost:8080" end end + + def self.extract_suffix_url(full_url) + return full_url unless full_url&.starts_with?(cloudflare_prefix) + + uri = URI.parse(full_url) + match = uri.path.match(%r{https?.+}) + CGI.unescape(match[0]) if match + end + + # This is a feature-flagged Cloudflare preference for hosted images only — works specifically with S3-hosted image sources. + def self.cloudflare_contextually_preferred?(img_src) + return false unless cloudflare_enabled? + return false unless FeatureFlag.enabled?(:cloudflare_preferred_for_hosted_images) + + img_src&.start_with?("https://#{ApplicationConfig['AWS_BUCKET_NAME']}.s3.amazonaws.com") || + (img_src&.start_with?(cloudflare_prefix) && !img_src&.end_with?("/")) + end + + def self.cloudflare_prefix + "https://#{ApplicationConfig['CLOUDFLARE_IMAGES_DOMAIN']}/#{CLOUDFLARE_DIRECTORY}/image" + end end end diff --git a/app/services/images/profile.rb b/app/services/images/profile.rb index 384c866e8e0fd..9b767f7c6fe1f 100644 --- a/app/services/images/profile.rb +++ b/app/services/images/profile.rb @@ -24,7 +24,8 @@ def self.call(image_url, length: 120) image_url || BACKUP_LINK, width: length, height: length, - crop: "fill", + crop: "crop", + never_imagga: true, ) end end diff --git a/app/services/images/profile_image_generator.rb b/app/services/images/profile_image_generator.rb new file mode 100644 index 0000000000000..76a9d9f885a7b --- /dev/null +++ b/app/services/images/profile_image_generator.rb @@ -0,0 +1,7 @@ +module Images + module ProfileImageGenerator + def self.call + Rails.root.join("app/assets/images/#{rand(1..40)}.png").open + end + end +end diff --git a/app/services/users/safe_remote_profile_image_url.rb b/app/services/images/safe_remote_profile_image_url.rb similarity index 83% rename from app/services/users/safe_remote_profile_image_url.rb rename to app/services/images/safe_remote_profile_image_url.rb index e7642daa5e588..44df9e2f3f00a 100644 --- a/app/services/users/safe_remote_profile_image_url.rb +++ b/app/services/images/safe_remote_profile_image_url.rb @@ -1,4 +1,4 @@ -module Users +module Images module SafeRemoteProfileImageUrl # Basic check for nil and blank URLs, alongside likely incomplete URLs, such as just "image.jpg". def self.call(url) @@ -6,7 +6,7 @@ def self.call(url) url.sub!("http://", "https://") url else - Users::ProfileImageGenerator.call + Images::ProfileImageGenerator.call end end end diff --git a/app/services/languages/detection.rb b/app/services/languages/detection.rb new file mode 100644 index 0000000000000..9c660b45ccd32 --- /dev/null +++ b/app/services/languages/detection.rb @@ -0,0 +1,26 @@ +module Languages + class Detection + attr_reader :text + + PROBABILITY_THRESHOLD = 0.5 + + def self.codes + CLD3::TaskContextParams::LANGUAGE_NAMES.map(&:to_s).freeze + end + + def self.call(...) + new(...).call + end + + def initialize(text) + @text = text + end + + def call(identifier: CLD3::NNetLanguageIdentifier.new(0, 1000)) + language_outcome = identifier.find_language(text) + return unless language_outcome.probability > PROBABILITY_THRESHOLD && language_outcome.reliable? + + language_outcome.language + end + end +end diff --git a/app/services/markdown_processor.rb b/app/services/markdown_processor.rb index e97dfdd0e3872..f8acb12e19691 100644 --- a/app/services/markdown_processor.rb +++ b/app/services/markdown_processor.rb @@ -27,12 +27,13 @@ module AllowedTags tbody td tfoot th thead time tr u ul ].freeze - # In FEED but not DISPLAY_AD: [i iframe] - # In DISPLAY_AD but not FEED: [abbr add figcaption hr kbd mark rp rt ruby source sub video] - DISPLAY_AD = %w[a abbr add b blockquote br center cite code col colgroup dd del div dl dt - em figcaption h1 h2 h3 h4 h5 h6 hr img kbd li mark ol p pre q rp rt - ruby small source span strong sub sup table tbody td tfoot th thead - time tr u ul video].freeze + # In FEED but not BILLBOARD: [i iframe] + # In BILLBOARD but not FEED: [abbr add figcaption hr kbd mark rp rt ruby source sub video] + # In BILLBOARD but not RENDERED_MARKDOWN_SCRUBBER: [div] + BILLBOARD = %w[a abbr add b blockquote br center cite code col colgroup dd del div dl dt + em figcaption h1 h2 h3 h4 h5 h6 hr img kbd li mark ol p pre q rp rt + ruby small source span strong sub sup table tbody td tfoot th thead + time tr u ul video].freeze # In FEED but not RENDERED_MARKDOWN_SCRUBBER: [div i iframe] # In RENDERED_MARKDOWN_SCRUBBER but not FEED: [abbr add figcaption hr kbd mark rp rt ruby source sub video] @@ -56,6 +57,8 @@ module AllowedTags SIDEBAR = %w[b br em i p strike strong u].freeze BADGE_ACHIEVEMENT_CONTEXT_MESSAGE = %w[a b code em i strong u].freeze + + EMAIL_COMMENT = %w[strong em a p span].freeze end # A container module for the allowed attributes in various rendering @@ -67,7 +70,7 @@ module AllowedAttributes PODCAST_SHOW = %w[alt class colspan data-conversation data-lang em height href id ref rel rowspan size span src start strong title value width].freeze - DISPLAY_AD = %w[alt class height href src width].freeze + BILLBOARD = %w[alt class height href src width].freeze RENDERED_MARKDOWN_SCRUBBER = %w[alt colspan controls data-conversation data-lang data-no-instant data-url href id loop name ref rel @@ -76,5 +79,7 @@ module AllowedAttributes MARKDOWN_PROCESSOR = %w[alt href src].freeze BADGE_ACHIEVEMENT_CONTEXT_MESSAGE = %w[href name].freeze + + EMAIL_COMMENT = %w[href].freeze end end diff --git a/app/services/markdown_processor/fixer/base.rb b/app/services/markdown_processor/fixer/base.rb index 60f174955cb33..70a7d4256db62 100644 --- a/app/services/markdown_processor/fixer/base.rb +++ b/app/services/markdown_processor/fixer/base.rb @@ -10,6 +10,9 @@ module Fixer class Base FRONT_MATTER_DETECTOR = /-{3}.*?-{3}/m + # Match @_username_ that is not preceded by backtick + USERNAME_WITH_UNDERSCORE_REGEXP = /(?<!`)@_\w+_/ + def self.call(markdown) return unless markdown @@ -55,9 +58,6 @@ def self.underscores_in_usernames(markdown) end.join end - # Match @_username_ that is not preceded by backtick - USERNAME_WITH_UNDERSCORE_REGEXP = /(?<!`)@_\w+_/ - # Escapes underscored username that is not in code def self.escape_underscored_username_in_line!(line) line.scan(USERNAME_WITH_UNDERSCORE_REGEXP).each do |to_escape| diff --git a/app/services/markdown_processor/parser.rb b/app/services/markdown_processor/parser.rb index aaa2acb628981..23e80c227f6cc 100644 --- a/app/services/markdown_processor/parser.rb +++ b/app/services/markdown_processor/parser.rb @@ -1,12 +1,12 @@ module MarkdownProcessor class Parser - include ApplicationHelper - BAD_XSS_REGEX = [ /src=["'](data|&)/i, %r{data:text/html[,;][\sa-z0-9]*}i, ].freeze + CODE_BLOCKS_REGEX = /(~{3}|`{3}|`{2}|`)[\s\S]*?\1/ + WORDS_READ_PER_MINUTE = 275.0 # @param content [String] The user input, mix of markdown and liquid. This might be an @@ -20,14 +20,16 @@ class Parser # # @see LiquidTagBase for more information regarding the liquid tag options. - def initialize(content, source: nil, user: nil, **liquid_tag_options) + def initialize(content, source: nil, user: nil, + liquid_tag_options: {}) @content = content @source = source @user = user @liquid_tag_options = liquid_tag_options.merge({ source: @source, user: @user }) end - def finalize(link_attributes: {}) + # @param prefix_images_options [Hash] params, that need to be passed further to HtmlParser#prefix_all_images + def finalize(link_attributes: {}, prefix_images_options: { width: 800, synchronous_detail_detection: false }) options = { hard_wrap: true, filter_html: false, link_attributes: link_attributes } renderer = Redcarpet::Render::HTMLRouge.new(options) markdown = Redcarpet::Markdown.new(renderer, Constants::Redcarpet::CONFIG) @@ -35,7 +37,8 @@ def finalize(link_attributes: {}) code_tag_content = convert_code_tags_to_triple_backticks(@content) escaped_content = escape_liquid_tags_in_codeblock(code_tag_content) html = markdown.render(escaped_content) - sanitized_content = sanitize_rendered_markdown(html) + sanitized_content = ActionController::Base.helpers.sanitize html, { scrubber: RenderedMarkdownScrubber.new } + begin # NOTE: [@rhymes] liquid 5.0.0 does not support ActiveSupport::SafeBuffer, # a String substitute, hence we force the conversion before passing it to Liquid::Template. @@ -43,11 +46,41 @@ def finalize(link_attributes: {}) parsed_liquid = Liquid::Template.parse(sanitized_content.to_str, @liquid_tag_options) html = markdown.render(parsed_liquid.render) + rescue NoMethodError => e + if e.message.include?('line_number') + # Handle the specific NoMethodError + Rails.logger.error("Liquid rendering error: #{e.message}") + html = sanitized_content.to_str + else + raise e + end rescue Liquid::SyntaxError => e html = e.message end - parse_html(html) + html = add_target_blank_to_outbound_links(html) + parse_html(html, prefix_images_options) + end + + def add_target_blank_to_outbound_links(html) + app_domain = Settings::General.app_domain + doc = Nokogiri::HTML.fragment(html) + doc.css('a[href^="http"]').each do |link| + href = link["href"] + next unless href&.exclude?(app_domain) + + link[:target] = "_blank" + existing_rel = link[:rel] + new_rel = %w[noopener noreferrer] + if existing_rel + existing_rel_values = existing_rel.split + new_rel = (existing_rel_values + new_rel).uniq.join(" ") + else + new_rel = new_rel.join(" ") + end + link[:rel] = new_rel + end + doc.to_html end def calculate_reading_time @@ -94,7 +127,8 @@ def tags_used end def catch_xss_attempts(markdown) - return unless markdown.match?(Regexp.union(BAD_XSS_REGEX)) + markdown_without_code_blocks = markdown.gsub(CODE_BLOCKS_REGEX, "") + return unless markdown_without_code_blocks.match?(Regexp.union(BAD_XSS_REGEX)) raise ArgumentError, I18n.t("services.markdown_processor.parser.invalid_markdown_detected") end @@ -117,7 +151,7 @@ def convert_code_tags_to_triple_backticks(content) return content unless /^<code>$/.match?(content) # return content if there is a <pre> and <code> tag - return content if /<code>/.match?(content) && /<pre>/.match?(content) + return content if content.include?("<code>") && content.include?("<pre>") # Convert all multiline code tags to triple backticks content.gsub(%r{^</?code>$}, "\n```\n") @@ -125,13 +159,13 @@ def convert_code_tags_to_triple_backticks(content) private - def parse_html(html) + def parse_html(html, prefix_images_options) return html if html.blank? Html::Parser .new(html) .remove_nested_linebreak_in_list - .prefix_all_images + .prefix_all_images(**prefix_images_options) .wrap_all_images_in_links .add_control_class_to_codeblock .add_control_panel_to_codeblock @@ -145,4 +179,4 @@ def parse_html(html) .html end end -end +end \ No newline at end of file diff --git a/app/services/medium_article_retrieval_service.rb b/app/services/medium_article_retrieval_service.rb index 37c283f690abe..0bbf335166977 100644 --- a/app/services/medium_article_retrieval_service.rb +++ b/app/services/medium_article_retrieval_service.rb @@ -1,14 +1,14 @@ class MediumArticleRetrievalService attr_reader :url - def initialize(url) - @url = url - end - def self.call(...) new(...).call end + def initialize(url) + @url = url + end + def call response = HTTParty.get(url) page = Nokogiri::HTML(response.body) diff --git a/app/services/mentions/create_all.rb b/app/services/mentions/create_all.rb index d43199a9e7a89..a20806de1aa48 100644 --- a/app/services/mentions/create_all.rb +++ b/app/services/mentions/create_all.rb @@ -3,14 +3,14 @@ module Mentions # This class will check to see if there are any @-mentions in the post, and will # create the associated mentions inline if necessary. class CreateAll - def initialize(notifiable) - @notifiable = notifiable - end - def self.call(...) new(...).call end + def initialize(notifiable) + @notifiable = notifiable + end + def call mentioned_users = users_mentioned_in_text_excluding_author diff --git a/app/services/moderator/banish_user.rb b/app/services/moderator/banish_user.rb index 17f43a5cbad49..384764e490a4d 100644 --- a/app/services/moderator/banish_user.rb +++ b/app/services/moderator/banish_user.rb @@ -18,6 +18,7 @@ def banish user.remove_from_mailchimp_newsletters if user.email? remove_profile_info handle_user_status("Suspended", I18n.t("services.moderator.banish_user.spam_account")) + delete_organization delete_user_activity delete_comments delete_articles @@ -48,5 +49,11 @@ def remove_profile_info def delete_vomit_reactions Reaction.where(reactable: user, category: "vomit").delete_all end + + def delete_organization + user.organizations.each do |organization| + organization.destroy! if organization.users.count == 1 + end + end end end diff --git a/app/services/moderator/manage_activity_and_roles.rb b/app/services/moderator/manage_activity_and_roles.rb index d4422d78734e1..2cb2f084418cd 100644 --- a/app/services/moderator/manage_activity_and_roles.rb +++ b/app/services/moderator/manage_activity_and_roles.rb @@ -2,16 +2,16 @@ module Moderator class ManageActivityAndRoles attr_reader :user, :admin, :user_params + def self.handle_user_roles(admin:, user:, user_params:) + new(user: user, admin: admin, user_params: user_params).update_roles + end + def initialize(user:, admin:, user_params:) @user = user @admin = admin @user_params = user_params end - def self.handle_user_roles(admin:, user:, user_params:) - new(user: user, admin: admin, user_params: user_params).update_roles - end - def delete_comments Users::DeleteComments.call(user) end @@ -29,11 +29,14 @@ def delete_user_podcasts end def remove_privileges - @user.remove_role(:workshop_pass) remove_mod_roles remove_tag_moderator_role end + def remove_notifications + Notifications::RemoveBySpammerWorker.perform_async(user.id) + end + def remove_mod_roles @user.remove_role(:trusted) @user.remove_role(:tag_moderator) @@ -58,6 +61,7 @@ def create_note(reason, content) ) end + # rubocop:disable Metrics/CyclomaticComplexity def handle_user_status(role, note) case role when "Admin" @@ -65,9 +69,18 @@ def handle_user_status(role, note) TagModerators::AddTrustedRole.call(user) when "Comment Suspended" comment_suspended + when "Limited" + limited when "Suspended" || "Spammer" user.add_role(:suspended) remove_privileges + when "Spam" + user.add_role(:spam) + remove_privileges + remove_notifications + resolve_spam_reports + confirm_flag_reactions + user.profile.touch when "Super Moderator" assign_elevated_role_to_user(user, :super_moderator) TagModerators::AddTrustedRole.call(user) @@ -91,9 +104,15 @@ def handle_user_status(role, note) TagModerators::AddTrustedRole.call(user) when "Warned" warned + when "Base Subscriber" + base_subscriber end create_note(role, note) + + user.articles.published.find_each(&:async_score_calc) + user.comments.find_each(&:calculate_score) end + # rubocop:enable Metrics/CyclomaticComplexity def assign_elevated_role_to_user(user, role) check_super_admin @@ -116,6 +135,11 @@ def comment_suspended remove_privileges end + def limited + user.add_role(:limited) + remove_privileges + end + def regular_member remove_negative_roles remove_mod_roles @@ -123,24 +147,38 @@ def regular_member def warned user.add_role(:warned) - user.remove_role(:suspended) + user.remove_role(:suspended) if user.suspended? + user.remove_role(:spam) if user.spam? remove_privileges end + def base_subscriber + user.add_role(:base_subscriber) + user.touch + user.profile&.touch + NotifyMailer.with(user: user).base_subscriber_role_email.deliver_now + end + def remove_negative_roles + user.remove_role(:limited) if user.limited? user.remove_role(:suspended) if user.suspended? + user.remove_role(:spam) if user.spam? user.remove_role(:warned) if user.warned? user.remove_role(:comment_suspended) if user.comment_suspended? end - def update_trusted_cache - Rails.cache.delete("user-#{@user.id}/has_trusted_role") - @user.trusted? - end - def update_roles handle_user_status(user_params[:user_status], user_params[:note_for_current_role]) - update_trusted_cache + end + + private + + def resolve_spam_reports + Users::ResolveSpamReportsWorker.perform_async(user.id) + end + + def confirm_flag_reactions + Users::ConfirmFlagReactionsWorker.perform_async(user.id) end end end diff --git a/app/services/moderator/merge_user.rb b/app/services/moderator/merge_user.rb index af17dacf01fff..72e31a54c290f 100644 --- a/app/services/moderator/merge_user.rb +++ b/app/services/moderator/merge_user.rb @@ -6,7 +6,7 @@ def self.call(admin:, keep_user:, delete_user_id:) attr_reader :keep_user, :admin, :delete_user_id - def initialize(admin:, keep_user:, delete_user_id:) # rubocop:disable Lint/MissingSuper + def initialize(admin:, keep_user:, delete_user_id:) @keep_user = keep_user @admin = admin @delete_user = User.find(delete_user_id.to_i) diff --git a/app/services/moderator/unpublish_all_articles.rb b/app/services/moderator/unpublish_all_articles.rb index e539ec2fa739c..4836a187dc311 100644 --- a/app/services/moderator/unpublish_all_articles.rb +++ b/app/services/moderator/unpublish_all_articles.rb @@ -2,6 +2,10 @@ # Create a corresponding audit_log record module Moderator class UnpublishAllArticles + def self.call(...) + new(...).call + end + # @param target_user_id [Integer] the id of the user whose posts are being unpublished # @param action_user_id [Integer] the id of the user who unpublishes # @param listener [String] listener for the audit logger @@ -13,10 +17,6 @@ def initialize(target_user_id:, action_user_id:, listener: :admin_api) delegate :user_data, to: Notifications - def self.call(...) - new(...).call - end - def call user = User.find_by(id: target_user_id) return unless user diff --git a/app/services/notification_subscriptions/subscribe.rb b/app/services/notification_subscriptions/subscribe.rb new file mode 100644 index 0000000000000..29467e245e5a4 --- /dev/null +++ b/app/services/notification_subscriptions/subscribe.rb @@ -0,0 +1,54 @@ +module NotificationSubscriptions + class Subscribe + attr_reader :current_user, :comment_id, :article_id, :config + + # Client-side needs this to be idempotent-ish, return existing subscription instead + # of raising uniqueness exception + def self.call(...) + new(...).call + end + + def initialize(current_user, comment_id: nil, article_id: nil, config: nil) + @current_user = current_user + @article_id = article_id + @comment_id = comment_id + @config = config || "all_comments" + end + + # Client-side needs this to be idempotent-ish, return existing subscription instead + # of raising uniqueness exception + def call + raise ArgumentError, "missing notifiable" if notifiable.blank? + + subscription = NotificationSubscription.find_or_initialize_by( + user: current_user, + config: config, + notifiable: notifiable, + ) + + if subscription.save + { updated: true, subscription: subscription } + else + { errors: subscription.errors_as_sentence } + end + end + + private + + def comment + @comment ||= Comment.find(comment_id) if comment_id.present? + end + + def article + @article ||= Article.find(article_id) if article_id.present? + end + + def notifiable + @notifiable ||= determine_notifiable + end + + def determine_notifiable + comment || article + end + end +end diff --git a/app/services/notification_subscriptions/unsubscribe.rb b/app/services/notification_subscriptions/unsubscribe.rb new file mode 100644 index 0000000000000..f82e5b69d661a --- /dev/null +++ b/app/services/notification_subscriptions/unsubscribe.rb @@ -0,0 +1,40 @@ +module NotificationSubscriptions + class Unsubscribe + attr_reader :current_user, :subscription_id + + def self.call(...) + new(...).call + end + + def initialize(current_user, subscription_id) + @current_user = current_user + @subscription_id = subscription_id + end + + def call + unsubscribe_subscription + end + + def unsubscribe_subscription + return { errors: "Subscription ID is missing" } if subscription_id.nil? + + notification = NotificationSubscription.find_by(user_id: current_user.id, + id: subscription_id) + return { errors: "Notification subscription not found" } if notification.nil? + + destroy_notification(notification) + end + + private + + def destroy_notification(notification) + notification.destroy + + if notification.destroyed? + { destroyed: true } + else + { errors: notification.errors_as_sentence } + end + end + end +end diff --git a/app/services/notification_subscriptions/update.rb b/app/services/notification_subscriptions/update.rb index 0c0d10c2f4ac4..264b76d210e80 100644 --- a/app/services/notification_subscriptions/update.rb +++ b/app/services/notification_subscriptions/update.rb @@ -1,13 +1,13 @@ module NotificationSubscriptions class Update - def initialize(notifiable) - @notifiable = notifiable - end - def self.call(...) new(...).call end + def initialize(notifiable) + @notifiable = notifiable + end + def call return unless [Article].include?(notifiable.class) diff --git a/app/services/notifications/milestone/send.rb b/app/services/notifications/milestone/send.rb index b7f5c1d29472c..a6ca05becf65c 100644 --- a/app/services/notifications/milestone/send.rb +++ b/app/services/notifications/milestone/send.rb @@ -3,6 +3,10 @@ module Milestone ARTICLE_FINAL_PUBLICATION_TIME_FOR_MILESTONE = Time.zone.local(2019, 2, 25) class Send + def self.call(...) + new(...).call + end + # @param type [String] - "View" or "Reaction" # @param article [Object] - ActiveRecord Article object def initialize(type, article) @@ -11,10 +15,6 @@ def initialize(type, article) @next_milestone = next_milestone end - def self.call(...) - new(...).call - end - def call return unless should_send_milestone? diff --git a/app/services/notifications/moderation/send.rb b/app/services/notifications/moderation/send.rb index 7fa5b87dcc180..2af4e8c6b79a9 100644 --- a/app/services/notifications/moderation/send.rb +++ b/app/services/notifications/moderation/send.rb @@ -3,7 +3,7 @@ module Notifications module Moderation MODERATORS_AVAILABILITY_DELAY = 22.hours class Send - SUPPORTED = [Comment].freeze + SUPPORTED = [Comment, Article].freeze def self.call(...) new(...).call @@ -14,17 +14,19 @@ def initialize(moderator, notifiable) @notifiable = notifiable end - delegate :user_data, :comment_data, to: Notifications + delegate :user_data, :comment_data, :article_data, to: Notifications def call - # notifiable is currently only comment return unless notifiable_supported?(notifiable) - # do not create the notification if the comment was created by the moderator - return if moderator == notifiable.user + # do not create the notification if the comment/article was created by + # the moderator of the user has the `limited` role + return if notifiable.user.limited? || moderator == notifiable.user json_data = { user: user_data(User.staff_account) } - json_data[notifiable.class.name.downcase] = public_send "#{notifiable.class.name.downcase}_data", notifiable + notifiable_name = notifiable.class.name.downcase + json_data[notifiable_name] = public_send "#{notifiable_name}_data", notifiable + json_data["#{notifiable_name}_user"] = user_data(notifiable.user) new_notification = Notification.create!( user_id: moderator.id, notifiable_id: notifiable.id, diff --git a/app/services/notifications/new_badge_achievement/send.rb b/app/services/notifications/new_badge_achievement/send.rb index 3fbb1b33cd4e7..7bacaccb1ad67 100644 --- a/app/services/notifications/new_badge_achievement/send.rb +++ b/app/services/notifications/new_badge_achievement/send.rb @@ -2,16 +2,16 @@ module Notifications module NewBadgeAchievement class Send + def self.call(...) + new(...).call + end + def initialize(badge_achievement) @badge_achievement = badge_achievement end delegate :user_data, to: Notifications - def self.call(...) - new(...).call - end - def call Notification.create( user_id: badge_achievement.user.id, @@ -27,6 +27,7 @@ def call attr_reader :badge_achievement def json_data + description = badge_achievement.include_default_description ? badge_achievement.badge.description : nil { user: user_data(badge_achievement.user), badge_achievement: { @@ -34,7 +35,7 @@ def json_data rewarding_context_message: badge_achievement.rewarding_context_message, badge: { title: badge_achievement.badge.title, - description: badge_achievement.badge.description, + description: description, badge_image_url: badge_achievement.badge.badge_image_url, credits_awarded: badge_achievement.badge.credits_awarded } diff --git a/app/services/notifications/new_comment/send.rb b/app/services/notifications/new_comment/send.rb index 4862baf17bb3d..f4550534bc519 100644 --- a/app/services/notifications/new_comment/send.rb +++ b/app/services/notifications/new_comment/send.rb @@ -5,16 +5,16 @@ module NewComment class Send include ActionView::Helpers::TextHelper + def self.call(...) + new(...).call + end + def initialize(comment) @comment = comment end delegate :user_data, :comment_data, to: Notifications - def self.call(...) - new(...).call - end - def call return if comment.score.negative? diff --git a/app/services/notifications/new_follower/send.rb b/app/services/notifications/new_follower/send.rb index e882cc4ef0b00..4d109d99c3dca 100644 --- a/app/services/notifications/new_follower/send.rb +++ b/app/services/notifications/new_follower/send.rb @@ -2,6 +2,10 @@ module Notifications module NewFollower class Send + def self.call(...) + new(...).call + end + # @param follow_data [Hash] # * :followable_id [Integer] # * :followable_type [String] - "User" or "Organization" @@ -16,13 +20,9 @@ def initialize(follow_data, is_read: false) delegate :user_data, to: Notifications - def self.call(...) - new(...).call - end - def call - recent_follows = Follow.where(followable_type: followable_type, followable_id: followable_id) - .where("created_at > ?", 24.hours.ago).order(created_at: :desc) + recent_follows = Follow.non_suspended(followable_type, followable_id) + .where("follows.created_at > ?", 24.hours.ago).order(created_at: :desc) notification_params = { action: "Follow" } case followable_type @@ -31,10 +31,9 @@ def call when "Organization" notification_params[:organization_id] = followable_id end - - followers = User.where(id: recent_follows.select(:follower_id)) + followers = User.where(id: recent_follows.map(&:follower_id)) aggregated_siblings = followers.map { |follower| user_data(follower) } - if aggregated_siblings.size.zero? + if aggregated_siblings.empty? notification = Notification.find_by(notification_params)&.destroy else json_data = { user: user_data(follower), aggregated_siblings: aggregated_siblings } diff --git a/app/services/notifications/new_mention/send.rb b/app/services/notifications/new_mention/send.rb index 81983901d334d..ecfe1b47240a0 100644 --- a/app/services/notifications/new_mention/send.rb +++ b/app/services/notifications/new_mention/send.rb @@ -7,14 +7,14 @@ class Send delegate :comment_data, to: Notifications delegate :article_data, to: Notifications - def initialize(mention) - @mention = mention - end - def self.call(...) new(...).call end + def initialize(mention) + @mention = mention + end + def call return if mention.mentionable.score.negative? diff --git a/app/services/notifications/notifiable_action/send.rb b/app/services/notifications/notifiable_action/send.rb index 7ff5e3a7d9b54..dd004bc242764 100644 --- a/app/services/notifications/notifiable_action/send.rb +++ b/app/services/notifications/notifiable_action/send.rb @@ -1,7 +1,12 @@ # send notification about the action ("Published") that happened on a notifiable (Article) module Notifications module NotifiableAction + FOLLOWER_SEND_LIMIT = 10_000 class Send + def self.call(...) + new(...).call + end + # @param notifiable [Article] # @param action [String] for now only "Published" def initialize(notifiable, action = nil) @@ -11,10 +16,6 @@ def initialize(notifiable, action = nil) delegate :user_data, :article_data, :organization_data, to: Notifications - def self.call(...) - new(...).call - end - def call return unless notifiable.is_a?(Article) @@ -30,14 +31,16 @@ def call # We explicitly need to exclude them from the article_followers array if they already # have a mention in order to avoid sending a user multiple notifications for one article. user_ids_with_article_mentions = notifiable.mentions&.pluck(:user_id) - article_followers = notifiable.followers.reject do |follower| - user_ids_with_article_mentions.include?(follower.id) - end - # We don't want to notify authors about their own articles, e.g. when - # they post under an organization. - article_followers -= [notifiable.user] - article_followers.sort_by(&:updated_at).last(10_000).reverse_each do |follower| + article_followers = User.joins("INNER JOIN follows ON follows.follower_id = users.id") + .where("(follows.followable_id = ? AND follows.followable_type = ?) + OR (follows.followable_id = ? AND follows.followable_type = ?)", + notifiable&.user&.id, "User", notifiable&.organization&.id, "Organization") + .where(follows: { subscription_status: "all_articles" }) + .where.not(id: (user_ids_with_article_mentions + [notifiable.user])) + .recently_active(FOLLOWER_SEND_LIMIT).distinct + + article_followers.find_each do |follower| now = Time.current notifications_attributes.push( user_id: follower.id, diff --git a/app/services/notifications/reactions/send.rb b/app/services/notifications/reactions/send.rb index 6ff3e6e5fb458..3e06d55dc379e 100644 --- a/app/services/notifications/reactions/send.rb +++ b/app/services/notifications/reactions/send.rb @@ -4,6 +4,10 @@ module Reactions class Send Response = Struct.new(:action, :notification_id) + def self.call(...) + new(...).call + end + # @param reaction_data [Hash] # * :reactable_id [Integer] - article or comment id # * :reactable_type [String] - "Article" or "Comment" @@ -16,10 +20,6 @@ def initialize(reaction_data, receiver) delegate :user_data, to: Notifications - def self.call(...) - new(...).call - end - # @return [OpenStruct, #action, #notification_id] def call return unless receiver.is_a?(User) || receiver.is_a?(Organization) @@ -46,7 +46,7 @@ def call notification_params[:organization_id] = receiver.id end - if aggregated_reaction_siblings.size.zero? + if aggregated_reaction_siblings.empty? Notification.where(notification_params).delete_all Response.new(:deleted) else diff --git a/app/services/notifications/remove_all.rb b/app/services/notifications/remove_all.rb index c44d56e1aebe0..4a41ef2e24e42 100644 --- a/app/services/notifications/remove_all.rb +++ b/app/services/notifications/remove_all.rb @@ -1,5 +1,9 @@ module Notifications class RemoveAll + def self.call(...) + new(...).call + end + def initialize(notifiable_ids, notifiable_type) return unless %w[Article Comment Mention].include?(notifiable_type) && notifiable_ids.present? @@ -7,10 +11,6 @@ def initialize(notifiable_ids, notifiable_type) @notifiable_ids = notifiable_ids end - def self.call(...) - new(...).call - end - def call Notification.where( notifiable_type: notifiable_type, diff --git a/app/services/notifications/remove_all_by_action.rb b/app/services/notifications/remove_all_by_action.rb index bf64b018e94c9..0fdbe8b366dcc 100644 --- a/app/services/notifications/remove_all_by_action.rb +++ b/app/services/notifications/remove_all_by_action.rb @@ -1,5 +1,9 @@ module Notifications class RemoveAllByAction + def self.call(...) + new(...).call + end + def initialize(notifiable_ids, notifiable_type, action) return unless %w[Article Comment Mention].include?(notifiable_type) && notifiable_ids.present? @@ -7,10 +11,6 @@ def initialize(notifiable_ids, notifiable_type, action) @action = action end - def self.call(...) - new(...).call - end - def call Notification.where( notifiable: notifiable_collection, diff --git a/app/services/notifications/remove_by_spammer.rb b/app/services/notifications/remove_by_spammer.rb new file mode 100644 index 0000000000000..890d69e001b73 --- /dev/null +++ b/app/services/notifications/remove_by_spammer.rb @@ -0,0 +1,25 @@ +# remove notifications created for spammer actions: +# follow user, create comments, create articles +module Notifications + class RemoveBySpammer + def self.call(...) + new(...).call + end + + def initialize(user) + @user = user + end + + def call + return unless user + + Notification.where(notifiable_type: "Follow", notifiable_id: user.follow_ids).delete_all + Notification.where(notifiable_type: "Comment", notifiable_id: user.comment_ids).delete_all + Notification.where(notifiable_type: "Article", action: "Published", notifiable_id: user.article_ids).delete_all + end + + private + + attr_reader :user + end +end diff --git a/app/services/notifications/tag_adjustment_notification/send.rb b/app/services/notifications/tag_adjustment_notification/send.rb index 4e6906c13d409..77ac6a28915e2 100644 --- a/app/services/notifications/tag_adjustment_notification/send.rb +++ b/app/services/notifications/tag_adjustment_notification/send.rb @@ -2,14 +2,14 @@ module Notifications module TagAdjustmentNotification class Send - def initialize(tag_adjustment) - @tag_adjustment = tag_adjustment - end - def self.call(...) new(...).call end + def initialize(tag_adjustment) + @tag_adjustment = tag_adjustment + end + def call article = tag_adjustment.article json_data = { diff --git a/app/services/notifications/update.rb b/app/services/notifications/update.rb index 6d9e5ba963cba..585b660e29f0c 100644 --- a/app/services/notifications/update.rb +++ b/app/services/notifications/update.rb @@ -2,15 +2,15 @@ module Notifications class Update delegate :article_data, :comment_data, :user_data, :organization_data, to: Notifications + def self.call(...) + new(...).call + end + def initialize(notifiable, action = nil) @notifiable = notifiable @action = action end - def self.call(...) - new(...).call - end - def call return unless [Article, Comment].include?(notifiable.class) diff --git a/app/services/notifications/welcome_notification/send.rb b/app/services/notifications/welcome_notification/send.rb index 7a6997f083db3..5639efe955845 100644 --- a/app/services/notifications/welcome_notification/send.rb +++ b/app/services/notifications/welcome_notification/send.rb @@ -2,6 +2,10 @@ module Notifications module WelcomeNotification class Send + def self.call(...) + new(...).call + end + def initialize(receiver_id, welcome_broadcast) @receiver_id = receiver_id @welcome_broadcast = welcome_broadcast @@ -9,10 +13,6 @@ def initialize(receiver_id, welcome_broadcast) delegate :user_data, to: Notifications - def self.call(...) - new(...).call - end - def call mascot_account = User.mascot_account json_data = { diff --git a/app/services/open_graph.rb b/app/services/open_graph.rb index 3e0849c652064..990251817ea11 100644 --- a/app/services/open_graph.rb +++ b/app/services/open_graph.rb @@ -85,7 +85,8 @@ def twitter def fetch_html(url) Rails.cache.fetch("#{url}_open_graph_html", expires_in: CACHE_EXPIRY_IN_MINUTES.minutes) do - Net::HTTP.get(URI(url)) + response = HTTParty.get(url, headers: { "User-Agent" => "#{Settings::Community.community_name} (#{URL.url})" }) + response&.body end end diff --git a/app/services/organizations/delete.rb b/app/services/organizations/delete.rb index 4664cd332279e..d1aec8551238c 100644 --- a/app/services/organizations/delete.rb +++ b/app/services/organizations/delete.rb @@ -1,5 +1,9 @@ module Organizations class Delete + def self.call(...) + new(...).call + end + def initialize(org) @org = org @article_ids = org.article_ids @@ -11,10 +15,6 @@ def call articles_sync end - def self.call(...) - new(...).call - end - private attr_reader :org, :article_ids diff --git a/app/services/page_view_rollup.rb b/app/services/page_view_rollup.rb new file mode 100644 index 0000000000000..21f3d75693601 --- /dev/null +++ b/app/services/page_view_rollup.rb @@ -0,0 +1,93 @@ +class PageViewRollup + ATTRIBUTES_PRESERVED = %i[article_id created_at user_id].freeze + ATTRIBUTES_DESTROYED = %i[id domain path referrer updated_at user_agent counts_for_number_of_views + time_tracked_in_seconds].freeze + + class ViewAggregator + Compact = Struct.new(:views, :article_id, :user_id) do + def to_h + super.except(:views).merge({ + counts_for_number_of_views: views.sum(&:counts_for_number_of_views), + time_tracked_in_seconds: views.sum(&:time_tracked_in_seconds) + }) + end + end + + def initialize + @aggregator = Hash.new do |level1, article_id| + level1[article_id] = Hash.new do |level2, user_id| + level2[user_id] = [] + end + end + end + + def <<(view) + @aggregator[view.article_id][view.user_id] << view + end + + def each + @aggregator.each_pair do |article_id, grouped_by_article_id| + grouped_by_article_id.each_pair do |user_id, views| + next unless views.size > 1 + + yield Compact.new(views, article_id, user_id) + end + end + end + + private + + attr_reader :aggregator + end + + def self.rollup(date, relation: PageView) + new(relation: relation).rollup(date) + end + + def initialize(relation:) + @relation = relation + end + + attr_reader :relation + + def rollup(date) + created = [] + fixed_date = date.to_datetime.beginning_of_day + + (0..23).each do |hour| + start_hour = fixed_date.change(hour: hour) + end_hour = fixed_date.change(hour: hour + 1) + rows = relation.where(user_id: nil, created_at: start_hour...end_hour) + aggregate_into_groups(rows).each do |compacted_views| + created << compact_records(start_hour, compacted_views) + end + end + + created + end + + private + + def aggregate_into_groups(rows) + aggregator = ViewAggregator.new + rows.in_batches.each_record do |event| + aggregator << event + end + + aggregator + end + + def compact_records(date, compacted) + result = nil + + relation.transaction do + result = relation.create!(compacted.to_h) do |event| + event.created_at = date + end + + relation.where(id: compacted.views).delete_all + end + + result + end +end diff --git a/app/services/podcasts/create_episode.rb b/app/services/podcasts/create_episode.rb index f30f02b8a4884..abc652ef0748c 100644 --- a/app/services/podcasts/create_episode.rb +++ b/app/services/podcasts/create_episode.rb @@ -1,14 +1,14 @@ module Podcasts class CreateEpisode + def self.call(...) + new(...).call + end + def initialize(podcast_id, item) @podcast_id = podcast_id @item = item.is_a?(EpisodeRssItem) ? item : EpisodeRssItem.new(item) end - def self.call(...) - new(...).call - end - def call attributes = podcast_episode_attributes diff --git a/app/services/podcasts/episode_rss_item.rb b/app/services/podcasts/episode_rss_item.rb index 311a1bf105759..ee1aef1c65eb1 100644 --- a/app/services/podcasts/episode_rss_item.rb +++ b/app/services/podcasts/episode_rss_item.rb @@ -5,12 +5,6 @@ class EpisodeRssItem attr_reader(*ATTRIBUTES) - def initialize(attributes) - ATTRIBUTES.each do |key| - instance_variable_set("@#{key}", attributes[key]) - end - end - def self.from_item(item) new( title: item.title, @@ -24,6 +18,12 @@ def self.from_item(item) ) end + def initialize(attributes) + ATTRIBUTES.each do |key| + instance_variable_set("@#{key}", attributes[key]) + end + end + def to_h ATTRIBUTES.index_with do |key| instance_variable_get("@#{key}") diff --git a/app/services/podcasts/get_media_url.rb b/app/services/podcasts/get_media_url.rb index 5b33335784125..52592381ce08b 100644 --- a/app/services/podcasts/get_media_url.rb +++ b/app/services/podcasts/get_media_url.rb @@ -11,14 +11,14 @@ class GetMediaUrl TIMEOUT = 20 - def initialize(enclosure_url) - @enclosure_url = enclosure_url.to_s - end - def self.call(...) new(...).call end + def initialize(enclosure_url) + @enclosure_url = enclosure_url.to_s + end + def call was_http = !enclosure_url.starts_with?(/https/i) https_url = enclosure_url.sub(/http:/i, "https:") diff --git a/app/services/podcasts/update_episode_media_url.rb b/app/services/podcasts/update_episode_media_url.rb index c77f4395d9aba..7ec875e77d449 100644 --- a/app/services/podcasts/update_episode_media_url.rb +++ b/app/services/podcasts/update_episode_media_url.rb @@ -1,14 +1,14 @@ module Podcasts class UpdateEpisodeMediaUrl + def self.call(...) + new(...).call + end + def initialize(episode, enclosure_url) @episode = episode @enclosure_url = enclosure_url end - def self.call(...) - new(...).call - end - def call result = GetMediaUrl.call(enclosure_url) episode.reachable = result.reachable diff --git a/app/services/rate_limit_checker.rb b/app/services/rate_limit_checker.rb index d69de5b618982..3c0984985ffd3 100644 --- a/app/services/rate_limit_checker.rb +++ b/app/services/rate_limit_checker.rb @@ -24,7 +24,7 @@ def initialize(user = nil) class LimitReached < StandardError attr_reader :retry_after - def initialize(retry_after) # rubocop:disable Lint/MissingSuper + def initialize(retry_after) @retry_after = retry_after end diff --git a/app/services/re_captcha/check_enabled.rb b/app/services/re_captcha/check_enabled.rb index 12e554f8460c4..9f4977c71e8f9 100644 --- a/app/services/re_captcha/check_enabled.rb +++ b/app/services/re_captcha/check_enabled.rb @@ -22,7 +22,7 @@ def call # recaptcha will not be enabled for tag moderator/trusted/admin users return false if @user.tag_moderator? || @user.trusted? || @user.any_admin? # recaptcha will be enabled if the user has been suspended - return true if @user.suspended? + return true if @user.spam_or_suspended? # recaptcha will be enabled if the user has a vomit or is too recent @user.vomited_on? || @user.created_at.after?(1.month.ago) diff --git a/app/services/reaction_handler.rb b/app/services/reaction_handler.rb index 0157ff70c2f93..c3944737f61c4 100644 --- a/app/services/reaction_handler.rb +++ b/app/services/reaction_handler.rb @@ -37,14 +37,14 @@ def initialize(params, current_user:) delegate :rate_limiter, to: :current_user def create - destroy_contradictory_mod_reactions if reactable_type == "Article" + destroy_contradictory_mod_reactions if %w[Article Comment].include?(reactable_type) return noop_result if existing_reaction create_new_reaction end def toggle - destroy_contradictory_mod_reactions if reactable_type == "Article" + destroy_contradictory_mod_reactions if %w[Article Comment].include?(reactable_type) return handle_existing_reaction if existing_reaction create_new_reaction @@ -85,11 +85,16 @@ def create_new_reaction reaction = build_reaction(category) result = result(reaction, nil) - if reaction.save - rate_limit_reaction_creation - sink_articles(reaction) - send_notifications(reaction) - update_last_reacted_at(reaction) + begin + if reaction.save + rate_limit_reaction_creation + sink_articles(reaction) + send_notifications(reaction) + record_feed_event(reaction) + update_last_reacted_at(reaction) + end + rescue ActiveRecord::RecordNotUnique + Rails.logger.error "Reaction already exists: #{reaction.inspect}" end result.action = "create" @@ -159,6 +164,13 @@ def send_notifications_without_delay(reaction) Notification.send_reaction_notification_without_delay(reaction, reaction.reactable.organization) end + def record_feed_event(reaction) + return unless (reaction.visible_to_public? || reaction.category == "readinglist") && + reaction.reactable_type == "Article" + + FeedEvent.record_journey_for(reaction.user, article: reaction.reactable, category: :reaction) + end + def rate_article(reaction) user_experience_level = current_user.setting.experience_level return unless user_experience_level diff --git a/app/services/search/organization.rb b/app/services/search/organization.rb new file mode 100644 index 0000000000000..2be7ad110a2ea --- /dev/null +++ b/app/services/search/organization.rb @@ -0,0 +1,47 @@ +module Search + class Organization + DEFAULT_SORT_BY = "name".freeze + ATTRIBUTES = %i[id name hotness_score rules_html supported short_summary bg_color_hex badge_id].freeze + + DEFAULT_PER_PAGE = 75 + private_constant :DEFAULT_PER_PAGE + + MAX_PER_PAGE = 150 # to avoid querying too many items, we set a maximum amount for a page + private_constant :MAX_PER_PAGE + + DEFAULT_SORT_DIRECTION = :desc + private_constant :DEFAULT_SORT_DIRECTION + + def self.search_documents( + term: nil, + sort_by: DEFAULT_SORT_BY, + sort_direction: DEFAULT_SORT_DIRECTION, + page: 0, + per_page: DEFAULT_PER_PAGE + ) + + page = page.to_i + 1 + per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min + + relation = ::Organization.all + + relation = relation.search_organizations(term) if term.present? + + relation = sort(relation, term, sort_by, sort_direction).page(page).per(per_page) + + Search::OrganizationSerializer + .new(relation, is_collection: true) + .serializable_hash[:data] + .pluck(:attributes) + end + + def self.sort(relation, term, sort_by, sort_direction) + return relation if term.present? && sort_by.blank? + + return relation.reorder(sort_by => sort_direction) if sort_direction + + relation.reorder(DEFAULT_SORT_BY) + end + private_class_method :sort + end +end diff --git a/app/services/search/user.rb b/app/services/search/user.rb index 1a82956393214..c0d3020fba2c8 100644 --- a/app/services/search/user.rb +++ b/app/services/search/user.rb @@ -25,6 +25,8 @@ def self.search_documents(term: nil, sort_by: :nil, sort_direction: :desc, page: relation = filter_suspended_users(relation) + relation = relation.registered + relation = relation.search_by_name_and_username(term) if term.present? relation = relation.select(*ATTRIBUTES) diff --git a/app/services/search/username.rb b/app/services/search/username.rb index e81727f3513b6..7dd11ff5248fd 100644 --- a/app/services/search/username.rb +++ b/app/services/search/username.rb @@ -18,6 +18,13 @@ def self.search_documents(term, context: nil, limit: MAX_RESULTS) serialize results end + def self.serialize(results) + Search::NestedUserSerializer + .new(results, is_collection: true) + .serializable_hash[:data] + .pluck(:attributes) + end + def initialize(context: nil) @scope = scope_with_context(context) if context @scope ||= scope_without_context @@ -50,12 +57,6 @@ def scope_with_context(context) .order("has_commented DESC") end - def self.serialize(results) - Search::NestedUserSerializer - .new(results, is_collection: true) - .serializable_hash[:data] - .pluck(:attributes) - end private_class_method :serialize end end diff --git a/app/services/segmented_users/bulk_delete.rb b/app/services/segmented_users/bulk_delete.rb new file mode 100644 index 0000000000000..0008012b307bb --- /dev/null +++ b/app/services/segmented_users/bulk_delete.rb @@ -0,0 +1,35 @@ +module SegmentedUsers + # Preferred way to quickly remove a large number of users from an AudienceSegment + class BulkDelete + Result = Struct.new(:succeeded, :failed, keyword_init: true) + + def self.call(audience_segment, user_ids:) + new(audience_segment).call(user_ids) + end + + # @param audience_segment [AudienceSegment] the segment to remove users from + def initialize(audience_segment) + @audience_segment = audience_segment + end + + # Deletes the provided users from the AudienceSegment in batches. + # It touches the segment if any users were successfully deleted. + # + # Warning: the joining `SegmentedUsers` records are deleted without triggering any + # application-defined callbacks. Doing so is the responsibility of the caller. + # + # @param user_ids [Array<Integer>] a list of `User` ids to process + # @return [SegmentedUsers::BulkDelete::Result] + def call(user_ids) + return unless @audience_segment.persisted? + + segmented_users = @audience_segment.segmented_users.where(user_id: user_ids) + valid_user_ids = segmented_users.pluck(:user_id) + deleted_count = segmented_users.in_batches.delete_all + + @audience_segment.touch if deleted_count.positive? + + Result.new(succeeded: valid_user_ids, failed: user_ids - valid_user_ids) + end + end +end diff --git a/app/services/segmented_users/bulk_upsert.rb b/app/services/segmented_users/bulk_upsert.rb new file mode 100644 index 0000000000000..82d8bc153bb9b --- /dev/null +++ b/app/services/segmented_users/bulk_upsert.rb @@ -0,0 +1,80 @@ +module SegmentedUsers + # Preferred way to add a large number of users to an AudienceSegment + # Normal ActiveRecord usage emits one INSERT statement per row. + class BulkUpsert + Result = Struct.new(:succeeded, :failed, keyword_init: true) + + def self.call(audience_segment, user_ids:) + new(audience_segment).call(user_ids) + end + + # @param audience_segment [AudienceSegment] the segment to add users to + def initialize(audience_segment) + @audience_segment = audience_segment + end + + # Upserts the provided users into the AudienceSegment in batches. + # It touches the `SegmentedUser` record of any users already in the list, as + # well as the segment itself if any users were successfully upserted. + # + # Warning: the joining `SegmentedUsers` records are created without triggering any + # application-defined callbacks. Doing so is the responsibility of the caller. + # + # @param user_ids [Array<Integer>] a list of `User` ids to process + # @return [SegmentedUsers::BulkUpsert::Result] + def call(user_ids) + return unless audience_segment.persisted? + + @upsert_time = Time.current + + valid_user_ids = User.where(id: user_ids).ids + upserted_user_ids = upsert_in_batches(valid_user_ids) + + audience_segment.touch unless upserted_user_ids.empty? + + Result.new(succeeded: upserted_user_ids, failed: user_ids - upserted_user_ids) + end + + private + + attr_reader :audience_segment, :upsert_time + + def upsert_in_batches(user_ids) + result = [] + + user_ids.in_groups_of(1000, false) do |ids| + succeeded = perform_upsert(ids).rows.flatten + result.concat(succeeded) + end + + result + end + + def perform_upsert(user_ids_batch) + segmented_users = build_records(user_ids_batch) + + # We only want to touch the `updated_at` column of records that already exist, + # but specifying that causes ActiveRecord to emit malformed SQL (multiple assignments to the same column). + # Turning off Rails' automatic timestamp management via `record_timestamps` + # and managing timestamps ourselves yields the correct query. + SegmentedUser.upsert_all( + segmented_users, + unique_by: :index_segmented_users_on_audience_segment_and_user, + update_only: [:updated_at], + returning: ["user_id"], + record_timestamps: false, + ) + end + + def build_records(user_ids_batch) + user_ids_batch.map do |user_id| + { + audience_segment_id: audience_segment.id, + user_id: user_id, + created_at: upsert_time, + updated_at: upsert_time + } + end + end + end +end diff --git a/app/services/settings/general/upsert.rb b/app/services/settings/general/upsert.rb index 318aa7f35c54b..f556da0964103 100644 --- a/app/services/settings/general/upsert.rb +++ b/app/services/settings/general/upsert.rb @@ -1,7 +1,7 @@ module Settings class General module Upsert - PARAMS_TO_BE_CLEANED = %i[sidebar_tags suggested_tags suggested_users].freeze + PARAMS_TO_BE_CLEANED = %i[sidebar_tags suggested_tags].freeze TAG_PARAMS = %w[sidebar_tags suggested_tags].freeze def self.call(settings) @@ -26,6 +26,7 @@ def self.clean_params(settings) settings[param] = settings[param]&.downcase&.delete(" ") if settings[param] end settings[:credit_prices_in_cents]&.transform_values!(&:to_i) + settings[:billboard_enabled_countries]&.transform_values!(&:to_sym) settings end @@ -34,8 +35,18 @@ def self.clean_params(settings) def self.create_tags_if_necessary(settings) return unless (settings.keys & TAG_PARAMS).any? - tags = Settings::General.suggested_tags + Settings::General.sidebar_tags - Tag.find_or_create_all_with_like_by_name(tags) + create_sidebar_tags + create_suggested_tags + end + + def self.create_sidebar_tags + Tag.find_or_create_all_with_like_by_name(Settings::General.sidebar_tags) + end + + def self.create_suggested_tags + suggested = Tag.find_or_create_all_with_like_by_name(Settings::General.suggested_tags) + Tag.where(suggested: true).update_all(suggested: false) + Tag.where(id: suggested).update_all(suggested: true) end def self.upload_logo(image) diff --git a/app/services/slack/announcer.rb b/app/services/slack/announcer.rb index d0ab850076561..1d0659a0ea363 100644 --- a/app/services/slack/announcer.rb +++ b/app/services/slack/announcer.rb @@ -2,6 +2,10 @@ module Slack # a thin wrapper on Slack::Notifier # for additional options, see https://github.com/stevenosloan/slack-notifier class Announcer + def self.call(...) + new(...).call + end + def initialize(message:, channel:, username:, icon_emoji:) @message = message @channel = channel @@ -9,10 +13,6 @@ def initialize(message:, channel:, username:, icon_emoji:) @icon_emoji = icon_emoji end - def self.call(...) - new(...).call - end - def call return if [message, channel, username, icon_emoji].any?(&:blank?) diff --git a/app/services/slack/messengers/article_published.rb b/app/services/slack/messengers/article_published.rb index 2de21016d3da4..7564d02423c15 100644 --- a/app/services/slack/messengers/article_published.rb +++ b/app/services/slack/messengers/article_published.rb @@ -1,15 +1,16 @@ module Slack module Messengers class ArticlePublished - def initialize(article:) - @article = article - end - def self.call(**args) new(**args).call end + def initialize(article:) + @article = article + end + def call + return if ENV["DISABLE_SLACK_NOTIFICATIONS"] == "true" return unless article.published && article.published_at > 10.minutes.ago message = I18n.t( diff --git a/app/services/slack/messengers/comment_user_warned.rb b/app/services/slack/messengers/comment_user_warned.rb index de1601feb1636..ce6ffd0b423e8 100644 --- a/app/services/slack/messengers/comment_user_warned.rb +++ b/app/services/slack/messengers/comment_user_warned.rb @@ -8,15 +8,15 @@ class CommentUserWarned Manage commenter - @%<username>s: %<internal_user_url>s TEXT + def self.call(...) + new(...).call + end + def initialize(comment:) @comment = comment @user = comment.user end - def self.call(...) - new(...).call - end - def call return unless user.warned? diff --git a/app/services/slack/messengers/feedback.rb b/app/services/slack/messengers/feedback.rb index 42cdd877b0feb..bd76ed78add4d 100644 --- a/app/services/slack/messengers/feedback.rb +++ b/app/services/slack/messengers/feedback.rb @@ -1,6 +1,10 @@ module Slack module Messengers class Feedback + def self.call(...) + new(...).call + end + def initialize(type:, category:, reported_url:, message:, user: nil) @user = user @type = type @@ -9,10 +13,6 @@ def initialize(type:, category:, reported_url:, message:, user: nil) @message = message end - def self.call(...) - new(...).call - end - def call reports_url = URL.url( Rails.application.routes.url_helpers.admin_reports_path, diff --git a/app/services/slack/messengers/note.rb b/app/services/slack/messengers/note.rb index 745e6380739ac..7901302f37fbb 100644 --- a/app/services/slack/messengers/note.rb +++ b/app/services/slack/messengers/note.rb @@ -9,6 +9,10 @@ class Note Message: %<message>s TEXT + def self.call(...) + new(...).call + end + def initialize(author_name:, status:, type:, report_id:, message:) @author_name = author_name @status = status @@ -17,10 +21,6 @@ def initialize(author_name:, status:, type:, report_id:, message:) @message = message end - def self.call(...) - new(...).call - end - def call report_url = URL.url( Rails.application.routes.url_helpers.admin_report_path(report_id), diff --git a/app/services/slack/messengers/potential_spammer.rb b/app/services/slack/messengers/potential_spammer.rb index 43eea0708eeea..769e207e9731c 100644 --- a/app/services/slack/messengers/potential_spammer.rb +++ b/app/services/slack/messengers/potential_spammer.rb @@ -1,14 +1,14 @@ module Slack module Messengers class PotentialSpammer - def initialize(user:) - @user = user - end - def self.call(...) new(...).call end + def initialize(user:) + @user = user + end + def call message = I18n.t( "services.slack.messengers.potential_spammer.body", diff --git a/app/services/slack/messengers/reaction_vomit.rb b/app/services/slack/messengers/reaction_vomit.rb index 4739004debf84..ae3cc2c6dd3c9 100644 --- a/app/services/slack/messengers/reaction_vomit.rb +++ b/app/services/slack/messengers/reaction_vomit.rb @@ -7,14 +7,14 @@ class ReactionVomit %<reactable_url>s TEXT - def initialize(reaction:) - @reaction = reaction - end - def self.call(...) new(...).call end + def initialize(reaction:) + @reaction = reaction + end + def call return unless reaction.category == "vomit" diff --git a/app/services/tag_moderators/add.rb b/app/services/tag_moderators/add.rb index 4c386cb174718..6eeab21cd3fb5 100644 --- a/app/services/tag_moderators/add.rb +++ b/app/services/tag_moderators/add.rb @@ -1,18 +1,21 @@ module TagModerators class Add - def self.call(user_ids, tag_ids) - new(user_ids, tag_ids).call + Result = Struct.new(:success?, :errors, keyword_init: true) + + def self.call(user_id, tag_id) + new(user_id, tag_id).call end - def initialize(user_ids, tag_ids) - @user_ids = user_ids - @tag_ids = tag_ids + def initialize(user_id, tag_id) + @user_id = user_id + @tag_id = tag_id end def call - user_ids.each_with_index do |user_id, index| - user = User.find(user_id) - tag = Tag.find(tag_ids[index]) + user = User.find(user_id) + notification_setting = user.notification_setting + if notification_setting.update(email_tag_mod_newsletter: true) + tag = Tag.find(tag_id) add_tag_mod_role(user, tag) ::TagModerators::AddTrustedRole.call(user) tag.update(supported: true) unless tag.supported? @@ -21,12 +24,16 @@ def call .with(user: user, tag: tag) .tag_moderator_confirmation_email .deliver_now + + Result.new(success?: true) + else + Result.new(success?: false, errors: notification_setting.errors_as_sentence) end end private - attr_accessor :user_ids, :tag_ids + attr_accessor :user_id, :tag_id def add_tag_mod_role(user, tag) unless user.notification_setting.email_tag_mod_newsletter? diff --git a/app/services/tag_moderators/add_trusted_role.rb b/app/services/tag_moderators/add_trusted_role.rb index 59a9f1fa6b55b..d351514f680e6 100644 --- a/app/services/tag_moderators/add_trusted_role.rb +++ b/app/services/tag_moderators/add_trusted_role.rb @@ -1,11 +1,10 @@ module TagModerators class AddTrustedRole def self.call(user) - return if user.has_trusted_role? || user.suspended? + return if user.has_trusted_role? || user.spam_or_suspended? user.add_role(:trusted) user.notification_setting.update(email_community_mod_newsletter: true) - Rails.cache.delete("user-#{user.id}/has_trusted_role") NotifyMailer.with(user: user).trusted_role_email.deliver_now return unless community_mod_newsletter_enabled? diff --git a/app/services/users/confirm_flag_reactions.rb b/app/services/users/confirm_flag_reactions.rb new file mode 100644 index 0000000000000..f89f368d32f9b --- /dev/null +++ b/app/services/users/confirm_flag_reactions.rb @@ -0,0 +1,27 @@ +module Users + class ConfirmFlagReactions + def self.call(...) + new(...).call + end + + def initialize(user) + @user = user + end + + def call + relation = Reaction.where(category: "vomit", status: "valid").live_reactable + user_flags = relation.where(reactable: user) + user_flags.update_all(status: "confirmed") + + article_flags = relation.where(reactable: user.articles) + article_flags.update_all(status: "confirmed") + + comment_flags = relation.where(reactable: user.comments) + comment_flags.update_all(status: "confirmed") + end + + private + + attr_reader :user + end +end diff --git a/app/services/users/delete.rb b/app/services/users/delete.rb index bdc6a90fcffdf..5f699f5f951e8 100644 --- a/app/services/users/delete.rb +++ b/app/services/users/delete.rb @@ -1,5 +1,9 @@ module Users class Delete + def self.call(...) + new(...).call + end + def initialize(user) @user = user end @@ -11,15 +15,11 @@ def call delete_user_activity user.remove_from_mailchimp_newsletters EdgeCache::Bust.call("/#{user.username}") - Users::SuspendedUsername.create_from_user(user) if user.suspended? + Users::SuspendedUsername.create_from_user(user) if user.spam_or_suspended? user.destroy Rails.cache.delete("user-destroy-token-#{user.id}") end - def self.call(...) - new(...).call - end - private attr_reader :user diff --git a/app/services/users/delete_activity.rb b/app/services/users/delete_activity.rb index 3399cfc6c958f..9f704cae113e2 100644 --- a/app/services/users/delete_activity.rb +++ b/app/services/users/delete_activity.rb @@ -10,7 +10,7 @@ def call(user) user.blocker_blocks.delete_all user.blocked_blocks.delete_all user.authored_notes.delete_all - user.display_ad_events.delete_all + user.billboard_events.delete_all user.email_messages.delete_all user.html_variants.delete_all user.poll_skips.delete_all diff --git a/app/services/users/profile_image_generator.rb b/app/services/users/profile_image_generator.rb deleted file mode 100644 index 2f14c6fcaf592..0000000000000 --- a/app/services/users/profile_image_generator.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Users - module ProfileImageGenerator - def self.call - File.open(Rails.root.join("app/assets/images/#{rand(1..40)}.png")) - end - end -end diff --git a/app/services/users/remove_role.rb b/app/services/users/remove_role.rb index 8eb1a1a90e3b4..f62471822c932 100644 --- a/app/services/users/remove_role.rb +++ b/app/services/users/remove_role.rb @@ -6,23 +6,22 @@ def self.call(...) new(...).call end - def initialize(user:, role:, resource_type:, admin:) + def initialize(user:, role:, resource_type:, resource_id: nil) @user = user @role = role @resource_type = resource_type&.safe_constantize - @admin = admin + @resource_id = resource_id @response = Response.new(success: false) end def call - return response if super_admin_role?(role) - return response if user_current_user?(user) - - if resource_type && user.remove_role(role, resource_type) + resource = resource_id ? resource_type.find(resource_id) : resource_type + if resource && user.remove_role(role, resource) response.success = true elsif user.remove_role(role) response.success = true end + user.profile.touch response rescue StandardError => e response.error_message = I18n.t("services.users.remove_role.error", e_message: e.message) @@ -31,20 +30,6 @@ def call private - attr_reader :user, :role, :resource_type, :admin, :response - - def super_admin_role?(role) - return false if role != :super_admin - - response.error_message = I18n.t("services.users.remove_role.remove_super") - true - end - - def user_current_user?(user) - return false if user.id != admin.id - - response.error_message = I18n.t("services.users.remove_role.remove_self") - true - end + attr_reader :user, :role, :resource_type, :resource_id, :response end end diff --git a/app/services/users/resolve_spam_reports.rb b/app/services/users/resolve_spam_reports.rb new file mode 100644 index 0000000000000..9a522eb9fed28 --- /dev/null +++ b/app/services/users/resolve_spam_reports.rb @@ -0,0 +1,35 @@ +module Users + class ResolveSpamReports + def self.call(...) + new(...).call + end + + def initialize(user) + @user = user + end + + def call + relation = FeedbackMessage.where(status: "Open", category: "spam") + + # profile reports by url and by path + profile_reports = relation.where(reported_url: [URL.url(user.path), user.path]) + profile_reports.update_all(status: "Resolved") + + # articles can be reported by url or by path + article_paths = user.articles.map(&:path) + article_paths += article_paths.map { |p| URL.url(p) } + article_reports = relation.where(reported_url: article_paths) + article_reports.update_all(status: "Resolved") + + # comments can be reported by url or by path + comment_paths = user.comments.map(&:path) + comment_paths += comment_paths.map { |p| URL.url(p) } + comment_reports = relation.where(reported_url: comment_paths) + comment_reports.update_all(status: "Resolved") + end + + private + + attr_reader :user + end +end diff --git a/app/services/users/suggest_recent.rb b/app/services/users/suggest_recent.rb deleted file mode 100644 index 6be2688e5353e..0000000000000 --- a/app/services/users/suggest_recent.rb +++ /dev/null @@ -1,81 +0,0 @@ -module Users - class SuggestRecent - def self.call(user, attributes_to_select: []) - new(user, attributes_to_select: attributes_to_select).suggest - end - - def initialize(user, attributes_to_select: []) - @user = user - @cached_followed_tag_names = user.decorate.cached_followed_tag_names - @attributes_to_select = attributes_to_select - end - - def suggest - if cached_followed_tag_names.any? - (recent_producers(3) - [user]).sample(50).uniq - else - (recent_commenters(4, 30) + recent_top_producers - [user]).uniq.sample(50) - end - end - - private - - attr_reader :user, :attributes_to_select, :cached_followed_tag_names - - def tagged_article_user_ids(num_weeks = 1) - Article.published - .tagged_with(cached_followed_tag_names.sample(5), any: true) - .where(score: article_score_average.., published_at: num_weeks.weeks.ago..) - .pluck(:user_id) - .each_with_object(Hash.new(0)) { |value, counts| counts[value] += 1 } - .sort_by { |_key, value| value } - .map(&:first) - end - - def recent_producers(num_weeks = 1) - relation_as_array( - user_relation.where(id: tagged_article_user_ids(num_weeks)), - limit: 80, - ) - end - - def recent_top_producers - relation = user_relation.where( - articles_count: established_user_article_count.., - comments_count: established_user_comment_count.., - ) - relation_as_array(relation, limit: 50) - end - - def recent_commenters(num_comments = 2, limit = 8) - relation_as_array(user_relation.where(comments_count: num_comments + 1..), limit: limit) - end - - def relation_as_array(relation, limit:) - relation = relation.joins(:profile).select(attributes_to_select) if attributes_to_select.any? - relation.order(updated_at: :desc).limit(limit).to_a - end - - def established_user_article_count - Rails.cache.fetch("established_user_article_count", expires_in: 1.day) do - user_relation.where(articles_count: 1..).average(:articles_count) || User.average(:articles_count) - end - end - - def established_user_comment_count - Rails.cache.fetch("established_user_comment_count", expires_in: 1.day) do - user_relation.where(comments_count: 1..).average(:comments_count) || User.average(:comments_count) - end - end - - def article_score_average - Rails.cache.fetch("article_score_average", expires_in: 1.day) do - Article.where(score: 0..).average(:score) || Article.average(:score) - end - end - - def user_relation - User.includes(:profile) - end - end -end diff --git a/app/services/users/update.rb b/app/services/users/update.rb index 50e9d45955924..915b6086781ae 100644 --- a/app/services/users/update.rb +++ b/app/services/users/update.rb @@ -115,7 +115,7 @@ def update_profile end def conditionally_resave_articles - return unless resave_articles? && !@user.suspended? + return unless resave_articles? && !@user.spam_or_suspended? Users::ResaveArticlesWorker.perform_async(@user.id) end diff --git a/app/services/users/username_generator.rb b/app/services/users/username_generator.rb new file mode 100644 index 0000000000000..b242de61f8f99 --- /dev/null +++ b/app/services/users/username_generator.rb @@ -0,0 +1,61 @@ +# Generates available username based on +# multiple generators in the following order: +# * list of supplied usernames +# * list of supplied usernames with suffix +# * random generated letters +# +# @todo Extract username validation in separate class +module Users + class UsernameGenerator + attr_reader :usernames + + def self.call(...) + new(...).call + end + + # @param usernames [Array<String>] a list of usernames + def initialize(usernames = [], detector: CrossModelSlug, generator: nil) + @detector = detector + @generator = generator || method(:random_username) + @usernames = usernames + end + + def call + first_available_from(normalized_usernames) || + first_available_from(suffixed_usernames) || + first_available_from(random_usernames) + end + + def normalized_usernames + @normalized_usernames ||= filtered_usernames.map { |s| s.downcase.gsub(/[^0-9a-z_]/i, "").delete(" ") } + end + + def filtered_usernames + @filtered_usernames ||= usernames.select { |s| s.is_a?(String) && s.present? } + end + + def random_username + ("a".."z").to_a.sample(12).join + end + + def random_usernames + Array.new(3) { @generator.call } + end + + private + + def first_available_from(list) + list.detect { |username| !username_exists?(username) } + end + + def username_exists?(username) + @detector.exists?(username) + end + + def suffixed_usernames + return [] unless filtered_usernames.any? + + normalized_usernames.map { |stem| [stem, rand(100)].join("_") } + end + end +end diff --git a/app/uploaders/article_image_uploader.rb b/app/uploaders/article_image_uploader.rb index 4c18f34873d95..9ff3de8b1af21 100644 --- a/app/uploaders/article_image_uploader.rb +++ b/app/uploaders/article_image_uploader.rb @@ -1,3 +1,4 @@ +require "open-uri" class ArticleImageUploader < BaseUploader def store_dir "uploads/articles/" @@ -6,4 +7,28 @@ def store_dir def filename "#{Array.new(20) { rand(36).to_s(36) }.join}.#{file.extension}" if original_filename.present? end + + def upload_from_url(url) + # Open the URL and create a temporary file + file = URI.open(url) # rubocop:disable Security/Open + temp_file = Tempfile.new(["upload", File.extname(file.base_uri.path)]) + temp_file.binmode + temp_file.write(file.read) + temp_file.rewind + + # Upload the tempfile using CarrierWave + store!(temp_file) + + # Important: Ensure you return the URL of the uploaded file + stored_file_url = self.url # This should return the actual URL where the file is stored + + # Cleanup + temp_file.close + temp_file.unlink + + stored_file_url + rescue StandardError => e + Rails.logger.error "Failed to handle file upload: #{e.message}" + nil + end end diff --git a/app/uploaders/base_uploader.rb b/app/uploaders/base_uploader.rb index 17fcec2eb9073..8d669a9b64191 100644 --- a/app/uploaders/base_uploader.rb +++ b/app/uploaders/base_uploader.rb @@ -30,10 +30,13 @@ def strip_exif return if file.content_type.include?("svg") manipulate! do |image| + image.auto_orient image.strip unless image.frames.count > FRAME_STRIP_MAX image = yield(image) if block_given? image end + rescue StandardError => e + Rails.logger.error("Error stripping EXIF data: #{e}") end def validate_frame_count diff --git a/app/uploaders/logo_uploader.rb b/app/uploaders/logo_uploader.rb index 5a8303c74cf0e..86caa4ce37f75 100644 --- a/app/uploaders/logo_uploader.rb +++ b/app/uploaders/logo_uploader.rb @@ -1,5 +1,5 @@ class LogoUploader < BaseUploader - MAX_FILE_SIZE = 3.megabytes + MAX_FILE_SIZE = 8.megabytes STORE_DIRECTORY = "uploads/logos/".freeze EXTENSION_ALLOWLIST = %w[png jpg jpeg jpe].freeze IMAGE_TYPE_ALLOWLIST = %i[png jpg jpeg jpe].freeze diff --git a/app/uploaders/profile_image_uploader.rb b/app/uploaders/profile_image_uploader.rb index b958fee385e05..7c1a74a544063 100644 --- a/app/uploaders/profile_image_uploader.rb +++ b/app/uploaders/profile_image_uploader.rb @@ -1,10 +1,12 @@ class ProfileImageUploader < BaseUploader + MAX_FILE_SIZE = 8.megabytes + def filename "#{secure_token}.#{file.extension}" if original_filename.present? end def size_range - 1..(2.megabytes) + 1..MAX_FILE_SIZE end protected diff --git a/app/validators/cross_model_slug_validator.rb b/app/validators/cross_model_slug_validator.rb new file mode 100644 index 0000000000000..737cef95070ea --- /dev/null +++ b/app/validators/cross_model_slug_validator.rb @@ -0,0 +1,57 @@ +class CrossModelSlugValidator < ActiveModel::EachValidator + FORMAT_REGEX = /\A[0-9a-z\-_]+\z/ + ORGANIZATION_FORMAT_REGEX = /\A(?![0-9]+\z)[0-9a-z\-_]+\z/ + ## allow / in page slugs + PAGE_FORMAT_REGEX = %r{\A[0-9a-z\-_/+]+\z} + PAGE_DIRECTORY_LIMIT = 6 + + def validate_each(record, attribute, value) + return if value.blank? + + correct_format?(record, attribute, value) + allowed_subdirectory_count?(record, attribute, value) + not_on_reserved_list?(record, attribute, value) + unique_across_models?(record, attribute, value) + end + + private + + def not_on_reserved_list?(record, attribute, value) + return false if record.instance_of?(::Page) || ReservedWords.all.exclude?(value) + + record.errors.add(attribute, I18n.t("validators.cross_model_slug_validator.is_reserved")) + end + + def correct_format?(record, attribute, value) + format_regex = case record.class.name + when "Organization" + ORGANIZATION_FORMAT_REGEX + when "Page" + PAGE_FORMAT_REGEX + else + FORMAT_REGEX + end + return false if value.match?(format_regex) + + record.errors.add(attribute, I18n.t("validators.cross_model_slug_validator.is_invalid")) + end + + def unique_across_models?(record, attribute, value) + # attribute_changed? is likely redundant, but is much cheaper than the cross-model exists check + return false unless record.public_send("#{attribute}_changed?") + return false unless already_exists?(value) + + record.errors.add(attribute, I18n.t("validators.cross_model_slug_validator.is_taken")) + end + + def allowed_subdirectory_count?(record, attribute, value) + return false unless record.instance_of?(::Page) + return false if value.split("/").count <= PAGE_DIRECTORY_LIMIT + + record.errors.add(attribute, I18n.t("validators.cross_model_slug_validator.too_many_subdirectories")) + end + + def already_exists?(value) + CrossModelSlug.exists?(value.downcase) + end +end diff --git a/app/validators/enabled_countries_hash_validator.rb b/app/validators/enabled_countries_hash_validator.rb new file mode 100644 index 0000000000000..bfa9e7b7e403e --- /dev/null +++ b/app/validators/enabled_countries_hash_validator.rb @@ -0,0 +1,21 @@ +class EnabledCountriesHashValidator < ActiveModel::EachValidator + VALID_HASH_VALUES = %i[with_regions without_regions].freeze + + def validate_each(record, attribute, value) + if value.blank? || !value.is_a?(Hash) + record.errors.add(attribute, + options[:message] || I18n.t("validators.iso3166_hash_validator.is_blank")) + return + end + + unless value.keys.all? { |key| ISO3166::Country.codes.include? key } + record.errors.add(attribute, + options[:message] || I18n.t("validators.iso3166_hash_validator.invalid_key")) + end + + return if value.values.all? { |value| VALID_HASH_VALUES.include? value } + + record.errors.add(attribute, + options[:message] || I18n.t("validators.iso3166_hash_validator.invalid_value")) + end +end diff --git a/app/validators/profile_validator.rb b/app/validators/profile_validator.rb index a3cd0d49e0f1b..4ea96f042ca28 100644 --- a/app/validators/profile_validator.rb +++ b/app/validators/profile_validator.rb @@ -51,6 +51,7 @@ def check_box_valid?(_record, _attribute) def text_area_valid?(record, attribute) text = record.public_send(attribute) + text = remove_inner_newlines(text) text.nil? || text.size <= MAX_TEXT_AREA_LENGTH end @@ -58,4 +59,8 @@ def text_field_valid?(record, attribute) text = record.public_send(attribute) text.nil? || text.size <= MAX_TEXT_FIELD_LENGTH end + + def remove_inner_newlines(text) + text.presence && text.tr("\r\n\t", " ").squeeze(" ") + end end diff --git a/app/validators/unique_cross_model_slug_validator.rb b/app/validators/unique_cross_model_slug_validator.rb deleted file mode 100644 index 10f5eeb7c7c86..0000000000000 --- a/app/validators/unique_cross_model_slug_validator.rb +++ /dev/null @@ -1,43 +0,0 @@ -## -# Validates if the give attribute is used across the reserved spaces. -class UniqueCrossModelSlugValidator < ActiveModel::EachValidator - class_attribute :model_and_attribute_name_for_uniqueness_test - - # Why a class attribute? Allow for other implementations to extend - # this behavior. - self.model_and_attribute_name_for_uniqueness_test = { - Organization => :slug, - Page => :slug, - Podcast => :slug, - User => :username - } - - def validate_each(record, attribute, value) - return unless already_exists?(value: value, record: record) - - record.errors.add(attribute, options[:message] || I18n.t("validators.unique_cross_model_slug_validator.is_taken")) - end - - private - - ## - # Answers the question if it's okay for the record to use the given value. - # - # @param value [String] the value we're to check in the various classes - # @param record [ActiveRecord::Base] the record that we're attempting to validate - # - # @return [TrueClass] if the value already exists across the various classes. - # @return [FalseClass] if the given value is not already used. - # - # @see CLASS_AND_ATTRIBUTE_NAME_FOR_UNIQUENESS_TEST - def already_exists?(value:, record:) - return false unless value - return true if value.include?("sitemap-") - - model_and_attribute_name_for_uniqueness_test.detect do |model, attribute| - next if record.is_a?(model) - - model.exists?(attribute => value) - end - end -end diff --git a/app/view_objects/articles/social_image.rb b/app/view_objects/articles/social_image.rb index dcb63b3a30fec..c1e81c2780390 100644 --- a/app/view_objects/articles/social_image.rb +++ b/app/view_objects/articles/social_image.rb @@ -2,8 +2,6 @@ module Articles class SocialImage include Rails.application.routes.url_helpers - SOCIAL_PREVIEW_MIGRATION_DATETIME = Time.zone.parse("2019-04-22T00:00:00Z") - def initialize(article, height: 500, width: 1000) @article = article @height = height @@ -14,35 +12,19 @@ def url image = user_defined_image if image.present? image = image.split("w_1000/").last if image.include?("w_1000/https://") - return Images::Optimizer.call(image, width: width, height: height, crop: "imagga_scale") + return Images::Optimizer.call(image, width: width, height: height, crop: "crop") + else + return Settings::General.main_social_image.to_s end - return legacy_article_social_image unless use_new_social_url? - - article_social_preview_url(article, format: :png, host: Settings::General.app_domain) end private attr_reader :article, :height, :width - def legacy_article_social_image - cache_key = "article-social-img-#{article}-#{article.updated_at.rfc3339}-#{article.comments_count}" - - Rails.cache.fetch(cache_key, expires_in: 1.hour) do - src = Images::GenerateSocialImage.call(article) - return src if src.start_with? "https://res.cloudinary.com/" - - Images::Optimizer.call(src, width: "1000", height: "500", crop: "imagga_scale") - end - end - - def use_new_social_url? - article.updated_at > SOCIAL_PREVIEW_MIGRATION_DATETIME - end - def user_defined_image - return article.social_image if article.social_image.present? return article.main_image if article.main_image.present? + return article.social_image if article.social_image.present? return article.video_thumbnail_url if article.video_thumbnail_url.present? end end diff --git a/app/view_objects/cloud_cover_url.rb b/app/view_objects/cloud_cover_url.rb index ff51291368d67..83442d288d896 100644 --- a/app/view_objects/cloud_cover_url.rb +++ b/app/view_objects/cloud_cover_url.rb @@ -10,9 +10,11 @@ def call return url if Rails.env.development? width = 1000 + height = Settings::UserExperience.cover_image_height + crop = Settings::UserExperience.cover_image_fit img_src = url_without_prefix_nesting(url, width) - Images::Optimizer.call(img_src, width: width, height: 420, crop: "imagga_scale") + Images::Optimizer.call(img_src, width: width, height: height, crop: crop) end private @@ -24,5 +26,5 @@ def url_without_prefix_nesting(url, width) url.split("w_#{width}/").last end - attr_reader :url + attr_reader :url, :height end diff --git a/app/views/admin/articles/_article_item.html.erb b/app/views/admin/articles/_article_item.html.erb new file mode 100644 index 0000000000000..5483ca976303d --- /dev/null +++ b/app/views/admin/articles/_article_item.html.erb @@ -0,0 +1,259 @@ +<% decorated_article = article.decorate %> +<div + data-controller="article" + data-article-id-value="<%= article.id %>" + data-article-pin-path-value="<%= stories_feed_pinned_article_path %>" + data-article-bg-highlighted-class="bg-highlighted" + data-article-border-highlighted-class="border-highlighted" + data-action="ajax:success@document->article#ajaxSuccess"> + + <article class="js-individual-article crayons-card p-6 flex flex-col gap-4"> + <header> + <% if decorated_article.pinned? || article.featured || article.approved || (!article.published? && article.published_from_feed?) || article.video || article.user&.warned? %> + <div class="flex gap-1 mb-1"> + <% if !article.published? && article.published_from_feed? %> + <span class="c-indicator c-indicator--danger"><%= t("views.moderations.article.unplublished") %></span> + <% end %> + <% if decorated_article.pinned? %> + <span class="c-indicator c-indicator--warning" data-testid="pinned-indicator"><%= t("views.moderations.article.pinned") %></span> + <% end %> + <% if article.featured %> + <span class="c-indicator c-indicator--success"><%= t("views.moderations.article.featured") %></span> + <% end %> + <% if article.approved %> + <span class="c-indicator c-indicator--info"><%= t("views.moderations.article.approved") %></span> + <% end %> + <% if article.video %> + <span class="c-indicator"><%= t("views.moderations.article.contains_video") %></span> + <% end %> + <% cache "admin-user-info-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> + <% if article.user&.warned? %> + <span class="c-indicator c-indicator--danger"><%= t("views.moderations.article.user_warned") %></span> + <% end %> + <% end %> + </div> + <% end %> + + <% if article.main_image.present? %> + <a href="<%= article.path %>" target="_blank" rel="noopener" class="c-link c-link--branded mb-4"> + <img src="<%= cloud_cover_url(article.main_image) %>" style="background: <%= article.main_image_background_hex_color %>; max-height:150px; max-width: 300px;" alt="" loading="lazy" class="block radius-default h-auto w-100" /> + </a> + <% end %> + + <div class="flex justify-between gap-4 items-center"> + <h2 class="crayons-subtitle-1"> + <a href="<%= article.path %>" target="_blank" rel="noopener" class="c-link c-link--branded"> + <%= article.title %> + </a> + </h2> + <div class="flex items-stretch gap-1"> + <% if article.skip_indexing? %> + <span class="crayons-card crayons-card--secondary px-3 py-1 flex items-center" title="<%= t("views.admin.articles.noindex.reason") %><%= t("views.admin.articles.noindex.reasons." + article.skip_indexing_reason) %>"> + <%= crayons_icon_tag("twemoji/warning", native: true, width: 18, height: 18) %> + </span> + <% end %> + + <span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.thumb_up") %>"> + <%= crayons_icon_tag("twemoji/thumb-up", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= article.privileged_reaction_counts["thumbsup"] || "0" %></span> + </span> + + <span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.thumb_down") %>"> + <%= crayons_icon_tag("twemoji/thumb-down", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= article.privileged_reaction_counts["thumbsdown"] || "0" %></span> + </span> + + <span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.vomit") %>"> + <%= crayons_icon_tag("twemoji/flag", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= @countable_vomits&.dig(article.id) || 0 %></span> + </span> + + <span class="crayons-card crayons-card--secondary px-3 py-1 ml-3 flex gap-2 items-center" title="<%= t("views.moderations.actions.score") %>"> + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= article.score %></span> + </span> + </div> + </div> + + <div class="-ml-1 fs-s flex items-center gap-4"> + <div class="flex gap-px"> + <% decorated_article.cached_tag_list_array.each do |tag| %> + <a class="crayons-tag crayons-tag--monochrome" href='<%= tag_path(tag) %>'><span class="crayons-tag__prefix">#</span><%= tag %></a> + <% end %> + </div> + + <span>❤️ <%= article.public_reactions_count %> <%= t("views.moderations.article.likes") %></span> + <span>💬 <%= article.comments_count %> <%= t("views.moderations.article.comments") %></span> + </div> + + <div class="flex justify-between gap-4 items-center"> + <div> + <a href="<%= admin_user_path(article.user_id) %>" class="c-link">@<%= article.user&.username %></a> + <% if article.published_from_feed? && !article.published? %> + RSS Import <%= article.created_at.strftime("%b %d, %Y") %> + Originally Published <%= article.published_at&.strftime("%b %d, %Y") %> + <% elsif article.crossposted_at? %> + Crossposted <%= article.crossposted_at.strftime("%b %d, %Y") %> & Published + <%= article.published_at&.strftime("%b %d, %Y") %> + <% else %> + <%= article.published_at&.strftime("%b %d, %Y") %> + <% end %> + </div> + + <div class="flex gap-1"> + <% if !defined?(show_on_individual_article_only) || !show_on_individual_article_only %> + <a href="/admin/content_manager/articles/<%= article.id %>" class="c-link c-link--block"> + <%= t("views.moderations.article.view_details") %> + </a> + <% end %> + <% if decorated_article.pinned? %> + <form method="post" action="<%= unpin_admin_article_path(decorated_article.id) %>" class="inline" + data-action="submit->article#unpinArticle"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" /> + <button type="submit" class="c-btn"> + <%= t("views.moderations.article.unpin_post") %> + </button> + </form> + <% else %> + <form method="post" action="<%= pin_admin_article_path(decorated_article.id) %>" class="inline" + data-action="submit->article#pinArticle"> + <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" /> + <button type="submit" class="c-btn"> + <%= t("views.moderations.article.pin_post") %> + </button> + </form> + <% end %> + <a class="c-link c-link--block" href="<%= admin_user_path(article.user_id) %>" target="_blank" rel="noopener"><%= t("views.moderations.article.user") %></a> + <a href="<%= article.path %>/edit" target="_blank" rel="noopener" class="c-link c-link--block"><%= t("views.moderations.article.edit") %></a> + </div> + </div> + </header> + + <% cache "admin-user-notes-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> + <% if article.user&.notes&.any? %> + <div class="crayons-card crayons-card--secondary p-4 pt-3"> + <div class="flex gap-2 items-center"> + <h3 class="crayons-subtitle-2"><%= article.user&.notes&.size %> <%= t("views.moderations.article.notes.user_notes") %></h3> + <a href="<%= admin_user_path(article.user_id) %>" class="c-link c-link--block c-link--branded"><%= t("views.moderations.article.notes.view_all") %></a> + </div> + <% article.user&.notes&.last(3)&.each do |note| %> + <p> + <%= note.content %> + <span class="color-secondary fs-s"> + – <%= note.author_id ? User.find(note.author_id).username : "No author" %> + <time datetime="<%= note.created_at.strftime("%Y-%m-%dT%H:%M:%S%z") %>" title="<%= note.created_at.strftime("%Y-%m-%d, %H:%M") %>"> + <%= note.created_at.strftime("%b %e '%y") %> + </time> + </span> + </p> + <% end %> + </div> + <% end %> + <% end %> + + <%= form_with url: admin_article_path(article.id), model: article, local: true, class: "crayons-card crayons-card--secondary p-4 flex flex-col gap-3" do |f| %> + <input name="utf8" type="hidden" value="✓"> + <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" /> + <input type="hidden" name="_method" value="patch" /> + <div class="flex gap-4"> + <div class="crayons-field flex-1"> + <label for="author_id_<%= article.id %>" class="crayons-field__label"><%= t("views.moderations.article.author_id") %></label> + <input id="author_id_<%= article.id %>" class="js-username_id_input crayons-textfield" size="6" name="article[user_id]" + value="<%= article.user_id %>"> + </div> + + <div class="crayons-field flex-1"> + <label for="co_author_ids_list_<%= article.id %>" class="crayons-field__label"><%= t("views.moderations.article.co_author_ids") %></label> + <input id="co_author_ids_list_<%= article.id %>" class="js-coauthor_username_id_input crayons-textfield" size="6" name="article[co_author_ids_list]" placeholder="Comma separated" + value="<%= article.co_author_ids&.join(", ") %>"> + </div> + <div class="crayons-field flex-1"> + <label for="max_score_<%= article.id %>" class="crayons-field__label"> + <%= t("views.moderations.article.max_score") %> + <span class="c-indicator"><%= t("views.moderations.article.no_max_if_zero") %></span> + </label> + <input id="max_score_<%= article.id %>" class="crayons-textfield" type="number" name="article[max_score]" value="<%= article.max_score %>"> + </div> + </div> + <% if article.published? %> + <div class="flex"> + <div class="crayons-field"> + <label for="published_at_<%= article.id %>" class="crayons-field__label"><%= t("views.moderations.article.published_at") %></label> + <div class="flex gap-1 items-center flex-wrap"> + <%= f.datetime_select :published_at, { required: true, include_blank: true, include_seconds: true }, class: "crayons-select w-auto" %> + <span class="whitespace-nowrap color-secondary">UTC</span> + </div> + </div> + </div> + <% end %> + <div class="flex items-center gap-4"> + <div class="crayons-field crayons-field--checkbox"> + <%= f.check_box :featured, id: "featured-#{article.id}", class: "crayons-checkbox" %> + <label for="featured-<%= article.id %>" class="crayons-field__label"> + <%= t("views.moderations.article.featured") %> + </label> + </div> + + <div class="crayons-field crayons-field--checkbox"> + <%= f.check_box :approved, id: "approved-#{article.id}", class: "crayons-checkbox" %> + <label for="approved-<%= article.id %>" class="crayons-field__label"> + <%= t("views.moderations.article.approved") %> + </label> + </div> + <div class="crayons-field crayons-field--checkbox"> + <%= f.check_box :email_digest_eligible, id: "email_digest_eligible-#{article.id}", class: "crayons-checkbox" %> + <label for="email_digest_eligible-<%= article.id %>" class="crayons-field__label"> + <%= t("views.moderations.article.email_digest_eligible") %> + </label> + </div> + </div> + <div> + <button class="c-btn c-btn--secondary"><%= t("views.moderations.article.save") %></button> + </div> + <% end %> + </article> + + <% if defined?(show_on_individual_article_only) && show_on_individual_article_only %> + <% privileged_article_reactions = article.reactions.privileged_category.select { |reaction| reaction.reactable_type == "Article" } %> + <% vomit_article_reactions = privileged_article_reactions.select { |reaction| reaction.category == "vomit" }.reverse %> + <% quality_article_reactions = (privileged_article_reactions - vomit_article_reactions).reverse %> + <article class="js-individual-article crayons-card py-6 flex flex-col mt-4"> + <h2 class="crayons-subtitle-2 mx-6"><%= t("views.admin.articles.priviliged_actions.title") %></h2> + <p class="crayons-subtitle-3 fw-normal color-secondary mt-1 mx-6"><%= t("views.admin.articles.priviliged_actions.description") %></p> + + <nav class="mt-4 pt-1 pb-2 px-3 member-data-heading" aria-label="Member details"> + <ul class="crayons-navigation crayons-navigation--horizontal"> + <li><%= link_to "Flags", admin_article_path(tab: :flags), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if params[:tab] == 'flags' || params[:tab].blank?}", aria: @current_tab == "flags" ? { current: "" } : {} %></li></li> + <li><%= link_to "Quality reactions", admin_article_path(tab: :quality_reactions), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if params[:tab] == 'quality_reactions'}", + aria: @current_tab == "quality_reactions" ? { current: "" } : {} %></li></li> + </ul> + </nav> + + <div id="reaction-content" class="flex flex-col gap-3 px-6 mt-6" style="overflow: auto; height: 406px;"> + <% if params[:tab].blank? || params[:tab] == "flags" %> + <%= render "admin/shared/flag_reactions_table", + vomit_reactions: vomit_article_reactions, + text_section: "articles", + empty_text: t("views.admin.articles.priviliged_actions.no_flags") %> + <% end %> + + <% if params[:tab] == "quality_reactions" %> + <% if quality_article_reactions.present? %> + <% quality_article_reactions.each do |quality_reaction| %> + <%= render "admin/shared/quality_action_item", quality_reaction: quality_reaction %> + <hr id="js__reaction__div__hr__<%= quality_reaction.id %>" class="w-100 hr-no-margins"> + <% end %> + <% else %> + <div class="flex flex-col justify-center items-center gap-4 h-100"> + <div class="flex p-4 gap-2 radius-default" style="background: #EEF2FF;"> + <%= crayons_icon_tag("quality-reactions", native: true, width: 56, height: 56) %> + </div> + <p class="crayons-subtitle-3 fw-normal color-secondary"><%= t("views.admin.articles.priviliged_actions.no_quality_reactions") %></p> + </div> + <% end %> + <% end %> + <div> + </article> + <% end %> +</div> diff --git a/app/views/admin/articles/_individual_article.html.erb b/app/views/admin/articles/_individual_article.html.erb deleted file mode 100644 index e165cc131082a..0000000000000 --- a/app/views/admin/articles/_individual_article.html.erb +++ /dev/null @@ -1,168 +0,0 @@ -<% decorated_article = article.decorate %> - -<article class="js-individual-article crayons-card p-6 flex flex-col gap-4" - data-controller="article" - data-article-id-value="<%= article.id %>" - data-article-pin-path-value="<%= stories_feed_pinned_article_path %>" - data-article-bg-highlighted-class="bg-highlighted" - data-article-border-highlighted-class="border-highlighted" - data-action="ajax:success@document->article#ajaxSuccess"> - - <header> - <% if decorated_article.pinned? || article.featured || article.approved || (!article.published? && article.published_from_feed?) || article.video || article.user&.warned? %> - <div class="flex gap-1 mb-1"> - <% if !article.published? && article.published_from_feed? %> - <span class="c-indicator c-indicator--danger">Unpublished</span> - <% end %> - <% if decorated_article.pinned? %> - <span class="c-indicator c-indicator--warning" data-testid="pinned-indicator">Pinned</span> - <% end %> - <% if article.featured %> - <span class="c-indicator c-indicator--success">Featured</span> - <% end %> - <% if article.approved %> - <span class="c-indicator c-indicator--info">Approved</span> - <% end %> - <% if article.video %> - <span class="c-indicator">Contains video</span> - <% end %> - <% cache "admin-user-info-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> - <% if article.user&.warned? %> - <span class="c-indicator c-indicator--danger">User warned</span> - <% end %> - <% end %> - </div> - <% end %> - - <% if article.main_image.present? %> - <a href="<%= article.path %>" target="_blank" rel="noopener" class="c-link c-link--branded mb-4"> - <img src="<%= cloud_cover_url(article.main_image) %>" style="background: <%= article.main_image_background_hex_color %>; max-height:150px; max-width: 300px;" alt="" loading="lazy" class="block radius-default h-auto w-100" /> - </a> - <% end %> - - <h2 class="crayons-subtitle-1"> - <a href="<%= article.path %>" target="_blank" rel="noopener" class="c-link c-link--branded"> - <%= article.title %> - </a> - </h2> - - <div class="-ml-1 fs-s flex items-center gap-4"> - <div class="flex gap-px"> - <% decorated_article.cached_tag_list_array.each do |tag| %> - <a class="crayons-tag crayons-tag--monochrome" href='<%= tag_path(tag) %>'><span class="crayons-tag__prefix">#</span><%= tag %></a> - <% end %> - </div> - - <span>❤️ <%= article.public_reactions_count %> likes</span> - <span>💬 <%= article.comments_count %> comments</span> - </div> - - <div class="flex justify-between gap-4 items-center"> - <div> - <a href="<%= admin_user_path(article.user_id) %>" class="c-link">@<%= article.user&.username %></a> - <% if article.published_from_feed? && !article.published? %> - RSS Import <%= article.created_at.strftime("%b %d, %Y") %> - Originally Published <%= article.published_at&.strftime("%b %d, %Y") %> - <% elsif article.crossposted_at? %> - Crossposted <%= article.crossposted_at.strftime("%b %d, %Y") %> & Published - <%= article.published_at&.strftime("%b %d, %Y") %> - <% else %> - <%= article.published_at&.strftime("%b %d, %Y") %> - <% end %> - </div> - - <div class="flex gap-1"> - <% if decorated_article.pinned? %> - <form method="post" action="<%= unpin_admin_article_path(decorated_article.id) %>" class="inline" - data-action="submit->article#unpinArticle"> - <input type="hidden" name="_method" value="delete" /> - <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" /> - <button type="submit" class="c-btn"> - Unpin post - </button> - </form> - <% else %> - <form method="post" action="<%= pin_admin_article_path(decorated_article.id) %>" class="inline" - data-action="submit->article#pinArticle"> - <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" /> - <button type="submit" class="c-btn"> - Pin post - </button> - </form> - <% end %> - <a class="c-link c-link--block" href="<%= admin_user_path(article.user_id) %>" target="_blank" rel="noopener">User</a> - <a href="<%= article.path %>/edit" target="_blank" rel="noopener" class="c-link c-link--block">Edit</a> - </div> - </div> - </header> - - <% cache "admin-user-notes-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> - <% if article.user&.notes&.any? %> - <div class="crayons-card crayons-card--secondary p-4 pt-3"> - <div class="flex gap-2 items-center"> - <h3 class="crayons-subtitle-2"><%= article.user&.notes&.size %> user notes</h3> - <a href="<%= admin_user_path(article.user_id) %>" class="c-link c-link--block c-link--branded">View all</a> - </div> - <% article.user&.notes&.last(3)&.each do |note| %> - <p> - <%= note.content %> - <span class="color-secondary fs-s"> - – <%= note.author_id ? User.find(note.author_id).username : "No author" %> - <time datetime="<%= note.created_at.strftime("%Y-%m-%dT%H:%M:%S%z") %>" title="<%= note.created_at.strftime("%Y-%m-%d, %H:%M") %>"> - <%= note.created_at.strftime("%b %e '%y") %> - </time> - </span> - </p> - <% end %> - </div> - <% end %> - <% end %> - - <%= form_with url: admin_article_path(article.id), model: article, local: true, class: "crayons-card crayons-card--secondary p-4 flex flex-col gap-3" do |f| %> - <input name="utf8" type="hidden" value="✓"> - <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" /> - <input type="hidden" name="_method" value="patch" /> - <div class="flex gap-4"> - <div class="crayons-field flex-1"> - <label for="author_id_<%= article.id %>" class="crayons-field__label">Author ID</label> - <input id="author_id_<%= article.id %>" class="crayons-textfield" size="6" name="article[user_id]" - value="<%= article.user_id %>"> - </div> - - <div class="crayons-field flex-1"> - <label for="co_author_ids_list_<%= article.id %>" class="crayons-field__label">Co-Author IDs</label> - <input id="co_author_ids_list_<%= article.id %>" class="crayons-textfield" size="6" name="article[co_author_ids_list]" placeholder="Comma separated" - value="<%= article.co_author_ids&.join(", ") %>"> - </div> - </div> - <% if article.published? %> - <div class="flex"> - <div class="crayons-field"> - <label for="published_at_<%= article.id %>" class="crayons-field__label">Published at</label> - <div class="flex gap-1 items-center flex-wrap"> - <%= f.datetime_select :published_at, { required: true, include_blank: true, include_seconds: true }, class: "crayons-select w-auto" %> - <span class="whitespace-nowrap color-secondary">UTC</span> - </div> - </div> - </div> - <% end %> - <div class="flex items-center gap-4"> - <div class="crayons-field crayons-field--checkbox"> - <%= f.check_box :featured, id: "featured-#{article.id}", class: "crayons-checkbox" %> - <label for="featured-<%= article.id %>" class="crayons-field__label"> - Featured - </label> - </div> - - <div class="crayons-field crayons-field--checkbox"> - <%= f.check_box :approved, id: "approved-#{article.id}", class: "crayons-checkbox" %> - <label for="approved-<%= article.id %>" class="crayons-field__label"> - Approved - </label> - </div> - </div> - <div> - <button class="c-btn c-btn--secondary">Save</button> - </div> - <% end %> -</article> diff --git a/app/views/admin/articles/index.html.erb b/app/views/admin/articles/index.html.erb index df4eaee72a1d8..e7ed45cb32569 100644 --- a/app/views/admin/articles/index.html.erb +++ b/app/views/admin/articles/index.html.erb @@ -20,7 +20,7 @@ <%= paginate @articles %> -<div +<div id="member-index-content" class="flex flex-col gap-4" data-controller="article-pinned-modal" data-article-pinned-modal-root-selector-value="#article-pin-modal-root" @@ -34,21 +34,22 @@ <% if @pinned_article.present? %> <div class="crayons-card crayons-card--elevated p-2"> <h2 class="crayons-subtitle-1 flex gap-2 items-center mb-2 p-2"><%= crayons_icon_tag("pin.svg") %> Pinned Article</h2> - <%= render partial: "individual_article", locals: { article: @pinned_article } %> + <%= render partial: "article_item", locals: { article: @pinned_article } %> </div> <% end %> <% if @featured_articles.present? %> <div class="crayons-card p-3"> <h2 class="crayons-subtitle-1 flex gap-2 items-center mb-2">Manually Featured Articles</h2> - <%= render partial: "individual_article", collection: @featured_articles, as: :article %> + <%= render partial: "article_item", collection: @featured_articles, as: :article %> </div> <% end %> - <%= render partial: "individual_article", collection: @articles, as: :article %> + <%= render partial: "article_item", collection: @articles, as: :article %> <%= paginate @articles %> <%= render partial: "pinned_article_modal" %> </div> <%= render partial: "image_upload_script" %> +<%= javascript_include_tag "admin/convertUserIdsToUsernameInputs" %> diff --git a/app/views/admin/articles/show.html.erb b/app/views/admin/articles/show.html.erb index 2933247ce249e..fec169412e4e4 100644 --- a/app/views/admin/articles/show.html.erb +++ b/app/views/admin/articles/show.html.erb @@ -8,6 +8,6 @@ data-article-pinned-modal-ok-button-id-value="article-pin-modal-ok" data-action="article-pinned-modal:open@document->article-pinned-modal#openModal"> - <%= render partial: "individual_article", locals: { article: @article } %> + <%= render partial: "article_item", locals: { article: @article, show_on_individual_article_only: true } %> <%= render partial: "pinned_article_modal" %> </div> diff --git a/app/views/admin/badge_achievements/award.html.erb b/app/views/admin/badge_achievements/award.html.erb index 6e5bb6796c2f7..8dfd68d18001e 100644 --- a/app/views/admin/badge_achievements/award.html.erb +++ b/app/views/admin/badge_achievements/award.html.erb @@ -19,10 +19,17 @@ <div class="crayons-field"> <%= f.label :message_markdown, "Override Default Message", class: "crayons-field__label" %> <p class="crayons-field__description"> - Supports Markdown + Supports Markdown. This overrides the "message" sent in notifications and emails, it does not override the badge description. </p> <%= f.text_area :message_markdown, size: "40x3", class: "crayons-textfield" %> </div> + <div class="crayons-field crayons-field--checkbox pb-2 pl-1"> + <%= f.check_box :include_default_description, checked: true %> + <%= f.label :include_default_description, "Include Default Description", class: "crayons-field__label pt-2" %> + </div> + <p class="crayons-field__description"> + If checked, the default badge <em>description</em> will be included above the message. If unchecked, only the message will be included. + </p> <div> <%= f.submit "Award Badges", class: "c-btn c-btn--primary" %> </div> diff --git a/app/views/admin/badges/edit.html.erb b/app/views/admin/badges/edit.html.erb index 245c470906ec6..65668f70b7577 100644 --- a/app/views/admin/badges/edit.html.erb +++ b/app/views/admin/badges/edit.html.erb @@ -33,6 +33,14 @@ <%= form.text_field :credits_awarded, class: "crayons-textfield" %> </div> + <div class="crayons-field crayons-field--checkbox "> + <%= form.check_box :allow_multiple_awards, class: "crayons-checkbox" %> + <%= form.label :allow_multiple_awards, class: "crayons-field__label" do %> + Allow multiple awards + <p class="crayons-field__description">Allows this badge to be awarded multiple times to the same user</p> + <% end %> + </div> + <div> <%= submit_tag "Update badge", class: "c-btn c-btn--primary" %> </div> diff --git a/app/views/admin/badges/new.html.erb b/app/views/admin/badges/new.html.erb index cd626ce478945..048683c36c56c 100644 --- a/app/views/admin/badges/new.html.erb +++ b/app/views/admin/badges/new.html.erb @@ -22,6 +22,15 @@ <%= form.text_field :credits_awarded, class: "crayons-textfield" %> </div> + <div class="crayons-field crayons-field--checkbox "> + <%= form.check_box :allow_multiple_awards, class: "crayons-checkbox" %> + <%= form.label :allow_multiple_awards, class: "crayons-field__label" do %> + Allow multiple awards + <p class="crayons-field__description">Allows this badge to be awarded multiple times to the same user</p> + <% end %> + </div> + + <div> <%= submit_tag "Create Badge", class: "c-btn c-btn--primary" %> </div> diff --git a/app/views/admin/billboards/_form.html.erb b/app/views/admin/billboards/_form.html.erb new file mode 100644 index 0000000000000..dafa4b50362fb --- /dev/null +++ b/app/views/admin/billboards/_form.html.erb @@ -0,0 +1,187 @@ +<div class="grid l:grid-cols-2 gap-6 mb-4"> + <div class="flex flex-col gap-4"> + <% if @billboard.creator.present? %> + <div class="crayons-field"> + <%= label_tag :creator, "Created by:", class: "crayons-field__label" %> + <%= text_field_tag :creator, @billboard.creator.name, class: "crayons-textfield", disabled: true, autocomplete: "off" %> + </div> + <% end %> + + <div class="crayons-field"> + <%= label_tag :name, "Name:", class: "crayons-field__label" %> + <%= text_field_tag :name, @billboard.name, class: "crayons-textfield", autocomplete: "off" %> + </div> + + <div class="crayons-field"> + <%= label_tag :organization_id, "Organization ID:", class: "crayons-field__label" %> + <%= text_field_tag :organization_id, @billboard.organization_id, class: "crayons-textfield", placeholder: "1234", autocomplete: "off" %> + </div> + + <div class="crayons-field"> + <%= label_tag :custom_display_label, "Custom author display name:", class: "crayons-field__label" %> + <%= text_field_tag :custom_display_label, @billboard.custom_display_label, size: "100x5", class: "crayons-textfield", autocomplete: "off" %> + </div> + + <div class="crayons-field"> + <%= label_tag :body_markdown, "Body Content:", class: "crayons-field__label" %> + <%= text_area_tag :body_markdown, @billboard.body_markdown, size: "100x5", class: "crayons-textfield" %> + </div> + + <div class="crayons-field"> + <%= label_tag :render_mode, "Render Mode:", class: "crayons-field__label" %> + <%= select_tag :render_mode, options_for_select([["Forem Markdown", "forem_markdown"], ["Raw", "raw"]], selected: @billboard.render_mode), class: "crayons-select" %> + </div> + + <div class="crayons-field"> + <%= label_tag :template, "Template:", class: "crayons-field__label" %> + <%= select_tag :template, options_for_select([["Authorship Box", "authorship_box"], ["Plain", "plain"]], selected: @billboard.template), class: "crayons-select" %> + </div> + + <div class="crayons-field"> + <%# styles below are a hack to line this up properly%> + <%= label_tag :color, "Border hex color:", class: "crayons-field__label" %> + <div class="flex items-center w-100" style="margin-top: -26px;"> + <style> + #color-popover-btn-color { top: 36px; } + </style> + <%= text_field_tag :color, @billboard.color, class: "crayons-textfield", placeholder: "#000000", data: { color_picker: true, label_text: "Border color" } %> + </div> + </div> + + <div class="crayons-field"> + <%= label_tag :placement_area, "Placement Area:", class: "crayons-field__label" %> + <%= select_tag :placement_area, options_for_select(billboards_placement_area_options_array, selected: @billboard.placement_area), include_blank: "Select...", class: "crayons-select js-placement-area" %> + </div> + + <div class="crayons-field billboard-requires-precision-targeting hidden"> + <%= label_tag :browser_context, "Browser context:", class: "crayons-field__label" %> + <%= select_tag :browser_context, options_for_select([["All browsers", "all_browsers"], ["Desktop", "desktop"], ["Mobile web", "mobile_web"], ["Mobile in-app", "mobile_in_app"]], selected: @billboard.browser_context), class: "crayons-select js-placement-area" %> + </div> + + <div class="crayons-field"> + <div id="billboard-targeted-tags"></div> + </div> + + <% if FeatureFlag.enabled?(Geolocation::FEATURE_FLAG) %> + <div class="crayons-field billboard-requires-precision-targeting hidden"> + <%= label_tag :target_geolocations, "Target Geolocations:", class: "crayons-field__label" %> + <%= text_field_tag :target_geolocations, @billboard.target_geolocations.map(&:to_iso3166).join(", "), class: "crayons-textfield", placeholder: "US-NY, CA-ON", autocomplete: "off" %> + </div> + <% end %> + + <div class="crayons-field hidden"> + <%= label_tag :tag_list, "Tag List:", class: "crayons-field__label" %> + <%= text_field_tag :tag_list, @billboard.tag_list.to_s, class: "crayons-textfield js-tags-textfield", autocomplete: "off" %> + </div> + + <div class="crayons-field hidden"> + <%= label_tag :exclude_article_ids, "Exclude Article IDs:", class: "crayons-field__label" %> + <%= text_field_tag :exclude_article_ids, @billboard.exclude_article_ids.join(", "), class: "crayons-textfield js-exclude-ids-textfield", autocomplete: "off" %> + </div> + + <div class="crayons-field"> + <fieldset aria-describedby="section-description" aria-describedby="display-to-description"> + <legend class="crayons-field crayons-field__label pl-0">Display to user group</legend> + <p id="display-to-description" class="crayons-field__description mb-2">Determines which user group will be able to see the Billboard</p> + + <label class="crayons-field crayons-field--radio mb-2"> + <%= radio_button_tag :display_to, "all", @billboard.display_to_all?, class: "crayons-radio" %> + <div class="crayons-field__label">All users</div> + </label> + + <label class="crayons-field crayons-field--radio mb-2"> + <%= radio_button_tag :display_to, "logged_in", @billboard.display_to_logged_in?, class: "crayons-radio" %> + <div class="crayons-field__label">Only logged in users</div> + </label> + + <label class="crayons-field crayons-field--radio mb-2"> + <%= radio_button_tag :display_to, "logged_out", @billboard.display_to_logged_out?, class: "crayons-radio" %> + <div class="crayons-field__label">Only logged out users</div> + </label> + </fieldset> + </div> + + <div class="crayons-field <%= "hidden" unless @billboard.display_to_logged_in? %>"> + <%= label_tag :audience_segment_id, "Users in segment:", class: "crayons-field__label" %> + <% if @billboard.audience_segment&.manual? %> + <%= select_tag :audience_segment_id, + options_for_select(single_audience_segment_option(@billboard), selected: @billboard.audience_segment_id), + disabled: true, + class: "crayons-select js-user-target" %> + <% else %> + <%= select_tag :audience_segment_id, + options_for_select(automatic_audience_segments_options_array, selected: @billboard.audience_segment_id), + include_blank: "All users", + class: "crayons-select js-user-target" %> + <% end %> + </div> + + <div class="crayons-field <%= "hidden" unless @billboard.display_to_logged_in? %>"> + <%= label_tag :target_role_names, "Target Role Names:", class: "crayons-field__label" %> + <%= text_field_tag :target_role_names, @billboard.target_role_names.join(", "), class: "crayons-textfield js-user-target", autocomplete: "off", placeholder: "e.g. admin, tag_moderator" %> + </div> + + <div class="crayons-field <%= "hidden" unless @billboard.display_to_logged_in? %>"> + <%= label_tag :exclude_role_names, "Exclude Role Names:", class: "crayons-field__label" %> + <%= text_field_tag :exclude_role_names, @billboard.exclude_role_names.join(", "), class: "crayons-textfield js-user-target", autocomplete: "off" , placeholder: "e.g. admin, tag_moderator" %> + </div> + + <div class="crayons-field"> + <fieldset aria-describedby="section-description" aria-describedby="type-of-description"> + <legend class="crayons-field crayons-field__label pl-0">Type</legend> + + <label class="crayons-field crayons-field--radio mb-2"> + <%= radio_button_tag :type_of, "in_house", @billboard.in_house?, class: "crayons-radio" %> + <div class="crayons-field__label">In-House Ad</div> + </label> + + <label class="crayons-field crayons-field--radio mb-2"> + <%= radio_button_tag :type_of, "community", @billboard.community?, class: "crayons-radio" %> + <div class="crayons-field__label">Community</div> + </label> + + <label class="crayons-field crayons-field--radio mb-2"> + <%= radio_button_tag :type_of, "external", @billboard.external?, class: "crayons-radio" %> + <div class="crayons-field__label">External</div> + </label> + </fieldset> + </div> + + <div class="crayons-field"> + <%= label_tag :published, "Published:", class: "crayons-field__label" %> + <%= select_tag :published, options_for_select([false, true], selected: @billboard.published), class: "crayons-select" %> + </div> + + <div class="crayons-field"> + <%= label_tag :approved, "Approved:", class: "crayons-field__label" %> + <%= select_tag :approved, options_for_select([false, true], selected: @billboard.approved), class: "crayons-select" %> + </div> + + <div class="crayons-field"> + <%= label_tag :priority, "Prioritized:", class: "crayons-field__label" %> + <%= select_tag :priority, options_for_select([false, true], selected: @billboard.priority), class: "crayons-select" %> + </div> + </div> + <div> + <div class="crayons-card crayons-card--secondary crayons-bb text-styles text-styles--billboard" style="<%= @billboard.style_string %>"> + <% if @billboard.persisted? %> + <%= @billboard.processed_html.html_safe %> + <% else %> + <div class="flex flex-col gap-3"> + <p> + Billboards will show up in the designated placement area <strong>once published and approved</strong>. You can safely preview them here before publishing. + </p> + <p> + Multiple billboards that share the same placement area will be swapped every few minutes. <strong>The units with the most engagement will show up the most often</strong>. + </p> + <p> + Organization ID is optional. Use it if you want to attribute a billboard to a specific organization. + </p> + </div> + <% end %> + </div> + </div> +</div> + +<%= javascript_include_tag "admin/billboards", defer: true %> +<%= javascript_include_tag "enhanceColorPickers", defer: true %> diff --git a/app/views/admin/billboards/edit.html.erb b/app/views/admin/billboards/edit.html.erb new file mode 100644 index 0000000000000..995817d8a07a4 --- /dev/null +++ b/app/views/admin/billboards/edit.html.erb @@ -0,0 +1,11 @@ +<%= stylesheet_link_tag "minimal", media: "all" %> +<h1 class="crayons-title mb-6 flex justify-between"> + Edit Billboard: <%= @billboard.name %> + <%= link_to "Details and Data", admin_billboard_path(@billboard), class: "crayons-btn crayons-btn--secondary" %> +</h1> +<div class="crayons-card p-6"> + <%= form_for([:admin, @billboard], url: admin_billboard_path(@billboard), method: :patch) do %> + <%= render "form" %> + <%= submit_tag "Update Billboard", class: "c-btn c-btn--primary" %> + <% end %> +</div> diff --git a/app/views/admin/billboards/index.html.erb b/app/views/admin/billboards/index.html.erb new file mode 100644 index 0000000000000..7109bbb28e634 --- /dev/null +++ b/app/views/admin/billboards/index.html.erb @@ -0,0 +1,62 @@ +<h1 class="crayons-title mb-3">Billboards</h1> + +<div + data-controller="confirmation-modal" + data-confirmation-modal-root-selector-value="#confirmation-modal-root" + data-confirmation-modal-content-selector-value="#confirmation-modal" + data-confirmation-modal-title-value="Confirm changes" + data-confirmation-modal-size-value="m"> + <nav class="flex mb-4" aria-label="Billboards navigation"> + <%= form_tag(admin_billboards_path, method: "get") do %> + <%= text_field_tag(:search, params[:search], aria: { label: "Search" }, class: "crayons-header--search-input crayons-textfield", placeholder: "Search", autocomplete: "off") %> + <% end %> + <div class="ml-auto"> + <div class="justify-end"> + <%= link_to "Make A New Billboard", new_admin_billboard_path, class: "crayons-btn" %> + </div> + </div> + </nav> + + <%= paginate @billboards %> + + <table class="crayons-table" width="100%"> + <thead> + <tr> + <th scope="col">Name</th> + <th scope="col">Placement Area</th> + <th scope="col">Display to User Group</th> + <th scope="col">Type</th> + <th scope="col">Public?</th> + <th scope="col">Success Rate</th> + </tr> + </thead> + <tbody class="crayons-card"> + <% @billboards.each do |billboard| %> + <tr data-row-id="<%= billboard.id %>"> + <td><%= link_to billboard.name, edit_admin_billboard_path(billboard) %></td> + <td><%= billboard.human_readable_placement_area %></td> + <td><%= billboard.display_to %></td> + <td><%= billboard.type_of.titleize %></td> + <% if billboard.published? && billboard.approved? %> + <td><span class="crayons-icon" role="img" aria-label="Ad is published and approved">✅</span></td> + <% else %> + <td><span class="crayons-icon" role="img" aria-label="Ad is not published or approved">❌</span></td> + <% end %> + <td><%= billboard.success_rate %></td> + <td><%= link_to "Details", admin_billboard_path(billboard), class: "crayons-btn" %></td> + <td><%= link_to "Edit", edit_admin_billboard_path(billboard), class: "crayons-btn crayons-btn--secondary" %></td> + <td> + <button + class="crayons-btn crayons-btn--danger" + data-item-id="<%= billboard.id %>" + data-endpoint="/admin/customization/billboards" + data-username="<%= current_user.username %>" + data-action="click->confirmation-modal#openModal">Destroy</button> + </td> + </tr> + <% end %> + </tbody> + </table> + <%= render partial: "admin/shared/destroy_confirmation_modal" %> + <%= paginate @billboards %> +</div> diff --git a/app/views/admin/billboards/new.html.erb b/app/views/admin/billboards/new.html.erb new file mode 100644 index 0000000000000..c0c6c7be94718 --- /dev/null +++ b/app/views/admin/billboards/new.html.erb @@ -0,0 +1,7 @@ +<h1 class="crayons-title mb-4">Make a new Billboard</h1> +<div class="crayons-card p-6"> + <%= form_for([:admin, @billboard], url: admin_billboards_path, method: :post) do %> + <%= render "form" %> + <%= submit_tag "Save Billboard", class: "c-btn c-btn--primary" %> + <% end %> +</div> diff --git a/app/views/admin/billboards/show.html.erb b/app/views/admin/billboards/show.html.erb new file mode 100644 index 0000000000000..9346a00cac82a --- /dev/null +++ b/app/views/admin/billboards/show.html.erb @@ -0,0 +1,151 @@ +<%= stylesheet_link_tag "minimal", media: "all" %> + +<h1 class="crayons-title mb-6 flex justify-between"> + Details and Data: <%= @billboard.name %> + <%= link_to "Edit", edit_admin_billboard_path(@billboard), class: "crayons-btn crayons-btn--secondary ml-2" %> +</h1> + +<div class="crayons-card p-6"> + <%# Main content %> + + <!-- Billboard Overview --> + <div class="grid l:grid-cols-2 gap-6 mb-4"> + <div class="flex flex-col gap-4"> + <% if @billboard.creator.present? %> + <div class="crayons-field"> + <strong>Created by:</strong> + <p><%= @billboard.creator.name %></p> + </div> + <% end %> + + <div class="crayons-field"> + <strong>Name:</strong> + <p><%= @billboard.name %></p> + </div> + + <div class="crayons-field"> + <strong>Organization ID:</strong> + <p><%= @billboard.organization_id.present? ? @billboard.organization_id : "N/A" %></p> + </div> + + <div class="crayons-field"> + <strong>Custom Author Display Name:</strong> + <p><%= @billboard.custom_display_label.present? ? @billboard.custom_display_label : "N/A" %></p> + </div> + + <div class="crayons-field"> + <strong>Body Content:</strong> + <pre><%= @billboard.body_markdown %></pre> + </div> + + <div class="crayons-field"> + <strong>Render Mode:</strong> + <p><%= @billboard.render_mode == "forem_markdown" ? "Forem Markdown" : "Raw" %></p> + </div> + + <div class="crayons-field"> + <strong>Template:</strong> + <p><%= @billboard.template == "authorship_box" ? "Authorship Box" : "Plain" %></p> + </div> + + <div class="crayons-field"> + <strong>Border Color:</strong> + <p><%= @billboard.color.present? ? @billboard.color : "#000000" %></p> + </div> + + <div class="crayons-field"> + <strong>Placement Area:</strong> + <p><%= @billboard.placement_area %></p> + </div> + + <% if FeatureFlag.enabled?(Geolocation::FEATURE_FLAG) %> + <div class="crayons-field"> + <strong>Target Geolocations:</strong> + <p><%= @billboard.target_geolocations.present? ? @billboard.target_geolocations.map(&:to_iso3166).join(", ") : "N/A" %></p> + </div> + <% end %> + + <div class="crayons-field"> + <strong>Display to:</strong> + <p> + <% if @billboard.display_to_all? %> + All users + <% elsif @billboard.display_to_logged_in? %> + Logged in users + <% else %> + Logged out users + <% end %> + </p> + </div> + + <% if @billboard.display_to_logged_in? %> + <div class="crayons-field"> + <strong>Target Role Names:</strong> + <p><%= @billboard.target_role_names.join(", ") %></p> + </div> + + <div class="crayons-field"> + <strong>Exclude Role Names:</strong> + <p><%= @billboard.exclude_role_names.join(", ") %></p> + </div> + <% end %> + + <div class="crayons-field"> + <strong>Published:</strong> + <p><%= @billboard.published ? "Yes" : "No" %></p> + </div> + + <div class="crayons-field"> + <strong>Approved:</strong> + <p><%= @billboard.approved ? "Yes" : "No" %></p> + </div> + + <div class="crayons-field"> + <strong>Prioritized:</strong> + <p><%= @billboard.priority ? "Yes" : "No" %></p> + </div> + + <div class="crayons-field"> + <strong>Type:</strong> + <p> + <% if @billboard.in_house? %> + In-House Ad + <% elsif @billboard.community? %> + Community + <% else %> + External + <% end %> + </p> + </div> + </div> + + <div> + <div class="crayons-card crayons-card--secondary crayons-bb text-styles text-styles--billboard" style="<%= @billboard.style_string %>"> + <% if @billboard.persisted? %> + <%= @billboard.processed_html.html_safe %> + <% end %> + </div> + <div class="crayons-card crayons-card--secondary crayons-bb text-styles text-styles--billboard mt-4" style="<%= @billboard.style_string %>"> + <div class="crayons-field"> + <strong>Impressions Count:</strong> + <p><%= @billboard.impressions_count %></p> + </div> + <div class="crayons-field"> + <strong>Clicks Count:</strong> + <p><%= @billboard.clicks_count %></p> + </div> + <div class="crayons-field"> + <strong>Success Rate:</strong> + <p><%= @billboard.success_rate %></p> + </div> + <% @events.each do |event| %> + <% next unless event.user %> + <div class="crayonds-card fs-xs"> + <strong><%= event.category %></strong> by @<%= event.user&.username %> at <%= event.created_at %> + </div> + <% end %> + </div> + </div> + </div> +</div> + diff --git a/app/views/admin/bulk_assign_role/index.html.erb b/app/views/admin/bulk_assign_role/index.html.erb new file mode 100644 index 0000000000000..80988155cb0cd --- /dev/null +++ b/app/views/admin/bulk_assign_role/index.html.erb @@ -0,0 +1,25 @@ +<header class="flex justify-between items-center mb-4"> + <h1 class="crayons-title"><%= t("views.admin.users.actions.bulk_assign_role.text") %></h1> +</header> +<div class="crayons-card p-6"> + <%= form_with(url: admin_bulk_assign_role_path, local: true, class: "flex flex-col gap-4") do |f| %> + <div class="crayons-field"> + <%= f.label :role, t("views.admin.users.actions.assign_role.role"), class: "crayons-field__label" %> + <%= f.select("role", role_options(current_user)["Statuses"], { include_blank: t("views.admin.users.actions.assign_role.select") }, class: "crayons-select") %> + </div> + <div class="crayons-field"> + <%= f.label :usernames, "Usernames", class: "crayons-field__label" %> + <p class="crayons-field__description"> + <%= t("views.admin.users.actions.assign_role.warning") %> + </p> + <%= f.text_area :usernames, placeholder: "username1, username2, username3", required: true, size: "40x3", class: "crayons-textfield" %> + </div> + <div class="crayons-field"> + <%= f.label :note_for_current_role, t("views.admin.users.actions.assign_role.note"), class: "crayons-field__label" %> + <%= f.text_area :note_for_current_role, required: true, class: "crayons-textfield", placeholder: "" %> + </div> + <div> + <%= f.submit t("views.admin.users.actions.assign_role.text"), class: "c-btn c-btn--primary" %> + </div> + <% end %> +</div> diff --git a/app/views/admin/comments/_comment.html.erb b/app/views/admin/comments/_comment.html.erb new file mode 100644 index 0000000000000..7dbb2a17de6ac --- /dev/null +++ b/app/views/admin/comments/_comment.html.erb @@ -0,0 +1,102 @@ +<div> + <article class="crayons-card p-4 pt-3 pb-2 flex flex-col gap-3"> + <% if comment.commentable %> + <header class="flex justify-between gap-4 items-center border-b-1 border-base-10 border-solid border-0 pb-3 -mx-2 px-2"> + <div class="flex gap-2 items-center"> + <% if comment.user %> + <a href="<%= comment.user.path %>" target="_blank" rel="noopener" class="shrink-0 c-link"> + <img width="32" height="32" class="radius-full block" src="<%= comment.user.profile_image_url_for(length: 64) %>" alt="<%= comment.user.username %> profile" loading="lazy" /> + </a> + <% end %> + <p> + <a href="<%= comment.user.path %>" target="_blank" rel="noopener" class="c-link c-link--branded fw-bold"><%= comment.user.username %></a> + on: + <a href="<%= comment.commentable.path %>" class="c-link c-link--branded"><%= comment.commentable.title %></a> + </p> + </div> + <div class="flex items-center gap-1"> + <span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.thumb_up") %>"> + <%= crayons_icon_tag("twemoji/thumb-up", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= comment.privileged_reaction_counts["thumbsup"] || "0" %></span> + </span> + + <span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.thumb_down") %>"> + <%= crayons_icon_tag("twemoji/thumb-down", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= comment.privileged_reaction_counts["thumbsdown"] || "0" %></span> + </span> + + <span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.vomit") %>"> + <%= crayons_icon_tag("twemoji/flag", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= @countable_vomits&.dig(comment.id) || 0 %></span> + </span> + + <span class="crayons-card crayons-card--secondary px-3 py-1 ml-3 flex gap-2 items-center" title="<%= t("views.moderations.actions.score") %>"> + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= comment.score %></span> + </span> + </div> + </header> + <% end %> + <div class="text-styles text-styles--tertiary"> + <%= sanitize comment.processed_html, + tags: %w[strong em p h1 h2 h3 h4 h5 h6 i u b code pre br ul ol li small sup img a span hr blockquote], + attributes: %w[href strong em ref rel src title alt class] %> + </div> + <footer class="fs-s flex gap-4 border-t-1 border-base-10 border-solid border-0 pt-2 -mx-2 pl-2 justify-between items-center"> + <span>❤️ <%= pluralize(comment.public_reactions_count, "like") %></span> + <div class="ml-auto"> + <a class="c-link c-link--block" href="<%= comment.path %>" target="_blank" rel="noopener"> + <%= t("views.moderations.comments.view_comment") %> + </a> + </div> + <% if !defined?(is_individual_comment) || !is_individual_comment %> + <a href="/admin/content_manager/comments/<%= comment.id %>" class="c-link c-link--block"> + <%= t("views.moderations.comments.view_details") %> + </a> + <% end %> + </footer> + </article> + + <% if defined?(is_individual_comment) && is_individual_comment %> + <% privileged_comment_reactions = comment.reactions.privileged_category %> + <% vomit_comment_reactions = privileged_comment_reactions.select { |reaction| reaction.category == "vomit" }.reverse %> + <% quality_comment_reactions = (privileged_comment_reactions - vomit_comment_reactions).reverse %> + <article class="js-individual-article crayons-card py-6 flex flex-col mt-4"> + <h2 class="crayons-subtitle-2 mx-6"><%= t("views.admin.comments.priviliged_actions.title") %></h2> + <p class="crayons-subtitle-3 fw-normal color-secondary mt-1 mx-6"><%= t("views.admin.comments.priviliged_actions.description") %></p> + + <nav class="mt-4 pt-1 pb-2 px-3 member-data-heading" aria-label="Member details"> + <ul class="crayons-navigation crayons-navigation--horizontal"> + <li><%= link_to "Flags", admin_comment_path(tab: :flags), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if params[:tab] == 'flags' || params[:tab].blank?}", aria: @current_tab == "flags" ? { current: "" } : {} %></li></li> + <li><%= link_to "Quality reactions", admin_comment_path(tab: :quality_reactions), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if params[:tab] == 'quality_reactions'}", + aria: @current_tab == "quality_reactions" ? { current: "" } : {} %></li></li> + </ul> + </nav> + + <div id="reaction-content" class="flex flex-col gap-3 px-6 mt-6" style="overflow: auto; height: 406px;"> + <% if params[:tab].blank? || params[:tab] == "flags" %> + <%= render "admin/shared/flag_reactions_table", + vomit_reactions: vomit_comment_reactions, + text_section: "comments", + empty_text: t("views.admin.comments.priviliged_actions.no_flags") %> + <% end %> + + <% if params[:tab] == "quality_reactions" %> + <% if quality_comment_reactions.present? %> + <% quality_comment_reactions.each do |quality_reaction| %> + <%= render "admin/shared/quality_action_item", quality_reaction: quality_reaction %> + <hr id="js__reaction__div__hr__<%= quality_reaction.id %>" class="w-100 hr-no-margins"> + <% end %> + <% else %> + <div class="flex flex-col justify-center items-center gap-4 h-100"> + <div class="flex p-4 gap-2 radius-default" style="background: #EEF2FF;"> + <%= crayons_icon_tag("quality-reactions", native: true, width: 56, height: 56) %> + </div> + <p class="crayons-subtitle-3 fw-normal color-secondary"><%= t("views.admin.comments.priviliged_actions.no_quality_reactions") %></p> + </div> + <% end %> + <% end %> + <div> + </article> + <% end %> +</div> diff --git a/app/views/admin/comments/index.html.erb b/app/views/admin/comments/index.html.erb index 2b5b4a228f79a..d27c1086b0435 100644 --- a/app/views/admin/comments/index.html.erb +++ b/app/views/admin/comments/index.html.erb @@ -1,58 +1,8 @@ <h1 class="crayons-title">Comments</h1> - <%= paginate @comments %> <div class="flex flex-col gap-2"> - <% @comments.each do |comment| %> - <article class="crayons-card p-4 pt-3 pb-2 flex flex-col gap-3"> - <% if comment.commentable %> - <header class="flex justify-between gap-4 items-center border-b-1 border-base-10 border-solid border-0 pb-3 -mx-2 px-2"> - <div class="flex gap-2 items-center"> - <% if comment.user %> - <a href="<%= comment.user.path %>" target="_blank" rel="noopener" class="shrink-0 c-link"> - <img width="32" height="32" class="radius-full block" src="<%= comment.user.profile_image_url_for(length: 64) %>" alt="<%= comment.user.username %> profile" loading="lazy" /> - </a> - <% end %> - <p> - <a href="<%= comment.user.path %>" target="_blank" rel="noopener" class="c-link c-link--branded fw-bold"><%= comment.user.username %></a> - on: - <a href="<%= comment.commentable.path %>" class="c-link c-link--branded"><%= comment.commentable.title %></a> - </p> - </div> - </header> - <% end %> - <div class="text-styles text-styles--tertiary"> - <%= sanitize comment.processed_html, - tags: %w[strong em p h1 h2 h3 h4 h5 h6 i u b code pre br ul ol li small sup img a span hr blockquote], - attributes: %w[href strong em ref rel src title alt class] %> - </div> - <footer class="fs-s flex gap-4 border-t-1 border-base-10 border-solid border-0 pt-2 -mx-2 pl-2 justify-between items-center"> - <span>❤️ <%= pluralize(comment.public_reactions_count, "like") %></span> - <% if comment.reactions.where(user_id: current_user.id).empty? %> - <%= form_tag(reactions_path, remote: true) do %> - <%= hidden_field_tag(:reactable_type, "Comment") %> - <%= hidden_field_tag(:reactable_id, comment.id) %> - <button class="c-btn c-btn--icon-left"> - <%= crayons_icon_tag(:heart, class: "c-btn__icon", width: 18, height: 18) %> - Like - </button> - <% end %> - <% end %> - <div class="ml-auto"> - <a class="c-link c-link--block" href="<%= comment.path %>" target="_blank" rel="noopener">View</a> - </div> - </footer> - </article> - <% end %> + <%= render partial: "comment", collection: @comments, locals: { is_individual_comment: false } %> </div> <%= paginate @comments %> - -<script> - window.addEventListener('load', function() { - document.querySelector("form").addEventListener("submit", function(e) { - this.classList.toggle("hidden"); - this.classList.toggle("inline"); - }); - }); -</script> diff --git a/app/views/admin/comments/show.html.erb b/app/views/admin/comments/show.html.erb new file mode 100644 index 0000000000000..9e2ffcf89ef4e --- /dev/null +++ b/app/views/admin/comments/show.html.erb @@ -0,0 +1,2 @@ +<h1 class="crayons-title">Comment</h1> +<%= render partial: "comment", locals: { comment: @comment, is_individual_comment: true } %> diff --git a/app/views/admin/creator_settings/_form.html.erb b/app/views/admin/creator_settings/_form.html.erb index 9e76d95b597e5..88e61b775bec4 100644 --- a/app/views/admin/creator_settings/_form.html.erb +++ b/app/views/admin/creator_settings/_form.html.erb @@ -82,4 +82,4 @@ </div> </fieldset> </div> -<%= javascript_packs_with_chunks_tag "admin/creatorOnboarding", defer: true %> +<%= javascript_include_tag "admin/creatorOnboarding", defer: true %> diff --git a/app/views/admin/display_ads/_form.html.erb b/app/views/admin/display_ads/_form.html.erb deleted file mode 100644 index 6ec7c7135f56b..0000000000000 --- a/app/views/admin/display_ads/_form.html.erb +++ /dev/null @@ -1,113 +0,0 @@ -<div class="grid l:grid-cols-2 gap-6 mb-4"> - <div class="flex flex-col gap-4"> - <% if @display_ad.creator.present? %> - <div class="crayons-field"> - <%= label_tag :creator, "Created by:", class: "crayons-field__label" %> - <%= text_field_tag :creator, @display_ad.creator.name, class: "crayons-textfield", disabled: true, autocomplete: "off" %> - </div> - <% end %> - - <div class="crayons-field"> - <%= label_tag :name, "Name:", class: "crayons-field__label" %> - <%= text_field_tag :name, @display_ad.name, class: "crayons-textfield", autocomplete: "off" %> - </div> - - <div class="crayons-field"> - <%= label_tag :organization_id, "Organization ID:", class: "crayons-field__label" %> - <%= text_field_tag :organization_id, @display_ad.organization_id, class: "crayons-textfield", placeholder: "1234", autocomplete: "off" %> - </div> - - <div class="crayons-field"> - <%= label_tag :body_markdown, "Body Markdown:", class: "crayons-field__label" %> - <%= text_area_tag :body_markdown, @display_ad.body_markdown, size: "100x5", class: "crayons-textfield" %> - </div> - - <div class="crayons-field"> - <%= label_tag :placement_area, "Placement Area:", class: "crayons-field__label" %> - <%= select_tag :placement_area, options_for_select(display_ads_placement_area_options_array, selected: @display_ad.placement_area), include_blank: "Select...", class: "crayons-select js-placement-area" %> - </div> - - <div class="crayons-field"> - <div id="display-ad-targeted-tags"></div> - </div> - - <div class="crayons-field hidden"> - <%= label_tag :tag_list, "Tag List:", class: "crayons-field__label" %> - <%= text_field_tag :tag_list, @display_ad.tag_list.to_s, class: "crayons-textfield js-tags-textfield", autocomplete: "off" %> - </div> - - <div class="crayons-field"> - <fieldset aria-describedby="section-description" aria-describedby="display-to-description"> - <legend class="crayons-field crayons-field__label pl-0">Display to user group</legend> - <p id="display-to-description" class="crayons-field__description mb-2">Determines which user group will be able to see the Display Ad</p> - - <label class="crayons-field crayons-field--radio mb-2"> - <%= radio_button_tag :display_to, "all", @display_ad.display_to_all?, class: "crayons-radio" %> - <div class="crayons-field__label">All users</div> - </label> - - <label class="crayons-field crayons-field--radio mb-2"> - <%= radio_button_tag :display_to, "logged_in", @display_ad.display_to_logged_in?, class: "crayons-radio" %> - <div class="crayons-field__label">Only logged in users</div> - </label> - - <label class="crayons-field crayons-field--radio mb-2"> - <%= radio_button_tag :display_to, "logged_out", @display_ad.display_to_logged_out?, class: "crayons-radio" %> - <div class="crayons-field__label">Only logged out users</div> - </label> - </fieldset> - </div> - - <div class="crayons-field"> - <fieldset aria-describedby="section-description" aria-describedby="type-of-description"> - <legend class="crayons-field crayons-field__label pl-0">Type</legend> - - <label class="crayons-field crayons-field--radio mb-2"> - <%= radio_button_tag :type_of, "in_house", @display_ad.in_house?, class: "crayons-radio" %> - <div class="crayons-field__label">In-House Ad</div> - </label> - - <label class="crayons-field crayons-field--radio mb-2"> - <%= radio_button_tag :type_of, "community", @display_ad.community?, class: "crayons-radio" %> - <div class="crayons-field__label">Community</div> - </label> - - <label class="crayons-field crayons-field--radio mb-2"> - <%= radio_button_tag :type_of, "external", @display_ad.external?, class: "crayons-radio" %> - <div class="crayons-field__label">External</div> - </label> - </fieldset> - </div> - - <div class="crayons-field"> - <%= label_tag :published, "Published:", class: "crayons-field__label" %> - <%= select_tag :published, options_for_select([false, true], selected: @display_ad.published), class: "crayons-select" %> - </div> - - <div class="crayons-field"> - <%= label_tag :approved, "Approved:", class: "crayons-field__label" %> - <%= select_tag :approved, options_for_select([false, true], selected: @display_ad.approved), class: "crayons-select" %> - </div> - </div> - <div> - <div class="crayons-card crayons-card--secondary p-4 crayons-sponsorship-widget text-styles"> - <% if @display_ad.persisted? %> - <%= @display_ad.processed_html.html_safe %> - <% else %> - <div class="flex flex-col gap-3"> - <p> - Display ads will show up in the designated placement area <strong>once published and approved</strong>. You can safely preview them here before publishing. - </p> - <p> - Multiple ads that share the same placement area will be swapped every few minutes. <strong>The units with the most engagement will show up the most often</strong>. - </p> - <p> - Organization ID is optional. Use it if you want to attribute an ad to a specific organization. - </p> - </div> - <% end %> - </div> - </div> -</div> - -<%= javascript_packs_with_chunks_tag "admin/displayAds", defer: true %> diff --git a/app/views/admin/display_ads/edit.html.erb b/app/views/admin/display_ads/edit.html.erb deleted file mode 100644 index 3ae4023984ee7..0000000000000 --- a/app/views/admin/display_ads/edit.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%= stylesheet_link_tag "minimal", media: "all" %> -<h1 class="crayons-title mb-6">Edit Display Ad:</h1> -<div class="crayons-card p-6"> - <%= form_for([:admin, @display_ad], method: :patch) do %> - <%= render "form" %> - <%= submit_tag "Update Display Ad", class: "c-btn c-btn--primary" %> - <% end %> -</div> diff --git a/app/views/admin/display_ads/index.html.erb b/app/views/admin/display_ads/index.html.erb deleted file mode 100644 index 99ea6bb73841a..0000000000000 --- a/app/views/admin/display_ads/index.html.erb +++ /dev/null @@ -1,61 +0,0 @@ -<h1 class="crayons-title mb-3">Display Ads</h1> - -<div - data-controller="confirmation-modal" - data-confirmation-modal-root-selector-value="#confirmation-modal-root" - data-confirmation-modal-content-selector-value="#confirmation-modal" - data-confirmation-modal-title-value="Confirm changes" - data-confirmation-modal-size-value="m"> - <nav class="flex mb-4" aria-label="Display Ads navigation"> - <%= form_tag(admin_display_ads_path, method: "get") do %> - <%= text_field_tag(:search, params[:search], aria: { label: "Search" }, class: "crayons-header--search-input crayons-textfield", placeholder: "Search", autocomplete: "off") %> - <% end %> - <div class="ml-auto"> - <div class="justify-end"> - <%= link_to "Make A New Display Ad", new_admin_display_ad_path, class: "crayons-btn" %> - </div> - </div> - </nav> - - <%= paginate @display_ads %> - - <table class="crayons-table" width="100%"> - <thead> - <tr> - <th scope="col">Name</th> - <th scope="col">Placement Area</th> - <th scope="col">Display to User Group</th> - <th scope="col">Type</th> - <th scope="col">Public?</th> - <th scope="col">Success Rate</th> - </tr> - </thead> - <tbody class="crayons-card"> - <% @display_ads.each do |display_ad| %> - <tr data-row-id="<%= display_ad.id %>"> - <td><%= link_to display_ad.name, edit_admin_display_ad_path(display_ad) %></td> - <td><%= display_ad.human_readable_placement_area %></td> - <td><%= display_ad.display_to %></td> - <td><%= display_ad.type_of.titleize %></td> - <% if display_ad.published? && display_ad.approved? %> - <td><span class="crayons-icon" role="img" aria-label="Ad is published and approved">✅</span></td> - <% else %> - <td><span class="crayons-icon" role="img" aria-label="Ad is not published or approved">❌</span></td> - <% end %> - <td><%= display_ad.success_rate %></td> - <td><%= link_to "Edit", edit_admin_display_ad_path(display_ad), class: "crayons-btn" %></td> - <td> - <button - class="crayons-btn crayons-btn--danger" - data-item-id="<%= display_ad.id %>" - data-endpoint="/admin/customization/display_ads" - data-username="<%= current_user.username %>" - data-action="click->confirmation-modal#openModal">Destroy</button> - </td> - </tr> - <% end %> - </tbody> - </table> - <%= render partial: "admin/shared/destroy_confirmation_modal" %> - <%= paginate @display_ads %> -</div> diff --git a/app/views/admin/display_ads/new.html.erb b/app/views/admin/display_ads/new.html.erb deleted file mode 100644 index acfc7564e1fbc..0000000000000 --- a/app/views/admin/display_ads/new.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<h1 class="crayons-title mb-4">Make a new Display Ad</h1> -<div class="crayons-card p-6"> - <%= form_for([:admin, @display_ad], method: :post) do %> - <%= render "form" %> - <%= submit_tag "Save Display Ad", class: "c-btn c-btn--primary" %> - <% end %> -</div> diff --git a/app/views/admin/emails/_form.html.erb b/app/views/admin/emails/_form.html.erb new file mode 100644 index 0000000000000..840e3a5c13baa --- /dev/null +++ b/app/views/admin/emails/_form.html.erb @@ -0,0 +1,65 @@ +<div class="grid l:grid-cols-2 gap-6 mb-4"> + <div class="crayons-field"> + <div class="crayons-field"> + <%= form.label :type_of, "Type:", class: "crayons-field__label" %> + <%= form.select :type_of, options_for_select(Email.type_ofs.keys, selected: @email.type_of), {}, { class: "crayons-textfield", autocomplete: "off" } %> + </div> + + <div class="crayons-field"> + <%= form.label :audience_segment_id, "Audience Segment:", class: "crayons-field__label" %> + <%= form.select :audience_segment_id, options_for_select([["Entire list", nil]] + @audience_segments.map { |s| [s.name || s.type_of, s.id]}), {}, { class: "crayons-textfield", autocomplete: "off" } %> + </div> + + <div class="crayons-field"> + <%= form.label :subject, "Subject:", class: "crayons-field__label" %> + <%= form.text_field :subject, class: "crayons-textfield", autocomplete: "off" %> + </div> + + <div class="crayons-field"> + <%= form.label :drip_day, "Drip Day (onboarding drip only):", class: "crayons-field__label" %> + <%= form.number_field :drip_day, class: "crayons-textfield", autocomplete: "off" %> + </div> + + <div class="crayons-field"> + <%= form.label :status, "Status:", class: "crayons-field__label" %> + <%= form.select :status, options_for_select(Email.statuses.keys, selected: @email.status), {}, { class: "crayons-textfield", autocomplete: "off" } %> + </div> + + <div class="crayons-field"> + <%= form.label :body, "Body Content:", class: "crayons-field__label" %> + <%= form.text_area :body, size: "100x5", class: "crayons-textfield" %> + </div> + </div> + + <div> + <div class="crayons-card crayons-card--secondary p-4"> + <% if @email.persisted? %> + <h2 class="crayons-title mb-2">Preview</h2> + <p><strong>Subject:</strong><%= Email.replace_merge_tags(@email.subject, current_user) %> </p> + <p><strong>Body:</strong></p> + <div class="crayons-article__body text-styles"> + <%= Email.replace_merge_tags(@email.body, current_user).html_safe %> + </div> + <% else %> + <div class="flex flex-col gap-3"> + <p> + Use this form to compose a new email. Fill in the subject, body, and specify the recipients. + </p> + <p> + You can use the following merge tags: + </p> + <p> + <code>*|name|*</code>, <code>*|username|*</code>, <code>*|email|*</code> + </p> + <p> + <strong> + One-off and newsletter emails will be sent immediately once marked as "Active". Onboarding drip emails will be sent on the specified day when marked "Active". + </strong> + </p> + </div> + <% end %> + </div> + </div> +</div> + +<%#= javascript_include_tag "admin/emails", defer: true %> diff --git a/app/views/admin/emails/edit.html.erb b/app/views/admin/emails/edit.html.erb new file mode 100644 index 0000000000000..b59b8d0d7696f --- /dev/null +++ b/app/views/admin/emails/edit.html.erb @@ -0,0 +1,12 @@ +<h1 class="crayons-title mb-4">Make a new Email</h1> +<% if @email.status != "draft" && @email.type_of != "onboarding_drip" %> + <div class="crayons-notice crayons-notice--danger p-4 my-2"> + This email is not an onboarding drip email. It has already been sent, and updating it will not have any affect on sent emails. + </div> +<% end %> +<div class="crayons-card p-6"> + <%= form_with(model: [:admin, @email], url: admin_email_path(@email), method: :patch, local: true) do |form| %> + <%= render "form", form: form %> + <%= submit_tag "Update Email", class: "c-btn c-btn--primary" %> + <% end %> +</div> diff --git a/app/views/admin/emails/index.html.erb b/app/views/admin/emails/index.html.erb new file mode 100644 index 0000000000000..79795fd0bdf4b --- /dev/null +++ b/app/views/admin/emails/index.html.erb @@ -0,0 +1,58 @@ +<h1 class="crayons-title mb-3">Emails</h1> + +<div + data-controller="confirmation-modal" + data-confirmation-modal-root-selector-value="#confirmation-modal-root" + data-confirmation-modal-content-selector-value="#confirmation-modal" + data-confirmation-modal-title-value="Confirm changes" + data-confirmation-modal-size-value="m"> + + <nav class="flex mb-4" aria-label="Emails navigation"> + <%= form_tag(admin_emails_path, method: "get") do %> + <%= text_field_tag(:search, params[:search], aria: { label: "Search" }, class: "crayons-header--search-input crayons-textfield", placeholder: "Search", autocomplete: "off") %> + <% end %> + <div class="ml-auto"> + <div class="justify-end"> + <%= link_to "Compose New Email", new_admin_email_path, class: "crayons-btn" %> + </div> + </div> + </nav> + + <%= paginate @emails %> + + + <table class="crayons-table" width="100%"> + <thead> + <tr> + <th scope="col">Type</th> + <th scope="col">Subject</th> + <th scope="col">Sent At</th> + <th scope="col">Segment</th> + <th scope="col">Body</th> + </tr> + </thead> + <tbody class="crayons-card"> + <% @emails.each do |email| %> + <tr data-row-id="<%= email.id %>" style="background: <%= email.bg_color %> !important"> + <td><%= email.type_of %> <%= "(#{email.drip_day})" if email.type_of == "onboarding_drip" %></td> + <td><%= link_to email.subject, admin_email_path(email) %></td> + <td><%= email.created_at %></td> + <td><%= email.audience_segment&.name || email.audience_segment&.type_of || "All" %></td> + <td><%= truncate(email.body, length: 100) %></td> + <td><%= link_to "Details", admin_email_path(email), class: "crayons-btn" %></td> + <td> + <button + class="crayons-btn crayons-btn--danger" + data-item-id="<%= email.id %>" + data-endpoint="/admin/emails" + data-username="<%= current_user.username %>" + data-action="click->confirmation-modal#openModal">Destroy</button> + </td> + </tr> + <% end %> + </tbody> + </table> + + <%= render partial: "admin/shared/destroy_confirmation_modal" %> + <%= paginate @emails %> +</div> diff --git a/app/views/admin/emails/new.html.erb b/app/views/admin/emails/new.html.erb new file mode 100644 index 0000000000000..4cb3d96447710 --- /dev/null +++ b/app/views/admin/emails/new.html.erb @@ -0,0 +1,7 @@ +<h1 class="crayons-title mb-4">Make a new Email</h1> +<div class="crayons-card p-6"> + <%= form_with(model: [:admin, @email], url: admin_emails_path, method: :post, local: true) do |form| %> + <%= render "form", form: form %> + <%= submit_tag "Save Email", class: "c-btn c-btn--primary" %> + <% end %> +</div> diff --git a/app/views/admin/emails/show.html.erb b/app/views/admin/emails/show.html.erb new file mode 100644 index 0000000000000..0d472700431a7 --- /dev/null +++ b/app/views/admin/emails/show.html.erb @@ -0,0 +1,33 @@ +<h1 class="crayons-title mb-4"> + <%= Email.replace_merge_tags(@email.subject, current_user) %> [<%= @email.type_of %>] [<%= @email.status %>] + <%= link_to "Edit", edit_admin_email_path(@email), class: "crayons-btn ml-3" %> +</h1> +<div class="crayons-card p-6 mb-4"> + <h2 class="crayons-title mb-4">Rendered Email</h2> + <div> + <%= Email.replace_merge_tags(@email.body, current_user).html_safe %> + </div> +</div> + +<div class="crayons-card p-6"> + <h2 class="crayons-title mb-4">Raw HTML</h2> + <div class="mb-4"> + <%= @email.subject %> + </div> + <pre> + <%= @email.body %> + </pre> +</div> + +<div class="crayons-card p-6 mb-4"> + <h2 class="crayons-title mb-4">Send test email</h2> + <%= form_with(model: [:admin, @email], local: true) do |f| %> + <div class="crayons-field"> + <%= f.label :test_email_addresses, class: "crayons-field__label" %> + <%= f.text_field :test_email_addresses, class: "crayons-textfield", placeholder: "email@address.com, email2@other.com" %> + </div> + <div class="crayons-field mt-2"> + <%= f.submit "Send Test Email", class: "crayons-btn crayons-btn--primary" %> + </div> + <% end %> +</div> diff --git a/app/views/admin/feedback_messages/_abuse_reports.html.erb b/app/views/admin/feedback_messages/_abuse_reports.html.erb index 187d5b20d8b7c..f1f8fdf023ae1 100644 --- a/app/views/admin/feedback_messages/_abuse_reports.html.erb +++ b/app/views/admin/feedback_messages/_abuse_reports.html.erb @@ -11,23 +11,24 @@ <a href="<%= admin_reports_path(state: @feedback_type, status: "Invalid") %>" class="crayons-navigation__item <%= "crayons-navigation__item--current" if @status == "Invalid" %>">Invalid</a> </ul> - <div id="vomitReactions"> - <details class="crayons-card w-100"> + <div id="vomitReactions" class="mt-2"> + <details class="crayons-card w-100 cursor-pointer"> <summary class="crayons-subtitle-2 p-5" id="vomitReactionsHeader"> Flag Reactions + <% if @status == "Open" %> + (<%= @vomits.size %>) + <% end %> </summary> <div id="vomitReactionsBodyContainer" aria-labelledby="vomitReactionsHeader" data-parent="#vomitReactions"> <div class="crayons-card__body" style="overflow: scroll; max-height: 500px;"> <% @vomits.each do |reaction| %> - <% next if (reaction.reactable_type == "Article" && !reaction.reactable.published) || (reaction.reactable_type == "User" && reaction.reactable&.banished?) %> - <div class="flex justify-between" data-controller="reaction" data-reaction-id-value="<%= reaction.id %>" data-reaction-url-value="<%= admin_reaction_path(reaction.id) %>"> <span> - <%= crayons_icon_tag("twemoji/suspicious", native: true, class: "mr-1", aria_hidden: true) %> <a href="<%= reaction.user.path %>" target="_blank" rel="noopener">@<%= reaction.user.username %></a> + <%= crayons_icon_tag("twemoji/flag", native: true, class: "mr-1", aria_hidden: true) %> <a href="<%= reaction.user.path %>" target="_blank" rel="noopener">@<%= reaction.user.username %></a> <% if reaction.user_id == Settings::General.mascot_user_id %> <strong>(auto-generated)</strong> <% end %> @@ -35,7 +36,9 @@ <span> <strong><%= reaction.reactable_type %>:</strong> <a href="<%= reaction.reactable.path %>" target="_blank" rel="noopener"><%= reaction.reactable_type == "User" ? reaction.reactable.username : reaction.reactable.title %></a> - <% if reaction.reactable_type == "User" && reaction.reactable.suspended? %> + <% if reaction.reactable_type == "User" && reaction.reactable.spam? %> + <span class="c-indicator c-indicator--danger">Spam</span> + <% elsif reaction.reactable_type == "User" && reaction.reactable.suspended? %> <span class="c-indicator c-indicator--danger">Suspended</span> <% end %> <% if reaction.reactable_type == "User" && reaction.reactable.vomited_on? %> diff --git a/app/views/admin/feedback_messages/_feedback_message.html.erb b/app/views/admin/feedback_messages/_feedback_message.html.erb index f0126ffb72f9d..34ec056d7a018 100644 --- a/app/views/admin/feedback_messages/_feedback_message.html.erb +++ b/app/views/admin/feedback_messages/_feedback_message.html.erb @@ -1,74 +1,80 @@ <% if action_name == "show" %> <% feedback_message_emails = @email_messages %> + <% feedback_message_notes = @notes %> <% else %> <% feedback_message_emails = @email_messages.select { |email| email.feedback_message_id == feedback_message.id } %> + <% feedback_message_notes = @notes.select { |note| note.noteable_id == feedback_message.id } %> <% end %> -<details open class="crayons-card" id="accordion-<%= feedback_message.id %>"> +<details <%= "open" if action_name == "show" %> class="crayons-card" id="accordion-<%= feedback_message.id %>"> <summary class="p-5"> <span id="header<%= feedback_message.id %>"> <span id="collapse__header__link-<%= feedback_message.id %>"> Report #<%= feedback_message.id %> - Submitted: <%= time_ago_in_words feedback_message.created_at %> ago </span> <div class="float-right"> + <span class="c-indicator c-indicator--warning crayons-subtitle-2"><%= feedback_message.category.titleize %></span> ✉️ Emails Sent: <%= feedback_message_emails.size %> | <%= link_to "Link to Report #{feedback_message.id}", admin_report_path(feedback_message.id) %> + </div> </span> </summary> <div aria-labelledby="header<%= feedback_message.id %>" data-parent="#accordion-<%= feedback_message.id %>"> <div class="crayons-card__body"> + <%# report details %> <div class="flex flex-wrap -mx-4"> <div class="w-75 max-w-75 px-4"> - <h3 class="crayons-subtitle-2"> + <h3 class="crayons-subtitle-3"> <% if feedback_message.offender %> Reporter and Affected: <% else %> Reporter: <% end %> </h3> - <h3 class="crayons-subtitle-2 fw-normal"> + <p> <% if feedback_message.reporter_id? %> - <%= feedback_message.reporter.name %> - <a href="<%= feedback_message.reporter.path %>">@<%= feedback_message.reporter.username %></a> + <div class="flex flex-1 items-center"> + <%= feedback_message.reporter.name %> + <a href="<%= feedback_message.reporter.path %>">@<%= feedback_message.reporter.username %></a> + <span class="fs-bold"><%= render partial: "admin/users/show/profile/status", locals: { user: feedback_message.reporter } %></span> + </div> <% else %> Anonymous <% end %> - </h3> + </p> <% if feedback_message.offender %> - <h3 class="crayons-subtitle-2"> + <h3 class="crayons-subtitle-3"> Offender: </h3> - <h3 class="crayons-subtitle-2 fw-normal"> - <%= feedback_message.offender.name %> - <a href="<%= feedback_message.offender.path %>">@<%= feedback_message.offender.username %></a> - </h3> + <p> + <div class="flex flex-1 items-center"> + <%= feedback_message.offender.name %> + <a href="<%= feedback_message.offender.path %>">@<%= feedback_message.offender.username %></a> + <%= render partial: "admin/users/show/profile/status", locals: { user: feedback_message.offender } %> + </div> + </p> <% else %> - <h3 class="crayons-subtitle-2"> + <h3 class="crayons-subtitle-3"> Reported URL (new tab): </h3> - <h3 class="crayons-subtitle-2 fw-normal"> + <p> <a href="<%= feedback_message.reported_url %>" target="_blank" rel="noopener"><%= feedback_message.reported_url %></a> - </h3> + </p> <% end %> </div> - <div class="w-25 max-w-25 px-4"> - <h3 class="crayons-subtitle-2 report__tags"> - <span class="c-indicator c-indicator--warning float-right"><%= feedback_message.category.titleize %></span> - </h3> - </div> <div class="w-100 max-w-100 px-4"> <% if feedback_message.offender %> - <h3 class="crayons-subtitle-2"> + <h3 class="crayons-subtitle-3"> Message from Offender: </h3> <div class="reported__message"> <%= raw(feedback_message.message) %> </div> <% else %> - <h3 class="crayons-subtitle-2"> + <h3 class="crayons-subtitle-3"> Message: </h3> <p> @@ -82,91 +88,126 @@ </div> </div> <hr> - <div class="flex flex-wrap -mx-4"> - <div class="w-100 max-w-100 px-4"> - <h3 class="crayons-subtitle-2">Previous Emails:</h3> - <div class="previous__emails__container"> - <% if feedback_message_emails.any? %> - <% feedback_message_emails.each do |email| %> - <div class="email__container"> - <p class="to__subject">Type: <%= email.utm_campaign&.capitalize %></p> - <p class="to__subject">To: <%= email.to %></p> - <p class="to__subject">Subject: <%= email.subject %></p> - <%= email.html_content.html_safe %> - </div> + <%# status %> + + <div class="flex flex-wrap -mx-4 my-3"> + <div class="w-100 max-w-100 px-4"> + <h3 class="crayons-subtitle-2">Set Status:</h3> + + <% case feedback_message.status %> + <% when 'Open' %> + <div class="flex"> + <%= button_tag "✅ Resolved", type: "button", data: { status: "Resolved", id: feedback_message.id }, class: "c-btn c-btn--primary block mt-3 mr-2 status-button" %> + <%= button_tag "❌ Invalid", type: "button", data: { status: "Invalid", id: feedback_message.id }, class: "c-btn c-btn--primary block mt-3 status-button" %> + </div> + <% when 'Invalid' %> + <div class="flex"> + <%= button_tag "❔ Open", type: "button", data: { status: "Open", id: feedback_message.id }, class: "c-btn c-btn--primary block mt-3 mr-2 status-button" %> + <%= button_tag "✅ Resolved", type: "button", data: { status: "Resolved", id: feedback_message.id }, class: "c-btn c-btn--primary block mt-3 status-button" %> + </div> + <% when 'Resolved' %> + <div class="flex"> + <%= button_tag "❔Open", type: "button", data: { status: "Open", id: feedback_message.id }, class: "c-btn c-btn--primary block mt-3 mr-2 status-button" %> + <%= button_tag "❌ Invalid", type: "button", data: { status: "Invalid", id: feedback_message.id }, class: "c-btn c-btn--primary block mt-3 status-button" %> + </div> + <% end %> + <div class="mt-2 fs-italic hidden" id="update_message__<%= feedback_message.id %>">You have marked this as Resolved</div> + + </div> +</div> + <%# emails %> + <details class="flex flex-wrap"> + <summary class="py-2"> + <span class="crayons-subtitle-2"> + ✉️ Emails Sent: <%= feedback_message_emails.size %> + </span> + </summary> + <%# previous emails %> + <div class="flex flex-wrap -mx-4"> + <div class="w-100 max-w-100 px-4"> + <h3 class="crayons-subtitle-2">Previous Emails:</h3> + <div class="previous__emails__container"> + <% if feedback_message_emails.any? %> + <% feedback_message_emails.each do |email| %> + <div class="email__container"> + <p class="to__subject">Type: <%= email.utm_campaign&.capitalize %></p> + <p class="to__subject">To: <%= email.to %></p> + <p class="to__subject">Subject: <%= email.subject %></p> + <%= email.html_content.html_safe %> + </div> + <% end %> + <% else %> + <h3 class="crayons-subtitle-2 fw-normal">No Email Records</h3> <% end %> - <% else %> - <h3 class="crayons-subtitle-2 fw-normal">No Email Records</h3> - <% end %> + </div> </div> </div> - </div> - <hr> - <div class="flex flex-wrap -mx-4"> - <div class="w-100 max-w-100 px-4"> - <h3 class="crayons-subtitle-2">Email Form:</h3> - <ul class="crayons-navigation crayons-navigation--horizontal" role="tablist"> - <li role="presentation" > - <a id="tab-reporter-<%= feedback_message.id %>" class="crayons-navigation__item crayons-navigation__item--current" href="#reporter-<%= feedback_message.id %>" aria-controls="reporter-<%= feedback_message.id %>" role="tab">Reporter</a> - </li> - <li role="presentation"> - <a id="tab-offender-<%= feedback_message.id %>" class="crayons-navigation__item" href="#offender-<%= feedback_message.id %>" aria-controls="offender-<%= feedback_message.id %>" role="tab">Offender</a> - </li> - <li role="presentation"> - <a id="tab-affected-<%= feedback_message.id %>" class="crayons-navigation__item" href="#affected-<%= feedback_message.id %>" aria-controls="affected-<%= feedback_message.id %>" role="tab">Affected</a> - </li> - </ul> - <div class="my-3"> - <div role="tabcard" class="" data-id="<%= feedback_message.id %>" data-userType="reporter" id="reporter-<%= feedback_message.id %>"> - <div class="crayons-notice crayons-notice--info" aria-live="polite">Please note that all users who report abuse receive an automatic confirmation email. You can send an additional, follow-up email to this user by using the form below.</div> - <br> - <h3 class="crayons-subtitle-2">Send To:</h3> - <%= email_field_tag :reporter_email_to, feedback_message.reporter&.email, class: "crayons-textfield my-1", id: "reporter__emailto__#{feedback_message.id}", required: true %> - <h3 class="crayons-subtitle-2">Subject:</h3> - <%= text_field_tag :reporter_email_subject, reporter_email_details[:subject], class: "crayons-textfield my-1", id: "reporter__subject__#{feedback_message.id}" %> - <h3 class="crayons-subtitle-2">Body:</h3> - <%= text_area_tag :reporter_email_body, reporter_email_details[:body], class: "crayons-textfield my-1", style: "height: 300px;", id: "reporter__body__#{feedback_message.id}" %> - </div> - <div role="tabcard" class="hidden" data-id="<%= feedback_message.id %>" data-userType="offender" id="offender-<%= feedback_message.id %>"> - <h3 class="crayons-subtitle-2">Send To:</h3> - <%= email_field_tag :offender_email_to, feedback_message.offender&.email, class: "crayons-textfield my-1", id: "offender__emailto__#{feedback_message.id}", required: true %> - <h3 class="crayons-subtitle-2">Subject:</h3> - <%= text_field_tag :offender_email_subject, offender_email_details[:subject], class: "crayons-textfield my-1", id: "offender__subject__#{feedback_message.id}" %> - <h3 class="crayons-subtitle-2">Body:</h3> - <%= text_area_tag :offender_email_body, offender_email_details[:body], class: "crayons-textfield my-1", style: "height: 300px;", id: "offender__body__#{feedback_message.id}" %> - </div> - <div role="tabcard" class="hidden" data-id="<%= feedback_message.id %>" data-userType="affected" id="affected-<%= feedback_message.id %>"> - <h3 class="crayons-subtitle-2">Send To:</h3> - <%= email_field_tag :affected_email_to, feedback_message.affected&.email, class: "crayons-textfield my-1", id: "affected__emailto__#{feedback_message.id}", required: true %> - <h3 class="crayons-subtitle-2">Subject:</h3> - <%= text_field_tag :affected_email_subject, affected_email_details[:subject], class: "crayons-textfield my-1", id: "affected__subject__#{feedback_message.id}" %> - <h3 class="crayons-subtitle-2">Body:</h3> - <%= text_area_tag :affected_email_body, affected_email_details[:body], class: "crayons-textfield my-1", style: "height: 300px;", id: "affected__body__#{feedback_message.id}" %> + <hr> + <%# email form %> + <div class="flex flex-wrap -mx-4"> + <div class="w-100 max-w-100 px-4"> + <h3 class="crayons-subtitle-2">Email Form:</h3> + <ul class="crayons-navigation crayons-navigation--horizontal" role="tablist"> + <li role="presentation"> + <a id="tab-reporter-<%= feedback_message.id %>" class="crayons-navigation__item crayons-navigation__item--current" href="#reporter-<%= feedback_message.id %>" aria-controls="reporter-<%= feedback_message.id %>" role="tab">Reporter</a> + </li> + <li role="presentation"> + <a id="tab-offender-<%= feedback_message.id %>" class="crayons-navigation__item" href="#offender-<%= feedback_message.id %>" aria-controls="offender-<%= feedback_message.id %>" role="tab">Offender</a> + </li> + <li role="presentation"> + <a id="tab-affected-<%= feedback_message.id %>" class="crayons-navigation__item" href="#affected-<%= feedback_message.id %>" aria-controls="affected-<%= feedback_message.id %>" role="tab">Affected</a> + </li> + </ul> + <div class="my-3"> + <div role="tabcard" class="tab-pane" data-id="<%= feedback_message.id %>" data-userType="reporter" id="reporter-<%= feedback_message.id %>"> + <div class="crayons-notice crayons-notice--info" aria-live="polite">Please note that all users who report abuse receive an automatic confirmation email. You can send an additional, follow-up email to this user by using the form below.</div> + <br> + <h3 class="crayons-subtitle-2">Send To:</h3> + <%= email_field_tag :reporter_email_to, feedback_message.reporter&.email, class: "crayons-textfield my-1", id: "reporter__emailto__#{feedback_message.id}", required: true, autocomplete: "off" %> + <h3 class="crayons-subtitle-2">Subject:</h3> + <%= text_field_tag :reporter_email_subject, reporter_email_details[:subject], class: "crayons-textfield my-1", id: "reporter__subject__#{feedback_message.id}", autocomplete: "off" %> + <h3 class="crayons-subtitle-2">Body:</h3> + <%= text_area_tag :reporter_email_body, reporter_email_details[:body], class: "crayons-textfield my-1", style: "height: 300px;", id: "reporter__body__#{feedback_message.id}" %> + </div> + <div role="tabcard" class="hidden" data-id="<%= feedback_message.id %>" data-userType="offender" id="offender-<%= feedback_message.id %>"> + <h3 class="crayons-subtitle-2">Send To:</h3> + <%= email_field_tag :offender_email_to, feedback_message.offender&.email, class: "crayons-textfield my-1", id: "offender__emailto__#{feedback_message.id}", required: true, autocomplete: "off" %> + <h3 class="crayons-subtitle-2">Subject:</h3> + <%= text_field_tag :offender_email_subject, offender_email_details[:subject], class: "crayons-textfield my-1", id: "offender__subject__#{feedback_message.id}", autocomplete: "off" %> + <h3 class="crayons-subtitle-2">Body:</h3> + <%= text_area_tag :offender_email_body, offender_email_details[:body], class: "crayons-textfield my-1", style: "height: 300px;", id: "offender__body__#{feedback_message.id}" %> + </div> + <div role="tabcard" class="hidden" data-id="<%= feedback_message.id %>" data-userType="affected" id="affected-<%= feedback_message.id %>"> + <h3 class="crayons-subtitle-2">Send To:</h3> + <%= email_field_tag :affected_email_to, feedback_message.affected&.email, class: "crayons-textfield my-1", id: "affected__emailto__#{feedback_message.id}", required: true, autocomplete: "off" %> + <h3 class="crayons-subtitle-2">Subject:</h3> + <%= text_field_tag :affected_email_subject, affected_email_details[:subject], class: "crayons-textfield my-1", id: "affected__subject__#{feedback_message.id}", autocomplete: "off" %> + <h3 class="crayons-subtitle-2">Body:</h3> + <%= text_area_tag :affected_email_body, affected_email_details[:body], class: "crayons-textfield my-1", style: "height: 300px;", id: "affected__body__#{feedback_message.id}" %> + </div> </div> + <button class="c-btn c-btn--primary" type="button" id="send__email__btn__<%= feedback_message.id %>">Send Email ✉️</button> </div> - <button class="c-btn c-btn--primary" type="button" id="send__email__btn__<%= feedback_message.id %>">Send Email ✉️</button> </div> - </div> - <div class="email__alert crayons-notice w-100 mt-2 hidden" id="email__alert__<%= feedback_message.id %>"> - </div> - <div class="flex flex-wrap -mx-4 my-3"> - <div class="w-100 max-w-100 px-4"> - <h3 class="crayons-subtitle-2">Status:</h3> - <%= f.select :status, %w[Open Invalid Resolved], {}, id: "status__#{feedback_message.id}" %> - <button class="c-btn c-btn--primary block mt-3" type="button" id="save__status__<%= feedback_message.id %>">Save Status</button> + <div class="email__alert crayons-notice w-100 mt-2 hidden" id="email__alert__<%= feedback_message.id %>"> </div> - </div> - <div class="flex flex-wrap -mx-4 my-3"> + </details> + <%# notes %> + <details class="flex flex-wrap"> + <summary class="py-2"> + <span class="crayons-subtitle-2"> + 📝 Notes: <%= feedback_message_notes.size %> + </span> + </summary> <div class="w-100 max-w-100 px-4"> - <h3 class="crayons-subtitle-2">Notes:</h3> <div id="notes__<%= feedback_message.id %>"> - <% feedback_message.notes&.order(:created_at)&.each do |note| %> + <% feedback_message_notes.each do |note| %> <div class="notes__border my-2 p-2"> <span class="c-indicator c-indicator--info float-right"> <%= time_ago_in_words note.created_at %> ago </span> <h3 class="crayons-subtitle-2"> - <%= note.author.name %>: + <%= note.author&.name || "No Author" %>: </h3> <p> <%= note.content %> @@ -182,14 +223,15 @@ <input type="textarea" name="content" - placeholder="Leave some notes about the status and context of this report." + placeholder="Leave some notes for the admin team about the status and context of this report." class="notefield" id="note__content__<%= feedback_message.id %>" required> <button class="c-btn c-btn--primary" type="button" id="note__submit__<%= feedback_message.id %>">Submit Note 📝</button> + <span class="fs-italic">Notes are only viewable by other admins</span> </div> </div> - </div> + </details> <div class="flex flex-wrap -mx-4"> <div class="w-100 max-w-100 px-4 flex justify-end"> <button class="c-btn c-btn--primary" type="button" id="minimize__report__button__<%= feedback_message.id %>"> @@ -231,51 +273,51 @@ document.getElementById(`reporter-${reportId}`).classList.add("hidden"); document.getElementById(`offender-${reportId}`).classList.add("hidden"); document.getElementById(`affected-${reportId}`).classList.add("hidden"); - + // Remove tab-pane class from all tab contents document.getElementById(`reporter-${reportId}`).classList.remove("tab-pane"); document.getElementById(`offender-${reportId}`).classList.remove("tab-pane"); document.getElementById(`affected-${reportId}`).classList.remove("tab-pane"); - + // Finally, make the tab active and its related content visible document.getElementById(`${tabName}-${reportId}`).classList.remove("hidden"); document.getElementById(`${tabName}-${reportId}`).classList.add("tab-pane"); document.getElementById(`tab-${tabName}-${reportId}`).classList.add("crayons-navigation__item--current"); } - function saveStatus(id) { - var formData = new FormData(); - var statusSelectTag = document.getElementById('status__' + id); - var statusBtn = document.getElementById('save__status__' + id); - formData.append('id', id); - formData.append('status', statusSelectTag.options[statusSelectTag.selectedIndex].value); - - fetch("<%= save_status_admin_reports_path %>", { - method: 'POST', - headers: { - 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").content, - }, - body: formData, - credentials: 'same-origin' - }) - .then(response => response.json() - .then(json => { - if (json.outcome === 'Success') { - console.log(json.outcome) - statusBtn.innerText = "Saved!"; - setTimeout(function () { - statusBtn.innerText = "Save Status" - }, 1000); - } else { - } - })) - } - - document.getElementById('save__status__' + <%= feedback_message.id %>).addEventListener('click', function (event) { - event.preventDefault(); - var reportId = <%= feedback_message.id %> - saveStatus(reportId); - }); +document.getElementById('accordion-<%= feedback_message.id %>').addEventListener('click', function(event) { + if (event.target.classList.contains('status-button')) { + var status = event.target.dataset.status; + var id = event.target.dataset.id; + saveStatus(id, status); + } +}); + +function saveStatus(id, status) { + var formData = new FormData(); + formData.append('id', id); + formData.append('status', status); // 'Open', 'Invalid', or 'Resolved' + + fetch("<%= save_status_admin_reports_path %>", { + method: 'POST', + headers: { + 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").content, + }, + body: formData, + credentials: 'same-origin' + }) + .then(response => response.json()) + .then(json => { + if (json.outcome === 'Success') { + document.getElementById('collapse__header__link-' + id).click(); + document.getElementById('update_message__' + id).innerHTML = `You have marked this as ${status}` + document.getElementById('update_message__' + id).classList.remove('hidden') + } else { + console.log(json); + } + }) + .catch(error => console.error('Error:', error)); +} function successfulEmail(alert) { alert.classList.remove("hidden"); diff --git a/app/views/admin/feedback_messages/index.html.erb b/app/views/admin/feedback_messages/index.html.erb index 21cae46829f03..4700f8594c79c 100644 --- a/app/views/admin/feedback_messages/index.html.erb +++ b/app/views/admin/feedback_messages/index.html.erb @@ -1,29 +1,31 @@ <%= render "style" %> -<h1 class="crayons-title mb-6">Suspicious activity</h1> - <div class="grid gap-6"> <%= render "abuse_reports" %> <div class="crayons-card p-4"> <%= search_form_for @q, url: admin_feedback_messages_path, class: "flex flex-row flex-wrap items-center crayons-card crayons-card--secondary p-3" do |f| %> - <%= f.label :reported_url_cont, "Reported URL", class: "crayons-field__label m-1 screen-reader-only" %> <%= f.search_field :reported_url_cont, placeholder: "Reported URL", class: "crayons-textfield w-auto m-1" %> <%= f.label :reporter_username_cont, "Reporter", class: "crayons-field__label m-1 screen-reader-only" %> <%= f.search_field :reporter_username_cont, placeholder: "Reporter", class: "crayons-textfield w-auto m-1" %> - - <%= f.select(:status_eq, options_for_select(%w[Open Invalid Resolved], @q.status_eq), { include_blank: true }, class: "crayons-select w-auto m-1") %> + <%= f.label :status_eq, "Status", class: "crayons-field__label m-1 screen-reader-only" %> + <%= f.select(:status_eq, options_for_select(%w[Open Invalid Resolved], params[:status] || "Open"), { include_blank: true }, class: "crayons-select w-auto m-1") %> + <%= f.label :category_eq, "Category", class: "crayons-field__label m-1 screen-reader-only" %> +<%= f.select :category_eq, options_for_select(FeedbackMessage::CATEGORIES, params[:category]), { include_blank: true }, class: "crayons-select w-auto m-1" %> <%= f.submit "Search", class: "crayons-btn crayons-btn--secondary m-1" %> <% end %> - <div class="pt-4 px-2 pb-4"> - <strong>Sort:</strong> - <%= tag.span(sort_link(@q, :reporter_username, "Reporter"), class: "mx-2") %> - <%= tag.span(sort_link(@q, :reported_url, "Reported URL"), class: "mx-2") %> - <%= tag.span(sort_link(@q, :created_at, "Creation Date"), class: "ml-2") %> + <div class="flex flex-wrap justify-between"> + <div class="pt-4 px-2 pb-4"> + <strong>Sort:</strong> + <%= tag.span(sort_link(@q, :reporter_username, "Reporter"), class: "mx-2") %> + <%= tag.span(sort_link(@q, :reported_url, "Reported URL"), class: "mx-2") %> + <%= tag.span(sort_link(@q, :created_at, "Creation Date"), class: "ml-2") %> + </div> + <button id="toggleAll" class="crayons-btn crayons-btn--secondary m-1">Expand All</button> </div> <%= paginate @feedback_messages %> @@ -39,3 +41,17 @@ </div> <%= paginate @feedback_messages %> + +<script> + document.getElementById('toggleAll').addEventListener('click', function() { + // Select only details elements with ids starting with 'accordion-' + const detailsElements = document.querySelectorAll('details[id^="accordion-"]'); + const shouldExpand = this.textContent.includes('Expand'); + + detailsElements.forEach(details => { + details.open = shouldExpand; + }); + + this.textContent = shouldExpand ? 'Collapse All' : 'Expand All'; + }); +</script> diff --git a/app/views/admin/gdpr_delete_requests/index.html.erb b/app/views/admin/gdpr_delete_requests/index.html.erb index bbaa6fa7500fe..6e320bf4e40b2 100644 --- a/app/views/admin/gdpr_delete_requests/index.html.erb +++ b/app/views/admin/gdpr_delete_requests/index.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "admin/users/gdprDeleteRequests", defer: true %> +<%= javascript_include_tag "admin/users/gdprDeleteRequests", defer: true %> <div id="gdpr-delete-requests-content" class="crayons-card overflow-admin-main-layout-padding p-4 m:p-0"> <header class="flex flex-col l:flex-row justify-between l:items-center p-0 m:p-7 pb-4"> <h1 class="crayons-title ml-3 l:ml-0">GDPR Actions</h1> diff --git a/app/views/admin/invitations/index.html.erb b/app/views/admin/invitations/index.html.erb index 494585c13fa53..a71dae4bc0642 100644 --- a/app/views/admin/invitations/index.html.erb +++ b/app/views/admin/invitations/index.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "admin/users/controls", "admin/users/memberIndex", defer: true %> +<%= javascript_include_tag "admin/users/controls", "admin/users/memberIndex", defer: true %> <div id="member-index-content" class="crayons-card overflow-admin-main-layout-padding p-0"> <header class="flex flex-col p-4 xl:p-7 pb-4"> <div class="flex flex-col l:flex-row justify-between l:items-center"> diff --git a/app/views/admin/invitations/new.html.erb b/app/views/admin/invitations/new.html.erb index f83b4e9877a67..f012cb78161d1 100644 --- a/app/views/admin/invitations/new.html.erb +++ b/app/views/admin/invitations/new.html.erb @@ -30,6 +30,27 @@ placeholder: "Email of invitee", required: true %> </div> + <div class="crayons-field mt-6"> + <%= f.label :custom_invite_subject, "Subject (Optional)", class: "crayons-field__label" %> + <%= f.text_field :custom_invite_subject, + class: "crayons-textfield", + placeholder: 'REPLACES "Invitation Instructions"', + required: true %> + </div> + <div class="crayons-field mt-6"> + <%= f.label :custom_invite_message, "Custom Message (Optional)", class: "crayons-field__label" %> + <%= f.text_area :custom_invite_message, + class: "crayons-textfield", + placeholder: "REPLACES INTRO: Hello... You have been invited to join...", + required: true %> + </div> + <div class="crayons-field mt-6"> + <%= f.label :custom_invite_footnote, "Custom Footer (Optional)", class: "crayons-field__label" %> + <%= f.text_field :custom_invite_footnote, + class: "crayons-textfield", + placeholder: "Comes right after invitation link", + required: true %> + </div> <div class="mt-6"> <%= f.submit "Invite User", class: "crayons-btn" %> </div> diff --git a/app/views/admin/listing_categories/_form.html.erb b/app/views/admin/listing_categories/_form.html.erb index 0da78c02adb86..d6b1f4dd2a47f 100644 --- a/app/views/admin/listing_categories/_form.html.erb +++ b/app/views/admin/listing_categories/_form.html.erb @@ -29,4 +29,4 @@ <%= text_area_tag :social_preview_description, @listing_category.social_preview_description, size: "100x10", class: "crayons-textfield" %> </div> </div> -<%= javascript_packs_with_chunks_tag "enhanceColorPickers", defer: true %> +<%= javascript_include_tag "enhanceColorPickers", defer: true %> diff --git a/app/views/admin/organizations/show.html.erb b/app/views/admin/organizations/show.html.erb index 2e80af86e00b6..9fd4c9a8a6df7 100644 --- a/app/views/admin/organizations/show.html.erb +++ b/app/views/admin/organizations/show.html.erb @@ -1,48 +1,86 @@ -<header class="flex items-center mb-6"> - <div> - <h1 class="crayons-title"><%= @organization.name %></h1> - <p class="color-base-60">Created <%= @organization.created_at.strftime("%b %e '%y") %></p> - </div> - - <div class="ml-auto"> - <%= link_to "View @#{@organization.name}", "/#{@organization.slug}", class: "crayons-btn", target: "_blank", rel: "noopener" %> - </div> +<%= javascript_include_tag "admin/organizations", defer: true %> +<header> + <section class="crayons-card flex flex-col l:flex-row gap-2 m:gap-4 l:gap-6 mb-2 m:mb-3 p-3 s:p-4 m:p-7"> + <span class="crayons-logo crayons-logo--3xl"> + <img src="<%= @organization.profile_image %>" width="100%" height="100%" alt="<%= @organization.name %> profile picture"> + </span> + <div class="grid gap-2 flex-1"> + <div class="s:flex items-center gap-5"> + <div class="flex flex-1 items-center"> + <h1 class="crayons-title lh-tight"> + <%= @organization.name %> + </h1> + </div> + <div class="flex relative justify-between s:justify-end gap-2 my-2 s:my-0"> + <%= link_to t("views.admin.organizations.profile.visit"), "/#{@organization.slug}", class: "c-cta", target: "_blank", rel: "noopener" %> + <div class="dropdown-trigger-container"> + <button type="button" class="c-btn c-btn--icon-alone dropdown-trigger" id="options-dropdown-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="options-dropdown"> + <%= crayons_icon_tag("overflow-vertical", title: t("views.admin.users.profile.options.icon")) %> + </button> + <div class="crayons-dropdown right-0 left-0 s:left-auto" id="options-dropdown"> + <ul class="p-0"> + <li> + <button + type="button" class="c-btn c-btn--destructive w-100 align-left" + data-modal-title="<%= t("views.admin.organizations.delete.heading", organization: @organization.name) %>" + data-modal-size="small" + data-modal-content-selector="#delete-organization"> + <%= t("views.admin.organizations.options.delete") %> + </button> + </li> + </ul> + </div> + </div> + </div> + </div> + <div class="fs-s mb-1 color-secondary"> + <%= @organization.slug %> <span class="opacity-50">•</span> <%= t("views.admin.organizations.profile.id", organizationid: @organization.id) %> <span class="opacity-50">•</span> + <%= t("views.admin.organizations.profile.since_html", time: tag.time(l(@organization.created_at, format: :short_with_yy), datetime: @organization.created_at.strftime("%Y-%m-%dT%H:%M:%S%z"), title: l(@organization.created_at, format: :admin_user))) %> + <span class="opacity-50">•</span> <%= t("views.admin.organizations.profile.total_members", count: @organization.organization_memberships.size) %> + </div> + <div> + <ul class="flex flex-col s:flex-row gap-3 s:gap-6 flex-wrap"> + <li> + <% if @organization.email %> + <a href="mailto:<%= @organization.email %>" class="c-link c-link--icon-left inline-block" target="_blank" rel="noopener"> + <%= crayons_icon_tag(:email, title: t("views.admin.organizations.profile.email"), class: "c-link__icon") %> + <%= @organization.email %> + </a> + <% else %> + <%= crayons_icon_tag(:email, title: t("views.admin.organizations.profile.email"), class: "c-link__icon") %> + N/A + <% end %> + </li> + <% if @organization.github_username %> + <li> + <a href="https://github.com/<%= @organization.github_username %>" class="c-link c-link--icon-left inline-block" target="_blank"> + <%= crayons_icon_tag(:github, title: t("views.admin.users.profile.github")) %> + <%= @organization.github_username %> + </a> + </li> + <% end %> + <% if @organization.twitter_username %> + <li> + <a href="https://twitter.com/<%= @organization.twitter_username %>" class="c-link c-link--icon-left inline-block" target="_blank"> + <%= crayons_icon_tag(:twitter, title: t("views.admin.users.profile.twitter")) %> + <%= @organization.twitter_username %> + </a> + </li> + <% end %> + </ul> + </div> + </div> + </section> </header> -<div class="crayons-card p-6 mb-6"> - <h3 class="crayons-subtitle-2 mb-4">General Info</h3> - <dl> - <dt>ID:</dt> - <dd><%= @organization.id %></dd> - <dt>Name:</dt> - <dd><%= @organization.name %></dd> - <dt>Membership Count:</dt> - <dd><%= @organization.organization_memberships.size %></dd> - <dt>Email:</dt> - <dd><%= @organization.email || "N/A" %></dd> - <dt>Twitter:</dt> - <% if @organization.twitter_username %> - <dd><%= link_to @organization.twitter_username, "https://twitter.com/#{@organization.twitter_username}" %></dd> - <% else %> - <dd>N/A</dd> - <% end %> - <dt>GitHub:</dt> - <% if @organization.github_username %> - <dd><%= link_to @organization.github_username, "https://github.com/#{@organization.github_username}" %></dd> - <% else %> - <dd>N/A</dd> - <% end %> - </dl> -</div> - <div class="crayons-card p-6 mb-6"> <% current_credits = @organization.unspent_credits_count %> <h3 class="crayons-subtitle-2 mb-4">Credits (current: <%= current_credits %>)</h3> <%= form_tag update_org_credits_admin_organization_path(@organization), method: :patch, class: "flex justify-between mb-2" do %> <div> <%= hidden_field_tag :credit_action, :add %> - <%= number_field_tag :credits, nil, in: 1...100_000, required: true, class: "crayons-textfield w-auto mr-3", size: 5, aria: { label: "Credits" } %> - <%= text_field_tag :note, "", placeholder: "Why are you adding these credits?", size: 50, required: true, class: "crayons-textfield w-auto mr-3", aria: { label: "Reason" } %> + <%= number_field_tag :credits, nil, in: 1...100_000, required: true, class: "crayons-textfield w-auto mr-3", size: 5, aria: { label: "Credits" }, autocomplete: "off" %> + <%= text_field_tag :note, "", placeholder: "Why are you adding these credits?", size: 50, required: true, class: "crayons-textfield w-auto mr-3", aria: { label: "Reason" }, autocomplete: "off" %> </div> <%= submit_tag "Add Org Credits", class: "crayons-btn" %> <% end %> @@ -50,8 +88,8 @@ <%= form_tag update_org_credits_admin_organization_path(@organization), method: :patch, class: "flex justify-between mb-2" do %> <div> <%= hidden_field_tag :credit_action, :remove %> - <%= number_field_tag :credits, nil, in: 1..current_credits, required: true, class: "crayons-textfield w-auto mr-3", size: 5, aria: { label: "Credits" } %> - <%= text_field_tag :note, "", placeholder: "Why are you removing these credits?", size: 50, required: true, class: "crayons-textfield w-auto mr-3", aria: { label: "Reason" } %> + <%= number_field_tag :credits, nil, in: 1..current_credits, required: true, class: "crayons-textfield w-auto mr-3", size: 5, aria: { label: "Credits" }, autocomplete: "off" %> + <%= text_field_tag :note, "", placeholder: "Why are you removing these credits?", size: 50, required: true, class: "crayons-textfield w-auto mr-3", aria: { label: "Reason" }, autocomplete: "off" %> </div> <%= submit_tag "Remove Org Credits", class: "crayons-btn crayons-btn--danger" %> <% end %> @@ -59,3 +97,19 @@ </div> <%= render "activity" %> + +<% error_message = deletion_modal_error_message(@organization) %> +<div class="hidden"> + <div id="delete-organization"> + <% if error_message %> + <div class="crayons-notice crayons-notice--danger"><%= error_message %></div> + <% else %> + <%= form_for(@organization, url: admin_organization_path(@organization), html: { class: "flex flex-col gap-6", method: :delete, onsubmit: "return confirm('#{j t('views.admin.organizations.delete.onsubmit')}')", id: nil }) do |f| %> + <%= t("views.admin.organizations.delete.desc2", organization: @organization.name) %> + <div> + <button class="c-btn c-btn--primary c-btn--destructive"><%= t("views.admin.organizations.delete.submit") %></button> + </div> + <% end %> + <% end %> + </div> +</div> diff --git a/app/views/admin/overview/_analytics.html.erb b/app/views/admin/overview/_analytics.html.erb index 2ece1c8784746..7ec355ba3a938 100644 --- a/app/views/admin/overview/_analytics.html.erb +++ b/app/views/admin/overview/_analytics.html.erb @@ -20,6 +20,10 @@ <%= radio_button_tag :period, 30, params[:period].to_i == 30, class: "crayons-radio", id: "30days" %> <label for="30days" class="crayons-field__label">Last 30 days</label> </li> + <li class="crayons-field crayons-field--radio"> + <%= radio_button_tag :period, 90, params[:period].to_i == 90, class: "crayons-radio", id: "90days" %> + <label for="90days" class="crayons-field__label">Last 90 days</label> +</li> </ul> </fieldset> <button type="submit" class="c-btn c-btn--secondary w-100">Submit</button> diff --git a/app/views/admin/overview/_health_check.html.erb b/app/views/admin/overview/_health_check.html.erb index 72624cd2d1bf4..259d916154bf9 100644 --- a/app/views/admin/overview/_health_check.html.erb +++ b/app/views/admin/overview/_health_check.html.erb @@ -1,7 +1,7 @@ <section class="crayons-card p-7 m:basis-1-3"> <h2 class="crayons-subtitle-1 mb-4">Health check</h2> - <a href="<%= admin_privileged_reactions_path %>" class="flex flex-col c-link c-link--block -mx-4"> + <a href="<%= admin_feedback_messages_path %>" class="flex flex-col c-link c-link--block -mx-4"> <strong class="fs-xl"><%= @data_counts.flags_count %></strong> <span>Flags raised by <span class="fw-bold">Moderators</span></span> <small class="opacity-75"> @@ -15,9 +15,4 @@ <strong class="fs-xl"><%= @data_counts.open_abuse_reports_count %></strong> <span><%= "Report".pluralize(@data_counts.open_abuse_reports_count) %> submitted by <span class="fw-bold">Users</span></span> </a> - - <a href="<%= admin_feedback_messages_path %>" class="flex flex-col c-link c-link--block -mx-4"> - <strong class="fs-xl"><%= @data_counts.possible_spam_users_count %></strong> - <span>Potential spam/abuse users</span> - </a> </section> diff --git a/app/views/admin/overview/_notices.html.erb b/app/views/admin/overview/_notices.html.erb new file mode 100644 index 0000000000000..70e2f785d9ded --- /dev/null +++ b/app/views/admin/overview/_notices.html.erb @@ -0,0 +1,11 @@ + +<% if ForemInstance.deployed_at.to_i.positive? && ForemInstance.deployed_at < 2.weeks.ago %> + <div class="crayons-notice crayons-notice--<%= ForemInstance.deployed_at < 4.weeks.ago ? "danger" : "warning" %> mb-6"> + <p class="p-1"> + It has been <strong><%= time_ago_in_words ForemInstance.deployed_at %></strong> since this Forem was deployed. We recommend you <a href="https://github.com/forem/forem" class="fw-bold">re-deploy from the main branch</a> to stay up-to-date. + </p> + <p class="p-1 py-2 fs-s"> + If you stay out of date for too long, it greatly increases the risk that something could go wrong when you try to re-deploy. + </p> + </div> +<% end %> diff --git a/app/views/admin/overview/index.html.erb b/app/views/admin/overview/index.html.erb index f025fb52e9395..67213c70918f8 100644 --- a/app/views/admin/overview/index.html.erb +++ b/app/views/admin/overview/index.html.erb @@ -1,8 +1,9 @@ -<%= javascript_packs_with_chunks_tag "admin/overview", defer: true %> +<%= javascript_include_tag "admin/overview", defer: true %> <h1 class="crayons-title mb-4"> Overview </h1> +<%= render "notices" %> <div class="flex flex-col gap-4"> <div class="flex flex-col l:flex-row gap-4"> diff --git a/app/views/admin/pages/_form.html.erb b/app/views/admin/pages/_form.html.erb index bdaf3bb62605d..f08ade782b423 100644 --- a/app/views/admin/pages/_form.html.erb +++ b/app/views/admin/pages/_form.html.erb @@ -47,6 +47,13 @@ <p class="crayons-field__description">For use with template type <code>json</code></p> <% end %> <%= form.text_area :body_json, rows: 10, class: "crayons-textfield" %> + </div> + <div class="crayons-field optional css"> + <%= form.label :body_css, class: "crayons-field__label" do %> + Body CSS + <p class="crayons-field__description">For use with template type <code>css</code></p> + <% end %> + <%= form.text_area :body_css, rows: 10, class: "crayons-textfield" %> </div> <div class="crayons-field"> <% if @page.social_image_url %> @@ -128,6 +135,8 @@ function showBodyFields() { document.querySelectorAll("select").forEach(el => { if (el.options[el.selectedIndex].value == "json") { document.querySelectorAll('.json').forEach(el => el.style.display = 'block'); + } else if (el.options[el.selectedIndex].value == "css") { + document.querySelectorAll('.css').forEach(el => el.style.display = 'block'); } else { document.querySelectorAll('.html').forEach(el => el.style.display = 'block'); } diff --git a/app/views/admin/privileged_reactions/index.html.erb b/app/views/admin/privileged_reactions/index.html.erb index 7e62490b2e7dd..64ea989710901 100644 --- a/app/views/admin/privileged_reactions/index.html.erb +++ b/app/views/admin/privileged_reactions/index.html.erb @@ -1,4 +1,4 @@ -<h1 class="crayons-title mb-3">Priviledged Reactions</h1> +<h1 class="crayons-title mb-3">Privileged Reactions</h1> <div class="mb-6"> <%= search_form_for @q, url: admin_privileged_reactions_path, class: "inline-flex" do |f| %> diff --git a/app/views/admin/profile_fields/_profile_field_form.html.erb b/app/views/admin/profile_fields/_profile_field_form.html.erb index 05fae72e52dc4..81f2e7da1a45e 100644 --- a/app/views/admin/profile_fields/_profile_field_form.html.erb +++ b/app/views/admin/profile_fields/_profile_field_form.html.erb @@ -2,12 +2,12 @@ <div class="crayons-field"> <%= form.label :group, class: "crayons-field__label" %> <%= select_tag "profile_field[profile_field_group_id]", - options_from_collection_for_select( - ProfileFieldGroup.all, - :id, :name, - selected: group&.id - ), - { class: "crayons-select profile__group-dropdown" } %> + options_from_collection_for_select( + ProfileFieldGroup.all, + :id, :name, + selected: group&.id + ), + { class: "crayons-select profile__group-dropdown" } %> </div> <div class="crayons-field"> <%= form.label :label, class: "crayons-field__label" %> @@ -23,11 +23,11 @@ </div> <div class="crayons-field"> <%= form.label :input_type, class: "crayons-field__label" %> - <%= select_tag :input_type, options_for_select(ProfileField.input_types.keys), class: "crayons-select" %> + <%= form.select :input_type, options_for_select(ProfileField.input_types.keys, form.object.input_type), {}, { class: "crayons-select" } %> </div> <div class="crayons-field"> <%= form.label :display_area, class: "crayons-field__label" %> - <%= select_tag :display_area, options_for_select(ProfileField.display_areas.keys), class: "crayons-select" %> + <%= form.select :display_area, options_for_select(ProfileField.display_areas.keys, form.object.display_area), {}, { class: "crayons-select" } %> </div> <div class="crayons-field crayons-field--checkbox"> <%= form.check_box :show_in_onboarding, class: "crayons-checkbox" %> diff --git a/app/views/admin/settings/forms/_authentication.html.erb b/app/views/admin/settings/forms/_authentication.html.erb index 71fa84badd58e..e3ec4ae3014ba 100644 --- a/app/views/admin/settings/forms/_authentication.html.erb +++ b/app/views/admin/settings/forms/_authentication.html.erb @@ -207,6 +207,15 @@ <hr class="my-0" /> <% end %> </section> + + <section class="crayons-field mb-6"> + <%= admin_config_label :new_user_status, model: Settings::Authentication %> + <%= admin_config_description Constants::Settings::Authentication.details[:new_user_status][:description] %> + <%= select_tag "settings_authentication[new_user_status]", + options_for_select(new_user_status_options, Settings::Authentication.new_user_status), + multiple: false, + class: "crayons-select" %> + </section> </section> <%= render "update_setting_button", f: f %> diff --git a/app/views/admin/settings/forms/_community.html.erb b/app/views/admin/settings/forms/_community.html.erb index 6116aadf385a7..8f3dca2706824 100644 --- a/app/views/admin/settings/forms/_community.html.erb +++ b/app/views/admin/settings/forms/_community.html.erb @@ -1,6 +1,6 @@ <%= form_for(Settings::Community.new, url: admin_settings_communities_path, - html: { data: { action: "submit->config#updateConfigurationSettings" } }) do |f| %> + html: { data: { action: "submit->config#updateConfigurationSettings" }, class: "hidden" }) do |f| %> <details class="crayons-card"> <summary class="crayons-subtitle-2 p-6">Community Content</summary> <div class="p-6 pt-0"> @@ -54,7 +54,7 @@ <%= admin_config_label :staff_user_id, model: Settings::Community %> <%= admin_config_description Constants::Settings::Community.details[:staff_user_id][:description] %> <%= f.text_field :staff_user_id, - class: "crayons-textfield", + class: "crayons-textfield js-username_id_input", value: Settings::Community.staff_user_id, placeholder: Constants::Settings::Community.details[:staff_user_id][:placeholder] %> </div> diff --git a/app/views/admin/settings/forms/_credits.html.erb b/app/views/admin/settings/forms/_credits.html.erb index 088c86a17a2c8..fd29ad01ed238 100644 --- a/app/views/admin/settings/forms/_credits.html.erb +++ b/app/views/admin/settings/forms/_credits.html.erb @@ -1,6 +1,6 @@ <%= form_for(Settings::General.new, url: admin_settings_general_settings_path, - html: { data: { action: "submit->config#updateConfigurationSettings" } }) do |f| %> + html: { data: { action: "submit->config#updateConfigurationSettings" }, class: "hidden" }) do |f| %> <details class="crayons-card"> <summary class="crayons-subtitle-2 p-6">Credits</summary> <div class="p-6 pt-0"> diff --git a/app/views/admin/settings/forms/_google_analytics.html.erb b/app/views/admin/settings/forms/_google_analytics.html.erb index 5b81f7c07540b..85851522f9788 100644 --- a/app/views/admin/settings/forms/_google_analytics.html.erb +++ b/app/views/admin/settings/forms/_google_analytics.html.erb @@ -2,16 +2,9 @@ url: admin_settings_general_settings_path, html: { data: { action: "submit->config#updateConfigurationSettings" } }) do |f| %> <details class="crayons-card"> - <summary class="crayons-subtitle-2 p-6">Google Analytics</summary> + <summary class="crayons-subtitle-2 p-6">Google Analytics and Cookies</summary> <div class="p-6 pt-0"> <fieldset class="grid gap-4"> - <div class="crayons-field"> - <%= admin_config_label :ga_tracking_id, "Google Universal Analytics View ID" %> - <%= admin_config_description Constants::Settings::General.details[:ga_tracking_id][:description] %> - <%= f.text_field :ga_tracking_id, - class: "crayons-textfield", - value: Settings::General.ga_tracking_id %> - </div> <div class="crayons-field"> <%= admin_config_label :ga_analytics_4_id, "Google Analytics 4 Measurement ID" %> <%= admin_config_description Constants::Settings::General.details[:ga_analytics_4_id][:description] %> @@ -19,6 +12,22 @@ class: "crayons-textfield", value: Settings::General.ga_analytics_4_id %> </div> + <div class="crayons-field"> + <%= admin_config_label :cookie_banner_user_context, model: Settings::General %> + <%= admin_config_description Constants::Settings::General.details[:cookie_banner_user_context][:description] %> + <%= select_tag "settings_general[cookie_banner_user_context]", + options_for_select(Settings::General::BANNER_USER_CONFIGS, Settings::General.cookie_banner_user_context), + multiple: false, + class: "crayons-select" %> + </div> + <div class="crayons-field"> + <%= admin_config_label :coolie_banner_platform_context, model: Settings::General %> + <%= admin_config_description Constants::Settings::General.details[:coolie_banner_platform_context][:description] %> + <%= select_tag "settings_general[coolie_banner_platform_context]", + options_for_select(Settings::General::BANNER_PLATFORM_CONFIGS, Settings::General.coolie_banner_platform_context), + multiple: false, + class: "crayons-select" %> + </div> </fieldset> <%= render "update_setting_button", f: f %> </div> diff --git a/app/views/admin/settings/forms/_mascot.html.erb b/app/views/admin/settings/forms/_mascot.html.erb index 02ffc730ee5a4..95afaea9bacb4 100644 --- a/app/views/admin/settings/forms/_mascot.html.erb +++ b/app/views/admin/settings/forms/_mascot.html.erb @@ -9,10 +9,10 @@ <div class="p-6 pt-0"> <fieldset class="grid gap-4"> <div class="crayons-field"> - <%= admin_config_label :mascot_user_id, "Mascot user ID" %> + <%= admin_config_label :mascot_user_id, "Mascot user" %> <%= admin_config_description Constants::Settings::General.details[:mascot_user_id][:description] %> <%= f.text_field :mascot_user_id, - class: "crayons-textfield", + class: "crayons-textfield js-username_id_input", value: Settings::General.mascot_user_id, min: 1, placeholder: Constants::Settings::General.details[:mascot_user_id][:placeholder] %> diff --git a/app/views/admin/settings/forms/_monetization.html.erb b/app/views/admin/settings/forms/_monetization.html.erb index db0d56621512d..91beba3572691 100644 --- a/app/views/admin/settings/forms/_monetization.html.erb +++ b/app/views/admin/settings/forms/_monetization.html.erb @@ -24,14 +24,22 @@ placeholder: Constants::Settings::General.details[:stripe_publishable_key][:placeholder] %> </div> - <div class="crayons-field"> - <%= admin_config_label :payment_pointer %> - <%= admin_config_description Constants::Settings::General.details[:payment_pointer][:description] %> - <%= f.text_field :payment_pointer, - class: "crayons-textfield", - value: Settings::General.payment_pointer, - placeholder: Constants::Settings::General.details[:payment_pointer][:placeholder] %> - </div> + <% if FeatureFlag.enabled?(Geolocation::FEATURE_FLAG) %> + <div class="crayons-field"> + <%= admin_config_label :billboard_enabled_countries %> + <%= admin_config_description Constants::Settings::General.details[:billboard_enabled_countries][:description] %> + <div id="billboard-enabled-countries-editor"></div> + </div> + + <div class="crayons-field hidden"> + <%= f.text_field :billboard_enabled_countries, + class: "crayons-textfield geolocation-multiselect", + value: billboard_enabled_countries_for_editing, + autocomplete: "off", + data: { all_countries: billboard_all_countries_for_editing } %> + </div> + <%= javascript_include_tag "admin/billboardEnabledCountries", defer: true %> + <% end %> </fieldset> <%= render "update_setting_button", f: f %> </div> diff --git a/app/views/admin/settings/forms/_onboarding.html.erb b/app/views/admin/settings/forms/_onboarding.html.erb index 7a80835ad2b05..3394504fbe51e 100644 --- a/app/views/admin/settings/forms/_onboarding.html.erb +++ b/app/views/admin/settings/forms/_onboarding.html.erb @@ -5,19 +5,6 @@ <summary class="crayons-subtitle-2 p-6">Onboarding</summary> <div class="p-6 pt-0"> <fieldset class="grid gap-4"> - <div class="crayons-field"> - <%= admin_config_label :onboarding_background_image %> - <%= admin_config_description Constants::Settings::General.details[:onboarding_background_image][:description] %> - <%= f.text_field :onboarding_background_image, - class: "crayons-textfield", - value: Settings::General.onboarding_background_image, - placeholder: Constants::Settings::General.details[:onboarding_background_image][:placeholder] %> - <div class="flex flex-wrap flex-row -mx-4 mt-2"> - <div class="w-100 max-w-100 px-4"> - <img alt="onboarding background image" class="max-w-100 h-auto" src="<%= Settings::General.onboarding_background_image %>" loading="lazy" /> - </div> - </div> - </div> <div class="crayons-field"> <%= admin_config_label :suggested_tags %> @@ -29,23 +16,35 @@ </div> <div class="crayons-field"> - <%= admin_config_label :suggested_users %> - <%= admin_config_description Constants::Settings::General.details[:suggested_users][:description] %> - <%= f.text_field :suggested_users, - class: "crayons-textfield", - value: Settings::General.suggested_users.join(","), - placeholder: Constants::Settings::General.details[:suggested_users][:placeholder] %> + <%= admin_config_label :newsletter_step_heading %> + <%= admin_config_description Constants::Settings::General.details[:onboarding_newsletter_content][:description] %> + <%= f.text_area :onboarding_newsletter_content, + class: "crayons-textfield", + value: Settings::General.onboarding_newsletter_content, + placeholder: Constants::Settings::General.details[:onboarding_newsletter_content][:placeholder] %> </div> - <div class="crayons-field crayons-field--checkbox"> - <%= f.check_box :prefer_manual_suggested_users, checked: Settings::General.prefer_manual_suggested_users, class: "crayons-checkbox" %> - <div class="mt-0"> - <%= admin_config_label :prefer_manual_suggested_users %> - <p class="crayons-field__description"> - <%= Constants::Settings::General.details[:prefer_manual_suggested_users][:description] %> - </p> - </div> - </div> + <div class="crayons-field"> + <%= admin_config_label Constants::Settings::General.details[:onboarding_newsletter_opt_in_head][:description] %> + <%= f.text_field :onboarding_newsletter_opt_in_head, + class: "crayons-textfield", + value: Settings::General.onboarding_newsletter_opt_in_head, + placeholder: Constants::Settings::General.details[:onboarding_newsletter_opt_in_head][:placeholder] %> + + <%= admin_config_label Constants::Settings::General.details[:onboarding_newsletter_opt_in_subhead][:description] %> + <%= f.text_field :onboarding_newsletter_opt_in_subhead, + class: "crayons-textfield", + value: Settings::General.onboarding_newsletter_opt_in_subhead, + placeholder: Constants::Settings::General.details[:onboarding_newsletter_opt_in_subhead][:placeholder] %> + <% if FeatureFlag.enabled?(Geolocation::FEATURE_FLAG) %> + <%= admin_config_label Constants::Settings::General.details[:geos_with_allowed_default_email_opt_in][:description] %> + <%= f.text_field :geos_with_allowed_default_email_opt_in, + class: "crayons-textfield", + value: Settings::General.geos_with_allowed_default_email_opt_in, + placeholder: Constants::Settings::General.details[:geos_with_allowed_default_email_opt_in][:placeholder] %> + <% end %> + </div> + </fieldset> <%= render "update_setting_button", f: f %> </div> diff --git a/app/views/admin/settings/forms/_smtp.html.erb b/app/views/admin/settings/forms/_smtp.html.erb index 487904fb2e73b..cfa8c4a871a1d 100644 --- a/app/views/admin/settings/forms/_smtp.html.erb +++ b/app/views/admin/settings/forms/_smtp.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "admin/config/smtp", defer: true %> +<%= javascript_include_tag "admin/config/smtp", defer: true %> <%= form_for(Settings::SMTP.new, url: admin_settings_smtp_settings_path, diff --git a/app/views/admin/settings/forms/_social_media.html.erb b/app/views/admin/settings/forms/_social_media.html.erb index 5b5cf24ec32ba..2b51448670b56 100644 --- a/app/views/admin/settings/forms/_social_media.html.erb +++ b/app/views/admin/settings/forms/_social_media.html.erb @@ -14,7 +14,7 @@ placeholder: Constants::Settings::General.details[:twitter_hashtag][:placeholder] %> </div> <%= f.fields_for :social_media_handles do |social_media_field| %> - <% Settings::General.social_media_handles.each do |platform, username| %> + <% Settings::General.social_media_services.each do |platform, username| %> <div class="crayons-field"> <%= admin_config_label platform %> <p class="crayons-field__description"> diff --git a/app/views/admin/settings/forms/_user_experience.html.erb b/app/views/admin/settings/forms/_user_experience.html.erb index 68e19920be654..00ce008c8fcde 100644 --- a/app/views/admin/settings/forms/_user_experience.html.erb +++ b/app/views/admin/settings/forms/_user_experience.html.erb @@ -5,7 +5,7 @@ <summary class="crayons-subtitle-2 p-6">User Experience and Brand</summary> <div class="p-6 pt-0"> <fieldset class="grid gap-4"> - <div class="crayons-fieldx"> + <div class="crayons-field"> <%= admin_config_label :feed_style, model: Settings::UserExperience %> <%= admin_config_description Constants::Settings::UserExperience.details[:feed_style][:description] %> <%= select_tag "settings_user_experience[feed_style]", @@ -21,6 +21,22 @@ multiple: false, class: "crayons-select" %> </div> + <div class="crayons-field"> + <%= admin_config_label :cover_image_height, model: Settings::UserExperience %> + <%= admin_config_description Constants::Settings::UserExperience.details[:cover_image_height][:description] %> + <%= f.number_field :cover_image_height, + class: "crayons-textfield", + value: Settings::UserExperience.cover_image_height, + placeholder: Constants::Settings::UserExperience.details[:cover_image_height][:placeholder] %> + </div> + <div class="crayons-field"> + <%= admin_config_label :cover_image_fit, model: Settings::UserExperience %> + <%= admin_config_description Constants::Settings::UserExperience.details[:cover_image_fit][:description] %> + <%= select_tag "settings_user_experience[cover_image_fit]", + options_for_select(Settings::UserExperience::COVER_IMAGE_FITS, Settings::UserExperience.cover_image_fit), + multiple: false, + class: "crayons-select" %> + </div> <div class="crayons-field"> <%= admin_config_label :tag_feed_minimum_score, model: Settings::UserExperience %> <%= admin_config_description Constants::Settings::UserExperience.details[:tag_feed_minimum_score][:description] %> @@ -45,6 +61,14 @@ value: Settings::UserExperience.index_minimum_score, placeholder: Constants::Settings::UserExperience.details[:index_minimum_score][:placeholder] %> </div> + <div class="crayons-field"> + <%= admin_config_label :index_minimum_date, model: Settings::UserExperience %> + <%= admin_config_description Constants::Settings::UserExperience.details[:index_minimum_date][:description] %> + <%= f.number_field :index_minimum_date, + class: "crayons-textfield", + value: Settings::UserExperience.index_minimum_date, + placeholder: Constants::Settings::UserExperience.details[:index_minimum_date][:placeholder] %> + </div> <div class="crayons-field"> <%= admin_config_label :default_font, model: Settings::UserExperience %> <%= admin_config_description Constants::Settings::UserExperience.details[:default_font][:description] %> @@ -91,10 +115,35 @@ </p> </div> </div> + <div class="crayons-field crayons-field--checkbox"> + <%= f.check_box :show_mobile_app_banner, checked: Settings::UserExperience.show_mobile_app_banner, class: "crayons-checkbox" %> + <div class="mt-0"> + <%= admin_config_label :show_mobile_app_banner, model: Settings::UserExperience %> + <p class="crayons-field__description"> + Do you want the Forem mobile banner to show on your Forem? + </p> + </div> + </div> + <div class="crayons-field"> + <%= admin_config_label :head_content, model: Settings::UserExperience %> + <%= admin_config_description Constants::Settings::UserExperience.details[:head_content][:description] %> + <%= f.text_area :head_content, + class: "crayons-textfield", + value: Settings::UserExperience.head_content, + placeholder: Constants::Settings::UserExperience.details[:head_content][:placeholder] %> + </div> + <div class="crayons-field"> + <%= admin_config_label :bottom_of_body_content, model: Settings::UserExperience %> + <%= admin_config_description Constants::Settings::UserExperience.details[:bottom_of_body_content][:description] %> + <%= f.text_area :bottom_of_body_content, + class: "crayons-textfield", + value: Settings::UserExperience.bottom_of_body_content, + placeholder: Constants::Settings::UserExperience.details[:bottom_of_body_content][:placeholder] %> + </div> </fieldset> <%= render "update_setting_button", f: f %> </div> </details> <% end %> </div> -<%= javascript_packs_with_chunks_tag "enhanceColorPickers", defer: true %> +<%= javascript_include_tag "enhanceColorPickers", defer: true %> diff --git a/app/views/admin/settings/show.html.erb b/app/views/admin/settings/show.html.erb index 00348cbfcff2b..643a03e9c168b 100644 --- a/app/views/admin/settings/show.html.erb +++ b/app/views/admin/settings/show.html.erb @@ -9,7 +9,6 @@ <%= render partial: "forms/api_tokens" %> <%= render partial: "forms/authentication" %> - <%= render partial: "forms/campaign" %> <%= render partial: "forms/community" %> <%= render partial: "forms/credits" %> <%= render partial: "forms/emails" %> @@ -27,3 +26,5 @@ <%= render partial: "forms/tags" %> <%= render partial: "forms/user_experience" %> </div> + +<%= javascript_include_tag "admin/convertUserIdsToUsernameInputs" %> diff --git a/app/views/admin/shared/_flag_reaction_item.html.erb b/app/views/admin/shared/_flag_reaction_item.html.erb new file mode 100644 index 0000000000000..a77c5d1887b57 --- /dev/null +++ b/app/views/admin/shared/_flag_reaction_item.html.erb @@ -0,0 +1,52 @@ +<div class="flex items-center gap-0 m:gap-4" + data-controller="reaction" + data-reaction-id-value="<%= vomit_reaction.id %>" + data-reaction-url-value="<%= admin_reaction_path(vomit_reaction.id) %>"> + <div class="flex items-center w-100"> + <div class="flex items-center p-0 w-100 m:w-40"> + <div class="crayons-card crayons-card--secondary p-3 mr-3 m:mr-7" title="Flag"> + <%= crayons_icon_tag("twemoji/flag", native: true, width: 24, height: 24) %> + </div> + <div class="p-0"> + <p class="crayons-subtitle-3"><%= vomit_reaction.user.name %></p> + <time datetime="<%= vomit_reaction.updated_at&.strftime("%Y-%m-%dT%H:%M:%S%z") %>" class="color-secondary fs-s shrink-0" title="<%= l(vomit_reaction.updated_at, format: :admin_user) if vomit_reaction.created_at %>"> + <%= l(vomit_reaction.updated_at, format: :long) %> + </time> + </div> + </div> + + <%# Note: There are discrepancies in status naming between the frontend (FE) and backend (BE). %> + <%# In the FE, 'Open/Unresolved' corresponds to 'valid' in the BE. This status appears when a trusted user flags an article, with a score of -50. %> + <%# In the FE, 'Valid' corresponds to 'confirmed' in the BE. This status appears when an admin user flags an article or marks a trusted user-flagged article as valid, with a score of -100. %> + <%# In both the FE and BE, 'Invalid' remains the same. This status appears when an admin user marks a flag or vomit reaction as invalid, with a score of 0. %> + <div class="flex w-100 m:w-60 gap-0 m:gap-6 ml-2"> + <% if vomit_reaction.status == 'valid' %> + <span class="c-indicator c-indicator--relaxed c-indicator--warning" title="<%= t("views.admin.shared.flags.open.title") %>"> + <%= t("views.admin.shared.flags.open.value") %> + </span> + <% elsif vomit_reaction.status == 'invalid' %> + <span class="c-indicator c-indicator--relaxed" title="<%= t("views.admin.shared.flags.invalid.title") %>"> + <%= t("views.admin.shared.flags.invalid.value") %> + </span> + <% elsif vomit_reaction.status == 'confirmed' %> + <span class="c-indicator c-indicator--relaxed c-indicator--danger" title="<%= t("views.admin.shared.flags.valid.title") %>"> + <%= t("views.admin.shared.flags.valid.value") %> + </span> + <% else %> + <%# This case should never arise. %> + <span class="c-indicator c-indicator--relaxed c-indicator--info" title="<%= t("views.admin.shared.flags.unidentified.title") %>"> + <%= t("views.admin.shared.flags.unidentified.value") %> + </span> + <% end %> + + <span + data-testid="flag-score" + class="crayons-card crayons-card--secondary px-3 py-1 ml-3 flex gap-2 items-center" + title="<%= t("views.admin.#{text_section}.flags.score") %>"> + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= vomit_reaction.points %></span> + </span> + </div> + </div> + <%= render "admin/shared/flag_reaction_item_dropdown_menu", vomit_reaction: vomit_reaction %> +</div> diff --git a/app/views/admin/shared/_flag_reaction_item_dropdown_menu.html.erb b/app/views/admin/shared/_flag_reaction_item_dropdown_menu.html.erb new file mode 100644 index 0000000000000..080439cedd97b --- /dev/null +++ b/app/views/admin/shared/_flag_reaction_item_dropdown_menu.html.erb @@ -0,0 +1,42 @@ +<div class="relative"> + <button type="button" id="<%= vomit_reaction.id %>-action-dropdown-btn" data-toggle-dropdown="<%= vomit_reaction.id %>-action-dropdown" class="c-btn c-btn--icon-alone ml-auto" + aria-expanded="false" aria-controls="<%= vomit_reaction.id %>-action-dropdown" aria-haspopup="true" aria-label="Flag actions: <%= vomit_reaction.id %>"> + <%= crayons_icon_tag("overflow-horizontal", aria_hidden: true) %> + </button> + <div id="<%= vomit_reaction.id %>-action-dropdown" class="crayons-dropdown right-0"> + <ul class="list-none"> + <% if vomit_reaction.status != 'confirmed' %> + <li> + <button + data-testid="mark-as-valid" + type="button" + data-mark-valid="<%= vomit_reaction.id %>" + data-status="confirmed" + data-reaction-target="confirmed" + data-action="reaction#updateReactionConfirmed" + data-remove-element="false" + class="c-btn align-left w-100 c-btn--icon-left"> + <%= crayons_icon_tag("checkmark", aria_hidden: true, class: "c-btn__icon") %> + <%= t("views.admin.shared.flags.actions.mark_as_valid") %> + </button> + </li> + <% end %> + <% if vomit_reaction.status != 'invalid' %> + <li> + <button + data-testid="mark-as-invalid" + type="button" + data-mark-invalid="<%= vomit_reaction.id %>" + data-altstatus="invalid" + data-reaction-target="invalid" + data-action="reaction#updateReactionInvalid" + data-remove-element="false" + class="c-btn align-left w-100 c-btn--icon-left"> + <%= crayons_icon_tag("x", aria_hidden: true, class: "c-btn__icon") %> + <%= t("views.admin.shared.flags.actions.mark_as_invalid") %> + </button> + </li> + <% end %> + </ul> + </div> +</div> diff --git a/app/views/admin/shared/_flag_reactions_table.html.erb b/app/views/admin/shared/_flag_reactions_table.html.erb new file mode 100644 index 0000000000000..50d5b9391f1ae --- /dev/null +++ b/app/views/admin/shared/_flag_reactions_table.html.erb @@ -0,0 +1,14 @@ +<%= javascript_include_tag "admin/shared/flagReactionItemDropdownButton", defer: true %> +<% if vomit_reactions.present? %> + <% vomit_reactions.each do |vomit_reaction| %> + <%= render "admin/shared/flag_reaction_item", vomit_reaction: vomit_reaction, text_section: text_section %> + <hr id="js__reaction__div__hr__<%= vomit_reaction.id %>" class="w-100 hr-no-margins"> + <% end %> +<% else %> + <div class="flex flex-col justify-center items-center gap-4 h-100"> + <div class="flex p-4 gap-2 radius-default" style="background: #EEF2FF;"> + <%= crayons_icon_tag("flags", native: true, width: 56, height: 56) %> + </div> + <p class="crayons-subtitle-3 fw-normal color-secondary"><%= empty_text %></p> + </div> +<% end %> diff --git a/app/views/admin/shared/_nested_sidebar.html.erb b/app/views/admin/shared/_nested_sidebar.html.erb index e18aacb897be3..41bad300ec912 100644 --- a/app/views/admin/shared/_nested_sidebar.html.erb +++ b/app/views/admin/shared/_nested_sidebar.html.erb @@ -10,19 +10,19 @@ <% menu_items.each do |group_name, group| %> <li> <a - class="c-link c-link--block c-link--icon-left <%= "c-link--current" if current?(request, group, group_name) %>" + class="c-link c-link--block c-link--icon-left <%= "c-link--current mb-1" if current?(request, group, group_name) %>" aria-current="<%= "page" if current?(request, group, group_name) && !group.has_multiple_children? %>" href="<%= nav_path(group, group_name) %>"> <%= crayons_icon_tag(group.svg, class: "c-link__icon", aria_hidden: true) %> <%= display_name(group_name) %> </a> <% if group.has_multiple_children? %> - <ul class="ml-6 mb-1 <%= current?(request, group, group_name) ? "" : "hidden" %>"> + <ul class="ml-6 mb-1 <%= current?(request, group, group_name) ? "block" : "hidden" %>"> <% group.children.each do |item| %> <% if item.visible %> <li> <a - class="c-link c-link--block inline-block" + class="c-link c-link--block" href="<%= admin_path %>/<%= group_name %>/<%= item.controller %>" <% if sidebar_item_active?(item) %> style="--bg: transparent" diff --git a/app/views/admin/shared/_quality_action_item.html.erb b/app/views/admin/shared/_quality_action_item.html.erb new file mode 100644 index 0000000000000..f02f62da51f81 --- /dev/null +++ b/app/views/admin/shared/_quality_action_item.html.erb @@ -0,0 +1,18 @@ +<div class="flex justify-between items-center"> + <div class="flex items-center"> + <div class="crayons-card crayons-card--secondary p-3 mr-7"> + <%= crayons_icon_tag("twemoji/#{quality_reaction.category == 'thumbsup' ? 'thumb-up' : 'thumb-down'}", native: true, title: t("views.moderations.actions.#{quality_reaction.category == 'thumbsup' ? 'thumb_up' : 'thumb_down'}"), width: 24, height: 24) %> + </div> + <div class="p-0"> + <p class="crayons-subtitle-3"><%= quality_reaction.user.name %></p> + <time datetime="<%= quality_reaction.updated_at&.strftime("%Y-%m-%dT%H:%M:%S%z") %>" class="color-secondary fs-s shrink-0" title="<%= l(quality_reaction.updated_at, format: :admin_user) if quality_reaction.created_at %>"> + <%= l(quality_reaction.updated_at, format: :long) %> + </time> + </div> + </div> + + <span class="crayons-card crayons-card--secondary px-3 py-1 ml-3 flex gap-2 items-center" title="Score affected by particular quality reaction"> + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= quality_reaction.points %></span> + </span> +</div> diff --git a/app/views/admin/tags/_form.html.erb b/app/views/admin/tags/_form.html.erb index 518c9e59abd26..d8325a0baec4c 100644 --- a/app/views/admin/tags/_form.html.erb +++ b/app/views/admin/tags/_form.html.erb @@ -12,14 +12,17 @@ <p class="crayons-field__description">Allows the tag to be a searchable result when writing a post or a listing</p> </label> </div> + <div class="crayons-field crayons-field--checkbox mt-3"> + <%= f.check_box :suggested, class: "crayons-checkbox" %> + <label class="crayons-field__label" for="tag_suggested"> + Suggested + <p class="crayons-field__description">Prioritizes the tag during user onboarding</p> + </label> + </div> <div class="crayons-field mt-3"> <%= f.label :badge_id, class: "crayons-field__label" %> <%= f.select(:badge_id, options_for_select(badges_for_options, tag.badge_id), { include_blank: true }, { class: "crayons-select" }) %> </div> - <div class="crayons-field mt-3"> - <%= f.label :social_preview_template, class: "crayons-field__label" %> - <%= f.select(:social_preview_template, Tag.social_preview_templates, {}, { class: "crayons-select" }) %> - </div> <div class="crayons-field mt-3"> <%= f.label :alias_for, class: "crayons-field__label" %> <%= f.text_field :alias_for, value: tag.alias_for, class: "crayons-textfield" %> @@ -56,4 +59,4 @@ </div> <%= f.submit class: "crayons-btn mt-3" %> <% end %> -<%= javascript_packs_with_chunks_tag "enhanceColorPickers", defer: true %> +<%= javascript_include_tag "enhanceColorPickers", defer: true %> diff --git a/app/views/admin/tags/edit.html.erb b/app/views/admin/tags/edit.html.erb index 286a604a2e3e5..c6dcf70c99803 100644 --- a/app/views/admin/tags/edit.html.erb +++ b/app/views/admin/tags/edit.html.erb @@ -13,6 +13,15 @@ <div class="crayons-card p-6"> <h2 class="crayons-subtitle-1">Moderators</h2> <div class="flex flex-col gap-2"> + <%= form_with url: admin_tag_moderator_path(@tag.id), model: [:admin, @tag], method: :post, local: true do |f| %> + <div class="crayons-field w-50 my-4"> + <%= f.label :username, "Add Moderator:", class: "crayons-field__label" %> + <div> + <%= f.text_field :username, class: "crayons-textfield js-username_input", placeholder: "username" %> + </div> + <%= f.submit "Add Moderator", class: "crayons-btn" %> + </div> + <% end %> <% if @tag_moderators.exists? %> <ul class="list-none"> <% @tag_moderators.each do |user| %> @@ -30,13 +39,6 @@ This tag currently has no moderators. </div> <% end %> - <%= form_with url: admin_tag_moderator_path(@tag.id), model: [:admin, @tag], method: :post, local: true do |f| %> - <div class="crayons-field w-50"> - <%= f.label :user_id, "Add Moderator (ID):", class: "crayons-field__label" %> - <%= f.text_field :user_id, class: "crayons-textfield" %> - <%= f.submit "Add Moderator", class: "crayons-btn" %> - </div> - <% end %> </div> </div> @@ -45,3 +47,5 @@ <%= render "form", tag: @tag, badges_for_options: @badges_for_options %> </div> </div> + +<%= javascript_include_tag "admin/convertUserIdsToUsernameInputs" %> diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 250f6bda69097..e4b3ccc86b96f 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "admin/users/memberIndex", defer: true %> +<%= javascript_include_tag "admin/users/memberIndex", defer: true %> <div id="member-index-content" class="crayons-card overflow-admin-main-layout-padding p-4 xl:p-0"> <header class="xl:p-7 pb-4 mx-2"> <div class="flex flex-col l:flex-row justify-between l:items-center"> diff --git a/app/views/admin/users/index/_controls.html.erb b/app/views/admin/users/index/_controls.html.erb index 8aa2e165bde45..957607c348f9c 100644 --- a/app/views/admin/users/index/_controls.html.erb +++ b/app/views/admin/users/index/_controls.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "admin/users/controls", defer: true %> +<%= javascript_include_tag "admin/users/controls", defer: true %> <%= form_with url: admin_users_path, method: :get, local: true do |f| %> <div class="flex m:flex-col xl:flex-row justify-between m:items-end xl:justify-end xl:items-center mb-4"> diff --git a/app/views/admin/users/index/_status_indicator.html.erb b/app/views/admin/users/index/_status_indicator.html.erb index 18dfce6ae84a2..74dbd567b6f7f 100644 --- a/app/views/admin/users/index/_status_indicator.html.erb +++ b/app/views/admin/users/index/_status_indicator.html.erb @@ -1 +1 @@ -<span class="c-indicator mr-2 inline-block" style="--bg: <%= status_to_indicator_color(status) %>"></span><%= t("views.admin.users.statuses.#{status}") %> +<%= t("views.admin.users.statuses.#{status}") %> diff --git a/app/views/admin/users/index/_user_status_indicator.html.erb b/app/views/admin/users/index/_user_status_indicator.html.erb index 81a52ea0e7031..5176a18224489 100644 --- a/app/views/admin/users/index/_user_status_indicator.html.erb +++ b/app/views/admin/users/index/_user_status_indicator.html.erb @@ -1,9 +1,13 @@ <% if user.suspended? %> <%= render "admin/users/index/status_indicator", status: "Suspended" %> +<% elsif user.spam? %> + <%= render "admin/users/index/status_indicator", status: "Spam" %> <% elsif user.warned? %> <%= render "admin/users/index/status_indicator", status: "Warned" %> <% elsif user.comment_suspended? %> <%= render "admin/users/index/status_indicator", status: "Comment Suspended" %> +<% elsif user.limited? %> + <%= render "admin/users/index/status_indicator", status: "Limited" %> <% elsif user.trusted? %> <%= render "admin/users/index/status_indicator", status: "Trusted" %> <% else %> diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 3a1c3c0ab0970..bd85d9bb307c4 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -51,11 +51,19 @@ <%= render "admin/users/show/reports/index" %> </section> <% elsif @current_tab == "flags" %> - <section class="crayons-card p-3 s:p-4 m:p-7"> + <section class="crayons-card p-3 s:p-4 m:p-6"> <%= render "admin/users/show/flags/index" %> </section> +<% elsif @current_tab == "articles" %> + <section class="crayons-card p-3 s:p-4 m:p-7"> + <%= render "admin/users/show/articles/index" %> + </section> <% elsif @current_tab == "unpublish_logs" %> <section class="crayons-card p-3 s:p-4 m:p-7"> <%= render "admin/users/show/unpublish_logs/index" %> </section> +<% elsif @current_tab == 'comments' %> + <section class="crayons-card p-3 s:p-4 m:p-7"> + <%= render "admin/users/show/comments/index" %> + </section> <% end %> diff --git a/app/views/admin/users/show/_profile.html.erb b/app/views/admin/users/show/_profile.html.erb index 3eab07bb1e7d0..8ea9abb900cf0 100644 --- a/app/views/admin/users/show/_profile.html.erb +++ b/app/views/admin/users/show/_profile.html.erb @@ -18,7 +18,7 @@ <h1 class="crayons-title lh-tight"> <%= @user.name %> </h1> - <%= render "admin/users/show/profile/status" %> + <%= render partial: "admin/users/show/profile/status", locals: { user: @user} %> </div> <%= render "admin/users/show/profile/actions" %> </div> diff --git a/app/views/admin/users/show/_tabs.html.erb b/app/views/admin/users/show/_tabs.html.erb index 27b8a83d66ed1..fa1fea04a826a 100644 --- a/app/views/admin/users/show/_tabs.html.erb +++ b/app/views/admin/users/show/_tabs.html.erb @@ -5,6 +5,8 @@ <li><%= link_to t("views.admin.users.tabs.emails"), admin_user_path(@user.id, tab: :emails), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if @current_tab == 'emails'}", aria: @current_tab == "emails" ? { current: "page" } : {} %></li></li> <li><%= link_to t("views.admin.users.tabs.reports"), admin_user_path(@user.id, tab: :reports), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if @current_tab == 'reports'}", aria: @current_tab == "reports" ? { current: "page" } : {} %></li></li> <li><%= link_to t("views.admin.users.tabs.flags"), admin_user_path(@user.id, tab: :flags), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if @current_tab == 'flags'}", aria: @current_tab == "flags" ? { current: "page" } : {} %></li></li> + <li><%= link_to t("views.admin.users.tabs.articles"), admin_user_path(@user.id, tab: :articles), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if @current_tab == 'articles'}", aria: @current_tab == "articles" ? { current: "page" } : {} %></li></li> + <li><%= link_to t("views.admin.users.tabs.comments"), admin_user_path(@user.id, tab: :comments), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if @current_tab == 'comments'}", aria: @current_tab == "comments" ? { current: "page" } : {} %></li></li> <% if @unpublish_all_data.exists? %> <li><%= link_to t("views.admin.users.tabs.unpublish_logs"), admin_user_path(@user.id, tab: :unpublish_logs), diff --git a/app/views/admin/users/show/articles/_index.html.erb b/app/views/admin/users/show/articles/_index.html.erb new file mode 100644 index 0000000000000..53960c72dcab4 --- /dev/null +++ b/app/views/admin/users/show/articles/_index.html.erb @@ -0,0 +1,21 @@ +<div class="flex flex-col h-100"> + <h2>All published posts</h2> + <p>View unpublished posts by visiting the <a href="https://dev.to/dashboard/<%= @user.username %>">user dashboard.</a></p> + <% published_articles = @articles.published %> + <% if published_articles.empty? %> + <div class="align-center flex flex-col justify-center my-auto py-7"> + No Published Articles + </div> + <% else %> + <div class="pt-3"> + <ul> + <% published_articles.each do |article| %> + <li> + <%= link_to article.title, article.path, target: "_blank", rel: "noopener" %> + <small>(<%= article.published_at.to_date %>)</small> + </li> + <% end %> + </ul> + </div> + <% end %> +</div> diff --git a/app/views/admin/users/show/comments/_index.html.erb b/app/views/admin/users/show/comments/_index.html.erb new file mode 100644 index 0000000000000..e6e77afd817f4 --- /dev/null +++ b/app/views/admin/users/show/comments/_index.html.erb @@ -0,0 +1,18 @@ +<div class="flex flex-col h-100"> + <h2>All Comments</h2> + <% if @comments.empty? %> + <div class="align-center flex flex-col justify-center my-auto py-7"> + No Comments + </div> + <% else %> + <div class="pt-3"> + <ul> + <% @comments.each do |comment| %> + <li> + <%= link_to truncate(comment.body_markdown, length: 200), comment.path, target: "_blank", rel: "noopener" %> + </li> + <% end %> + </ul> + </div> + <% end %> +</div> diff --git a/app/views/admin/users/show/emails/_verification.html.erb b/app/views/admin/users/show/emails/_verification.html.erb index 10019c0be457f..165f765f6f1d6 100644 --- a/app/views/admin/users/show/emails/_verification.html.erb +++ b/app/views/admin/users/show/emails/_verification.html.erb @@ -1,14 +1,29 @@ -<%= form_with url: verify_email_ownership_admin_user_path(@user), local: true, class: "crayons-card mb-4 p-3 m:p-4 s:pl-4 m:pl-7 pr-4 flex flex-col s:flex-row justify-between gap-4 s:items-center" do |f| %> - <% unless @last_email_verification_date %> - <div> - <h2 class="crayons-subtitle-1"><%= t("views.admin.users.emails.not_verified.subtitle") %></h2> - <p class="color-secondary"><%= t("views.admin.users.emails.not_verified.desc", user: @user.name) %></p> - </div> - <%= f.button t("views.admin.users.emails.verify"), class: "c-btn c-btn--secondary whitespace-nowrap" %> +<div class="crayons-card mb-4 p-3 m:p-4 s:pl-4 m:pl-7 pr-4"> + <% unless @user.confirmed? %> + <%= form_with url: send_email_confirmation_admin_user_path(@user), local: true, class: "flex flex-col s:flex-row justify-between gap-4 s:items-center" do |f| %> + <div> + <h2 class="crayons-subtitle-1"><%= t("views.admin.users.emails.not_confirmed.subtitle") %></h2> + <p class="color-secondary"><%= t("views.admin.users.emails.not_confirmed.desc", user: @user.name) %></p> + </div> + <%= f.button t("views.admin.users.emails.confirm"), class: "c-btn c-btn--secondary whitespace-nowrap" %> + <% end %> <% else %> - <p> - <%= t("views.admin.users.emails.verified_html", time: tag.time(l(@last_email_verification_date, format: :email_verified), datetime: @last_email_verification_date.strftime("%Y-%m-%dT%H:%M:%S%z"), class: "fw-medium whitespace-nowrap")) %> - </p> - <%= f.button t("views.admin.users.emails.reverify"), class: "c-btn c-btn--secondary whitespace-nowrap" %> + <%= form_with url: verify_email_ownership_admin_user_path(@user), local: true, class: "flex flex-col s:flex-row justify-between gap-4 s:items-center" do |f| %> + <% unless @last_email_verification_date %> + <div> + <h2 class="crayons-subtitle-1"><%= t("views.admin.users.emails.not_verified.subtitle") %></h2> + <p class="color-secondary"><%= t("views.admin.users.emails.not_verified.desc", user: @user.name) %></p> + </div> + <% if false # Not yet designed to be visible %> + <%= f.button t("views.admin.users.emails.manually_verify"), class: "c-btn c-btn--secondary whitespace-nowrap", value: "manual_verify" %> + <% end %> + <%= f.button t("views.admin.users.emails.verify"), class: "c-btn c-btn--secondary whitespace-nowrap", value: "send_verification_email" %> + <% else %> + <p> + <%= t("views.admin.users.emails.verified_html", time: tag.time(l(@last_email_verification_date, format: :email_verified), datetime: @last_email_verification_date.strftime("%Y-%m-%dT%H:%M:%S%z"), class: "fw-medium whitespace-nowrap")) %> + </p> + <%= f.button t("views.admin.users.emails.reverify"), class: "c-btn c-btn--secondary whitespace-nowrap" %> + <% end %> + <% end %> <% end %> -<% end %> +</div> \ No newline at end of file diff --git a/app/views/admin/users/show/flags/_index.html.erb b/app/views/admin/users/show/flags/_index.html.erb index 87ebd27e25e99..b4a96e23477bd 100644 --- a/app/views/admin/users/show/flags/_index.html.erb +++ b/app/views/admin/users/show/flags/_index.html.erb @@ -1,43 +1,24 @@ -<h2 class="crayons-subtitle-1">Flags received (<%= @related_vomit_reactions.count %>)</h2> - -<% unless @related_vomit_reactions.empty? %> - <div class="pt-3"> - <% @related_vomit_reactions.each do |flag| %> - <article class="c-list-item flex gap-2"> - <span class="color-secondary shrink-0"> - <% if flag.reactable_type == "Article" %> - <%= crayons_icon_tag("post", aria_hidden: true) %> - <% elsif flag.reactable_type == "User" %> - <%= crayons_icon_tag("user-line", aria_hidden: true) %> - <% elsif flag.reactable_type == "Comment" %> - <%= crayons_icon_tag("comment", aria_hidden: true) %> - <% end %> - </span> - <div class="flex-1"> - <h3 class="crayons-subtitle-2 mb-1"> - <%= link_to flag.reactable.path, class: "c-link" do %> - <% if flag.reactable_type == "Article" %> - <%= t("views.admin.users.flags.type.article", title: flag.reactable.title) %> - <% elsif flag.reactable_type == "User" %> - <%= t("views.admin.users.flags.type.profile") %> - <% elsif flag.reactable_type == "Comment" %> - <%= t("views.admin.users.flags.type.comment", title: flag.reactable.title) %> - <% end %> - <% end %> - </h3> - <p class="fs-s color-secondary"> - <%= t("views.admin.users.flags.origin_html", user: link_to(flag.user.username, admin_user_path(flag.user_id), class: "c-link c-link--branded")) %> - </p> - </div> - <time datetime="<%= flag.created_at&.strftime("%Y-%m-%dT%H:%M:%S%z") %>" class="color-secondary fs-s shrink-0" title="<%= l(flag.created_at, format: :admin_user) if flag.created_at %>"> - <%= l(flag.created_at, format: :short_with_yy) %> - </time> - </article> - <% end %> +<div class="flex justify-between gap-10"> + <div class="flex flex-col flex-1"> + <h2 class="crayons-subtitle-2 px-1"><%= t("views.admin.users.priviliged_actions.title") %></h2> + <p class="crayons-subtitle-3 fw-normal color-secondary px-1 mt-1"><%= t("views.admin.users.priviliged_actions.description") %></p> </div> -<% else %> - <div class="align-center flex flex-col justify-center my-auto py-8"> - <h3 class="crayons-subtitle-2 mb-2"><%= t("views.admin.users.flags.empty1", user: @user.name) %></h3> - <p class="color-secondary"><%= t("views.admin.users.flags.empty2", user: @user.name) %></p> + <div class="flex items-start gap-1"> + <span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.vomit") %>"> + <%= crayons_icon_tag("twemoji/flag", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= @countable_flags %></span> + </span> + + <span class="crayons-card crayons-card--secondary px-3 py-1 ml-3 flex gap-2 items-center" title="<%= t("views.admin.users.flags.total_score") %>"> + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <span class="fs-s fw-medium lh-base"><%= @user.score %></span> + </span> </div> -<% end %> +</div> + +<div id="reaction-content" class="flex flex-col gap-3 px-1 pt-6" style="overflow: auto; height: 406px;"> + <%= render "admin/shared/flag_reactions_table", + vomit_reactions: @user_vomit_reactions, + text_section: "users", + empty_text: t("views.admin.users.priviliged_actions.no_flags") %> +<div> diff --git a/app/views/admin/users/show/notes/_index.html.erb b/app/views/admin/users/show/notes/_index.html.erb index c9085b4101fa0..f1941f933b68d 100644 --- a/app/views/admin/users/show/notes/_index.html.erb +++ b/app/views/admin/users/show/notes/_index.html.erb @@ -8,13 +8,13 @@ <% else %> <div class="pt-3"> <% @notes.each do |note| %> - <% author = User.find(note.author_id).username %> + <% author = User.find_by(id: note.author_id)&.username %> <article class="c-list-item flex justify-between gap-4"> - <h3 class="screen-reader-only"><%= t("views.admin.users.notes.note_by", author: author, time: l(note.created_at, format: :short_with_yy)) %></h3> + <h3 class="screen-reader-only"><%= t("views.admin.users.notes.note_by", author: author || t("views.admin.users.notes.unknown_user"), time: l(note.created_at, format: :short_with_yy)) %></h3> <div> <p><%= note.content %></p> <p class="fs-s color-secondary"> - <%= t("views.admin.users.notes.reason_by_html", reason: note.reason.presence, by: note.author_id.present? ? t("views.admin.users.notes.by_html", author: tag.span(author, class: "fw-medium")) : "") %> + <%= t("views.admin.users.notes.reason_by_html", reason: note.reason.presence, by: t("views.admin.users.notes.by_html", author: tag.span(author || t("views.admin.users.notes.unknown_user_html"), class: "fw-medium"))) %> </p> </div> <time datetime="<%= note.created_at.strftime("%Y-%m-%dT%H:%M:%S%z") %>" class="color-secondary fs-s shrink-0" title="<%= l(note.created_at, format: :admin_user) %>"> diff --git a/app/views/admin/users/show/overview/_roles.html.erb b/app/views/admin/users/show/overview/_roles.html.erb index a43a82a4fc907..34bc083560472 100644 --- a/app/views/admin/users/show/overview/_roles.html.erb +++ b/app/views/admin/users/show/overview/_roles.html.erb @@ -17,21 +17,26 @@ </div> <% else %> <ul class="flex flex-wrap gap-2"> - <% @user.roles.reject { |role| role.name == "tag_moderator" }.each do |role| %> + <% @user.roles.reject(&:tag_moderator?).each do |role| %> <li> - <% if role.name == "banned" || role.name == "suspended" || role.name == "super_admin" || @user.id == current_user.id %> + <% if !policy(role).remove_role? %> <button class="c-pill c-pill--description-icon crayons-tooltip__activator cursor-help" type="button" aria-disabled="true"> <%= crayons_icon_tag(:lock, class: "c-pill__description-icon", aria_hidden: true, width: 18, height: 18) %> - <%= t("views.admin.users.overview.roles.name.#{role.name}", default: role.resource_name ? role.resource_name.to_s : role_display_name(role)) %> + <%= t("views.admin.users.overview.roles.name.#{role.name_labelize.underscore.parameterize(separator: '_')}", default: role.resource_name ? role.resource_name.to_s : role_display_name(role)) %> <span data-testid="tooltip" class="crayons-tooltip__content"><%= t("views.admin.users.overview.roles.locked") %></span> </button> <% else %> - <%= button_to url_for(action: :destroy, user_id: @user.id, role: role.name.to_sym, resource_type: role.resource_type), method: :delete, data: { confirm: t("views.admin.users.overview.roles.remove_confirm") }, class: "c-pill c-pill--action-icon" do %> + <% confirm_dialog = if role.super_admin? + t("views.admin.users.overview.roles.remove_confirm_super_admin") + else + t("views.admin.users.overview.roles.remove_confirm") + end %> + <%= button_to url_for(action: :destroy, user_id: @user.id, role_id: role.id, resource_type: role.resource_type), method: :delete, data: { confirm: confirm_dialog }, class: "c-pill c-pill--action-icon" do %> <span class="screen-reader-only"><%= t("views.admin.users.overview.roles.remove") %></span> - <%= t("views.admin.users.overview.roles.name.#{role.name}", default: role.resource_name ? role.resource_name.to_s : role_display_name(role)) %> + <%= t("views.admin.users.overview.roles.name.#{role.name_labelize.underscore.parameterize(separator: '_')}", default: role.resource_name ? role.resource_name.to_s : role_display_name(role)) %> <%= crayons_icon_tag(:x, class: "c-pill__action-icon", aria_hidden: true, width: 18, height: 18) %> <% end %> <% end %> diff --git a/app/views/admin/users/show/overview/_tag_moderation.html.erb b/app/views/admin/users/show/overview/_tag_moderation.html.erb index 0518d322b72b9..3450166a85cd9 100644 --- a/app/views/admin/users/show/overview/_tag_moderation.html.erb +++ b/app/views/admin/users/show/overview/_tag_moderation.html.erb @@ -9,22 +9,48 @@ </p> </header> -<% moderated_tags = @user.roles.select { |role| role.name == "tag_moderator" } %> +<% moderated_tags = @user.roles.select(&:tag_moderator?) %> <% if moderated_tags.any? %> <ul class="flex flex-wrap gap-2 mb-4"> <% moderated_tags.each do |role| %> <li> - <%# You can't currently remove a tag from this view %> - <button class="c-pill cursor-default" aria-disabled="true" aria-describedby="tag-moderation-description" style="--icon-right-color: var(--color-secondary); --icon-right-color-hover: var(--accent-danger);" type="button"> - <span class="screen-reader-only"><%= t("views.admin.users.overview.tag_mod.remove") %></span> - #<%= role.resource_name.to_s %> - </button> + <% if policy(role).remove_role? %> + <% confirm_dialog = if role.super_admin? + t("views.admin.users.overview.roles.remove_confirm_super_admin") + else + t("views.admin.users.overview.roles.remove_confirm") + end %> + <%= button_to url_for(action: :destroy, user_id: @user.id, role_id: role.id, resource_type: role.resource_type, resource_id: role.resource_id), + method: :delete, data: { confirm: confirm_dialog }, class: "c-pill c-pill--action-icon" do %> + + <span class="screen-reader-only"><%= t("views.admin.users.overview.tag_mod.remove") %></span> + #<%= role.resource_name.to_s %> + + <%= crayons_icon_tag(:x, class: "c-pill__action-icon", aria_hidden: true, width: 18, height: 18) %> + <% end %> + <% else %> + <button + class="c-pill c-pill--description-icon crayons-tooltip__activator cursor-help" + type="button" + aria-disabled="true"> + <%= crayons_icon_tag(:lock, class: "c-pill__description-icon", aria_hidden: true, width: 18, height: 18) %> + #<%= role.resource_name.to_s %> + <span data-testid="tooltip" class="crayons-tooltip__content"><%= t("views.admin.users.overview.roles.locked") %></span> + </button> + <% end %> </li> <% end %> </ul> <% end %> +<%= form_with url: add_tag_mod_role_admin_user_path(@user.id), scope: :user, method: :post, local: true, html: { class: "" } do |f| %> + <div class="crayons-field w-50 my-4"> + <%= f.text_field :tag_name, placeholder: "One tag name", size: 50, required: true, class: "crayons-textfield w-auto mr-3", autocomplete: "off" %> + <%= f.submit "Create tag mod role", class: "crayons-btn" %> + </div> +<% end %> -<div id="tag-moderation-description" class="crayons-notice crayons-notice--warning"> - <%= t("views.admin.users.overview.tag_mod.notice_html", tag_page: link_to(t("views.admin.users.overview.tag_mod.tag_page"), admin_tags_path, class: "c-link c-link--branded"), user_id: tag.strong(@user.id)) %> +<div id="tag-moderation-description" class="crayons-notice crayons-notice--warning mt-4"> + <%= t("views.admin.users.overview.tag_mod.notice_html", tag_page: link_to(t("views.admin.users.overview.tag_mod.tag_page"), + admin_tags_path, class: "c-link c-link--branded"), username: tag.strong(@user.username)) %> </div> diff --git a/app/views/admin/users/show/profile/_actions.html.erb b/app/views/admin/users/show/profile/_actions.html.erb index 293724eae972d..764299daf5798 100644 --- a/app/views/admin/users/show/profile/_actions.html.erb +++ b/app/views/admin/users/show/profile/_actions.html.erb @@ -1,6 +1,6 @@ -<%= javascript_packs_with_chunks_tag "admin/editUser", defer: true %> +<%= javascript_include_tag "admin/editUser", defer: true %> <div class="flex relative justify-between s:justify-end gap-2 my-2 s:my-0"> - <a id="tag-priority-link" href="<%= dashboard_show_user_path(username: @user.username ) %>" class="c-link c-link--block c-link--icon-alone" aria-label="<%= t("views.main.tags.aria_label") %>" title="<%= t("views.main.tags.aria_label") %>"> + <a id="tag-priority-link" href="<%= dashboard_show_user_path(username: @user.username) %>" class="c-link c-link--block c-link--icon-alone" aria-label="<%= t("views.main.tags.aria_label") %>" title="<%= t("views.main.tags.aria_label") %>"> <%= crayons_icon_tag("dashboard-line") %> </a> <%= link_to t("views.admin.users.profile.visit"), @user.path, class: "c-cta", target: "_blank", rel: "noopener" %> @@ -13,6 +13,7 @@ <% if @user.access_locked? %> <li><%= link_to t("views.admin.users.profile.locked.unlock"), unlock_access_admin_user_path(@user), method: :patch, class: "c-link c-link--block" %></li> <% end %> + <li><button type="button" class="c-btn w-100 align-left" data-modal-title="<%= t("views.admin.users.update_email.heading", user: @user.name) %>" data-modal-size="medium" data-modal-content-selector="#update-email"><%= t("views.admin.users.profile.options.update_email") %></button></li> <li><button type="button" class="c-btn w-100 align-left" data-modal-title="<%= t("views.admin.users.export.heading", user: @user.name) %>" data-modal-size="small" data-modal-content-selector="#export-data"><%= t("views.admin.users.profile.options.export") %></button></li> <li><button type="button" class="c-btn w-100 align-left" data-modal-title="<%= t("views.admin.users.merge.heading") %>" data-modal-size="small" data-modal-content-selector="#merge-accounts"><%= t("views.admin.users.profile.options.merge") %></button></li> <% if @user.articles_count > 0 %> @@ -29,6 +30,8 @@ <% if @user.identities.any? %> <li><button type="button" class="c-btn w-100 align-left" data-modal-title="<%= t("views.admin.users.social.heading") %>" data-modal-size="medium" data-modal-content-selector="#remove-social-accounts"><%= t("views.admin.users.profile.options.remove_social") %></button></li> <% end %> + <li><button type="button" class="c-btn w-100 align-left" data-modal-title="<%= t("views.admin.users.reputation.heading") %>" data-modal-size="medium" data-modal-content-selector="#change-reputation"><%= t("views.admin.users.reputation.change") %></button></li> + <li><button type="button" class="c-btn w-100 align-left" data-modal-title="<%= t("views.admin.users.max_score.heading") %>" data-modal-size="medium" data-modal-content-selector="#change-max-score"><%= t("views.admin.users.max_score.change") %></button></li> <li> <button type="button" class="c-btn c-btn--destructive w-100 align-left" @@ -48,9 +51,12 @@ <%# These are contents for modals %> <div class="hidden"> <%= render "admin/users/show/profile/actions/export" %> + <%= render "admin/users/show/profile/actions/update_email" %> <%= render "admin/users/show/profile/actions/merge" %> <%= render "admin/users/modals/unpublish_modal" %> <%= render "admin/users/show/profile/actions/social_accounts" %> + <%= render "admin/users/show/profile/actions/change_reputation" %> + <%= render "admin/users/show/profile/actions/change_max_score" %> <%= render "admin/users/modals/banish_modal" %> <%= render "admin/users/show/profile/actions/delete" %> </div> diff --git a/app/views/admin/users/show/profile/_status.html.erb b/app/views/admin/users/show/profile/_status.html.erb index 5f4aa98d9fda9..508cb8bddf086 100644 --- a/app/views/admin/users/show/profile/_status.html.erb +++ b/app/views/admin/users/show/profile/_status.html.erb @@ -1,14 +1,22 @@ <span class="ml-3 whitespace-nowrap" title="<%= t("views.admin.users.status_title") %>"> <span class="screen-reader-only"><%= t("views.admin.users.status_reader") %></span> - <% if @user.suspended? %> + <% if user.spam? %> + <span data-testid="user-status" class="c-indicator c-indicator--danger c-indicator--relaxed"><%= t("views.admin.users.statuses.Spam") %></span> + <% elsif user.suspended? %> <span data-testid="user-status" class="c-indicator c-indicator--danger c-indicator--relaxed"><%= t("views.admin.users.statuses.Suspended") %></span> - <% elsif @user.warned? %> + <% elsif user.warned? %> <span data-testid="user-status" class="c-indicator c-indicator--warning c-indicator--relaxed"><%= t("views.admin.users.statuses.Warned") %></span> - <% elsif @user.comment_suspended? %> + <% elsif user.comment_suspended? %> <span data-testid="user-status" class="c-indicator c-indicator--warning c-indicator--relaxed"><%= t("views.admin.users.statuses.Comment Suspended") %></span> - <% elsif @user.trusted? %> + <% elsif user.limited? %> + <span data-testid="user-status" class="c-indicator c-indicator--warning c-indicator--relaxed"><%= t("views.admin.users.statuses.Limited") %></span> + <% elsif user.trusted? %> <span data-testid="user-status" class="c-indicator c-indicator--success c-indicator--relaxed"><%= t("views.admin.users.statuses.Trusted") %></span> <% else %> <span data-testid="user-status" class="c-indicator c-indicator--relaxed"><%= t("views.admin.users.statuses.Good standing") %></span> <% end %> + <% if user.max_score > 0 %> + <span class="screen-reader-only"><%= t("views.admin.users.max_score_reader") %></span> + <span data-testid="user-max-score" class="c-indicator c-indicator--relaxed">Max: <%= user.max_score %></span> + <% end %> </span> diff --git a/app/views/admin/users/show/profile/actions/_change_max_score.html.erb b/app/views/admin/users/show/profile/actions/_change_max_score.html.erb new file mode 100644 index 0000000000000..a5722c1dd736d --- /dev/null +++ b/app/views/admin/users/show/profile/actions/_change_max_score.html.erb @@ -0,0 +1,17 @@ +<div id="change-max-score"> + <%= form_for(@user, url: max_score_admin_user_path(@user), + html: { class: "flex flex-col gap-4", method: :patch }) do |f| %> + <p><%= t("views.admin.users.max_score.desc_html", max_score: @user.max_score) %></p> + <div class="crayons-field mb-4"> + <%= f.label :max_score, t("views.admin.users.max_score.max_score"), class: "crayons-field__label" %> + <%= f.text_field :max_score, placeholder: @user.max_score, class: "crayons-textfield", type: "number", inputmode: "numeric", step: "1", min: 0, aria: { describedby: "reputation_modifier" } %> + </div> + <div class="crayons-field mb-4"> + <%= f.label :new_note, t("views.admin.users.max_score.change_note"), class: "crayons-field__label" %> + <%= f.text_area :new_note, size: 50, required: true, class: "crayons-textfield", id: "new_note" %> + </div> + <div> + <%= f.button t("views.admin.users.max_score.submit"), class: "c-btn c-btn--primary", type: "submit" %> + </div> + <% end %> +</div> diff --git a/app/views/admin/users/show/profile/actions/_change_reputation.html.erb b/app/views/admin/users/show/profile/actions/_change_reputation.html.erb new file mode 100644 index 0000000000000..d6d1be3dfe80c --- /dev/null +++ b/app/views/admin/users/show/profile/actions/_change_reputation.html.erb @@ -0,0 +1,17 @@ +<div id="change-reputation"> + <%= form_for(@user, url: reputation_modifier_admin_user_path(@user), + html: { class: "flex flex-col gap-4", method: :patch }) do |f| %> + <p><%= t("views.admin.users.reputation.desc_html", reputation_modifier: @user.reputation_modifier) %></p> + <div class="crayons-field mb-4"> + <%= f.label :reputation_modifier, t("views.admin.users.reputation.reputation_modifier"), class: "crayons-field__label" %> + <%= f.text_field :reputation_modifier, placeholder: @user.reputation_modifier, class: "crayons-textfield", type: "number", inputmode: "numeric", step: "0.01", min: "0", max: 5, aria: { describedby: "reputation_modifier" } %> + </div> + <div class="crayons-field mb-4"> + <%= f.label :new_note, t("views.admin.users.reputation.change_note"), class: "crayons-field__label" %> + <%= f.text_area :new_note, size: 50, required: true, class: "crayons-textfield", id: "new_note" %> + </div> + <div> + <%= f.button t("views.admin.users.reputation.submit"), class: "c-btn c-btn--primary", type: "submit" %> + </div> + <% end %> +</div> diff --git a/app/views/admin/users/show/profile/actions/_update_email.html.erb b/app/views/admin/users/show/profile/actions/_update_email.html.erb new file mode 100644 index 0000000000000..f20e3d4ca4f70 --- /dev/null +++ b/app/views/admin/users/show/profile/actions/_update_email.html.erb @@ -0,0 +1,13 @@ +<div id="update-email"> + <%= form_for(@user, url: update_email_admin_user_path(@user), + html: { class: "flex flex-col gap-4", method: :patch }) do |f| %> + <p><%= t("views.admin.users.update_email.desc_html", email: @user.email) %></p> + <div class="crayons-field mb-4"> + <%= f.label :new_email, t("views.admin.users.update_email.new_email"), class: "crayons-field__label" %> + <%= f.email_field :email, class: "crayons-textfield", required: true %> + </div> + <div> + <%= f.button t("views.admin.users.update_email.submit"), class: "c-btn c-btn--primary", type: "submit" %> + </div> + <% end %> +</div> diff --git a/app/views/api/v0/articles/search.json.jbuilder b/app/views/api/v0/articles/search.json.jbuilder new file mode 100644 index 0000000000000..23cc776b690ec --- /dev/null +++ b/app/views/api/v0/articles/search.json.jbuilder @@ -0,0 +1,23 @@ +json.array! @articles do |article| + json.partial! "api/v0/articles/article", article: article + + json.body_markdown article.body_markdown if article.respond_to?(:body_markdown) + + # /api/articles and /api/articles/:id have opposite representations + # of `tag_list` and `tags and we can't align them without breaking the API, + # this is fully documented in the API docs + # see <https://github.com/forem/forem/issues/4206> for more details + json.tag_list article.cached_tag_list_array + json.tags article.cached_tag_list + + json.partial! "api/v0/shared/user", user: article.user + + if article.organization + json.partial! "api/v0/shared/organization", organization: article.organization + end + + flare_tag = FlareTag.new(article).tag + if flare_tag + json.partial! "api/v0/articles/flare_tag", flare_tag: flare_tag + end +end diff --git a/app/views/api/v0/users/me.json.jbuilder b/app/views/api/v0/users/me.json.jbuilder new file mode 100644 index 0000000000000..76f8211b0eee1 --- /dev/null +++ b/app/views/api/v0/users/me.json.jbuilder @@ -0,0 +1 @@ +json.partial! "api/v0/shared/user_show", user: @user diff --git a/app/views/api/v1/articles/search.json.jbuilder b/app/views/api/v1/articles/search.json.jbuilder new file mode 100644 index 0000000000000..285917f292aac --- /dev/null +++ b/app/views/api/v1/articles/search.json.jbuilder @@ -0,0 +1,23 @@ +json.array! @articles do |article| + json.partial! "api/v1/articles/article", article: article + + json.body_markdown article.body_markdown if article.respond_to?(:body_markdown) + + # /api/articles and /api/articles/:id have opposite representations + # of `tag_list` and `tags and we can't align them without breaking the API, + # this is fully documented in the API docs + # see <https://github.com/forem/forem/issues/4206> for more details + json.tag_list article.cached_tag_list_array + json.tags article.cached_tag_list + + json.partial! "api/v1/shared/user", user: article.user + + if article.organization + json.partial! "api/v1/shared/organization", organization: article.organization + end + + flare_tag = FlareTag.new(article).tag + if flare_tag + json.partial! "api/v1/articles/flare_tag", flare_tag: flare_tag + end +end diff --git a/app/views/api/v1/audience_segments/users.json.jbuilder b/app/views/api/v1/audience_segments/users.json.jbuilder new file mode 100644 index 0000000000000..c901adcb9cb12 --- /dev/null +++ b/app/views/api/v1/audience_segments/users.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @users do |user| + json.partial! "api/v1/shared/user_show", user: user +end diff --git a/app/views/api/v1/shared/_user_show_extended.json.jbuilder b/app/views/api/v1/shared/_user_show_extended.json.jbuilder new file mode 100644 index 0000000000000..d187567781339 --- /dev/null +++ b/app/views/api/v1/shared/_user_show_extended.json.jbuilder @@ -0,0 +1,20 @@ +json.type_of "user" + +json.extract!( + user, + :id, + :username, + :name, + :twitter_username, + :github_username, +) + +json.email user.setting.display_email_on_profile ? user.email : nil + +Profile.static_fields.each do |attr| + json.set! attr, user.profile.public_send(attr) +end + +json.joined_at I18n.l(user.created_at, format: :json) +json.profile_image user.profile_image_url_for(length: 320) +json.badge_ids user.badge_ids diff --git a/app/views/api/v1/users/me.json.jbuilder b/app/views/api/v1/users/me.json.jbuilder new file mode 100644 index 0000000000000..f754f12410d1a --- /dev/null +++ b/app/views/api/v1/users/me.json.jbuilder @@ -0,0 +1,2 @@ +json.partial! "api/v1/shared/user_show_extended", user: @user +json.followers_count @user.good_standing_followers_count diff --git a/app/views/api/v1/users/show.json.jbuilder b/app/views/api/v1/users/show.json.jbuilder index cea14ce99393f..bb9d3c4f1f294 100644 --- a/app/views/api/v1/users/show.json.jbuilder +++ b/app/views/api/v1/users/show.json.jbuilder @@ -1 +1 @@ -json.partial! "api/v1/shared/user_show", user: @user +json.partial! "api/v1/shared/user_show_extended", user: @user diff --git a/app/views/articles/_actions.html.erb b/app/views/articles/_actions.html.erb index fcb5dd56b7f79..483390ccc31ef 100644 --- a/app/views/articles/_actions.html.erb +++ b/app/views/articles/_actions.html.erb @@ -1,13 +1,18 @@ <div class="crayons-article-actions print-hidden"> <div class="crayons-article-actions__inner"> - <% if @article.published? %> - <% if FeatureFlag.enabled?(:multiple_reactions) %> - <%= render partial: "articles/multiple_reactions" %> - <% else %> - <%= render partial: "articles/original_reactions" %> + + <%= render partial: "articles/multiple_reactions" if @article.published? %> + + <div id="mod-actions-menu-btn-area" class="print-hidden trusted-visible-block align-center"> + <% if user_signed_in? %> + <button class="crayons-btn crayons-btn--ghost crayons-btn--icon-rounded mod-actions-menu-btn"> + <svg width="24" height="24" class="crayons-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-labelledby="d6cd43ffbad3fe639e2e95c901ee88c8"> + <title id="d6cd43ffbad3fe639e2e95c901ee88c8">Moderation + + + <% end %> - <% end %> - +
+ + +
+ + <% if timeframe_check("week") || timeframe_check("month") || timeframe_check("year") || timeframe_check("infinity") %> +
    +
+ <% end %> + + +<% else %> + +<% end %> \ No newline at end of file diff --git a/app/views/articles/_full_comment_area.html.erb b/app/views/articles/_full_comment_area.html.erb index 2283243cf0dae..12f89d5e2f9af 100644 --- a/app/views/articles/_full_comment_area.html.erb +++ b/app/views/articles/_full_comment_area.html.erb @@ -1,37 +1,41 @@ -<% cache("whole-comment-area-#{@article.id}-#{@article.last_comment_at}-#{@article.show_comments}-#{@discussion_lock&.updated_at}-#{@comments_order}-#{user_signed_in?}", expires_in: 2.hours) do %> +<% cache("whole-comment-area-#{@article.id}-#{@article.comments.cache_key_with_version}-#{@article.show_comments}-#{@discussion_lock&.updated_at}-#{@comments_order}-#{user_signed_in?}", expires_in: 2.hours) do %>
<% if @article.show_comments %>

<%= t("views.articles.comments.subtitle.#{@comments_order}_html", - num: tag.span(t("views.articles.comments.num", num: @article.comments_count), class: "js-comments-count", data: { comments_count: @article.comments_count })) %> + num: tag.span(t("views.articles.comments.num", num: @comments_count), class: "js-comments-count", data: { comments_count: @comments_count })) %>

- + <% if user_signed_in? %> + + <% end %>
- + <% if user_signed_in? %> + + <% end %>
- +
<% if @article.comments_count > 0 %> + <% @current_comment_index = 0 %> <%= render partial: "articles/comment_tree", collection: article_comment_tree(@article, @comments_to_show_count, @comments_order), as: :comment_node, diff --git a/app/views/articles/_liquid.html.erb b/app/views/articles/_liquid.html.erb index 609dbf6fe988a..eb9c6cf9604d4 100644 --- a/app/views/articles/_liquid.html.erb +++ b/app/views/articles/_liquid.html.erb @@ -19,7 +19,7 @@
-
+
"> <% if @collection %> <%= render "articles/collection", rendered_article: @article, @@ -178,7 +191,19 @@ <% end %>
- <%= @article.processed_html.html_safe %> + <% if @article.type_of == "status" && @article.processed_html_final.present? %> + <% if user_signed_in? %> +
+ <%= @article.processed_html_final.html_safe %> +
+ <% else %> + + Sign in to view linked content + + <% end %> + <% else %> + <%= @article.processed_html_final.html_safe %> + <% end %>
<% if @article.long_markdown? && @collection %> @@ -187,23 +212,17 @@ collection: @collection, articles: @collection_articles %> <% end %> + <% if @article.long_markdown? %> +
+ <% end %>
<%= render "articles/full_comment_area" %> - <% cache("article-bottom-content-#{rand(5)}-#{@article.id}-#{user_signed_in?}-#{(@organization || @user).latest_article_updated_at}", expires_in: 15.minutes) do %> - <% @display_ad = DisplayAd.for_display("post_comments", user_signed_in?, @article.decorate.cached_tag_list_array) %> - <% if @display_ad %> -
- <%= @display_ad.processed_html.html_safe %> -
- <% end %> - <% end %> +
+
+
<% cache("article-bottom-content-#{@article.id}-#{user_signed_in?}-#{(@organization || @user).latest_article_updated_at}", expires_in: 18.hours) do %> <% suggested_articles = Articles::Suggest.call(@article, max: 4) %> @@ -232,22 +251,54 @@ -<%= render "moderations/modals/suspend_user_modal" %> -<%= render "moderations/modals/unsuspend_user_modal" %> -<%= render "moderations/modals/unpublish_all_posts" %> -<%= render "moderations/modals/unpublish_post_modal" %> -<%= render "moderations/modals/flag_user_modal" %> -<%= render "moderations/modals/unflag_user_modal" %> +<% if user_signed_in? %> + <%= render "moderations/modals/suspend_user_modal" %> + <%= render "moderations/modals/unsuspend_user_modal" %> + <%= render "moderations/modals/unpublish_all_posts" %> + <%= render "moderations/modals/unpublish_post_modal" %> + <%= render "moderations/modals/flag_user_modal" %> + <%= render "moderations/modals/unflag_user_modal" %> +<% end %>
-<%= javascript_packs_with_chunks_tag "followButtons", defer: true %> +<%= javascript_include_tag "followButtons", "billboard", "localizeArticleDates", "articleReactions", defer: true %> -<% cache("article-show-scripts", expires_in: 8.hours) do %> - +
+ +<% if user_signed_in? %> + + -<% end %> +<% end %> \ No newline at end of file diff --git a/app/views/articles/stats.html.erb b/app/views/articles/stats.html.erb index 5757c6821ac09..c65d197cde998 100644 --- a/app/views/articles/stats.html.erb +++ b/app/views/articles/stats.html.erb @@ -1,9 +1,10 @@ <% title t("views.stats.meta.title") %> -
-

<%= t("views.stats.heading_html", article: link_to(@article.title, @article.current_state_path, id: "article", data: { "article-id" => @article.id, "organization-id" => @organization_id })) %>

+
+

+ <%= link_to(@article.title, @article.current_state_path, id: "article", data: { "article-id" => @article.id, "organization-id" => @organization_id }) %>

<%= render "shared/stats" %>
-<%= javascript_packs_with_chunks_tag "analyticsArticle", defer: true %> +<%= javascript_include_tag "analyticsArticle", defer: true %> diff --git a/app/views/auth_pass/iframe.html.erb b/app/views/auth_pass/iframe.html.erb new file mode 100644 index 0000000000000..f3a99fbbadcbc --- /dev/null +++ b/app/views/auth_pass/iframe.html.erb @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/views/billboards/show.html.erb b/app/views/billboards/show.html.erb new file mode 100644 index 0000000000000..a54cca82999a6 --- /dev/null +++ b/app/views/billboards/show.html.erb @@ -0,0 +1,3 @@ +<% if @billboard %> + <%= render partial: "shared/billboard", locals: { billboard: @billboard, data_context_type: BillboardEvent::CONTEXT_TYPE_ARTICLE } %> +<% end %> diff --git a/app/views/collections/show.html.erb b/app/views/collections/show.html.erb index d2ea5c9005005..ec42d28bb21a3 100644 --- a/app/views/collections/show.html.erb +++ b/app/views/collections/show.html.erb @@ -17,7 +17,7 @@ <% @articles.each do |article| %> <%= render "articles/single_story", story: article.decorate, featured: article.main_image.present? %> <% end %> - <%= javascript_packs_with_chunks_tag "followButtons", "feedPreviewCards", "hideBookmarkButtons", defer: true %> + <%= javascript_include_tag "followButtons", "feedPreviewCards", "hideBookmarkButtons", "localizeArticleDates", defer: true %> <% else %>

<%= t("views.series.list.empty") %>

<% end %> diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 1d4261c3571c8..26ae97e651334 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -1,4 +1,8 @@ <% if comment && comment.user %> + <% if @current_comment_index.to_i > 6 && !@shown_billboard && comment.depth.zero? %> +
+ <% @shown_billboard = true %> + <% end %> <% if comment.depth < 3 %>
m:crayons-avatar--l mt-4 m:mt-3<% else %>mt-4<% end %>"> - <%= image_tag(Images::Optimizer.call(Settings::General.mascot_image_url, width: 32, height: 32, crop: "imagga_scale"), class: "crayons-avatar__image overflow-hidden", alt: "Sloan, the sloth mascot", loading: "lazy") %> + <%= image_tag(Images::Optimizer.call(Settings::General.mascot_image_url, width: 32, height: 32), class: "crayons-avatar__image overflow-hidden", alt: "Sloan, the sloth mascot", loading: "lazy") %> <% else %> diff --git a/app/views/comments/_comment_header.html.erb b/app/views/comments/_comment_header.html.erb index ccc0ea540d254..27838e818fe46 100644 --- a/app/views/comments/_comment_header.html.erb +++ b/app/views/comments/_comment_header.html.erb @@ -10,6 +10,7 @@
<% if @tags.empty? %>
@@ -46,35 +51,42 @@
No results match that query
<% else %> -
- <% @tags.each do |tag| %> -
-

- <%= render_tag_link(tag.accessible_name) %> -

- <% if tag.short_summary.present? %> -

<%= sanitize tag.short_summary %>

- <% end %> -

- <%= t("views.tags.published", count: tag.taggings_count) %> -

- - <% if tag %> -
+
+ <% @tags.includes([:badge]).each do |tag| %> +
+
+
+

+ <%= render_tag_link(tag.accessible_name) %> +

+
<%= t("views.tags.published", count: number_with_delimiter(tag.taggings_count, delimiter: ",")) %>
+
+ <% if tag.short_summary.present? %> +

<%= sanitize tag.short_summary %>

+ <% end %> +
+
+
+
- <% end %> + <% if tag.badge_id && tag.badge %> + + <% end %> +
- <% if tag.badge_id && tag.badge %> - - <% end %>
<% end %>
<% end %> -<%= javascript_packs_with_chunks_tag "followButtons", defer: true %> + +<%= javascript_include_tag "tagFollows", defer: true %> diff --git a/app/views/tags/onboarding.json.jbuilder b/app/views/tags/onboarding.json.jbuilder deleted file mode 100644 index c6a960e2f76dd..0000000000000 --- a/app/views/tags/onboarding.json.jbuilder +++ /dev/null @@ -1,4 +0,0 @@ -json.array! @tags.each do |tag| - json.extract!(tag, :id, :name, :bg_color_hex, :text_color_hex) - json.following nil -end diff --git a/app/views/users/_account.html.erb b/app/views/users/_account.html.erb index a6710b7d118b6..8d37f2f6a9e9f 100644 --- a/app/views/users/_account.html.erb +++ b/app/views/users/_account.html.erb @@ -27,6 +27,20 @@ <% end %>
+ <% if @user.base_subscriber? %> +
+

+ Cancel DEV++ Subscription +

+ + <%= form_with(url: stripe_subscription_path("me"), method: :delete) do |f| %> + <%= f.label :verification, 'Verify with phrase "pleasecancelmyplusplus"', class: "crayons-field__label" %> + <%= f.text_field :verification, class: "crayons-textfield mt-1", placeholder: "Put verification phrase here" %> + <%= f.submit "Delete Subscription", class: "crayons-btn crayon-btn--secondary mt-2" %> + <% end %> +
+ <% end %> +

<%= t("views.settings.danger") %> @@ -58,6 +72,11 @@ <% end %> <% end %> + <% elsif @user.identities.enabled.size == 1 %> +
+

<%= t("views.settings.account.remove.heading") %>

+

<%= t("views.settings.account.remove.unable_to_remove") %>

+
<% end %>
diff --git a/app/views/users/_additional_authentication.html.erb b/app/views/users/_additional_authentication.html.erb index e4b2192d3476e..7bbae3b80f09d 100644 --- a/app/views/users/_additional_authentication.html.erb +++ b/app/views/users/_additional_authentication.html.erb @@ -9,7 +9,7 @@ <% next if provider.provider_name == :apple %> <% unless @user.authenticated_through?(provider.provider_name) %> <%= form_with url: provider.sign_in_path(state: "profile", origin: URL.url("/settings")), class: "flex w-100", local: true do |f| %> - <%= f.button type: :submit, class: "crayons-btn crayons-btn--icon-left crayons-btn--brand-#{provider.provider_name} m-1" do %> + <%= f.button type: :submit, class: "crayons-btn crayons-btn--icon-left crayons-btn--brand-#{provider.provider_name} m-1 w-100" do %> <%= crayons_icon_tag(provider.provider_name, title: provider.official_name) %> <%= t("views.settings.account.connect", provider: provider.official_name) %> <% end %> diff --git a/app/views/users/_api_keys.html.erb b/app/views/users/_api_keys.html.erb index 9e9df305bb44b..6a9b847fe8e45 100644 --- a/app/views/users/_api_keys.html.erb +++ b/app/views/users/_api_keys.html.erb @@ -1,22 +1,22 @@ -
+
-

<%= t("views.settings.extensions.api.heading", community: community_name) %> - <%= t("core.beta") %> +

<%= t("views.settings.extensions.api.heading", community: community_name) %> + <%= t("core.beta") %>

<%= t("views.settings.extensions.api.desc_html", community: community_name, doc: link_to(t("views.settings.extensions.api.doc"), "https://developers.forem.com/api")) %>

-

<%= t("views.settings.extensions.api.gen.heading") %>

+

<%= t("views.settings.extensions.api.gen.heading") %>

<%= form_tag users_api_secrets_path, method: :post do %> <%= fields_for :api_secret do |api_secret| %> -
+
<%= api_secret.label(:description, t("views.settings.extensions.api.gen.label"), class: "crayons-field__label") %> -

<% t("views.settings.extensions.api.gen.text") %>

+

<% t("views.settings.extensions.api.gen.text") %>

<%= api_secret.text_field(:description, placeholder: t("views.settings.extensions.api.gen.placeholder"), required: true, class: "crayons-textfield") %>
<% end %> - + <% end %>
@@ -25,12 +25,12 @@

<%= t("views.settings.extensions.api.active.heading") %>

<% @user.api_secrets.order(created_at: :desc).each do |api_secret| %> -
- <%= api_secret.description %> -
-
-

<%= api_secret.secret %>

-

<%== t("views.settings.extensions.api.active.created", time: tag.time(api_secret.created_at.to_s, datetime: api_secret.created_at.rfc3339)) %>

+
+ <%= api_secret.description %> +
+
+

<%= api_secret.secret %>

+

<%== t("views.settings.extensions.api.active.created", time: tag.time(api_secret.created_at.to_s, datetime: api_secret.created_at.rfc3339)) %>

<%= form_tag users_api_secret_path(api_secret.id), class: "api__secret__revoke", method: :delete do %> <%= button_tag t("views.settings.extensions.api.revoke"), class: "crayons-btn crayons-btn--danger" %> diff --git a/app/views/users/_badges_area.html.erb b/app/views/users/_badges_area.html.erb index 721edd98c2238..5bb4d06d55910 100644 --- a/app/views/users/_badges_area.html.erb +++ b/app/views/users/_badges_area.html.erb @@ -1,19 +1,24 @@
-
-

<%= t("views.badges.heading") %>

-
+ <% if defined?(show_heading) ? show_heading : true %> +
+

<%= t("views.badges.heading") %>

+
+ <% end %>
-
- <% achievements.each_with_index do |achievement, i| %> + <% grid_class ||= "grid-cols-3 s:grid-cols-4 m:grid-cols-2" %> +
+ <% grouped_badges.each_with_index do |(badge_id, badge_achievements), i| %> + <% achievement = badge_achievements.first %>
+ class="js-profile-badge <% if i >= limit %>hidden<% end %> relative"> <%= achievement.badge_title %> + <% if badge_achievements.length > 1 %>
"><%= badge_achievements.length %>
<% end %>
- <% if limit > 1 && count > limit %> + <% if limit > 1 && grouped_badges.size > limit %> <% end %> diff --git a/app/views/users/_comments_section.html.erb b/app/views/users/_comments_section.html.erb index 8788fa143d5fc..43f09315f4091 100644 --- a/app/views/users/_comments_section.html.erb +++ b/app/views/users/_comments_section.html.erb @@ -28,7 +28,7 @@ <% if comment.commentable.present? && comment.commentable.published && !comment.deleted %>

- <%= comment.commentable.title %> + <%= truncate(comment.commentable.title, length: 75) %>

diff --git a/app/views/users/_content_preferences.html.erb b/app/views/users/_content_preferences.html.erb new file mode 100644 index 0000000000000..42c04fa5661bc --- /dev/null +++ b/app/views/users/_content_preferences.html.erb @@ -0,0 +1,31 @@ +<%= javascript_include_tag "userProfileSettings", defer: true %> + + +

+
+

+ Augmented Content Preferences +

+

In plain english, please tell us what you want to see more of and see less of on DEV.

+

+
+ <%= form_with model: @users_setting, url: users_settings_path, html: { id: "user-profile-form" } do |f| %> +
+ <%= f.label :content_preferences_input, "Content preferences (Plain text)", class: "crayons-field__label" %> + <%= f.text_area :content_preferences_input, + class: "crayons-textfield", + style: "height: 180px;", + maxlength: 750, + aria: { describedby: "content-preferences-description" }, + data: { character_span_id: "content-preferences-characters" } %> +

+ <%= t("views.settings.characters") %> + <%=[@users_setting.content_preferences_input.to_s.size, 750].min %>/750 +

+
+ +
+ +
+ <% end %> +
diff --git a/app/views/users/_customization.html.erb b/app/views/users/_customization.html.erb index 4b18d1bf262d0..932a89d579323 100644 --- a/app/views/users/_customization.html.erb +++ b/app/views/users/_customization.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "stickySaveFooter", defer: true %> +<%= javascript_include_tag "stickySaveFooter", defer: true %> <%= form_for @users_setting, url: users_settings_path, @@ -72,14 +72,13 @@

- -
+

<%= t("views.settings.custom.sponsor") %>

- <%= t("views.settings.custom.sponsor_desc") %> + <%= t("views.settings.custom.sponsor_desc_html") %>

diff --git a/app/views/users/_errors.html.erb b/app/views/users/_errors.html.erb index 85a4553e172e3..3704e0b53ed86 100644 --- a/app/views/users/_errors.html.erb +++ b/app/views/users/_errors.html.erb @@ -1,7 +1,7 @@

<%= f.check_box :mod_roundrobin_notifications, class: "crayons-checkbox" %> - <%= f.label :mod_roundrobin_notifications, "Send me occasional community-success mod notifications", class: "crayons-field__label" %> + <%= f.label :mod_roundrobin_notifications, t("helpers.label.users_notification_setting.mod_roundrobin_notifications"), class: "crayons-field__label" %>
<% end %> diff --git a/app/views/users/_org_admin.html.erb b/app/views/users/_org_admin.html.erb index 90199d158bda5..ca63e75658667 100644 --- a/app/views/users/_org_admin.html.erb +++ b/app/views/users/_org_admin.html.erb @@ -57,7 +57,7 @@

<%= t("views.settings.org.admin.secret_code") %>

- "> + ">
@@ -99,26 +99,6 @@
-
- <%= f.label :nav_image, "Logo for light themes", class: "crayons-field__label" %> -
- <% if @organization.nav_image_url.present? %> - <%= @organization.name %> profile image - <% end %> - <%= f.file_field :nav_image, accept: "image/*", class: "crayons-card crayons-card--secondary p-3 flex items-center flex-1 w-100" %> -
-
- -
- <%= f.label :dark_nav_image, "Logo for dark themes", class: "crayons-field__label" %> -
- <% if @organization.dark_nav_image_url.present? %> - <%= @organization.name %> profile image - <% end %> - <%= f.file_field :dark_nav_image, accept: "image/*", class: "crayons-card crayons-card--secondary p-3 flex items-center flex-1 w-100" %> -
-
-
<%= f.label :twitter_username, "Twitter username", class: "crayons-field__label" %> <%= f.text_field :twitter_username, class: "crayons-textfield" %> diff --git a/app/views/users/_org_non_member.html.erb b/app/views/users/_org_non_member.html.erb index e3b7221dbc92a..665a2b70e93e9 100644 --- a/app/views/users/_org_non_member.html.erb +++ b/app/views/users/_org_non_member.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "validateFileInputs", defer: true %> +<%= javascript_include_tag "validateFileInputs", defer: true %>

<%= t("views.settings.org.join.heading") %>

@@ -61,7 +61,6 @@
<%= f.label :url, class: "crayons-field__label" do |translation| %> <%= translation %> - ">* <% end %> <%= f.url_field :url, class: "crayons-textfield" %>
@@ -69,7 +68,6 @@
<%= f.label :summary, class: "crayons-field__label" do |translation| %> <%= translation %> - ">* <% end %> <%= f.text_area :summary, class: "crayons-textfield", rows: 3 %>
@@ -77,7 +75,6 @@
<%= f.label :proof, class: "crayons-field__label" do |translation| %> <%= translation %> - ">* <% end %>

<%= t("views.settings.org.create.proof") %>

<%= f.text_area :proof, class: "crayons-textfield" %> diff --git a/app/views/users/_profile.html.erb b/app/views/users/_profile.html.erb index 3fa282a173915..f4936abb99555 100644 --- a/app/views/users/_profile.html.erb +++ b/app/views/users/_profile.html.erb @@ -1,4 +1,4 @@ -<%= javascript_packs_with_chunks_tag "enhanceColorPickers", "stickySaveFooter", "userProfileSettings", "validateFileInputs", defer: true %> +<%= javascript_include_tag "enhanceColorPickers", "stickySaveFooter", "userProfileSettings", "validateFileInputs", defer: true %> <%= render "users/additional_authentication" %> @@ -45,7 +45,7 @@
<% if @user.profile_image_url.present? %> - <%= @user.username %> profile image + <%= @user.username %> profile image <% end %> <%= f.file_field "user[profile_image]", accept: "image/*", class: "crayons-card crayons-card--secondary p-3 flex items-center flex-1 w-100" %> diff --git a/app/views/users/_publishing_from_rss.html.erb b/app/views/users/_publishing_from_rss.html.erb index c5871a354fa72..c91c4f626fe48 100644 --- a/app/views/users/_publishing_from_rss.html.erb +++ b/app/views/users/_publishing_from_rss.html.erb @@ -1,5 +1,5 @@ <% if policy(Article).create? %> -
+

<%= t("views.settings.extensions.rss.heading", community: community_name) %> diff --git a/app/views/users/_response_templates.html.erb b/app/views/users/_response_templates.html.erb index e693a6e611de0..6be9d759b6acd 100644 --- a/app/views/users/_response_templates.html.erb +++ b/app/views/users/_response_templates.html.erb @@ -1,4 +1,4 @@ -
+

<%= t("views.settings.extensions.comment.heading") %> diff --git a/app/views/users/_sidebar.html.erb b/app/views/users/_sidebar.html.erb index a83edbaf66b32..2dd3a858d54be 100644 --- a/app/views/users/_sidebar.html.erb +++ b/app/views/users/_sidebar.html.erb @@ -1,5 +1,14 @@