diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 000000000..07162a22d --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,65 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: json,jsonb,hstore +:hide_limit_column_types: integer,bigint,boolean +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +- enterprise/app/models +:require: [] +:root_dir: +- '' diff --git a/.bundler-audit.yml b/.bundler-audit.yml index afe8702ac..ffbfa18e0 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -1,3 +1,23 @@ --- ignore: - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) + - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) + # Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor. + # Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable. + - GHSA-jp94-3292-c3xv + # Rails 7.1 has no patched release for the Active Storage proxy range + # advisories. Chatwoot limits proxy range requests locally. + - CVE-2026-33658 + # Rails 7.1 has no patched release for this Active Storage direct-upload + # advisory. Chatwoot filters internal metadata keys locally. + - CVE-2026-33173 + - CVE-2026-33174 + # Rails 7.1 has no patched release for these Rails advisories. These are not + # reachable through Chatwoot's current usage patterns and should be removed + # once we upgrade to Rails 7.2.3.1+. + - CVE-2026-33168 + - CVE-2026-33169 + - CVE-2026-33170 + - CVE-2026-33176 + - CVE-2026-33195 + - CVE-2026-33202 diff --git a/.circleci/config.yml b/.circleci/config.yml index c67063ae6..f764cb611 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,97 +1,88 @@ -# Ruby CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-ruby/ for more details -# -version: 2 +version: 2.1 +orbs: + node: circleci/node@6.1.0 + qlty-orb: qltysh/qlty-orb@0.0 + +# Shared defaults for setup steps defaults: &defaults working_directory: ~/build - docker: - # specify the version you desire here - - image: cimg/ruby:3.2.2-browsers - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - - image: cimg/postgres:15.3 - - image: cimg/redis:6.2.6 - environment: - - RAILS_LOG_TO_STDOUT: false - - COVERAGE: true - - LOG_LEVEL: warn - parallelism: 4 + machine: + image: ubuntu-2204:2024.05.1 resource_class: large + environment: + RAILS_LOG_TO_STDOUT: false + COVERAGE: true + LOG_LEVEL: warn jobs: - build: + # Separate job for linting (no parallelism needed) + lint: <<: *defaults steps: - checkout + # Install minimal system dependencies for linting - run: - name: Configure Bundler + name: Install System Dependencies command: | - echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV - source $BASH_ENV - gem install bundler + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ + libpq-dev \ + build-essential \ + git \ + curl \ + libssl-dev \ + zlib1g-dev \ + libreadline-dev \ + libyaml-dev \ + openjdk-11-jdk \ + jq \ + software-properties-common \ + ca-certificates \ + imagemagick \ + libxml2-dev \ + libxslt1-dev \ + file \ + g++ \ + gcc \ + autoconf \ + gnupg2 \ + patch \ + ruby-dev \ + liblzma-dev \ + libgmp-dev \ + libncurses5-dev \ + libffi-dev \ + libgdbm6 \ + libgdbm-dev \ + libvips - run: - name: Which bundler? - command: bundle -v - - - run: - name: Swap node versions + name: Install RVM and Ruby 3.4.4 command: | - set +e - wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" - nvm install v20 - echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV - echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV - - # Run bundler - # Load installed gems from cache if possible, bundle install then save cache - # Multiple caches are used to increase the chance of a cache hit - - - restore_cache: - keys: - - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - - - run: bundle install --frozen --path ~/.bundle - - save_cache: - paths: - - ~/.bundle - key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - - # Only necessary if app uses webpacker or yarn in some other way - - restore_cache: - keys: - - chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} - - chatwoot-yarn- + sudo apt-get install -y gpg + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + \curl -sSL https://get.rvm.io | bash -s stable + echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV + source ~/.rvm/scripts/rvm + rvm install "3.4.4" + rvm use 3.4.4 --default + gem install bundler -v 2.5.16 - run: - name: yarn - command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn - - # Store yarn / webpacker cache - - save_cache: - key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} - paths: - - ~/.cache/yarn - - - run: - name: Download cc-test-reporter + name: Install Application Dependencies command: | - mkdir -p ~/tmp - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter - chmod +x ~/tmp/cc-test-reporter - - persist_to_workspace: - root: ~/tmp - paths: - - cc-test-reporter + source ~/.rvm/scripts/rvm + bundle install - # verify swagger specification + - node/install: + node-version: '24.13' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + + # Swagger verification - run: name: Verify swagger API specification command: | @@ -101,106 +92,283 @@ jobs: echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." exit 1 fi - curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar - java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json - - # Database setup - - run: bundle exec rake db:create - - run: bundle exec rake db:schema:load + mkdir -p ~/tmp + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar + java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json + # Bundle audit - run: name: Bundle audit command: bundle exec bundle audit update && bundle exec bundle audit check -v + # Rubocop linting - run: name: Rubocop - command: bundle exec rubocop - - # - run: - # name: Brakeman - # command: bundle exec brakeman + command: bundle exec rubocop --parallel + # ESLint linting - run: name: eslint - command: yarn run eslint + command: pnpm run eslint - # Run frontend tests - - run: - name: Run frontend tests - command: | - mkdir -p ~/tmp/test-results/frontend_specs - ~/tmp/cc-test-reporter before-build - TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings) - yarn test:coverage --profile 10 \ - --out ~/tmp/test-results/yarn.xml \ - -- ${TESTFILES} - - run: - name: Code Climate Test Coverage - command: | - ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" + # Separate job for frontend tests + frontend-tests: + <<: *defaults + steps: + - checkout + - node/install: + node-version: '24.13' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i - # Run rails tests + - run: + name: Run frontend tests (with coverage) + command: pnpm run test:coverage + + - run: + name: Move coverage files if they exist + command: | + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/frontend || true + fi + when: always + + - persist_to_workspace: + root: ~/build + paths: + - coverage + + # Backend tests with parallelization + backend-tests: + <<: *defaults + parallelism: 20 + steps: + - checkout + - node/install: + node-version: '24.13' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + + - run: + name: Add PostgreSQL repository and update + command: | + sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + sudo apt-get update -y + + - run: + name: Install System Dependencies + command: | + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ + libpq-dev \ + redis-server \ + postgresql-common \ + postgresql-16 \ + postgresql-16-pgvector \ + build-essential \ + git \ + curl \ + libssl-dev \ + zlib1g-dev \ + libreadline-dev \ + libyaml-dev \ + openjdk-11-jdk \ + jq \ + software-properties-common \ + ca-certificates \ + imagemagick \ + libxml2-dev \ + libxslt1-dev \ + file \ + g++ \ + gcc \ + autoconf \ + gnupg2 \ + patch \ + ruby-dev \ + liblzma-dev \ + libgmp-dev \ + libncurses5-dev \ + libffi-dev \ + libgdbm6 \ + libgdbm-dev \ + libvips + + - run: + name: Install RVM and Ruby 3.4.4 + command: | + sudo apt-get install -y gpg + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + \curl -sSL https://get.rvm.io | bash -s stable + echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV + source ~/.rvm/scripts/rvm + rvm install "3.4.4" + rvm use 3.4.4 --default + gem install bundler -v 2.5.16 + + - run: + name: Install Application Dependencies + command: | + source ~/.rvm/scripts/rvm + bundle install + + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + + # Configure environment and database + - run: + name: Database Setup and Configure Environment Variables + command: | + pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '') + sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql + chmod 644 ${PWD}/.circleci/setup_chatwoot.sql + mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/ + sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql + cp .env.example .env + sed -i '/^FRONTEND_URL/d' .env + sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env + sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env + sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env + sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env + echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" + + # Database setup + - run: + name: Run DB migrations + command: bundle exec rails db:chatwoot_prepare + + # Run backend tests (parallelized) - run: name: Run backend tests command: | mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-artifacts - mkdir -p coverage - ~/tmp/cc-test-reporter before-build - TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) - bundle exec rspec --format progress \ + mkdir -p ~/build/coverage/backend + + # Use round-robin distribution (same as GitHub Actions) for better test isolation + # This prevents tests with similar timing from being grouped on the same runner + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" + + for i in "${!SPEC_FILES[@]}"; do + if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done + + bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ --format RspecJunitFormatter \ --out ~/tmp/test-results/rspec.xml \ - -- ${TESTFILES} + -- $TESTS no_output_timeout: 30m - - run: - name: Code Climate Test Coverage - command: | - ~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json" - - persist_to_workspace: - root: coverage - paths: - - codeclimate.*.json - # collect reports + # Store test results for better splitting in future runs - store_test_results: path: ~/tmp/test-results - - store_artifacts: - path: ~/tmp/test-artifacts - - store_artifacts: - path: log - upload-coverage: - working_directory: ~/build - docker: - # specify the version you desire here - - image: circleci/ruby:3.0.2-node-browsers - environment: - - CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f + - run: + name: Move coverage files if they exist + command: | + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/backend || true + fi + when: always + + - persist_to_workspace: + root: ~/build + paths: + - coverage + + # Collect coverage from all jobs + coverage: + <<: *defaults steps: + - checkout - attach_workspace: at: ~/build + + # Qlty coverage publish + - qlty-orb/coverage_publish: + files: | + coverage/frontend/lcov.info + - run: - name: Download cc-test-reporter + name: List coverage directory contents command: | - mkdir -p ~/tmp - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter - chmod +x ~/tmp/cc-test-reporter - - persist_to_workspace: - root: ~/tmp - paths: - - cc-test-reporter + ls -R ~/build/coverage || echo "No coverage directory" + + - store_artifacts: + path: coverage + destination: coverage + + build: + <<: *defaults + steps: - run: - name: Upload coverage results to Code Climate + name: Legacy build aggregator command: | - ~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input - + echo "All main jobs passed; build job kept only for GitHub required check compatibility." workflows: version: 2 - - commit: + build: jobs: - - build - - upload-coverage: + - lint + - frontend-tests + - backend-tests + - coverage: requires: - - build + - frontend-tests + - backend-tests + - build: + requires: + - lint + - coverage diff --git a/.circleci/setup_chatwoot.sql b/.circleci/setup_chatwoot.sql new file mode 100644 index 000000000..4e5430f1d --- /dev/null +++ b/.circleci/setup_chatwoot.sql @@ -0,0 +1,11 @@ +CREATE USER chatwoot CREATEDB; +ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD'; +ALTER ROLE chatwoot SUPERUSER; + +UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1'; +DROP DATABASE template1; +CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE'; +UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1'; + +\c template1; +VACUUM FREEZE; diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index d8b8d985b..000000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,58 +0,0 @@ -version: '2' -plugins: - rubocop: - enabled: false - channel: rubocop-0-73 - eslint: - enabled: false - csslint: - enabled: true - scss-lint: - enabled: true - brakeman: - enabled: false -checks: - similar-code: - enabled: false - method-count: - enabled: true - config: - threshold: 32 - file-lines: - enabled: true - config: - threshold: 300 - method-lines: - config: - threshold: 50 -exclude_patterns: - - 'spec/' - - '**/specs/' - - 'db/*' - - 'bin/**/*' - - 'db/**/*' - - 'config/**/*' - - 'public/**/*' - - 'vendor/**/*' - - 'node_modules/**/*' - - 'lib/tasks/auto_annotate_models.rake' - - 'app/test-matchers.js' - - 'docs/*' - - '**/*.md' - - '**/*.yml' - - 'app/javascript/dashboard/i18n/locale' - - '**/*.stories.js' - - 'stories/' - - 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js' - - 'app/javascript/shared/constants/countries.js' - - 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js' - - 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js' - - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' - - 'app/javascript/dashboard/i18n/index.js' - - 'app/javascript/widget/i18n/index.js' - - 'app/javascript/survey/i18n/index.js' - - 'app/javascript/shared/constants/locales.js' - - 'app/javascript/dashboard/helper/specs/macrosFixtures.js' - - 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js' diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3fd4f1a31..9e8c36fdb 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,5 +4,15 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest # Do the set up required for chatwoot app WORKDIR /workspace + +# Copy dependency files first for better caching +COPY package.json pnpm-lock.yaml ./ +COPY Gemfile Gemfile.lock ./ + +# Install dependencies (will be cached if files don't change) +RUN pnpm install --frozen-lockfile && \ + gem install bundler && \ + bundle install --jobs=$(nproc) + +# Copy source code after dependencies are installed COPY . /workspace -RUN yarn && gem install bundler && bundle install diff --git a/.devcontainer/Dockerfile.base b/.devcontainer/Dockerfile.base index fe31dc42e..dc7d4eb8c 100644 --- a/.devcontainer/Dockerfile.base +++ b/.devcontainer/Dockerfile.base @@ -1,12 +1,16 @@ - -ARG VARIANT +ARG VARIANT="ubuntu-22.04" FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} +ENV DEBIAN_FRONTEND=noninteractive + ARG NODE_VERSION ARG RUBY_VERSION ARG USER_UID ARG USER_GID +ARG PNPM_VERSION="10.2.0" +ENV PNPM_VERSION ${PNPM_VERSION} +ENV RUBY_CONFIGURE_OPTS=--disable-install-doc # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ @@ -15,61 +19,80 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ && chmod -R $USER_UID:$USER_GID /home/vscode; \ fi -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends \ - build-essential \ - libssl-dev \ - zlib1g-dev \ - gnupg2 \ - tar \ - tzdata \ - postgresql-client \ - libpq-dev \ - yarn \ - git \ - imagemagick \ - tmux \ - zsh \ - git-flow \ - npm \ - libyaml-dev +RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \ + && curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ + && apt-get update \ + && apt-get -y install --no-install-recommends \ + build-essential \ + libssl-dev \ + zlib1g-dev \ + gnupg \ + tar \ + tzdata \ + postgresql-client \ + libpq-dev \ + git \ + imagemagick \ + libyaml-dev \ + curl \ + ca-certificates \ + tmux \ + nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -# Install rbenv and ruby -RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \ +# Install rbenv and ruby for root user first +RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \ && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ && echo 'eval "$(rbenv init -)"' >> ~/.bashrc ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH" -RUN git clone https://github.com/rbenv/ruby-build.git && \ +RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \ PREFIX=/usr/local ./ruby-build/install.sh RUN rbenv install $RUBY_VERSION && \ rbenv global $RUBY_VERSION && \ rbenv versions -# Install overmind +# Set up rbenv for vscode user +RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \ + && su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \ + && su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \ + && su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \ + && su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION" + +# Install overmind and gh in single layer RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ && gunzip overmind.gz \ - && sudo mv overmind /usr/local/bin \ - && chmod +x /usr/local/bin/overmind - - -# Install gh -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && sudo apt update \ - && sudo apt install gh + && mv overmind /usr/local/bin \ + && chmod +x /usr/local/bin/overmind \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Do the set up required for chatwoot app WORKDIR /workspace -COPY . /workspace +RUN chown vscode:vscode /workspace -# set up ruby -COPY Gemfile Gemfile.lock ./ -RUN gem install bundler && bundle install +# set up node js, pnpm and claude code in single layer +RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ + && npm cache clean --force -# set up node js -RUN npm install n -g && \ - n $NODE_VERSION -RUN npm install --global yarn -RUN yarn +# Switch to vscode user +USER vscode +ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH" + +# Copy dependency files first for better caching +COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./ + +# Install dependencies as vscode user +RUN eval "$(rbenv init -)" \ + && gem install bundler -N \ + && bundle install --jobs=$(nproc) \ + && pnpm install --frozen-lockfile + +# Copy source code after dependencies are installed +COPY --chown=vscode:vscode . /workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d2dac356b..2e237bbcb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,17 +4,26 @@ "dockerComposeFile": "docker-compose.yml", "settings": { - "terminal.integrated.shell.linux": "/bin/zsh" + "terminal.integrated.shell.linux": "/bin/zsh", + "extensions.showRecommendationsOnlyOnDemand": true, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "search.exclude": { + "**/node_modules": true, + "**/tmp": true, + "**/log": true, + "**/coverage": true, + "**/public/packs": true + } }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ - "rebornix.Ruby", + "Shopify.ruby-lsp", "misogi.ruby-rubocop", - "wingrunr21.vscode-ruby", "davidpallinder.rails-test-runner", - "eamodio.gitlens", "github.copilot", "mrmlnc.vscode-duplicate" ], @@ -23,15 +32,15 @@ // 5432 postgres // 6379 redis // 1025,8025 mailhog - "forwardPorts": [8025, 3000, 3035], + "forwardPorts": [8025, 3000, 3036], - "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn", + "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install", "portsAttributes": { "3000": { "label": "Rails Server" }, - "3035": { - "label": "Webpack Dev Server" + "3036": { + "label": "Vite Dev Server" }, "8025": { "label": "Mailhog UI" diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml new file mode 100644 index 000000000..375742ff7 --- /dev/null +++ b/.devcontainer/docker-compose.base.yml @@ -0,0 +1,18 @@ +# Docker Compose file for building the base image in GitHub Actions +# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base + +version: '3' + +services: + base: + build: + context: .. + dockerfile: .devcontainer/Dockerfile.base + args: + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '24.13.0' + RUBY_VERSION: '3.4.4' + # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. + USER_UID: '1000' + USER_GID: '1000' + image: ghcr.io/chatwoot/chatwoot_codespace:latest diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 17021d1e7..d696f99cc 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,30 +5,17 @@ version: '3' services: - base: - build: - context: .. - dockerfile: .devcontainer/Dockerfile.base - args: - VARIANT: "ubuntu-22.04" - NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.2.2" - # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. - USER_UID: "1000" - USER_GID: "1000" - image: base:latest - app: build: context: .. dockerfile: .devcontainer/Dockerfile args: - VARIANT: "ubuntu-22.04" - NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.2.2" + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '24.13.0' + RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. - USER_UID: "1000" - USER_GID: "1000" + USER_UID: '1000' + USER_GID: '1000' volumes: - ..:/workspace:cached @@ -40,7 +27,7 @@ services: network_mode: service:db db: - image: postgres:latest + image: pgvector/pgvector:pg16 restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 4ffee2d3a..36db5cfd9 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -2,12 +2,15 @@ cp .env.example .env sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env -sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env -sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env -# uncomment the webpacker env variable -sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env -# fix the error with webpacker -echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc +sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env + +# Setup Claude Code API key if available +if [ -n "$CLAUDE_CODE_API_KEY" ]; then + mkdir -p ~/.claude + echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json + echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh + chmod +x ~/.claude/anthropic_key.sh +fi # codespaces make the ports public -gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME +gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME diff --git a/.env.example b/.env.example index 177cb7fc8..69b1b9cde 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,18 @@ +# Learn about the various environment variables at +# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables + # Used to verify the integrity of signed cookies. so ensure a secure value is set +# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. +# Use `rake secret` to generate this variable SECRET_KEY_BASE=replace_with_lengthy_secure_hex +# Active Record Encryption keys (required for MFA/2FA functionality) +# Generate these keys by running: rails db:encryption:init +# IMPORTANT: Use different keys for each environment (development, staging, production) +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= + # Replace with the URL you are planning to use for your app FRONTEND_URL=http://0.0.0.0:3000 # To use a dedicated URL for help center pages @@ -23,6 +35,9 @@ FORCE_SSL=false ENABLE_ACCOUNT_SIGNUP=false # Redis config +# specify the configs via single URL or individual variables +# ref: https://www.iana.org/assignments/uri-schemes/prov/redis +# You can also use the following format for the URL: redis://:password@host:port/db_number REDIS_URL=redis://redis:6379 # If you are using docker-compose, set this variable's value to be any string, # which will be the password for the redis service running inside the docker-compose @@ -77,10 +92,14 @@ SMTP_OPENSSL_VERIFY_MODE=peer # Comment out the following environment variables if required by your SMTP server # SMTP_TLS= # SMTP_SSL= +# SMTP_OPEN_TIMEOUT +# SMTP_READ_TIMEOUT # Mail Incoming # This is the domain set for the reply emails when conversation continuity is enabled MAILER_INBOUND_EMAIL_DOMAIN= +# Maximum time in seconds to process a single IMAP email +# EMAIL_PROCESSING_TIMEOUT_SECONDS=60 # Set this to the appropriate ingress channel with regards to incoming emails # Possible values are : # relay for Exim, Postfix, Qmail @@ -88,6 +107,7 @@ MAILER_INBOUND_EMAIL_DOMAIN= # mandrill for Mandrill # postmark for Postmark # sendgrid for Sendgrid +# ses for Amazon SES RAILS_INBOUND_EMAIL_SERVICE= # Use one of the following based on the email ingress service # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html @@ -97,6 +117,10 @@ RAILS_INBOUND_EMAIL_PASSWORD= MAILGUN_INGRESS_SIGNING_KEY= MANDRILL_INGRESS_API_KEY= +# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name) +# Configure only if the rails_inbound_email_service = ses +ACTION_MAILBOX_SES_SNS_TOPIC= + # Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: # Inbound webhook URL format: # https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails @@ -180,14 +204,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: ## Sentry # SENTRY_DSN= -## LogRocket -# LOG_ROCKET_PROJECT_ID=xxxxx/some-project - -# MICROSOFT CLARITY -# MS_CLARITY_TOKEN=xxxxxxxxx - -# GOOGLE_TAG_MANAGER -# GOOGLE_TAG = GTM-XXXXXXX ## Scout ## https://scoutapm.com/docs/ruby/configuration @@ -206,6 +222,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables # DD_TRACE_AGENT_URL= + # MaxMindDB API key to download GeoLite2 City database # IP_LOOKUP_API_KEY= @@ -214,6 +231,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # ENABLE_RACK_ATTACK=true # RACK_ATTACK_LIMIT=300 # ENABLE_RACK_ATTACK_WIDGET_API=true +# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules +# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 + +## SafeFetch private network access +## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs. +# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints @@ -245,16 +268,18 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # AI powered features ## OpenAI key # OPENAI_API_KEY= -# Sentiment analysis model file path -SENTIMENT_FILE_PATH= - # Housekeeping/Performance related configurations # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# REDIS_ALFRED_SIZE=10 +# REDIS_VELMA_SIZE=10 diff --git a/.eslintrc.js b/.eslintrc.js index 03e7e995b..6b5205ad7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,16 +2,38 @@ module.exports = { extends: [ 'airbnb-base/legacy', 'prettier', - 'plugin:vue/recommended', - 'plugin:storybook/recommended', - 'plugin:cypress/recommended', + 'plugin:vue/vue3-recommended', + 'plugin:vitest-globals/recommended', + // use recommended-legacy when upgrading the plugin to v4 + 'plugin:@intlify/vue-i18n/recommended', ], + overrides: [ + { + files: ['**/*.spec.{j,t}s?(x)'], + env: { + 'vitest-globals/env': true, + }, + }, + { + files: ['**/*.story.vue'], + rules: { + 'vue/no-undef-components': [ + 'error', + { + ignorePatterns: ['Variant', 'Story'], + }, + ], + // Story files can have static strings, it doesn't need to handle i18n always. + 'vue/no-bare-strings-in-template': 'off', + 'no-console': 'off', + }, + }, + ], + plugins: ['html', 'prettier'], parserOptions: { - parser: '@babel/eslint-parser', - ecmaVersion: 2020, + ecmaVersion: 'latest', sourceType: 'module', }, - plugins: ['html', 'prettier', 'babel'], rules: { 'prettier/prettier': ['error'], camelcase: 'off', @@ -27,6 +49,161 @@ module.exports = { 'import/no-unresolved': 'off', 'vue/html-indent': 'off', 'vue/multi-word-component-names': 'off', + 'vue/next-tick-style': ['error', 'callback'], + 'vue/block-order': [ + 'error', + { + order: ['script', 'template', 'style'], + }, + ], + 'vue/component-name-in-template-casing': [ + 'error', + 'PascalCase', + { + registeredComponentsOnly: true, + }, + ], + 'vue/component-options-name-casing': ['error', 'PascalCase'], + 'vue/custom-event-name-casing': ['error', 'camelCase'], + 'vue/define-emits-declaration': ['error'], + 'vue/define-macros-order': [ + 'error', + { + order: ['defineProps', 'defineEmits'], + defineExposeLast: false, + }, + ], + 'vue/define-props-declaration': ['error', 'runtime'], + 'vue/match-component-import-name': ['error'], + 'vue/no-bare-strings-in-template': [ + 'error', + { + allowlist: [ + '(', + ')', + ',', + '.', + '&', + '+', + '-', + '=', + '*', + '/', + '#', + '%', + '!', + '?', + ':', + '[', + ']', + '{', + '}', + '<', + '>', + '⌘', + '📄', + '🎉', + '🚀', + '💬', + '👥', + '📥', + '🔖', + '❌', + '✅', + '\u00b7', + '\u2022', + '\u2010', + '\u2013', + '\u2014', + '\u2212', + '|', + ], + attributes: { + '/.+/': [ + 'title', + 'aria-label', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', + ], + input: ['placeholder'], + }, + directives: ['v-text'], + }, + ], + 'vue/no-empty-component-block': 'error', + 'vue/no-multiple-objects-in-class': 'error', + 'vue/no-root-v-if': 'warn', + 'vue/no-static-inline-styles': [ + 'error', + { + allowBinding: false, + }, + ], + 'vue/no-template-target-blank': [ + 'error', + { + allowReferrer: false, + enforceDynamicLinks: 'always', + }, + ], + 'vue/no-required-prop-with-default': [ + 'error', + { + autofix: false, + }, + ], + 'vue/no-this-in-before-route-enter': 'error', + 'vue/no-undef-components': [ + 'error', + { + ignorePatterns: [ + '^woot-', + '^fluent-', + '^multiselect', + '^router-link', + '^router-view', + '^ninja-keys', + '^FormulateForm', + '^FormulateInput', + '^highlightjs', + ], + }, + ], + 'vue/no-unused-emit-declarations': 'error', + 'vue/no-unused-refs': 'error', + 'vue/no-use-v-else-with-v-for': 'error', + 'vue/prefer-true-attribute-shorthand': 'error', + 'vue/no-useless-v-bind': [ + 'error', + { + ignoreIncludesComment: false, + ignoreStringEscape: false, + }, + ], + 'vue/no-v-text': 'error', + 'vue/padding-line-between-blocks': ['error', 'always'], + 'vue/prefer-separate-static-class': 'error', + 'vue/require-explicit-slots': 'error', + 'vue/require-macro-variable-name': [ + 'error', + { + defineProps: 'props', + defineEmits: 'emit', + defineSlots: 'slots', + useSlots: 'slots', + useAttrs: 'attrs', + }, + ], + 'vue/no-unused-properties': [ + 'error', + { + groups: ['props'], + deepData: false, + ignorePublicMembers: false, + unreferencedOptions: [], + }, + ], 'vue/max-attributes-per-line': [ 'error', { @@ -55,20 +232,25 @@ module.exports = { 'vue/singleline-html-element-content-newline': 'off', 'import/extensions': ['off'], 'no-console': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'warn', + '@intlify/vue-i18n/no-unused-keys': [ + 'warn', + { + extensions: ['.js', '.vue'], + }, + ], }, settings: { - 'import/resolver': { - webpack: { - config: 'config/webpack/resolve.js', - }, + 'vue-i18n': { + localeDir: './app/javascript/*/i18n/**.json', }, }, env: { browser: true, - jest: true, node: true, }, globals: { bus: true, + vi: true, }, }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1015fe997..499e5c120 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,2 @@ -## All javascript files should be reviewed by pranav before merging -*.js @pranavrajs -*.vue @pranavrajs - - ## All enterprise related files should be reviewed by sojan before merging /enterprise/* @sojan-official diff --git a/.github/dashboard-screen.png b/.github/dashboard-screen.png deleted file mode 100644 index 847ae1582..000000000 Binary files a/.github/dashboard-screen.png and /dev/null differ diff --git a/.github/screenshots/dashboard-dark.png b/.github/screenshots/dashboard-dark.png new file mode 100644 index 000000000..4d08b52b9 Binary files /dev/null and b/.github/screenshots/dashboard-dark.png differ diff --git a/.github/screenshots/dashboard.png b/.github/screenshots/dashboard.png new file mode 100644 index 000000000..b8b99be49 Binary files /dev/null and b/.github/screenshots/dashboard.png differ diff --git a/.github/screenshots/header-dark.png b/.github/screenshots/header-dark.png new file mode 100644 index 000000000..84931aee3 Binary files /dev/null and b/.github/screenshots/header-dark.png differ diff --git a/.github/screenshots/header.png b/.github/screenshots/header.png new file mode 100644 index 000000000..f10ca0faf Binary files /dev/null and b/.github/screenshots/header.png differ diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py new file mode 100644 index 000000000..064361b26 --- /dev/null +++ b/.github/scripts/ghsa_linear_sync.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Sync triage GitHub security advisories to Linear issues.""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +import requests + +GITHUB_API = "https://api.github.com" +LINEAR_API = "https://api.linear.app/graphql" + +SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4} +SEVERITY_COLOR = { + "critical": 15548997, + "high": 15105570, + "medium": 15844367, + "low": 3066993, +} +DEFAULT_COLOR = 9807270 + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + sys.exit(f"Missing required env var: {name}") + return value + + +def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]: + url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories" + params: dict[str, Any] | None = {"state": "triage", "per_page": 100} + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + advisories: list[dict[str, Any]] = [] + while url: + r = requests.get(url, headers=headers, params=params, timeout=30) + r.raise_for_status() + advisories.extend(r.json()) + next_link = r.links.get("next") + url = next_link["url"] if next_link else None + params = None + return advisories + + +def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]: + r = requests.post( + LINEAR_API, + headers={"Authorization": api_key}, + json={"query": query, "variables": variables}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def linear_issue_exists(ghsa_id: str, api_key: str) -> bool: + query = ( + "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) " + "{ nodes { id } } }" + ) + resp = linear_call(query, {"q": ghsa_id}, api_key) + return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0 + + +def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None: + query = ( + "mutation($input: IssueCreateInput!) { issueCreate(input: $input) " + "{ success issue { identifier url } } }" + ) + resp = linear_call(query, {"input": input_data}, api_key) + create = resp.get("data", {}).get("issueCreate") or {} + if not create.get("success"): + return None + return create.get("issue") + + +def reporter_login(advisory: dict[str, Any]) -> str: + for credit in advisory.get("credits") or []: + user = (credit or {}).get("user") or {} + if user.get("login"): + return user["login"] + return "unknown" + + +def cvss_score(advisory: dict[str, Any]) -> str: + score = (advisory.get("cvss") or {}).get("score") + return str(score) if score is not None else "n/a" + + +def build_description(adv: dict[str, Any]) -> str: + return ( + f"**GHSA:** {adv['ghsa_id']}\n" + f"**CVE:** {adv.get('cve_id') or 'n/a'}\n" + f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n" + f"**Reporter:** {reporter_login(adv)}\n" + f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n" + f"**Advisory:** {adv['html_url']}\n\n" + f"---\n\n" + f"{adv.get('description') or 'No description provided.'}" + ) + + +def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None: + severity = adv.get("severity") or "unknown" + title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250] + payload = { + "username": "GHSA Sync", + "embeds": [ + { + "title": title, + "url": issue["url"], + "color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR), + "fields": [ + {"name": "Linear", "value": issue["identifier"], "inline": True}, + { + "name": "Severity", + "value": f"{severity} (CVSS {cvss_score(adv)})", + "inline": True, + }, + { + "name": "Advisory", + "value": f"[GitHub]({adv['html_url']})", + "inline": True, + }, + ], + } + ], + } + try: + requests.post(webhook_url, json=payload, timeout=10) + except requests.RequestException: + pass + + +def main() -> int: + repo = required_env("GITHUB_REPOSITORY") + gh_token = required_env("GHSA_READ_TOKEN") + linear_api_key = required_env("LINEAR_API_KEY") + team_id = required_env("LINEAR_TEAM_ID") + project_id = required_env("LINEAR_PROJECT_ID") + label_id = required_env("LINEAR_LABEL_ID") + discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None + + advisories = fetch_triage_advisories(repo, gh_token) + print(f"Fetched {len(advisories)} triage advisories") + + created = skipped = failed = 0 + + for adv in advisories: + ghsa_id = adv.get("ghsa_id") + if not ghsa_id: + failed += 1 + continue + + try: + if linear_issue_exists(ghsa_id, linear_api_key): + skipped += 1 + continue + + severity = adv.get("severity") or "unknown" + issue = linear_create_issue( + { + "title": f"[{ghsa_id}] {adv.get('summary', '')}", + "description": build_description(adv), + "teamId": team_id, + "projectId": project_id, + "labelIds": [label_id], + "priority": SEVERITY_PRIORITY.get(severity, 3), + }, + linear_api_key, + ) + except requests.RequestException: + failed += 1 + continue + + if not issue: + failed += 1 + continue + + created += 1 + if discord_webhook: + post_discord(adv, issue, discord_webhook) + + print(f"Created {created}, skipped {skipped}, failed {failed}") + return 1 if failed > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/auto-assign-pr.yml b/.github/workflows/auto-assign-pr.yml new file mode 100644 index 000000000..98df89707 --- /dev/null +++ b/.github/workflows/auto-assign-pr.yml @@ -0,0 +1,28 @@ +name: Auto-assign PR to Author + +on: + pull_request: + types: [opened] + +jobs: + auto-assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Auto-assign PR to author + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: pull_number, + assignees: [author] + }); + + console.log(`Assigned PR #${pull_number} to ${author}`); \ No newline at end of file diff --git a/.github/workflows/deploy_check.yml b/.github/workflows/deploy_check.yml index 7fda2b1a4..9f2ae42d8 100644 --- a/.github/workflows/deploy_check.yml +++ b/.github/workflows/deploy_check.yml @@ -6,6 +6,14 @@ name: Deploy Check on: pull_request: +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: deployment_check: name: Check Deployment diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml new file mode 100644 index 000000000..3d992662a --- /dev/null +++ b/.github/workflows/frontend-fe.yml @@ -0,0 +1,44 @@ +name: Frontend Lint & Test + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install pnpm dependencies + run: pnpm install --frozen-lockfile + + - name: Run eslint + run: pnpm run eslint + + - name: Run frontend tests with coverage + run: | + mkdir -p coverage + pnpm run test:coverage diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml new file mode 100644 index 000000000..a21fbea7f --- /dev/null +++ b/.github/workflows/ghsa-linear-sync.yml @@ -0,0 +1,29 @@ +name: Sync GHSA advisories to Linear + +on: + schedule: + - cron: '0 4 * * *' # daily at 09:30 IST + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install requests==2.32.3 + - name: Sync advisories + env: + GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} + LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: python3 .github/scripts/ghsa_linear_sync.py diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index c42909856..5420e829b 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -25,13 +25,5 @@ jobs: with: issue-inactive-days: '30' issue-lock-reason: 'resolved' - issue-comment: > - This issue has been automatically locked since there - has not been any recent activity after it was closed. - Please open a new issue for related bugs. pr-inactive-days: '30' pr-lock-reason: 'resolved' - pr-comment: > - This pull request has been automatically locked since there - has not been any recent activity after it was closed. - Please open a new issue for related bugs. diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml index e9f84c313..cef07cc2f 100644 --- a/.github/workflows/logging_percentage_check.yml +++ b/.github/workflows/logging_percentage_check.yml @@ -5,6 +5,14 @@ on: branches: - develop +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: log_lines_check: runs-on: ubuntu-latest diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index d11fe6401..e0c5ed88e 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -2,7 +2,7 @@ # # # # Linux nightly installer action # # This action will try to install and setup -# # chatwoot on an Ubuntu 20.04 machine using +# # chatwoot on an Ubuntu 22.04 machine using # # the linux installer script. # # # # This is set to run daily at midnight. @@ -14,9 +14,12 @@ on: - cron: "0 0 * * *" workflow_dispatch: +permissions: + contents: read + jobs: nightly: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: get installer diff --git a/.github/workflows/publish_codespace_image.yml b/.github/workflows/publish_codespace_image.yml index 647608473..c1b0e4e28 100644 --- a/.github/workflows/publish_codespace_image.yml +++ b/.github/workflows/publish_codespace_image.yml @@ -3,6 +3,10 @@ name: Publish Codespace Base Image on: workflow_dispatch: +permissions: + contents: read + packages: write + jobs: publish-code-space-image: runs-on: ubuntu-latest @@ -19,6 +23,5 @@ jobs: - name: Build the Codespace Base Image run: | - docker-compose -f .devcontainer/docker-compose.yml build base - docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest + docker compose -f .devcontainer/docker-compose.base.yml build base docker push ghcr.io/chatwoot/chatwoot_codespace:latest diff --git a/.github/workflows/publish_ee_docker.yml b/.github/workflows/publish_ee_docker.yml new file mode 100644 index 000000000..982054a18 --- /dev/null +++ b/.github/workflows/publish_ee_docker.yml @@ -0,0 +1,143 @@ +# # +# # This action will publish Chatwoot EE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot EE docker images + +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +env: + DOCKER_REPO: chatwoot/chatwoot + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools inspect $TAG diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml index d48b82a58..994e5cef8 100644 --- a/.github/workflows/publish_foss_docker.yml +++ b/.github/workflows/publish_foss_docker.yml @@ -5,6 +5,7 @@ # # name: Publish Chatwoot CE docker images + on: push: branches: @@ -14,20 +15,33 @@ on: - v* workflow_dispatch: +env: + DOCKER_REPO: chatwoot/chatwoot + +permissions: + contents: read + jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} env: - GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches + GIT_REF: ${{ github.head_ref || github.ref_name }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Strip enterprise code run: | @@ -38,26 +52,97 @@ jobs: run: | echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile - - name: set docker tag + - name: Set Docker Tags run: | - echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV + fi - - name: replace docker tag if master - if: github.ref_name == 'master' - run: | - echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - uses: docker/login-action@v1 + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v2 + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 with: context: . file: docker/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.DOCKER_TAG }} + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools inspect $TAG diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index b0b2372ae..c2a626388 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -1,10 +1,6 @@ -# # -# # This action will strip the enterprise folder -# # and run the spec. -# # This is set to run against every PR. -# # - name: Run Chatwoot CE spec +permissions: + contents: read on: push: branches: @@ -14,20 +10,65 @@ on: workflow_dispatch: jobs: - test: - runs-on: ubuntu-20.04 + # Separate linting jobs for faster feedback + lint-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Run Rubocop + run: bundle exec rubocop --parallel + + lint-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run ESLint + run: pnpm run eslint + + # Frontend tests run in parallel with backend + frontend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run frontend tests + run: pnpm run test:coverage + + # Backend tests with parallelization + backend-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ci_node_total: [16] + ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + services: postgres: - image: postgres:15.3 + image: pgvector/pgvector:pg16 env: POSTGRES_USER: postgres - POSTGRES_PASSWORD: "" + POSTGRES_PASSWORD: '' POSTGRES_DB: postgres POSTGRES_HOST_AUTH_METHOD: trust ports: - 5432:5432 - # needed because the postgres container does not provide a healthcheck - # tmpfs makes DB faster by using RAM options: >- --mount type=tmpfs,destination=/var/lib/postgresql/data --health-cmd pg_isready @@ -35,53 +76,71 @@ jobs: --health-timeout 5s --health-retries 5 redis: - image: redis + image: redis:alpine ports: - 6379:6379 options: --entrypoint redis-server steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - repository: ${{ github.event.pull_request.head.repo.full_name }} + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: yarn + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' - - name: yarn - run: yarn install + - name: Install pnpm dependencies + run: pnpm i - - name: Strip enterprise code - run: | - rm -rf enterprise - rm -rf spec/enterprise + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise - - name: Create database - run: bundle exec rake db:create + - name: Create database + run: bundle exec rake db:create - - name: Seed database - run: bundle exec rake db:schema:load + - name: Seed database + run: bundle exec rake db:schema:load - - name: yarn check-files - run: yarn install --check-files + - name: Run backend tests (parallelized) + run: | + # Get all spec files and split them using round-robin distribution + # This ensures slow tests are distributed evenly across all nodes + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" - # Run rails tests - - name: Run backend tests - run: | - bundle exec rspec --profile=10 --format documentation - env: - NODE_OPTIONS: --openssl-legacy-provider + for i in "${!SPEC_FILES[@]}"; do + # Assign spec to this node if: index % total == node_index + if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done - - name: Upload rails log folder - uses: actions/upload-artifact@v4 - if: always() - with: - name: rails-log-folder - path: log + if [ -n "$TESTS" ]; then + bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS + fi + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: rspec-results-${{ matrix.ci_node_index }} + path: tmp/rspec_results.json + + - name: Upload rails log folder + uses: actions/upload-artifact@v4 + if: failure() + with: + name: rails-log-folder-${{ matrix.ci_node_index }} + path: log diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml new file mode 100644 index 000000000..69d019cc9 --- /dev/null +++ b/.github/workflows/run_mfa_spec.yml @@ -0,0 +1,100 @@ +name: Run MFA Tests +permissions: + contents: read + +on: + pull_request: + +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + # Only run if MFA test keys are available + if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]') + + services: + postgres: + image: pgvector/pgvector:pg15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '' + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + env: + RAILS_ENV: test + POSTGRES_HOST: localhost + # Active Record encryption keys required for MFA - test keys only, not for production use + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7' + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8' + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Create database + run: bundle exec rake db:create + + - name: Install pgvector extension + run: | + PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;" + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: Run MFA-related backend tests + run: | + bundle exec rspec \ + spec/services/mfa/token_service_spec.rb \ + spec/services/mfa/authentication_service_spec.rb \ + spec/requests/api/v1/profile/mfa_controller_spec.rb \ + spec/controllers/devise_overrides/sessions_controller_spec.rb \ + spec/models/application_record_external_credentials_encryption_spec.rb \ + --profile=10 \ + --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Run MFA-related tests in user_spec + run: | + # Run specific MFA-related tests from user_spec + bundle exec rspec spec/models/user_spec.rb \ + -e "two factor" \ + -e "2FA" \ + -e "MFA" \ + -e "otp" \ + -e "backup code" \ + --profile=10 \ + --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload test logs + uses: actions/upload-artifact@v4 + if: failure() + with: + name: mfa-test-logs + path: | + log/test.log + tmp/screenshots/ diff --git a/.github/workflows/run_response_bot_spec.yml b/.github/workflows/run_response_bot_spec.yml deleted file mode 100644 index c594ff404..000000000 --- a/.github/workflows/run_response_bot_spec.yml +++ /dev/null @@ -1,85 +0,0 @@ -# # -# # This workflow will run specs related to response bot -# # This can only be activated in installations Where vector extension is available. -# # - -name: Run Response Bot spec -on: - push: - branches: - - develop - - master - pull_request: - workflow_dispatch: - -jobs: - test: - runs-on: ubuntu-20.04 - services: - postgres: - image: ankane/pgvector - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: "" - POSTGRES_DB: postgres - POSTGRES_HOST_AUTH_METHOD: trust - ports: - - 5432:5432 - # needed because the postgres container does not provide a healthcheck - # tmpfs makes DB faster by using RAM - options: >- - --mount type=tmpfs,destination=/var/lib/postgresql/data - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis - ports: - - 6379:6379 - options: --entrypoint redis-server - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - repository: ${{ github.event.pull_request.head.repo.full_name }} - - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: yarn - - - name: yarn - run: yarn install - - - name: Create database - run: bundle exec rake db:create - - - name: Seed database - run: bundle exec rake db:schema:load - - - name: Enable ResponseBotService in installation - run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation" - - # Run Response Bot specs - - name: Run backend tests - run: | - bundle exec rspec \ - spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \ - spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \ - spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \ - spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \ - --profile=10 \ - --format documentation - - - name: Upload rails log folder - uses: actions/upload-artifact@v4 - if: always() - with: - name: rails-log-folder - path: log diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 0526aeedb..909636a75 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -5,9 +5,17 @@ on: branches: - develop +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -19,23 +27,29 @@ jobs: with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 with: - node-version: 20 - cache: 'yarn' + node-version: 24 + cache: 'pnpm' - - name: yarn - run: yarn install + - name: pnpm + run: pnpm install - name: Strip enterprise code run: | rm -rf enterprise rm -rf spec/enterprise + - name: setup env + run: | + cp .env.example .env + - name: Run asset compile run: bundle exec rake assets:precompile env: - NODE_OPTIONS: --openssl-legacy-provider + RAILS_ENV: production - name: Size Check - run: yarn run size + run: pnpm run size diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml new file mode 100644 index 000000000..96a6c69ac --- /dev/null +++ b/.github/workflows/test_docker_build.yml @@ -0,0 +1,43 @@ +name: Test Docker Build + +on: + pull_request: + branches: + - develop + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + test-build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: false + load: false + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} diff --git a/.gitignore b/.gitignore index 028a97f09..017c5c224 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,16 @@ master.key public/uploads public/packs* +public/assets/administrate* +public/assets/action*.js +public/assets/activestorage*.js +public/assets/trix* +public/assets/belongs_to*.js +public/assets/manifest*.js +public/assets/manifest*.js +public/assets/*.js.gz +public/assets/secretField* +public/assets/.sprockets-manifest-*.json # VIM files *.swp @@ -61,8 +71,6 @@ test/cypress/videos/* /config/master.key /config/*.enc -#ignore files under .vscode directory -.vscode # yalc for local testing .yalc @@ -75,4 +83,24 @@ yalc.lock yarn-debug.log* .yarn-integrity -/storybook-static \ No newline at end of file +# Vite Ruby +/public/vite* +# Vite uses dotenv and suggests to ignore local-only env files. See +# https://vitejs.dev/guide/env-and-mode.html#env-files +*.local + + +# TextEditors & AI Agents config files +.vscode +.claude/settings.local.json +.cursor +.codex/ +.claude/ +CLAUDE.local.md + +# Histoire deployment +.netlify +.histoire +.pnpm-store/* +local/ +Procfile.worktree diff --git a/.husky/pre-commit b/.husky/pre-commit index adda426ad..b3aceacd6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,8 +4,8 @@ # lint js and vue files npx --no-install lint-staged -# lint only staged ruby files -git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a +# lint only staged ruby files that still exist (not deleted) +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true # stage rubocop changes to files -git diff --name-only --cached | xargs git add +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true diff --git a/.nvmrc b/.nvmrc index 6f7af3750..cf2efde81 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.5.1 \ No newline at end of file +24.13.0 \ No newline at end of file diff --git a/.qlty/.gitignore b/.qlty/.gitignore new file mode 100644 index 000000000..30366188d --- /dev/null +++ b/.qlty/.gitignore @@ -0,0 +1,7 @@ +* +!configs +!configs/** +!hooks +!hooks/** +!qlty.toml +!.gitignore diff --git a/.qlty/configs/.hadolint.yaml b/.qlty/configs/.hadolint.yaml new file mode 100644 index 000000000..8f7e23e45 --- /dev/null +++ b/.qlty/configs/.hadolint.yaml @@ -0,0 +1,2 @@ +ignored: + - DL3008 diff --git a/.qlty/configs/.shellcheckrc b/.qlty/configs/.shellcheckrc new file mode 100644 index 000000000..6a38d9281 --- /dev/null +++ b/.qlty/configs/.shellcheckrc @@ -0,0 +1 @@ +source-path=SCRIPTDIR \ No newline at end of file diff --git a/.qlty/configs/.yamllint.yaml b/.qlty/configs/.yamllint.yaml new file mode 100644 index 000000000..d22fa7799 --- /dev/null +++ b/.qlty/configs/.yamllint.yaml @@ -0,0 +1,8 @@ +rules: + document-start: disable + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 000000000..780b38374 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,84 @@ +# This file was automatically generated by `qlty init`. +# You can modify it to suit your needs. +# We recommend you to commit this file to your repository. +# +# This configuration is used by both Qlty CLI and Qlty Cloud. +# +# Qlty CLI -- Code quality toolkit for developers +# Qlty Cloud -- Fully automated Code Health Platform +# +# Try Qlty Cloud: https://qlty.sh +# +# For a guide to configuration, visit https://qlty.sh/d/config +# Or for a full reference, visit https://qlty.sh/d/qlty-toml +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/.yarn/**", + "**/*.d.ts", + "**/assets/**", + "**/bower_components/**", + "**/build/**", + "**/cache/**", + "**/config/**", + "**/db/**", + "**/deps/**", + "**/dist/**", + "**/extern/**", + "**/external/**", + "**/generated/**", + "**/Godeps/**", + "**/gradlew/**", + "**/mvnw/**", + "**/node_modules/**", + "**/protos/**", + "**/seed/**", + "**/target/**", + "**/templates/**", + "**/testdata/**", + "**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", + "**/test_*.*", + "**/spec_*.*", +] + +[smells] +mode = "comment" + +[smells.boolean_logic] +threshold = 4 + +[smells.file_complexity] +threshold = 66 +enabled = true + +[smells.return_statements] +threshold = 4 + +[smells.nested_control_flow] +threshold = 4 + +[smells.function_parameters] +threshold = 4 + +[smells.function_complexity] +threshold = 5 + +[smells.duplication] +enabled = true +threshold = 20 + +[[source]] +name = "default" +default = true diff --git a/.rubocop.yml b/.rubocop.yml index 1cdfbc713..d87f08bfd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,14 @@ -require: +plugins: - rubocop-performance - rubocop-rails - rubocop-rspec + - rubocop-factory_bot + +require: - ./rubocop/use_from_email.rb - ./rubocop/custom_cop_location.rb + - ./rubocop/attachment_download.rb + - ./rubocop/one_class_per_file.rb Layout/LineLength: Max: 150 @@ -13,44 +18,67 @@ Metrics/ClassLength: Exclude: - 'app/models/message.rb' - 'app/models/conversation.rb' + Metrics/MethodLength: Max: 19 + Exclude: + - 'enterprise/lib/captain/agent.rb' + RSpec/ExampleLength: - Max: 25 + Max: 50 + Style/Documentation: Enabled: false + Style/ExponentialNotation: Enabled: false + Style/FrozenStringLiteralComment: Enabled: false + Style/SymbolArray: Enabled: false + Style/OpenStructUse: Enabled: false + +Chatwoot/AttachmentDownload: + Enabled: true + Exclude: + - 'spec/**/*' + - 'test/**/*' + Style/OptionalBooleanParameter: Exclude: - 'app/services/email_templates/db_resolver_service.rb' - 'app/dispatchers/dispatcher.rb' + Style/GlobalVars: Exclude: - 'config/initializers/01_redis.rb' - 'config/initializers/rack_attack.rb' - 'lib/redis/alfred.rb' - 'lib/global_config.rb' + Style/ClassVars: Exclude: - 'app/services/email_templates/db_resolver_service.rb' + Lint/MissingSuper: Exclude: - 'app/drops/base_drop.rb' + Lint/SymbolConversion: Enabled: false + Lint/EmptyBlock: Exclude: - 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder' + Lint/OrAssignmentToConstant: Exclude: - 'lib/redis/config.rb' + Metrics/BlockLength: Max: 30 Exclude: @@ -58,10 +86,16 @@ Metrics/BlockLength: - '**/routes.rb' - 'config/environments/*' - db/schema.rb + Metrics/ModuleLength: Exclude: - lib/seeders/message_seeder.rb - spec/support/slack_stubs.rb + +Rails/HelperInstanceVariable: + Exclude: + - enterprise/app/helpers/captain/chat_helper.rb + - enterprise/app/helpers/captain/chat_response_helper.rb Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' @@ -71,74 +105,101 @@ Rails/ApplicationController: - 'app/controllers/platform_controller.rb' - 'app/controllers/public_controller.rb' - 'app/controllers/survey/responses_controller.rb' + Rails/FindEach: Enabled: true Include: - 'app/**/*.rb' + Rails/CompactBlank: Enabled: false + Rails/EnvironmentVariableAccess: Enabled: false + Rails/TimeZoneAssignment: Enabled: false + Rails/RedundantPresenceValidationOnBelongsTo: Enabled: false + +Rails/InverseOf: + Exclude: + - enterprise/app/models/captain/assistant.rb + +Rails/UniqueValidationWithoutIndex: + Exclude: + - app/models/canned_response.rb + - app/models/telegram_bot.rb + - enterprise/app/models/captain_inbox.rb + - 'app/models/channel/twitter_profile.rb' + - 'app/models/webhook.rb' + - 'app/models/contact.rb' + Style/ClassAndModuleChildren: EnforcedStyle: compact Exclude: - 'config/application.rb' - 'config/initializers/monkey_patches/*' + Style/MapToHash: Enabled: false + Style/HashSyntax: Enabled: true EnforcedStyle: no_mixed_keys EnforcedShorthandSyntax: never + RSpec/NestedGroups: Enabled: true Max: 4 + RSpec/MessageSpies: Enabled: false + RSpec/StubbedMock: Enabled: false -RSpec/FactoryBot/SyntaxMethods: - Enabled: false + Naming/VariableNumber: Enabled: false + Naming/MemoizedInstanceVariableName: Exclude: - 'app/models/message.rb' + Style/GuardClause: Exclude: - 'app/builders/account_builder.rb' - 'app/models/attachment.rb' - 'app/models/message.rb' + Metrics/AbcSize: Max: 26 Exclude: - 'app/controllers/concerns/auth_helper.rb' -Rails/UniqueValidationWithoutIndex: - Exclude: - - 'app/models/channel/twitter_profile.rb' - - 'app/models/webhook.rb' - - 'app/models/contact.rb' + - 'app/models/integrations/hook.rb' - 'app/models/canned_response.rb' - 'app/models/telegram_bot.rb' + Rails/RenderInline: Exclude: - 'app/controllers/swagger_controller.rb' + Rails/ThreeStateBooleanColumn: Exclude: - 'db/migrate/20230503101201_create_sla_policies.rb' + RSpec/IndexedLet: Enabled: false + RSpec/NamedSubject: Enabled: false # we should bring this down RSpec/MultipleExpectations: Max: 7 + RSpec/MultipleMemoizedHelpers: Max: 14 @@ -152,6 +213,9 @@ UseFromEmail: CustomCopLocation: Enabled: true +Style/OneClassPerFile: + Enabled: true + AllCops: NewCops: enable Exclude: @@ -166,3 +230,121 @@ AllCops: - 'tmp/**/*' - 'storage/**/*' - 'db/migrate/20230426130150_init_schema.rb' + +FactoryBot/SyntaxMethods: + Enabled: false + +# Disable new rules causing errors +Layout/LeadingCommentSpace: + Enabled: false + +Style/ReturnNilInPredicateMethodDefinition: + Enabled: false + +Style/RedundantParentheses: + Enabled: false + +Performance/StringIdentifierArgument: + Enabled: false + +Layout/EmptyLinesAroundExceptionHandlingKeywords: + Enabled: false + +Lint/LiteralAsCondition: + Enabled: false + +Style/RedundantReturn: + Enabled: false + +Layout/SpaceAroundOperators: + Enabled: false + +Rails/EnvLocal: + Enabled: false + +Rails/WhereRange: + Enabled: false + +Lint/UselessConstantScoping: + Enabled: false + +Style/MultipleComparison: + Enabled: false + +Bundler/OrderedGems: + Enabled: false + +RSpec/ExampleWording: + Enabled: false + +RSpec/ReceiveMessages: + Enabled: false + +FactoryBot/AssociationStyle: + Enabled: false + +Rails/EnumSyntax: + Enabled: false + +Lint/RedundantTypeConversion: + Enabled: false + +# Additional rules to disable +Rails/RedundantActiveRecordAllMethod: + Enabled: false + +Layout/TrailingEmptyLines: + Enabled: true + +Style/SafeNavigationChainLength: + Enabled: false + +Lint/SafeNavigationConsistency: + Enabled: false + +Lint/CopDirectiveSyntax: + Enabled: false + +# Final set of rules to disable +FactoryBot/ExcessiveCreateList: + Enabled: false + +RSpec/MissingExpectationTargetMethod: + Enabled: false + +Performance/InefficientHashSearch: + Enabled: false + +Style/RedundantSelfAssignmentBranch: + Enabled: false + +Style/YAMLFileRead: + Enabled: false + +Layout/ExtraSpacing: + Enabled: false + +Style/RedundantFilterChain: + Enabled: false + +Performance/MapMethodChain: + Enabled: false + +Rails/RootPathnameMethods: + Enabled: false + +Style/SuperArguments: + Enabled: false + +# Final remaining rules to disable +Rails/Delegate: + Enabled: false + +Style/CaseLikeIf: + Enabled: false + +FactoryBot/RedundantFactoryOption: + Enabled: false + +FactoryBot/FactoryAssociationWithStrategy: + Enabled: false diff --git a/.ruby-version b/.ruby-version index be94e6f53..f9892605c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.4.4 diff --git a/.scss-lint.yml b/.scss-lint.yml index 1cc029441..2477dfffb 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -283,3 +283,4 @@ exclude: - 'app/javascript/widget/assets/scss/sdk.css' - 'app/assets/stylesheets/administrate/reset/_normalize.scss' - 'app/javascript/shared/assets/stylesheets/*.scss' + - 'app/javascript/dashboard/assets/scss/_woot.scss' diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index cb32634c2..000000000 --- a/.storybook/main.js +++ /dev/null @@ -1,56 +0,0 @@ -const path = require('path'); -const resolve = require('../config/webpack/resolve'); - -// Chatwoot's webpack.config.js -process.env.NODE_ENV = 'development'; -const custom = require('../config/webpack/environment'); - -module.exports = { - stories: [ - '../stories/**/*.stories.mdx', - '../app/javascript/**/*.stories.@(js|jsx|ts|tsx)', - ], - addons: [ - { - name: '@storybook/addon-docs', - options: { - vueDocgenOptions: { - alias: { - '@': path.resolve(__dirname, '../'), - }, - }, - }, - }, - '@storybook/addon-links', - '@storybook/addon-essentials', - { - /** - * Fix Storybook issue with PostCSS@8 - * @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085 - */ - name: '@storybook/addon-postcss', - options: { - postcssLoaderOptions: { - implementation: require('postcss'), - }, - }, - }, - ], - webpackFinal: config => { - const newConfig = { - ...config, - resolve: { - ...config.resolve, - modules: custom.resolvedModules.map(i => i.value), - }, - }; - - newConfig.module.rules.push({ - test: /\.scss$/, - use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'], - include: path.resolve(__dirname, '../app/javascript'), - }); - - return newConfig; - }, -}; diff --git a/.storybook/preview.js b/.storybook/preview.js deleted file mode 100644 index 3f98c2cd8..000000000 --- a/.storybook/preview.js +++ /dev/null @@ -1,48 +0,0 @@ -import { addDecorator } from '@storybook/vue'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import VueI18n from 'vue-i18n'; -import Vuelidate from 'vuelidate'; -import Multiselect from 'vue-multiselect'; -import VueDOMPurifyHTML from 'vue-dompurify-html'; -import FluentIcon from 'shared/components/FluentIcon/DashboardIcon'; - -import WootUiKit from '../app/javascript/dashboard/components'; -import i18n from '../app/javascript/dashboard/i18n'; -import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer'; - -import '../app/javascript/dashboard/assets/scss/storybook.scss'; - -Vue.use(VueI18n); -Vue.use(Vuelidate); -Vue.use(WootUiKit); -Vue.use(Vuex); -Vue.use(VueDOMPurifyHTML, domPurifyConfig); - -Vue.component('multiselect', Multiselect); -Vue.component('fluent-icon', FluentIcon); - -const store = new Vuex.Store({}); -const i18nConfig = new VueI18n({ - locale: 'en', - messages: i18n, -}); - -addDecorator(() => ({ - template: '', - i18n: i18nConfig, - store, - beforeCreate: function() { - this.$root._i18n = this.$i18n; - }, -})); - -export const parameters = { - actions: { argTypesRegex: '^on[A-Z].*' }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, -}; diff --git a/.windsurf/rules/chatwoot.md b/.windsurf/rules/chatwoot.md new file mode 120000 index 000000000..b7e6491d3 --- /dev/null +++ b/.windsurf/rules/chatwoot.md @@ -0,0 +1 @@ +../../AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..2ab6373b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# Chatwoot Development Guidelines + +## Build / Test / Lint + +- **Setup**: `bundle install && pnpm install` +- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` +- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification) +- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios) +- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too: + - UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`). + - CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find())"` (or call `Seeders::AccountSeeder.new(account: Account.find()).perform!` directly). +- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` +- **Lint Ruby**: `bundle exec rubocop -a` +- **Test JS**: `pnpm test` or `pnpm test:watch` +- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` +- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` +- **Run Project**: `overmind start -f Procfile.dev` +- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) +- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used +- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) + +## Code Style + +- **Ruby**: Follow RuboCop rules (150 character max line length) +- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended) +- **Vue Components**: Use PascalCase +- **Events**: Use camelCase +- **I18n**: No bare strings in templates; use i18n +- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`) +- **Models**: Validate presence/uniqueness, add proper indexes +- **Type Safety**: Use PropTypes in Vue, strong params in Rails +- **Naming**: Use clear, descriptive names with consistent casing +- **Vue API**: Always use Composition API with ` + + - +.v-popper--theme-tooltip .v-popper__inner { + background: black !important; + font-size: 0.75rem; + padding: 4px 8px !important; + border-radius: 6px; + font-weight: 400; +} + +.v-popper--theme-tooltip .v-popper__arrow-container { + display: none; +} + diff --git a/app/javascript/dashboard/api/account.js b/app/javascript/dashboard/api/account.js index 82b0c434c..c0dcf05f3 100644 --- a/app/javascript/dashboard/api/account.js +++ b/app/javascript/dashboard/api/account.js @@ -9,6 +9,13 @@ class AccountAPI extends ApiClient { createAccount(data) { return axios.post(`${this.apiVersion}/accounts`, data); } + + async getCacheKeys() { + const response = await axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/cache_keys` + ); + return response.data.cache_keys; + } } export default new AccountAPI(); diff --git a/app/javascript/dashboard/api/agentBots.js b/app/javascript/dashboard/api/agentBots.js index 4de6fcee0..a16b252de 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -1,9 +1,34 @@ +/* global axios */ import ApiClient from './ApiClient'; class AgentBotsAPI extends ApiClient { constructor() { super('agent_bots', { accountScoped: true }); } + + create(data) { + return axios.post(this.url, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + deleteAgentBotAvatar(botId) { + return axios.delete(`${this.url}/${botId}/avatar`); + } + + resetAccessToken(botId) { + return axios.post(`${this.url}/${botId}/reset_access_token`); + } + + resetSecret(botId) { + return axios.post(`${this.url}/${botId}/reset_secret`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/agentCapacityPolicies.js b/app/javascript/dashboard/api/agentCapacityPolicies.js new file mode 100644 index 000000000..7792ce469 --- /dev/null +++ b/app/javascript/dashboard/api/agentCapacityPolicies.js @@ -0,0 +1,43 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AgentCapacityPolicies extends ApiClient { + constructor() { + super('agent_capacity_policies', { accountScoped: true }); + } + + getUsers(policyId) { + return axios.get(`${this.url}/${policyId}/users`); + } + + addUser(policyId, userData) { + return axios.post(`${this.url}/${policyId}/users`, { + user_id: userData.id, + capacity: userData.capacity, + }); + } + + removeUser(policyId, userId) { + return axios.delete(`${this.url}/${policyId}/users/${userId}`); + } + + createInboxLimit(policyId, limitData) { + return axios.post(`${this.url}/${policyId}/inbox_limits`, { + inbox_id: limitData.inboxId, + conversation_limit: limitData.conversationLimit, + }); + } + + updateInboxLimit(policyId, limitId, limitData) { + return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, { + conversation_limit: limitData.conversationLimit, + }); + } + + deleteInboxLimit(policyId, limitId) { + return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`); + } +} + +export default new AgentCapacityPolicies(); diff --git a/app/javascript/dashboard/api/assignmentPolicies.js b/app/javascript/dashboard/api/assignmentPolicies.js new file mode 100644 index 000000000..e6baca97a --- /dev/null +++ b/app/javascript/dashboard/api/assignmentPolicies.js @@ -0,0 +1,36 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AssignmentPolicies extends ApiClient { + constructor() { + super('assignment_policies', { accountScoped: true }); + } + + getInboxes(policyId) { + return axios.get(`${this.url}/${policyId}/inboxes`); + } + + setInboxPolicy(inboxId, policyId) { + return axios.post( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`, + { + assignment_policy_id: policyId, + } + ); + } + + getInboxPolicy(inboxId) { + return axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } + + removeInboxPolicy(inboxId) { + return axios.delete( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } +} + +export default new AssignmentPolicies(); diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index dde817866..b9dc59964 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -38,13 +38,7 @@ export default { } return false; }, - profileUpdate({ - password, - password_confirmation, - displayName, - avatar, - ...profileAttributes - }) { + profileUpdate({ displayName, avatar, ...profileAttributes }) { const formData = new FormData(); Object.keys(profileAttributes).forEach(key => { const hasValue = profileAttributes[key] === undefined; @@ -53,16 +47,22 @@ export default { } }); formData.append('profile[display_name]', displayName || ''); - if (password && password_confirmation) { - formData.append('profile[password]', password); - formData.append('profile[password_confirmation]', password_confirmation); - } if (avatar) { formData.append('profile[avatar]', avatar); } return axios.put(endPoints('profileUpdate').url, formData); }, + profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) { + return axios.put(endPoints('profileUpdate').url, { + profile: { + current_password: currentPassword, + password, + password_confirmation: passwordConfirmation, + }, + }); + }, + updateUISettings({ uiSettings }) { return axios.put(endPoints('profileUpdate').url, { profile: { ui_settings: uiSettings }, @@ -102,4 +102,14 @@ export default { const urlData = endPoints('resendConfirmation'); return axios.post(urlData.url); }, + resetAccessToken() { + const urlData = endPoints('resetAccessToken'); + return axios.post(urlData.url); + }, + getSessions() { + return axios.get('/api/v1/profile/sessions'); + }, + revokeSession(id) { + return axios.delete(`/api/v1/profile/sessions/${id}`); + }, }; diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js new file mode 100644 index 000000000..157eba74e --- /dev/null +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainAssistant extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + }, + }); + } + + playground({ assistantId, messageContent, messageHistory }) { + return axios.post(`${this.url}/${assistantId}/playground`, { + message_content: messageContent, + message_history: messageHistory, + }); + } +} + +export default new CaptainAssistant(); diff --git a/app/javascript/dashboard/api/captain/bulkActions.js b/app/javascript/dashboard/api/captain/bulkActions.js new file mode 100644 index 000000000..fd69a1108 --- /dev/null +++ b/app/javascript/dashboard/api/captain/bulkActions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainBulkActionsAPI extends ApiClient { + constructor() { + super('captain/bulk_actions', { accountScoped: true }); + } +} + +export default new CaptainBulkActionsAPI(); diff --git a/app/javascript/dashboard/api/captain/copilotMessages.js b/app/javascript/dashboard/api/captain/copilotMessages.js new file mode 100644 index 000000000..49e05398a --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotMessages.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CopilotMessages extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } + + get(threadId) { + return axios.get(`${this.url}/${threadId}/copilot_messages`); + } + + create({ threadId, ...rest }) { + return axios.post(`${this.url}/${threadId}/copilot_messages`, rest); + } +} + +export default new CopilotMessages(); diff --git a/app/javascript/dashboard/api/captain/copilotThreads.js b/app/javascript/dashboard/api/captain/copilotThreads.js new file mode 100644 index 000000000..7fdce3b91 --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotThreads.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CopilotThreads extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } +} + +export default new CopilotThreads(); diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js new file mode 100644 index 000000000..471c2846b --- /dev/null +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -0,0 +1,42 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainCustomTools extends ApiClient { + constructor() { + super('captain/custom_tools', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { page, searchKey }, + }); + } + + show(id) { + return axios.get(`${this.url}/${id}`); + } + + create(data = {}) { + return axios.post(this.url, { + custom_tool: data, + }); + } + + update(id, data = {}) { + return axios.put(`${this.url}/${id}`, { + custom_tool: data, + }); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } + + test(data = {}) { + return axios.post(`${this.url}/test`, { + custom_tool: data, + }); + } +} + +export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/api/captain/document.js b/app/javascript/dashboard/api/captain/document.js new file mode 100644 index 000000000..e23a8c460 --- /dev/null +++ b/app/javascript/dashboard/api/captain/document.js @@ -0,0 +1,27 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainDocument extends ApiClient { + constructor() { + super('captain/documents', { accountScoped: true }); + } + + get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) { + return axios.get(this.url, { + params: { + page, + search_key: searchKey, + assistant_id: assistantId, + filter, + source, + sort, + }, + }); + } + + sync(id) { + return axios.post(`${this.url}/${id}/sync`); + } +} + +export default new CaptainDocument(); diff --git a/app/javascript/dashboard/api/captain/inboxes.js b/app/javascript/dashboard/api/captain/inboxes.js new file mode 100644 index 000000000..e0a1efdfe --- /dev/null +++ b/app/javascript/dashboard/api/captain/inboxes.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainInboxes extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId } = {}) { + return axios.get(`${this.url}/${assistantId}/inboxes`); + } + + create(params = {}) { + const { assistantId, inboxId } = params; + return axios.post(`${this.url}/${assistantId}/inboxes`, { + inbox: { inbox_id: inboxId }, + }); + } + + delete(params = {}) { + const { assistantId, inboxId } = params; + return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`); + } +} + +export default new CaptainInboxes(); diff --git a/app/javascript/dashboard/api/captain/preferences.js b/app/javascript/dashboard/api/captain/preferences.js new file mode 100644 index 000000000..f1ce30582 --- /dev/null +++ b/app/javascript/dashboard/api/captain/preferences.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainPreferences extends ApiClient { + constructor() { + super('captain/preferences', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + updatePreferences(data) { + return axios.put(this.url, data); + } +} + +export default new CaptainPreferences(); diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js new file mode 100644 index 000000000..d48bd81c7 --- /dev/null +++ b/app/javascript/dashboard/api/captain/response.js @@ -0,0 +1,22 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainResponses extends ApiClient { + constructor() { + super('captain/assistant_responses', { accountScoped: true }); + } + + get({ page = 1, search, assistantId, documentId, status } = {}) { + return axios.get(this.url, { + params: { + page, + search, + assistant_id: assistantId, + document_id: documentId, + status, + }, + }); + } +} + +export default new CaptainResponses(); diff --git a/app/javascript/dashboard/api/captain/scenarios.js b/app/javascript/dashboard/api/captain/scenarios.js new file mode 100644 index 000000000..3e61c28a3 --- /dev/null +++ b/app/javascript/dashboard/api/captain/scenarios.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainScenarios extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId, page = 1, searchKey } = {}) { + return axios.get(`${this.url}/${assistantId}/scenarios`, { + params: { page, searchKey }, + }); + } + + show({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/scenarios/${id}`); + } + + create({ assistantId, ...data } = {}) { + return axios.post(`${this.url}/${assistantId}/scenarios`, { + scenario: data, + }); + } + + update({ assistantId, id }, data = {}) { + return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, { + scenario: data, + }); + } + + delete({ assistantId, id }) { + return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`); + } +} + +export default new CaptainScenarios(); diff --git a/app/javascript/dashboard/api/captain/tasks.js b/app/javascript/dashboard/api/captain/tasks.js new file mode 100644 index 000000000..1b5a38335 --- /dev/null +++ b/app/javascript/dashboard/api/captain/tasks.js @@ -0,0 +1,107 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +/** + * A client for the Captain Tasks API. + * @extends ApiClient + */ +class TasksAPI extends ApiClient { + /** + * Creates a new TasksAPI instance. + */ + constructor() { + super('captain/tasks', { accountScoped: true }); + } + + /** + * Rewrites content with a specific operation. + * @param {Object} options - The rewrite options. + * @param {string} options.content - The content to rewrite. + * @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc). + * @param {string} [options.conversationId] - The conversation ID for context (required for 'improve'). + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the rewritten content. + */ + rewrite({ content, operation, conversationId }, signal) { + return axios.post( + `${this.url}/rewrite`, + { + content, + operation, + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Summarizes a conversation. + * @param {string} conversationId - The conversation ID to summarize. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the summary. + */ + summarize(conversationId, signal) { + return axios.post( + `${this.url}/summarize`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets a reply suggestion for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the reply suggestion. + */ + replySuggestion(conversationId, signal) { + return axios.post( + `${this.url}/reply_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets label suggestions for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with label suggestions. + */ + labelSuggestion(conversationId, signal) { + return axios.post( + `${this.url}/label_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Sends a follow-up message to continue refining a previous task result. + * @param {Object} options - The follow-up options. + * @param {Object} options.followUpContext - The follow-up context from a previous task. + * @param {string} options.message - The follow-up message/request from the user. + * @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context. + */ + followUp({ followUpContext, message, conversationId }, signal) { + return axios.post( + `${this.url}/follow_up`, + { + follow_up_context: followUpContext, + message, + conversation_display_id: conversationId, + }, + { signal } + ); + } +} + +export default new TasksAPI(); diff --git a/app/javascript/dashboard/api/captain/tools.js b/app/javascript/dashboard/api/captain/tools.js new file mode 100644 index 000000000..20edaa95e --- /dev/null +++ b/app/javascript/dashboard/api/captain/tools.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainTools extends ApiClient { + constructor() { + super('captain/assistants/tools', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { + params, + }); + } +} + +export default new CaptainTools(); diff --git a/app/javascript/dashboard/api/changelog.js b/app/javascript/dashboard/api/changelog.js new file mode 100644 index 000000000..8cf0cdea1 --- /dev/null +++ b/app/javascript/dashboard/api/changelog.js @@ -0,0 +1,16 @@ +import axios from 'axios'; +import ApiClient from './ApiClient'; +import { CHANGELOG_API_URL } from 'shared/constants/links'; + +class ChangelogApi extends ApiClient { + constructor() { + super('changelog', { apiVersion: 'v1' }); + } + + // eslint-disable-next-line class-methods-use-this + fetchFromHub() { + return axios.get(CHANGELOG_API_URL); + } +} + +export default new ChangelogApi(); diff --git a/app/javascript/dashboard/api/channel/googleClient.js b/app/javascript/dashboard/api/channel/googleClient.js new file mode 100644 index 000000000..8b59919e4 --- /dev/null +++ b/app/javascript/dashboard/api/channel/googleClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class MicrosoftClient extends ApiClient { + constructor() { + super('google', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new MicrosoftClient(); diff --git a/app/javascript/dashboard/api/channel/instagramClient.js b/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 000000000..51ae26448 --- /dev/null +++ b/app/javascript/dashboard/api/channel/instagramClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class InstagramChannel extends ApiClient { + constructor() { + super('instagram', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new InstagramChannel(); diff --git a/app/javascript/dashboard/api/channel/tiktokClient.js b/app/javascript/dashboard/api/channel/tiktokClient.js new file mode 100644 index 000000000..389eb2699 --- /dev/null +++ b/app/javascript/dashboard/api/channel/tiktokClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class TiktokChannel extends ApiClient { + constructor() { + super('tiktok', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new TiktokChannel(); diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js new file mode 100644 index 000000000..14dd56ec9 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -0,0 +1,102 @@ +import { Device } from '@twilio/voice-sdk'; +import VoiceAPI from './voiceAPIClient'; + +const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected'); + +class TwilioVoiceClient extends EventTarget { + constructor() { + super(); + this.device = null; + this.activeConnection = null; + this.initialized = false; + this.inboxId = null; + } + + async initializeDevice(inboxId) { + this.destroyDevice(); + + const response = await VoiceAPI.getToken(inboxId); + const { token, account_id } = response || {}; + if (!token) throw new Error('Invalid token'); + + this.device = new Device(token, { + allowIncomingWhileBusy: true, + disableAudioContextSounds: true, + appParams: { account_id }, + }); + + this.device.removeAllListeners(); + this.device.on('connect', conn => { + this.activeConnection = conn; + conn.on('disconnect', this.onDisconnect); + }); + + this.device.on('disconnect', this.onDisconnect); + + this.device.on('tokenWillExpire', async () => { + const r = await VoiceAPI.getToken(this.inboxId); + if (r?.token) this.device.updateToken(r.token); + }); + + this.initialized = true; + this.inboxId = inboxId; + + return this.device; + } + + get hasActiveConnection() { + return !!this.activeConnection; + } + + setMuted(shouldMute) { + if (!this.activeConnection) return false; + this.activeConnection.mute(shouldMute); + return shouldMute; + } + + endClientCall() { + if (this.activeConnection) { + this.activeConnection.disconnect(); + } + this.activeConnection = null; + if (this.device) { + this.device.disconnectAll(); + } + } + + destroyDevice() { + if (this.device) { + this.device.destroy(); + } + this.activeConnection = null; + this.device = null; + this.initialized = false; + this.inboxId = null; + } + + async joinClientCall({ to, conversationId, callSid }) { + if (!this.device || !this.initialized || !to) return null; + if (this.activeConnection) return this.activeConnection; + + const params = { + To: to, + is_agent: 'true', + conversation_id: conversationId, + call_sid: callSid, + }; + + const connection = await this.device.connect({ params }); + this.activeConnection = connection; + + connection.on('disconnect', this.onDisconnect); + + return connection; + } + + onDisconnect = () => { + this.activeConnection = null; + this.dispatchEvent(createCallDisconnectedEvent()); + }; +} + +export default new TwilioVoiceClient(); diff --git a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js new file mode 100644 index 000000000..41ff7007c --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; +import ContactsAPI from '../../contacts'; + +class VoiceAPI extends ApiClient { + constructor() { + super('voice', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + initiateCall(contactId, inboxId) { + return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); + } + + leaveConference({ inboxId, conversationId, callSid }) { + return axios + .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + params: { conversation_id: conversationId, call_sid: callSid }, + }) + .then(r => r.data); + } + + joinConference({ conversationId, inboxId, callSid }) { + return axios + .post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + conversation_id: conversationId, + call_sid: callSid, + }) + .then(r => r.data); + } + + getToken(inboxId) { + if (!inboxId) return Promise.reject(new Error('Inbox ID is required')); + return axios + .get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`) + .then(r => r.data); + } +} + +export default new VoiceAPI(); diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js new file mode 100644 index 000000000..ec24aae34 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -0,0 +1,45 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; + +class WhatsappCallsAPI extends ApiClient { + constructor() { + super('whatsapp_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`).then(r => r.data); + } + + initiate(conversationId, sdpOffer) { + return axios + .post(`${this.url}/initiate`, { + conversation_id: conversationId, + sdp_offer: sdpOffer, + }) + .then(r => r.data); + } + + accept(callId, sdpAnswer) { + return axios + .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer }) + .then(r => r.data); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`).then(r => r.data); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data); + } + + uploadRecording(callId, blob, filename = 'call-recording.webm') { + const formData = new FormData(); + formData.append('recording', blob, filename); + return axios + .post(`${this.url}/${callId}/upload_recording`, formData) + .then(r => r.data); + } +} + +export default new WhatsappCallsAPI(); diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js new file mode 100644 index 000000000..8f51f4878 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -0,0 +1,21 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class WhatsappChannel extends ApiClient { + constructor() { + super('whatsapp', { accountScoped: true }); + } + + createEmbeddedSignup(params) { + return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params); + } + + reauthorizeWhatsApp({ inboxId, ...params }) { + return axios.post(`${this.baseUrl()}/whatsapp/authorization`, { + ...params, + inbox_id: inboxId, + }); + } +} + +export default new WhatsappChannel(); diff --git a/app/javascript/dashboard/api/companies.js b/app/javascript/dashboard/api/companies.js new file mode 100644 index 000000000..a45b21d68 --- /dev/null +++ b/app/javascript/dashboard/api/companies.js @@ -0,0 +1,63 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +const buildParams = params => + new URLSearchParams( + Object.entries(params).filter( + ([key, value]) => value !== undefined && (value !== '' || key === 'q') + ) + ).toString(); + +class CompanyAPI extends ApiClient { + constructor() { + super('companies', { accountScoped: true }); + } + + get(params = {}) { + const { page = 1, sort = 'name' } = params; + const requestURL = `${this.url}?${buildParams({ page, sort })}`; + return axios.get(requestURL); + } + + search(query = '', page = 1, sort = 'name') { + const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`; + return axios.get(requestURL); + } + + listContacts(id, page = 1) { + return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`); + } + + listNotes(id) { + return axios.get(`${this.url}/${id}/notes`); + } + + listConversations(id) { + return axios.get(`${this.url}/${id}/conversations`); + } + + searchContacts(id, query = '', page = 1) { + const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`; + return axios.get(requestURL); + } + + createContact(id, payload) { + return axios.post(`${this.url}/${id}/contacts`, payload); + } + + removeContact(id, contactId) { + return axios.delete(`${this.url}/${id}/contacts/${contactId}`); + } + + destroyCustomAttributes(id, customAttributes) { + return axios.post(`${this.url}/${id}/destroy_custom_attributes`, { + custom_attributes: customAttributes, + }); + } + + destroyAvatar(id) { + return axios.delete(`${this.url}/${id}/avatar`); + } +} + +export default new CompanyAPI(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 61312dd24..0b32c0bc2 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -27,8 +27,23 @@ class ContactAPI extends ApiClient { return axios.get(requestURL); } - getConversations(contactId) { - return axios.get(`${this.url}/${contactId}/conversations`); + show(id) { + return axios.get(`${this.url}/${id}?include_contact_inboxes=false`); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data); + } + + getConversations(contactId, { inboxId } = {}) { + const params = inboxId ? { inbox_id: inboxId } : {}; + return axios.get(`${this.url}/${contactId}/conversations`, { params }); + } + + getAttachments(contactId, page = 1) { + return axios.get(`${this.url}/${contactId}/attachments`, { + params: { page }, + }); } getContactableInboxes(contactId) { @@ -39,17 +54,29 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } + initiateCall(contactId, inboxId, conversationId = null) { + return axios.post(`${this.url}/${contactId}/call`, { + inbox_id: inboxId, + conversation_id: conversationId, + }); + } + updateContactLabels(contactId, labels) { return axios.post(`${this.url}/${contactId}/labels`, { labels }); } - search(search = '', page = 1, sortAttr = 'name', label = '') { + search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) { let requestURL = `${this.url}/search?${buildContactParams( page, sortAttr, label, search )}`; + return axios.get(requestURL, { signal: options.signal }); + } + + active(page = 1, sortAttr = 'name') { + let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`; return axios.get(requestURL); } @@ -77,8 +104,8 @@ class ContactAPI extends ApiClient { return axios.delete(`${this.url}/${contactId}/avatar`); } - exportContacts() { - return axios.get(`${this.url}/export`); + exportContacts(queryPayload) { + return axios.post(`${this.url}/export`, queryPayload); } } diff --git a/app/javascript/dashboard/api/conversations.js b/app/javascript/dashboard/api/conversations.js index 876103694..1de9aee29 100644 --- a/app/javascript/dashboard/api/conversations.js +++ b/app/javascript/dashboard/api/conversations.js @@ -13,6 +13,10 @@ class ConversationApi extends ApiClient { updateLabels(conversationID, labels) { return axios.post(`${this.url}/${conversationID}/labels`, { labels }); } + + getUnreadCounts() { + return axios.get(`${this.url}/unread_counts`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/customRole.js b/app/javascript/dashboard/api/customRole.js new file mode 100644 index 000000000..5074657d5 --- /dev/null +++ b/app/javascript/dashboard/api/customRole.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class CustomRole extends ApiClient { + constructor() { + super('custom_roles', { accountScoped: true }); + } +} + +export default new CustomRole(); diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 31337b7fc..ecd3f0170 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -51,6 +51,10 @@ const endPoints = { resendConfirmation: { url: '/api/v1/profile/resend_confirmation', }, + + resetAccessToken: { + url: '/api/v1/profile/reset_access_token', + }, }; export default page => { diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index bb95335ad..9e6d40a62 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -17,6 +17,16 @@ class EnterpriseAccountAPI extends ApiClient { getLimits() { return axios.get(`${this.url}limits`); } + + toggleDeletion(action) { + return axios.post(`${this.url}toggle_deletion`, { + action_type: action, + }); + } + + createTopupCheckout(credits) { + return axios.post(`${this.url}topup_checkout`, { credits }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 6c9dca986..47d2eb26d 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -10,15 +10,18 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('update'); expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); + expect(accountAPI).toHaveProperty('toggleDeletion'); + expect(accountAPI).toHaveProperty('createTopupCheckout'); + expect(accountAPI).toHaveProperty('getLimits'); }); describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -42,5 +45,45 @@ describe('#enterpriseAccountAPI', () => { '/enterprise/api/v1/subscription' ); }); + + it('#toggleDeletion with delete action', () => { + accountAPI.toggleDeletion('delete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'delete' } + ); + }); + + it('#toggleDeletion with undelete action', () => { + accountAPI.toggleDeletion('undelete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'undelete' } + ); + }); + + it('#createTopupCheckout with credits', () => { + accountAPI.createTopupCheckout(1000); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits: 1000 } + ); + }); + + it('#createTopupCheckout with different credit amounts', () => { + const creditAmounts = [1000, 2500, 6000, 12000]; + creditAmounts.forEach(credits => { + accountAPI.createTopupCheckout(credits); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits } + ); + }); + }); + + it('#getLimits', () => { + accountAPI.getLimits(); + expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits'); + }); }); }); diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index e5847dc1b..bab45bcb5 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -52,12 +52,13 @@ class ArticlesAPI extends PortalsAPI { } createArticle({ portalSlug, articleObj }) { - const { content, title, author_id, category_id } = articleObj; + const { content, title, authorId, categoryId, locale } = articleObj; return axios.post(`${this.url}/${portalSlug}/articles`, { content, title, - author_id, - category_id, + author_id: authorId, + category_id: categoryId, + locale, }); } @@ -71,6 +72,34 @@ class ArticlesAPI extends PortalsAPI { category_slug: categorySlug, }); } + + bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) { + return axios.post( + `${this.url}/${portalSlug}/articles/bulk_actions/translate`, + { ids: articleIds, locale, category_id: categoryId, force } + ); + } + + bulkUpdateStatus({ portalSlug, articleIds, status }) { + return axios.patch( + `${this.url}/${portalSlug}/articles/bulk_actions/update_status`, + { ids: articleIds, status } + ); + } + + bulkUpdateCategory({ portalSlug, articleIds, categoryId }) { + return axios.patch( + `${this.url}/${portalSlug}/articles/bulk_actions/update_category`, + { ids: articleIds, category_id: categoryId } + ); + } + + bulkDelete({ portalSlug, articleIds }) { + return axios.delete( + `${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`, + { data: { ids: articleIds } } + ); + } } export default new ArticlesAPI(); diff --git a/app/javascript/dashboard/api/helpCenter/categories.js b/app/javascript/dashboard/api/helpCenter/categories.js index 01658497e..eda54aadb 100644 --- a/app/javascript/dashboard/api/helpCenter/categories.js +++ b/app/javascript/dashboard/api/helpCenter/categories.js @@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI { delete({ portalSlug, categoryId }) { return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`); } + + reorder({ portalSlug, reorderedGroup }) { + return axios.post(`${this.url}/${portalSlug}/categories/reorder`, { + positions_hash: reorderedGroup, + }); + } } export default new CategoriesAPI(); diff --git a/app/javascript/dashboard/api/helpCenter/portals.js b/app/javascript/dashboard/api/helpCenter/portals.js index 7c6210dbd..d65dcaf1a 100644 --- a/app/javascript/dashboard/api/helpCenter/portals.js +++ b/app/javascript/dashboard/api/helpCenter/portals.js @@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient { deleteLogo(portalSlug) { return axios.delete(`${this.url}/${portalSlug}/logo`); } + + sendCnameInstructions(portalSlug, email) { + return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email }); + } + + sslStatus(portalSlug) { + return axios.get(`${this.url}/${portalSlug}/ssl_status`); + } } export default PortalsAPI; diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 94cc81354..f94fca452 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -15,6 +15,7 @@ class ConversationApi extends ApiClient { teamId, conversationType, sortBy, + updatedWithin, }) { return axios.get(this.url, { params: { @@ -26,6 +27,7 @@ class ConversationApi extends ApiClient { labels, conversation_type: conversationType, sort_by: sortBy, + updated_within: updatedWithin, }, }); } @@ -61,10 +63,9 @@ class ConversationApi extends ApiClient { } assignAgent({ conversationId, agentId }) { - return axios.post( - `${this.url}/${conversationId}/assignments?assignee_id=${agentId}`, - {} - ); + return axios.post(`${this.url}/${conversationId}/assignments`, { + assignee_id: agentId, + }); } assignTeam({ conversationId, teamId }) { @@ -131,6 +132,14 @@ class ConversationApi extends ApiClient { getAllAttachments(conversationId) { return axios.get(`${this.url}/${conversationId}/attachments`); } + + getInboxAssistant(conversationId) { + return axios.get(`${this.url}/${conversationId}/inbox_assistant`); + } + + delete(conversationId) { + return axios.delete(`${this.url}/${conversationId}`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 8f294a0ee..06b85078e 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -12,6 +12,7 @@ export const buildCreatePayload = ({ bccEmails = '', toEmails = '', templateParams, + isVoiceMessage = false, }) => { let payload; if (files && files.length !== 0) { @@ -33,6 +34,9 @@ export const buildCreatePayload = ({ if (contentAttributes) { payload.append('content_attributes', JSON.stringify(contentAttributes)); } + if (isVoiceMessage) { + payload.append('is_voice_message', true); + } } else { payload = { content: message, @@ -64,6 +68,7 @@ class MessageApi extends ApiClient { bccEmails = '', toEmails = '', templateParams, + isVoiceMessage = false, }) { return axios({ method: 'post', @@ -78,6 +83,7 @@ class MessageApi extends ApiClient { bccEmails, toEmails, templateParams, + isVoiceMessage, }), }); } diff --git a/app/javascript/dashboard/api/inboxHealth.js b/app/javascript/dashboard/api/inboxHealth.js new file mode 100644 index 000000000..b8f69fcfe --- /dev/null +++ b/app/javascript/dashboard/api/inboxHealth.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InboxHealthAPI extends ApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + } + + getHealthStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/health`); + } + + registerWebhook(inboxId) { + return axios.post(`${this.url}/${inboxId}/register_webhook`); + } +} + +export default new InboxHealthAPI(); diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 8c09791c8..3c1d17fc8 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -28,6 +28,44 @@ class Inboxes extends CacheEnabledApiClient { agent_bot: botId, }); } + + syncTemplates(inboxId) { + return axios.post(`${this.url}/${inboxId}/sync_templates`); + } + + createCSATTemplate(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template`, { + template, + }); + } + + getCSATTemplateStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/csat_template`); + } + + analyzeCSATTemplateUtility(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, { + template, + }); + } + + resetSecret(inboxId) { + return axios.post(`${this.url}/${inboxId}/reset_secret`); + } + + enableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`); + } + + disableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); + } + + setInboundCalls(inboxId, enabled) { + return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, { + inbound_calls_enabled: enabled, + }); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js index 2b816e603..d4ffcbca3 100644 --- a/app/javascript/dashboard/api/integrations.js +++ b/app/javascript/dashboard/api/integrations.js @@ -32,6 +32,12 @@ class IntegrationsAPI extends ApiClient { deleteHook(hookId) { return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`); } + + connectShopify({ shopDomain }) { + return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, { + shop_domain: shopDomain, + }); + } } export default new IntegrationsAPI(); diff --git a/app/javascript/dashboard/api/integrations/linear.js b/app/javascript/dashboard/api/integrations/linear.js new file mode 100644 index 000000000..bb327b7e8 --- /dev/null +++ b/app/javascript/dashboard/api/integrations/linear.js @@ -0,0 +1,49 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class LinearAPI extends ApiClient { + constructor() { + super('integrations/linear', { accountScoped: true }); + } + + getTeams() { + return axios.get(`${this.url}/teams`); + } + + getTeamEntities(teamId) { + return axios.get(`${this.url}/team_entities?team_id=${teamId}`); + } + + createIssue(data) { + return axios.post(`${this.url}/create_issue`, data); + } + + link_issue(conversationId, issueId, title) { + return axios.post(`${this.url}/link_issue`, { + issue_id: issueId, + conversation_id: conversationId, + title: title, + }); + } + + getLinkedIssue(conversationId) { + return axios.get( + `${this.url}/linked_issues?conversation_id=${conversationId}` + ); + } + + unlinkIssue(linkId, issueIdentifier, conversationId) { + return axios.post(`${this.url}/unlink_issue`, { + link_id: linkId, + issue_id: issueIdentifier, + conversation_id: conversationId, + }); + } + + searchIssues(query) { + return axios.get(`${this.url}/search_issue?q=${query}`); + } +} + +export default new LinearAPI(); diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js deleted file mode 100644 index ad203a14c..000000000 --- a/app/javascript/dashboard/api/integrations/openapi.js +++ /dev/null @@ -1,75 +0,0 @@ -/* global axios */ - -import ApiClient from '../ApiClient'; - -/** - * Represents the data object for a OpenAI hook. - * @typedef {Object} ConversationMessageData - * @property {string} [tone] - The tone of the message. - * @property {string} [content] - The content of the message. - * @property {string} [conversation_display_id] - The display ID of the conversation (optional). - */ - -/** - * A client for the OpenAI API. - * @extends ApiClient - */ -class OpenAIAPI extends ApiClient { - /** - * Creates a new OpenAIAPI instance. - */ - constructor() { - super('integrations', { accountScoped: true }); - - /** - * The conversation events supported by the API. - * @type {string[]} - */ - this.conversation_events = [ - 'summarize', - 'reply_suggestion', - 'label_suggestion', - ]; - - /** - * The message events supported by the API. - * @type {string[]} - */ - this.message_events = ['rephrase']; - } - - /** - * Processes an event using the OpenAI API. - * @param {Object} options - The options for the event. - * @param {string} [options.type='rephrase'] - The type of event to process. - * @param {string} [options.content] - The content of the event. - * @param {string} [options.tone] - The tone of the event. - * @param {string} [options.conversationId] - The ID of the conversation to process the event for. - * @param {string} options.hookId - The ID of the hook to use for processing the event. - * @returns {Promise} A promise that resolves with the result of the event processing. - */ - processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) { - /** - * @type {ConversationMessageData} - */ - let data = { - tone, - content, - }; - - if (this.conversation_events.includes(type)) { - data = { - conversation_display_id: conversationId, - }; - } - - return axios.post(`${this.url}/hooks/${hookId}/process_event`, { - event: { - name: type, - data, - }, - }); - } -} - -export default new OpenAIAPI(); diff --git a/app/javascript/dashboard/api/integrations/shopify.js b/app/javascript/dashboard/api/integrations/shopify.js new file mode 100644 index 000000000..0b6ce8ec1 --- /dev/null +++ b/app/javascript/dashboard/api/integrations/shopify.js @@ -0,0 +1,17 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class ShopifyAPI extends ApiClient { + constructor() { + super('integrations/shopify', { accountScoped: true }); + } + + getOrders(contactId) { + return axios.get(`${this.url}/orders`, { + params: { contact_id: contactId }, + }); + } +} + +export default new ShopifyAPI(); diff --git a/app/javascript/dashboard/api/liveReports.js b/app/javascript/dashboard/api/liveReports.js new file mode 100644 index 000000000..1435da258 --- /dev/null +++ b/app/javascript/dashboard/api/liveReports.js @@ -0,0 +1,20 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class LiveReportsAPI extends ApiClient { + constructor() { + super('live_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getConversationMetric(params = {}) { + return axios.get(`${this.url}/conversation_metrics`, { params }); + } + + getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) { + return axios.get(`${this.url}/grouped_conversation_metrics`, { + params: { group_by: groupBy }, + }); + } +} + +export default new LiveReportsAPI(); diff --git a/app/javascript/dashboard/api/mfa.js b/app/javascript/dashboard/api/mfa.js new file mode 100644 index 000000000..38cb93810 --- /dev/null +++ b/app/javascript/dashboard/api/mfa.js @@ -0,0 +1,28 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class MfaAPI extends ApiClient { + constructor() { + super('profile/mfa', { accountScoped: false }); + } + + enable() { + return axios.post(`${this.url}`); + } + + verify(otpCode) { + return axios.post(`${this.url}/verify`, { otp_code: otpCode }); + } + + disable(password, { otpCode, backupCode } = {}) { + return axios.delete(this.url, { + data: { password, otp_code: otpCode, backup_code: backupCode }, + }); + } + + regenerateBackupCodes(otpCode) { + return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode }); + } +} + +export default new MfaAPI(); diff --git a/app/javascript/dashboard/api/notion_auth.js b/app/javascript/dashboard/api/notion_auth.js new file mode 100644 index 000000000..8a0027f9b --- /dev/null +++ b/app/javascript/dashboard/api/notion_auth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class NotionOAuthClient extends ApiClient { + constructor() { + super('notion', { accountScoped: true }); + } + + generateAuthorization() { + return axios.post(`${this.url}/authorization`); + } +} + +export default new NotionOAuthClient(); diff --git a/app/javascript/dashboard/api/onboarding.js b/app/javascript/dashboard/api/onboarding.js new file mode 100644 index 000000000..e15d16da3 --- /dev/null +++ b/app/javascript/dashboard/api/onboarding.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class OnboardingAPI extends ApiClient { + constructor() { + super('onboarding', { accountScoped: true }); + } + + update(data) { + return axios.patch(this.url, data); + } + + getHelpCenterGeneration() { + return axios.get(`${this.url}/help_center_generation`); + } +} + +export default new OnboardingAPI(); diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 52fa7f444..00f040f8e 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -61,9 +61,15 @@ class ReportsAPI extends ApiClient { }); } - getConversationTrafficCSV() { + getConversationsSummaryReports({ from: since, to: until, businessHours }) { + return axios.get(`${this.url}/conversations_summary`, { + params: { since, until, business_hours: businessHours }, + }); + } + + getConversationTrafficCSV({ daysBefore = 6 } = {}) { return axios.get(`${this.url}/conversation_traffic`, { - params: { timezone_offset: getTimeOffset() }, + params: { timezone_offset: getTimeOffset(), days_before: daysBefore }, }); } diff --git a/app/javascript/dashboard/api/samlSettings.js b/app/javascript/dashboard/api/samlSettings.js new file mode 100644 index 000000000..7c0f5b266 --- /dev/null +++ b/app/javascript/dashboard/api/samlSettings.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SamlSettingsAPI extends ApiClient { + constructor() { + super('saml_settings', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + create(data) { + return axios.post(this.url, { saml_settings: data }); + } + + update(data) { + return axios.put(this.url, { saml_settings: data }); + } + + delete() { + return axios.delete(this.url); + } +} + +export default new SamlSettingsAPI(); diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js index 7dc98dcf2..10214f3f5 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -14,26 +14,48 @@ class SearchAPI extends ApiClient { }); } - contacts({ q }) { + contacts({ q, page = 1, since, until }) { return axios.get(`${this.url}/contacts`, { params: { q, + page: page, + since, + until, }, }); } - conversations({ q }) { + conversations({ q, page = 1, since, until }) { return axios.get(`${this.url}/conversations`, { params: { q, + page: page, + since, + until, }, }); } - messages({ q }) { + messages({ q, page = 1, since, until, from, inboxId }) { return axios.get(`${this.url}/messages`, { params: { q, + page: page, + since, + until, + from, + inbox_id: inboxId, + }, + }); + } + + articles({ q, page = 1, since, until }) { + return axios.get(`${this.url}/articles`, { + params: { + q, + page: page, + since, + until, }, }); } diff --git a/app/javascript/dashboard/api/slaReports.js b/app/javascript/dashboard/api/slaReports.js new file mode 100644 index 000000000..fedc988b2 --- /dev/null +++ b/app/javascript/dashboard/api/slaReports.js @@ -0,0 +1,78 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SLAReportsAPI extends ApiClient { + constructor() { + super('applied_slas', { accountScoped: true }); + } + + get({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + page, + } = {}) { + return axios.get(this.url, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + page, + }, + }); + } + + download({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + } = {}) { + return axios.get(`${this.url}/download`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + label_list, + sla_policy_id, + }, + }); + } + + getMetrics({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + label_list, + sla_policy_id, + } = {}) { + return axios.get(`${this.url}/metrics`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + label_list, + team_id, + sla_policy_id, + }, + }); + } +} + +export default new SLAReportsAPI(); diff --git a/app/javascript/dashboard/api/specs/account.spec.js b/app/javascript/dashboard/api/specs/account.spec.js index 7e213b2a8..4da8b3a46 100644 --- a/app/javascript/dashboard/api/specs/account.spec.js +++ b/app/javascript/dashboard/api/specs/account.spec.js @@ -15,10 +15,10 @@ describe('#accountAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/accountActions.spec.js b/app/javascript/dashboard/api/specs/accountActions.spec.js index 330c117ff..dc73e4948 100644 --- a/app/javascript/dashboard/api/specs/accountActions.spec.js +++ b/app/javascript/dashboard/api/specs/accountActions.spec.js @@ -10,10 +10,10 @@ describe('#ContactsAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/agentBots.spec.js b/app/javascript/dashboard/api/specs/agentBots.spec.js index c89dbfdf5..bf57804c0 100644 --- a/app/javascript/dashboard/api/specs/agentBots.spec.js +++ b/app/javascript/dashboard/api/specs/agentBots.spec.js @@ -9,5 +9,6 @@ describe('#AgentBotsAPI', () => { expect(AgentBotsAPI).toHaveProperty('create'); expect(AgentBotsAPI).toHaveProperty('update'); expect(AgentBotsAPI).toHaveProperty('delete'); + expect(AgentBotsAPI).toHaveProperty('resetAccessToken'); }); }); diff --git a/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js new file mode 100644 index 000000000..43932aa71 --- /dev/null +++ b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js @@ -0,0 +1,98 @@ +import agentCapacityPolicies from '../agentCapacityPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AgentCapacityPoliciesAPI', () => { + it('creates correct instance', () => { + expect(agentCapacityPolicies).toBeInstanceOf(ApiClient); + expect(agentCapacityPolicies).toHaveProperty('get'); + expect(agentCapacityPolicies).toHaveProperty('show'); + expect(agentCapacityPolicies).toHaveProperty('create'); + expect(agentCapacityPolicies).toHaveProperty('update'); + expect(agentCapacityPolicies).toHaveProperty('delete'); + expect(agentCapacityPolicies).toHaveProperty('getUsers'); + expect(agentCapacityPolicies).toHaveProperty('addUser'); + expect(agentCapacityPolicies).toHaveProperty('removeUser'); + expect(agentCapacityPolicies).toHaveProperty('createInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + put: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getUsers', () => { + agentCapacityPolicies.getUsers(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users' + ); + }); + + it('#addUser', () => { + const userData = { id: 456, capacity: 20 }; + agentCapacityPolicies.addUser(123, userData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users', + { + user_id: 456, + capacity: 20, + } + ); + }); + + it('#removeUser', () => { + agentCapacityPolicies.removeUser(123, 456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users/456' + ); + }); + + it('#createInboxLimit', () => { + const limitData = { inboxId: 1, conversationLimit: 10 }; + agentCapacityPolicies.createInboxLimit(123, limitData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits', + { + inbox_id: 1, + conversation_limit: 10, + } + ); + }); + + it('#updateInboxLimit', () => { + const limitData = { conversationLimit: 15 }; + agentCapacityPolicies.updateInboxLimit(123, 789, limitData); + expect(axiosMock.put).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789', + { + conversation_limit: 15, + } + ); + }); + + it('#deleteInboxLimit', () => { + agentCapacityPolicies.deleteInboxLimit(123, 789); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/agents.spec.js b/app/javascript/dashboard/api/specs/agents.spec.js index 20dd36688..0df0fd8d9 100644 --- a/app/javascript/dashboard/api/specs/agents.spec.js +++ b/app/javascript/dashboard/api/specs/agents.spec.js @@ -14,7 +14,7 @@ describe('#AgentAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js index 02c0f82d8..b40613739 100644 --- a/app/javascript/dashboard/api/specs/article.spec.js +++ b/app/javascript/dashboard/api/specs/article.spec.js @@ -14,10 +14,10 @@ describe('#PortalAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -44,10 +44,10 @@ describe('#PortalAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -71,10 +71,10 @@ describe('#PortalAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -98,10 +98,10 @@ describe('#PortalAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -129,10 +129,10 @@ describe('#PortalAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -153,4 +153,33 @@ describe('#PortalAPI', () => { ); }); }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#bulkUpdateCategory', () => { + articlesAPI.bulkUpdateCategory({ + portalSlug: 'room-rental', + articleIds: [1, 2, 3], + categoryId: 7, + }); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles/bulk_actions/update_category', + { ids: [1, 2, 3], category_id: 7 } + ); + }); + }); }); diff --git a/app/javascript/dashboard/api/specs/assignableAgents.spec.js b/app/javascript/dashboard/api/specs/assignableAgents.spec.js index 5280162b3..d553d55cb 100644 --- a/app/javascript/dashboard/api/specs/assignableAgents.spec.js +++ b/app/javascript/dashboard/api/specs/assignableAgents.spec.js @@ -4,10 +4,10 @@ describe('#AssignableAgentsAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js new file mode 100644 index 000000000..8d0aea7d0 --- /dev/null +++ b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js @@ -0,0 +1,70 @@ +import assignmentPolicies from '../assignmentPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AssignmentPoliciesAPI', () => { + it('creates correct instance', () => { + expect(assignmentPolicies).toBeInstanceOf(ApiClient); + expect(assignmentPolicies).toHaveProperty('get'); + expect(assignmentPolicies).toHaveProperty('show'); + expect(assignmentPolicies).toHaveProperty('create'); + expect(assignmentPolicies).toHaveProperty('update'); + expect(assignmentPolicies).toHaveProperty('delete'); + expect(assignmentPolicies).toHaveProperty('getInboxes'); + expect(assignmentPolicies).toHaveProperty('setInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('getInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('removeInboxPolicy'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getInboxes', () => { + assignmentPolicies.getInboxes(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/assignment_policies/123/inboxes' + ); + }); + + it('#setInboxPolicy', () => { + assignmentPolicies.setInboxPolicy(456, 123); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy', + { + assignment_policy_id: 123, + } + ); + }); + + it('#getInboxPolicy', () => { + assignmentPolicies.getInboxPolicy(456); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + + it('#removeInboxPolicy', () => { + assignmentPolicies.removeInboxPolicy(456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js b/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js index 2cdcec56e..c79051977 100644 --- a/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js +++ b/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js @@ -13,10 +13,10 @@ describe('#FBChannel', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/companies.spec.js b/app/javascript/dashboard/api/specs/companies.spec.js new file mode 100644 index 000000000..ca1d905de --- /dev/null +++ b/app/javascript/dashboard/api/specs/companies.spec.js @@ -0,0 +1,96 @@ +import companyAPI from '../companies'; +import ApiClient from '../ApiClient'; + +describe('#CompanyAPI', () => { + it('creates correct instance', () => { + expect(companyAPI).toBeInstanceOf(ApiClient); + expect(companyAPI).toHaveProperty('get'); + expect(companyAPI).toHaveProperty('show'); + expect(companyAPI).toHaveProperty('update'); + expect(companyAPI).toHaveProperty('delete'); + expect(companyAPI).toHaveProperty('search'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get includes pagination and sorting params', () => { + companyAPI.get({}); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies?page=1&sort=name' + ); + }); + + it('#search encodes query params', () => { + companyAPI.search('acme & co', 2, 'domain'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain' + ); + }); + + it('#search keeps empty query param for backend validation', () => { + companyAPI.search('', 1, 'name'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=&page=1&sort=name' + ); + }); + + it('#destroyAvatar deletes the company avatar endpoint', () => { + companyAPI.destroyAvatar(1); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/companies/1/avatar' + ); + }); + + it('#listContacts fetches company contacts', () => { + companyAPI.listContacts(1, 2); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts?page=2' + ); + }); + + it('#searchContacts encodes contact search params', () => { + companyAPI.searchContacts(1, 'jane & co', 3); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3' + ); + }); + + it('#createContact links a contact to the company', () => { + companyAPI.createContact(1, { contact_id: 2 }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts', + { contact_id: 2 } + ); + }); + + it('#removeContact unlinks a contact from the company', () => { + companyAPI.removeContact(1, 2); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts/2' + ); + }); + + it('#destroyCustomAttributes removes company custom attributes', () => { + companyAPI.destroyCustomAttributes(1, ['plan']); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/companies/1/destroy_custom_attributes', + { custom_attributes: ['plan'] } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index b4eaf7333..f55ecdfaa 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -17,10 +17,10 @@ describe('#ContactsAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -41,7 +41,8 @@ describe('#ContactsAPI', () => { it('#getConversations', () => { contactAPI.getConversations(1); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/1/conversations' + '/api/v1/contacts/1/conversations', + { params: {} } ); }); @@ -68,7 +69,19 @@ describe('#ContactsAPI', () => { it('#search', () => { contactAPI.search('leads', 1, 'date', 'customer-support'); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: undefined } + ); + }); + + it('#search with signal', () => { + const controller = new AbortController(); + contactAPI.search('leads', 1, 'date', 'customer-support', { + signal: controller.signal, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: controller.signal } ); }); diff --git a/app/javascript/dashboard/api/specs/conversations.spec.js b/app/javascript/dashboard/api/specs/conversations.spec.js index 6b3db7404..686db4098 100644 --- a/app/javascript/dashboard/api/specs/conversations.spec.js +++ b/app/javascript/dashboard/api/specs/conversations.spec.js @@ -11,15 +11,16 @@ describe('#ConversationApi', () => { expect(conversationsAPI).toHaveProperty('delete'); expect(conversationsAPI).toHaveProperty('getLabels'); expect(conversationsAPI).toHaveProperty('updateLabels'); + expect(conversationsAPI).toHaveProperty('getUnreadCounts'); }); describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -47,5 +48,12 @@ describe('#ConversationApi', () => { } ); }); + + it('#getUnreadCounts', () => { + conversationsAPI.getUnreadCounts(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/unread_counts' + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/csatReports.spec.js b/app/javascript/dashboard/api/specs/csatReports.spec.js index 7c1707e1e..788f0eba2 100644 --- a/app/javascript/dashboard/api/specs/csatReports.spec.js +++ b/app/javascript/dashboard/api/specs/csatReports.spec.js @@ -11,10 +11,10 @@ describe('#Reports API', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js index 2c56f4e00..febf6f7a1 100644 --- a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js +++ b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js @@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => { expect(categoriesAPI).toHaveProperty('create'); expect(categoriesAPI).toHaveProperty('update'); expect(categoriesAPI).toHaveProperty('delete'); + expect(categoriesAPI).toHaveProperty('reorder'); }); }); diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index ecc833e16..de0d7a7d0 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -24,10 +24,10 @@ describe('#ConversationAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -46,6 +46,7 @@ describe('#ConversationAPI', () => { page: 1, labels: [], teamId: 1, + updatedWithin: 20, }); expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', { params: { @@ -55,6 +56,7 @@ describe('#ConversationAPI', () => { assignee_type: 'me', page: 1, labels: [], + updated_within: 20, }, }); }); @@ -90,8 +92,10 @@ describe('#ConversationAPI', () => { it('#assignAgent', () => { conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); expect(axiosMock.post).toHaveBeenCalledWith( - `/api/v1/conversations/12/assignments?assignee_id=34`, - {} + `/api/v1/conversations/12/assignments`, + { + assignee_id: 34, + } ); }); diff --git a/app/javascript/dashboard/api/specs/inbox/message.spec.js b/app/javascript/dashboard/api/specs/inbox/message.spec.js index 0d45b2157..84c0b9cf2 100644 --- a/app/javascript/dashboard/api/specs/inbox/message.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/message.spec.js @@ -15,10 +15,10 @@ describe('#ConversationAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -83,5 +83,29 @@ describe('#ConversationAPI', () => { template_params: undefined, }); }); + + it('appends is_voice_message when isVoiceMessage is true', () => { + const formPayload = buildCreatePayload({ + message: 'voice message', + echoId: 42, + isPrivate: false, + files: [new Blob(['audio-data'], { type: 'audio/ogg' })], + isVoiceMessage: true, + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('is_voice_message')).toEqual('true'); + }); + + it('does not append is_voice_message when isVoiceMessage is false', () => { + const formPayload = buildCreatePayload({ + message: 'regular audio', + echoId: 43, + isPrivate: false, + files: [new Blob(['audio-data'], { type: 'audio/ogg' })], + isVoiceMessage: false, + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('is_voice_message')).toBeNull(); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index 8834ceb07..64ba44aea 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -12,15 +12,16 @@ describe('#InboxesAPI', () => { expect(inboxesAPI).toHaveProperty('getCampaigns'); expect(inboxesAPI).toHaveProperty('getAgentBot'); expect(inboxesAPI).toHaveProperty('setAgentBot'); + expect(inboxesAPI).toHaveProperty('syncTemplates'); }); describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -40,5 +41,12 @@ describe('#InboxesAPI', () => { inboxesAPI.deleteInboxAvatar(2); expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar'); }); + + it('#syncTemplates', () => { + inboxesAPI.syncTemplates(2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/inboxes/2/sync_templates' + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/integrations.spec.js b/app/javascript/dashboard/api/specs/integrations.spec.js index 5ccbda436..cc20fd8f6 100644 --- a/app/javascript/dashboard/api/specs/integrations.spec.js +++ b/app/javascript/dashboard/api/specs/integrations.spec.js @@ -18,10 +18,10 @@ describe('#integrationAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/integrations/dyte.spec.js b/app/javascript/dashboard/api/specs/integrations/dyte.spec.js index 4bbe0484a..1c544f976 100644 --- a/app/javascript/dashboard/api/specs/integrations/dyte.spec.js +++ b/app/javascript/dashboard/api/specs/integrations/dyte.spec.js @@ -11,10 +11,10 @@ describe('#accountAPI', () => { describe('createAMeeting', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { @@ -39,10 +39,10 @@ describe('#accountAPI', () => { describe('addParticipantToMeeting', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/app/javascript/dashboard/api/specs/integrations/linear.spec.js new file mode 100644 index 000000000..3f33e3ed9 --- /dev/null +++ b/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -0,0 +1,241 @@ +import LinearAPIClient from '../../integrations/linear'; +import ApiClient from '../../ApiClient'; + +describe('#linearAPI', () => { + it('creates correct instance', () => { + expect(LinearAPIClient).toBeInstanceOf(ApiClient); + expect(LinearAPIClient).toHaveProperty('getTeams'); + expect(LinearAPIClient).toHaveProperty('getTeamEntities'); + expect(LinearAPIClient).toHaveProperty('createIssue'); + expect(LinearAPIClient).toHaveProperty('link_issue'); + expect(LinearAPIClient).toHaveProperty('getLinkedIssue'); + expect(LinearAPIClient).toHaveProperty('unlinkIssue'); + expect(LinearAPIClient).toHaveProperty('searchIssues'); + }); + + describe('getTeams', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeams(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/teams' + ); + }); + }); + + describe('getTeamEntities', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeamEntities(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/team_entities?team_id=1' + ); + }); + }); + + describe('createIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); + + it('creates a valid request with conversation_id', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + conversation_id: 123, + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); + }); + + describe('link_issue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.link_issue(1, 2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 2, + conversation_id: 1, + } + ); + }); + + it('creates a valid request with title', () => { + LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 'ENG-123', + conversation_id: 1, + title: 'Sample Issue', + } + ); + }); + }); + + describe('getLinkedIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getLinkedIssue(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/linked_issues?conversation_id=1' + ); + }); + }); + + describe('unlinkIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request with link_id only', () => { + LinearAPIClient.unlinkIssue('link123'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: undefined, + conversation_id: undefined, + } + ); + }); + + it('creates a valid request with all parameters', () => { + LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: 'ENG-456', + conversation_id: 789, + } + ); + }); + }); + + describe('searchIssues', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.searchIssues('query'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/search_issue?q=query' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/notifications.spec.js b/app/javascript/dashboard/api/specs/notifications.spec.js index fe748fe19..770a6840d 100644 --- a/app/javascript/dashboard/api/specs/notifications.spec.js +++ b/app/javascript/dashboard/api/specs/notifications.spec.js @@ -13,10 +13,10 @@ describe('#NotificationAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 05d4a152c..e458633d0 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -20,10 +20,10 @@ describe('#Reports API', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/search.spec.js b/app/javascript/dashboard/api/specs/search.spec.js new file mode 100644 index 000000000..251ea760e --- /dev/null +++ b/app/javascript/dashboard/api/specs/search.spec.js @@ -0,0 +1,134 @@ +import searchAPI from '../search'; +import ApiClient from '../ApiClient'; + +describe('#SearchAPI', () => { + it('creates correct instance', () => { + expect(searchAPI).toBeInstanceOf(ApiClient); + expect(searchAPI).toHaveProperty('get'); + expect(searchAPI).toHaveProperty('contacts'); + expect(searchAPI).toHaveProperty('conversations'); + expect(searchAPI).toHaveProperty('messages'); + expect(searchAPI).toHaveProperty('articles'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + vi.clearAllMocks(); + }); + + it('#get', () => { + searchAPI.get({ q: 'test query' }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', { + params: { q: 'test query' }, + }); + }); + + it('#contacts', () => { + searchAPI.contacts({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#contacts with date filters', () => { + searchAPI.contacts({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + + it('#conversations', () => { + searchAPI.conversations({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + } + ); + }); + + it('#conversations with date filters', () => { + searchAPI.conversations({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 }, + } + ); + }); + + it('#messages', () => { + searchAPI.messages({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: undefined, + until: undefined, + from: undefined, + inbox_id: undefined, + }, + }); + }); + + it('#messages with all filters', () => { + searchAPI.messages({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inboxId: 10, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inbox_id: 10, + }, + }); + }); + + it('#articles', () => { + searchAPI.articles({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#articles with date filters', () => { + searchAPI.articles({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/slaReports.spec.js b/app/javascript/dashboard/api/specs/slaReports.spec.js new file mode 100644 index 000000000..827b44cad --- /dev/null +++ b/app/javascript/dashboard/api/specs/slaReports.spec.js @@ -0,0 +1,104 @@ +import SLAReportsAPI from '../slaReports'; +import ApiClient from '../ApiClient'; + +describe('#SLAReports API', () => { + it('creates correct instance', () => { + expect(SLAReportsAPI).toBeInstanceOf(ApiClient); + expect(SLAReportsAPI.apiVersion).toBe('/api/v1'); + expect(SLAReportsAPI).toHaveProperty('get'); + expect(SLAReportsAPI).toHaveProperty('getMetrics'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get', () => { + SLAReportsAPI.get({ + page: 1, + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', { + params: { + page: 1, + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + }); + }); + it('#getMetrics', () => { + SLAReportsAPI.getMetrics({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/metrics', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + } + ); + }); + it('#download', () => { + SLAReportsAPI.download({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/download', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/teams.spec.js b/app/javascript/dashboard/api/specs/teams.spec.js index 3a59f2c51..c7bfc4d1c 100644 --- a/app/javascript/dashboard/api/specs/teams.spec.js +++ b/app/javascript/dashboard/api/specs/teams.spec.js @@ -16,10 +16,10 @@ describe('#TeamsAPI', () => { describe('API calls', () => { const originalAxios = window.axios; const axiosMock = { - post: jest.fn(() => Promise.resolve()), - get: jest.fn(() => Promise.resolve()), - patch: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), }; beforeEach(() => { diff --git a/app/javascript/dashboard/api/specs/tiktokClient.spec.js b/app/javascript/dashboard/api/specs/tiktokClient.spec.js new file mode 100644 index 000000000..5250e2c7b --- /dev/null +++ b/app/javascript/dashboard/api/specs/tiktokClient.spec.js @@ -0,0 +1,35 @@ +import ApiClient from '../ApiClient'; +import tiktokClient from '../channel/tiktokClient'; + +describe('#TiktokClient', () => { + it('creates correct instance', () => { + expect(tiktokClient).toBeInstanceOf(ApiClient); + expect(tiktokClient).toHaveProperty('generateAuthorization'); + }); + + describe('#generateAuthorization', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/settings'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('posts to the authorization endpoint', () => { + tiktokClient.generateAuthorization({ state: 'test-state' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/tiktok/authorization', + { state: 'test-state' } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js new file mode 100644 index 000000000..fad26bf6f --- /dev/null +++ b/app/javascript/dashboard/api/summaryReports.js @@ -0,0 +1,50 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SummaryReportsAPI extends ApiClient { + constructor() { + super('summary_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getTeamReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/team`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getAgentReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/agent`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getInboxReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/inbox`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getLabelReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/label`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } +} + +export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/api/yearInReview.js b/app/javascript/dashboard/api/yearInReview.js new file mode 100644 index 000000000..fb0661804 --- /dev/null +++ b/app/javascript/dashboard/api/yearInReview.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class YearInReviewAPI extends ApiClient { + constructor() { + super('year_in_review', { accountScoped: true, apiVersion: 'v2' }); + } + + get(year) { + return axios.get(`${this.url}`, { + params: { year }, + }); + } +} + +export default new YearInReviewAPI(); diff --git a/app/javascript/dashboard/assets/images/auth/auth--bg.svg b/app/javascript/dashboard/assets/images/auth/auth--bg.svg new file mode 100644 index 000000000..8e1a708fd --- /dev/null +++ b/app/javascript/dashboard/assets/images/auth/auth--bg.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/assets/images/auth/bottom-right.svg b/app/javascript/dashboard/assets/images/auth/bottom-right.svg new file mode 100644 index 000000000..36796767e --- /dev/null +++ b/app/javascript/dashboard/assets/images/auth/bottom-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/dashboard/assets/images/auth/signup-bg.jpg b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg new file mode 100644 index 000000000..884a57922 Binary files /dev/null and b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg differ diff --git a/app/javascript/dashboard/assets/images/auth/top-left.svg b/app/javascript/dashboard/assets/images/auth/top-left.svg new file mode 100644 index 000000000..832d11b2d --- /dev/null +++ b/app/javascript/dashboard/assets/images/auth/top-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/dashboard/assets/scss/_animations.scss b/app/javascript/dashboard/assets/scss/_animations.scss deleted file mode 100644 index bb01c369f..000000000 --- a/app/javascript/dashboard/assets/scss/_animations.scss +++ /dev/null @@ -1,117 +0,0 @@ -.slide-fade-enter-active { - transition: all 0.3s var(--ease-in-cubic); -} - -.slide-fade-leave-active { - transition: all 0.3s var(--ease-out-cubic); -} - -.slide-fade-enter, -.slide-fade-leave-to { - opacity: 0; - transform: translateX(10px); -} - -.slide-fade-enter { - transform: translateX($space-micro); -} - -.slide-fade-leave-to { - transform: translateX($space-medium); -} - -.conversations-list-enter-active, -.conversations-list-leave-active { - transition: all 0.25s var(--ease-out-cubic); -} - -.conversations-list-enter, -.conversations-list-leave-to { - opacity: 0; - transform: translateX($space-medium); -} - -.menu-list-enter-active, -.menu-list-leave-active { - transition: opacity 0.3s var(--ease-out-cubic), - transform 0.2s var(--ease-out-cubic); -} - -.menu-list-leave-to { - opacity: 0; - position: absolute; - transform: translateX($space-small); -} - -.menu-list-enter { - opacity: 0; - transform: translateX(-$space-small); -} - -.slide-up-enter-active { - transition: all 0.3s var(--ease-in-cubic); -} - -.slide-up-leave-active { - transition: all 0.3s var(--ease-out-cubic); -} - -.slide-up-enter, -.slide-up-leave-to { - opacity: 0; - transform: translateY(-$space-medium); -} - -.menu-slide-enter-active, -.menu-slide-leave-active { - transform: translateY(0); - transition: transform 0.25s var(--ease-in-cubic), - opacity 0.15s var(--ease-in-cubic); -} - -.menu-slide-enter, -.menu-slide-leave-to { - opacity: 0; - transform: translateY($space-small); -} - -.toast-fade-enter-active { - transition: all 0.3s var(--ease-in-sine); -} - -.toast-fade-leave-active { - transition: all 0.1s var(--ease-out-sine); -} - -.toast-fade-enter, -.toast-fade-leave-to { - opacity: 0; - transform: translateY(-$space-small); -} - -.modal-fade-enter-active { - transition: all 0.3s var(--ease-in-sine); -} - -.modal-fade-leave-active { - transition: all 0.1s var(--ease-out-sine); -} - -.modal-fade-enter, -.modal-fade-leave-to { - opacity: 0; -} - -.network-notification-fade-enter-active { - transition: all 0.1s var(--ease-in-sine); -} - -.network-notification-fade-leave-active { - transition: all 0.1s var(--ease-out-sine); -} - -.network-notification-fade-enter, -.network-notification-fade-leave-to { - opacity: 0; - transform: translateY(-$space-small); -} diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss new file mode 100644 index 000000000..108da3889 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_base.scss @@ -0,0 +1,203 @@ +// scss-lint:disable QualifyingElement + +// Base typography +// ------------------------- +h1, +h2, +h3, +h4, +h5, +h6 { + @apply font-medium text-n-slate-12; +} + +p { + text-rendering: optimizeLegibility; + @apply mb-2 leading-[1.65] text-sm; + + a { + @apply text-n-brand dark:text-n-brand cursor-pointer; + } +} + +a { + @apply text-sm; +} + +hr { + @apply clear-both max-w-full h-0 my-5 mx-0 border-slate-300 dark:border-slate-600; +} + +ul, +ol, +dl { + @apply list-disc list-outside leading-[1.65]; +} + +ul:not(.reset-base), +ol:not(.reset-base), +dl:not(.reset-base) { + @apply mb-0; +} + +// Button base +button { + font-family: inherit; + @apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50; +} + +// Form elements +// ------------------------- +label { + @apply text-n-slate-12 block m-0 leading-7 text-sm font-medium; +} + +.input-wrap, +.help-text { + @apply text-n-slate-11 text-sm font-medium; +} + +// Focus outline removal +.button, +textarea { + outline: none; +} + +// Field base styles (Input, TextArea, Select) +@layer components { + .field-base { + @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; + } + + .field-disabled { + @apply opacity-50 outline-n-weak dark:outline-n-weak cursor-not-allowed; + } + + .field-error { + @apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8; + } +} + +$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)"; + +#{$form-input-selector} { + @apply field-base h-10; + + &[disabled] { + @apply field-disabled; + } + + &.error { + @apply field-error mb-1; + } +} + +input[type='file'] { + @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm; +} + +// Select +select { + background-image: url("data:image/svg+xml;utf8,"); + background-size: 9px 6px; + + @apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3; + + &[disabled] { + @apply field-disabled; + } + + option:not(:disabled) { + @apply bg-n-solid-2 text-n-slate-12; + } +} + +// Textarea +textarea { + @apply field-base h-16; + + &[disabled] { + @apply field-disabled; + } +} + +// Add mb-1 when .help-text exists within the same label container +label:has(.help-text) { + input, + textarea, + select { + margin-bottom: 0.25rem !important; + } +} + +// FormKit support +.formkit-outer[data-invalid='true'] { + #{$form-input-selector}, + textarea, + select { + @apply field-error; + } + + .formkit-message { + @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full; + } +} + +.error { + #{$form-input-selector}, + input:not([type]), + textarea, + select { + @apply field-error; + } + + // Only add mb-1 when .message exists within the same .error container + // And exclude no-margin from the margin-bottom + &:has(.message) { + input:not(.no-margin), + textarea, + select { + margin-bottom: 0.25rem !important; + } + } + + .message { + @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full; + } +} + +.input-group.small { + input { + @apply text-sm h-8; + } + + .error { + @apply text-n-ruby-9 dark:text-n-ruby-9; + } +} + +// Code styling +code { + font-family: 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', + '"Liberation Mono"', '"Courier New"', 'monospace'; + @apply text-xs border-0; + + &.hljs { + @apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12 rounded-lg p-5; + + .hljs-number, + .hljs-string { + @apply text-n-ruby-9 dark:text-n-ruby-9; + } + + .hljs-name, + .hljs-tag { + @apply text-n-slate-11; + } + } +} + +// Table +table { + @apply border-spacing-0 text-sm w-full; +} diff --git a/app/javascript/dashboard/assets/scss/_date-picker.scss b/app/javascript/dashboard/assets/scss/_date-picker.scss deleted file mode 100644 index 2132d5fd5..000000000 --- a/app/javascript/dashboard/assets/scss/_date-picker.scss +++ /dev/null @@ -1,82 +0,0 @@ -@import '~vue2-datepicker/scss/index'; - -.date-picker { - &.no-margin { - .mx-input { - @apply mb-0; - } - } - - &:not(.auto-width) { - .mx-datepicker-range { - @apply w-[320px]; - } - } - - .mx-datepicker { - @apply w-full; - } - - .mx-input { - @apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none; - } - - .mx-input:disabled, - .mx-input[readonly] { - @apply bg-white dark:bg-slate-900 cursor-pointer; - } - - .mx-icon-calendar { - @apply dark:text-slate-500; - } -} - -.mx-datepicker-main { - @apply border-0 bg-white dark:bg-slate-800; - - .cell { - &.disabled { - @apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300; - } - - &:hover, - &.hover-in-range, - &.in-range { - @apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100; - } - } - - .mx-time { - @apply border-0 bg-white dark:bg-slate-800; - - .mx-time-header { - @apply border-0; - } - - .mx-time-item { - &.disabled { - @apply bg-slate-25 dark:bg-slate-900; - } - - &:hover { - @apply bg-slate-75 dark:bg-slate-700; - } - } - } - - .today { - @apply font-semibold; - } -} - -.mx-datepicker-popup { - @apply z-[99999]; -} - -.mx-datepicker-inline { - @apply w-full; - - .mx-calendar { - @apply w-full; - } -} diff --git a/app/javascript/dashboard/assets/scss/_formulate.scss b/app/javascript/dashboard/assets/scss/_formulate.scss deleted file mode 100644 index d683ce3c7..000000000 --- a/app/javascript/dashboard/assets/scss/_formulate.scss +++ /dev/null @@ -1,38 +0,0 @@ -@import '~dashboard/assets/scss/variables'; - -.formulate-input { - .formulate-input-errors { - list-style-type: none; - margin: 0; - padding: 0; - } - - .formulate-input-error { - color: var(--r-400); - display: block; - font-size: var(--font-size-small); - font-weight: $font-weight-normal; - margin-bottom: $space-one; - width: 100%; - } -} - -.integration-hooks { - .formulate-input[data-type='checkbox'] { - .formulate-input-wrapper { - @apply flex; - - .formulate-input-element { - @apply pr-2; - - input { - @apply mb-0; - } - } - } - - .formulate-input-element-decorator { - @apply hidden; - } - } -} diff --git a/app/javascript/dashboard/assets/scss/_helper-classes.scss b/app/javascript/dashboard/assets/scss/_helper-classes.scss deleted file mode 100644 index 48ee1918b..000000000 --- a/app/javascript/dashboard/assets/scss/_helper-classes.scss +++ /dev/null @@ -1,22 +0,0 @@ -// loader class -.spinner { - @include color-spinner(); - @apply inline-block h-6 py-0 px-6 relative align-middle w-6; - - &.message { - @include normal-shadow; - @apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0; - - &::before { - @apply -ml-3 -mt-3; - } - } - - &.small { - @apply h-4 w-4; - - &::before { - @apply h-4 -mt-2 w-4; - } - } -} diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss deleted file mode 100644 index ea40c1f3a..000000000 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ /dev/null @@ -1,47 +0,0 @@ -// scss-lint:disable SpaceAfterPropertyColon -// @import 'shared/assets/fonts/inter'; - -html, -body { - font-family: - 'PlusJakarta', - Inter, - -apple-system, - system-ui, - BlinkMacSystemFont, - 'Segoe UI', - Roboto, - 'Helvetica Neue', - Arial, - sans-serif !important; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - height: 100%; - margin: 0; - padding: 0; - width: 100%; -} - -.app-wrapper { - @apply h-full flex-grow-0 min-h-0 w-full; - - .button--fixed-top { - @apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row; - } -} - -.banner + .app-wrapper { - // Reduce the height of the dashboard to make room for the banner. - // And causing the top right green-action button to be pushed down when scrolling. - @apply h-[calc(100%-48px)]; - - .button--fixed-top { - @apply top-14; - } - - .off-canvas-content { - .button--fixed-top { - @apply top-2; - } - } -} diff --git a/app/javascript/dashboard/assets/scss/_mixins.scss b/app/javascript/dashboard/assets/scss/_mixins.scss deleted file mode 100644 index 2fbb9fa7c..000000000 --- a/app/javascript/dashboard/assets/scss/_mixins.scss +++ /dev/null @@ -1,246 +0,0 @@ -@import '~dashboard/assets/scss/variables'; -@import '~widget/assets/scss/mixins'; - -$spinner-before-border-color: rgba(255, 255, 255, 0.7); - -//borders -@mixin border-nil() { - border-color: transparent; - border: 0; -} - -@mixin thin-border($color) { - border: 1px solid $color; -} - -@mixin custom-border-bottom($size, $color) { - border-bottom: $size solid $color; -} - -@mixin custom-border-top($size, $color) { - border-top: $size solid $color; -} - -@mixin border-normal() { - @apply border border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-left() { - @apply border-l border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-top() { - @apply border-t border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-right() { - @apply border-r border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-bottom() { - @apply border-b border-slate-50 dark:border-slate-700; -} - -@mixin border-light() { - @apply border border-slate-25 dark:border-slate-700; -} - -@mixin border-light-left() { - @apply border-l border-slate-25 dark:border-slate-700; -} - -@mixin border-light-top() { - @apply border-t border-slate-25 dark:border-slate-700; -} - -@mixin border-light-right() { - @apply border-r border-slate-25 dark:border-slate-700; -} - -@mixin border-light-bottom() { - @apply border-b border-slate-25 dark:border-slate-700; -} - -// background -@mixin background-gray() { - background: $color-background; -} - -@mixin background-light() { - @apply bg-slate-50 dark:bg-slate-800; -} - -@mixin background-white() { - @apply bg-white dark:bg-slate-900; -} - -// input form -@mixin ghost-input() { - box-shadow: none; - border-color: transparent; - - &:active, - &:hover, - &:focus { - border-color: transparent; - box-shadow: none; - } -} - -// flex-layout -@mixin space-between() { - display: flex; - justify-content: space-between; -} - -@mixin space-between-column() { - @include space-between; - flex-direction: column; -} - -@mixin space-between-row() { - @include space-between; - flex-direction: row; -} - -@mixin flex-shrink() { - flex: 0 0 auto; - max-width: 100%; -} - -@mixin flex-weight($value) { - // Grab flex-grow for older browsers. - $flex-grow: nth($value, 1); - - // 2009 - @include prefixer(box-flex, $flex-grow, webkit moz spec); - - // 2011 (IE 10), 2012 - @include prefixer(flex, $value, webkit moz ms spec); -} - -// full height -@mixin full-height() { - height: 100%; -} - -@mixin round-corner() { - border-radius: 1000px; -} - -@mixin scroll-on-hover() { - overflow: hidden; - - &:hover { - overflow-y: auto; - } -} - - -@mixin horizontal-scroll() { - overflow-y: auto; -} - -@mixin elegant-card() { - @include normal-shadow; - border-radius: $space-small; -} - -@mixin color-spinner() { - @keyframes spinner { - to { - transform: rotate(360deg); - } - } - - &::before { - animation: spinner .9s linear infinite; - border: 2px solid $spinner-before-border-color; - border-radius: 50%; - border-top-color: lighten($color-woot, 10%); - box-sizing: border-box; - content: ''; - height: $space-medium; - left: 50%; - margin-left: -$space-one; - margin-top: -$space-one; - position: absolute; - top: 50%; - width: $space-medium; - } -} - -// -------------------------------------------------------- -// arrows -// -------------------------------------------------------- -// $direction: top, left, right, bottom, top-left, top-right, bottom-left, bottom-right -// $color: hex, rgb or rbga -// $size: px or em -// @example -// .element{ -// @include arrow(top, #000, 50px); -// } -@mixin arrow($direction, $color, $size) { - display: block; - height: 0; - width: 0; - content: ''; - - @if $direction == 'top' { - border-bottom: $size solid $color; - border-left: $size solid transparent; - border-right: $size solid transparent; - } - - @else if $direction == 'right' { - border-bottom: $size solid transparent; - border-left: $size solid $color; - border-top: $size solid transparent; - } - - @else if $direction == 'bottom' { - border-left: $size solid transparent; - border-right: $size solid transparent; - border-top: $size solid $color; - } - - @else if $direction == 'left' { - border-bottom: $size solid transparent; - border-right: $size solid $color; - border-top: $size solid transparent; - } - - @else if $direction == 'top-left' { - border-right: $size solid transparent; - border-top: $size solid $color; - } - - @else if $direction == 'top-right' { - border-left: $size solid transparent; - border-top: $size solid $color; - } - - @else if $direction == 'bottom-left' { - border-bottom: $size solid $color; - border-right: $size solid transparent; - } - - @else if $direction == 'bottom-right' { - border-bottom: $size solid $color; - border-left: $size solid transparent; - } -} - -@mixin text-ellipsis { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -@mixin three-column-grid($column-one-width: 16rem, - $column-three-width: 16rem) { - width: 100%; - height: 100%; - display: grid; - grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr); -} diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss new file mode 100644 index 000000000..784edce6c --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -0,0 +1,296 @@ +// scss-lint:disable PropertySortOrder +@layer base { + // NEXT COLORS START + :root { + // slate + --slate-1: 252 252 253; + --slate-2: 249 249 251; + --slate-3: 240 240 243; + --slate-4: 232 232 236; + --slate-5: 224 225 230; + --slate-6: 217 217 224; + --slate-7: 205 206 214; + --slate-8: 185 187 198; + --slate-9: 139 141 152; + --slate-10: 128 131 141; + --slate-11: 96 100 108; + --slate-12: 28 32 36; + + --iris-1: 253 253 255; + --iris-2: 248 248 255; + --iris-3: 240 241 254; + --iris-4: 230 231 255; + --iris-5: 218 220 255; + --iris-6: 203 205 255; + --iris-7: 184 186 248; + --iris-8: 155 158 240; + --iris-9: 91 91 214; + --iris-10: 81 81 205; + --iris-11: 87 83 198; + --iris-12: 39 41 98; + + --blue-1: 251 253 255; + --blue-2: 245 249 255; + --blue-3: 233 243 255; + --blue-4: 218 236 255; + --blue-5: 201 226 255; + --blue-6: 181 213 255; + --blue-7: 155 195 252; + --blue-8: 117 171 247; + --blue-9: 39 129 246; + --blue-10: 16 115 233; + --blue-11: 8 109 224; + --blue-12: 11 50 101; + + --ruby-1: 255 252 253; + --ruby-2: 255 247 248; + --ruby-3: 254 234 237; + --ruby-4: 255 220 225; + --ruby-5: 255 206 214; + --ruby-6: 248 191 200; + --ruby-7: 239 172 184; + --ruby-8: 229 146 163; + --ruby-9: 229 70 102; + --ruby-10: 220 59 93; + --ruby-11: 202 36 77; + --ruby-12: 100 23 43; + + --amber-1: 254 253 251; + --amber-2: 254 251 233; + --amber-3: 255 247 194; + --amber-4: 255 238 156; + --amber-5: 251 229 119; + --amber-6: 243 214 115; + --amber-7: 233 193 98; + --amber-8: 226 163 54; + --amber-9: 255 197 61; + --amber-10: 255 186 24; + --amber-11: 171 100 0; + --amber-12: 79 52 34; + + --teal-1: 250 254 253; + --teal-2: 243 251 249; + --teal-3: 224 248 243; + --teal-4: 204 243 234; + --teal-5: 184 234 224; + --teal-6: 161 222 210; + --teal-7: 131 205 193; + --teal-8: 83 185 171; + --teal-9: 18 165 148; + --teal-10: 13 155 138; + --teal-11: 0 133 115; + --teal-12: 13 61 56; + + --gray-1: 252 252 252; + --gray-2: 249 249 249; + --gray-3: 240 240 240; + --gray-4: 232 232 232; + --gray-5: 224 224 224; + --gray-6: 217 217 217; + --gray-7: 206 206 206; + --gray-8: 187 187 187; + --gray-9: 141 141 141; + --gray-10: 131 131 131; + --gray-11: 100 100 100; + --gray-12: 32 32 32; + + --violet-1: 253 252 254; + --violet-2: 250 248 255; + --violet-3: 244 240 254; + --violet-4: 235 228 255; + --violet-5: 225 217 255; + --violet-6: 212 202 254; + --violet-7: 194 178 248; + --violet-8: 169 153 236; + --violet-9: 110 86 207; + --violet-10: 100 84 196; + --violet-11: 101 85 183; + --violet-12: 47 38 95; + + --background-color: 247 247 247; + --surface-1: 254 254 254; + --surface-2: 255 255 255; + --surface-active: 255 255 255; + --background-input-box: 0, 0, 0, 0.03; + --text-blue: 1 22 44; + --text-purple: 2 4 49; + --text-amber: 37 24 1; + --border-container: 236 236 236; + --border-strong: 226 227 231; + --border-weak: 234 234 234; + --border-blue-strong: 18 61 117; + --solid-1: 255 255 255; + --solid-2: 255 255 255; + --solid-3: 255 255 255; + --solid-active: 255 255 255; + --solid-amber: 255 228 181; + --solid-blue: 218 236 255; + --solid-blue-2: 251 253 255; + --solid-iris: 230 231 255; + --solid-purple: 230 231 255; + --solid-red: 254 200 201; + --solid-amber-button: 255 221 141; + --card-color: 255 255 255; + --overlay: 0, 0, 0, 0.12; + --overlay-avatar: 255, 255, 255, 0.67; + --button-color: 255 255 255; + --button-hover-color: 255, 255, 255, 0.2; + --label-background: 247 247 247; + --label-border: 0, 0, 0, 0.04; + + --alpha-1: 215, 215, 215, 0.22; + --alpha-2: 196, 197, 198, 0.22; + --alpha-3: 255, 255, 255, 0.96; + --black-alpha-1: 0, 0, 0, 0.12; + --black-alpha-2: 0, 0, 0, 0.04; + --border-blue: 39, 129, 246, 0.5; + --white-alpha: 255, 255, 255, 0.8; + } + + .dark { + // slate + --slate-1: 17 17 19; + --slate-2: 24 25 27; + --slate-3: 33 34 37; + --slate-4: 39 42 45; + --slate-5: 46 49 53; + --slate-6: 54 58 63; + --slate-7: 67 72 78; + --slate-8: 90 97 105; + --slate-9: 105 110 119; + --slate-10: 119 123 132; + --slate-11: 176 180 186; + --slate-12: 237 238 240; + + --iris-1: 19 19 30; + --iris-2: 23 22 37; + --iris-3: 32 34 72; + --iris-4: 38 42 101; + --iris-5: 48 51 116; + --iris-6: 61 62 130; + --iris-7: 74 74 149; + --iris-8: 89 88 177; + --iris-9: 91 91 214; + --iris-10: 84 114 228; + --iris-11: 158 177 255; + --iris-12: 224 223 254; + + --blue-1: 10 17 28; + --blue-2: 15 24 38; + --blue-3: 15 39 72; + --blue-4: 10 49 99; + --blue-5: 18 61 117; + --blue-6: 29 84 134; + --blue-7: 40 89 156; + --blue-8: 48 106 186; + --blue-9: 39 129 246; + --blue-10: 21 116 231; + --blue-11: 126 182 255; + --blue-12: 205 227 255; + + --ruby-1: 25 17 19; + --ruby-2: 30 21 23; + --ruby-3: 58 20 30; + --ruby-4: 78 19 37; + --ruby-5: 94 26 46; + --ruby-6: 111 37 57; + --ruby-7: 136 52 71; + --ruby-8: 179 68 90; + --ruby-9: 229 70 102; + --ruby-10: 236 90 114; + --ruby-11: 255 148 157; + --ruby-12: 254 210 225; + + --amber-1: 22 18 12; + --amber-2: 29 24 15; + --amber-3: 48 32 8; + --amber-4: 63 39 0; + --amber-5: 77 48 0; + --amber-6: 92 61 5; + --amber-7: 113 79 25; + --amber-8: 143 100 36; + --amber-9: 255 197 61; + --amber-10: 255 214 10; + --amber-11: 255 202 22; + --amber-12: 255 231 179; + + --teal-1: 13 21 20; + --teal-2: 17 28 27; + --teal-3: 13 45 42; + --teal-4: 2 59 55; + --teal-5: 8 72 67; + --teal-6: 20 87 80; + --teal-7: 28 105 97; + --teal-8: 32 126 115; + --teal-9: 18 165 148; + --teal-10: 14 179 158; + --teal-11: 11 216 182; + --teal-12: 173 240 221; + + --gray-1: 17 17 17; + --gray-2: 25 25 25; + --gray-3: 34 34 34; + --gray-4: 42 42 42; + --gray-5: 49 49 49; + --gray-6: 58 58 58; + --gray-7: 72 72 72; + --gray-8: 96 96 96; + --gray-9: 110 110 110; + --gray-10: 123 123 123; + --gray-11: 180 180 180; + --gray-12: 238 238 238; + + --violet-1: 20 17 31; + --violet-2: 27 21 37; + --violet-3: 41 31 67; + --violet-4: 50 37 85; + --violet-5: 60 46 105; + --violet-6: 71 56 135; + --violet-7: 86 70 151; + --violet-8: 110 86 171; + --violet-9: 110 86 207; + --violet-10: 125 109 217; + --violet-11: 169 153 236; + --violet-12: 226 221 254; + + --background-color: 28 29 32; + --surface-1: 20 21 23; + --surface-2: 22 23 26; + --surface-active: 53 57 66; + --background-input-box: 255, 255, 255, 0.02; + --text-blue: 213 234 255; + --text-purple: 232 233 254; + --text-amber: 255 247 234; + --border-strong: 46 45 50; + --border-weak: 31 31 37; + --border-blue-strong: 201 226 255; + --solid-1: 23 23 26; + --solid-2: 29 30 36; + --solid-3: 44 45 54; + --solid-active: 53 57 66; + --solid-amber: 56 50 41; + --solid-blue: 15 57 102; + --solid-blue-2: 26 29 35; + --solid-iris: 38 42 101; + --solid-purple: 51 51 107; + --solid-red: 90 33 34; + --solid-amber-button: 255 221 141; + --card-color: 28 30 34; + --overlay: 0, 0, 0, 0.4; + --overlay-avatar: 0, 0, 0, 0.05; + --button-color: 42 43 51; + --button-hover-color: 0, 0, 0, 0.15; + --label-background: 36 38 45; + --label-border: 255, 255, 255, 0.03; + + --alpha-1: 35, 36, 42, 0.8; + --alpha-2: 147, 153, 176, 0.12; + --alpha-3: 33, 34, 38, 0.95; + --black-alpha-1: 0, 0, 0, 0.3; + --black-alpha-2: 0, 0, 0, 0.2; + --border-blue: 39, 129, 246, 0.5; + --border-container: 255, 255, 255, 0; + --white-alpha: 255, 255, 255, 0.1; + } +} +// NEXT COLORS END diff --git a/app/javascript/dashboard/assets/scss/_rtl.scss b/app/javascript/dashboard/assets/scss/_rtl.scss deleted file mode 100644 index 36679fc60..000000000 --- a/app/javascript/dashboard/assets/scss/_rtl.scss +++ /dev/null @@ -1,305 +0,0 @@ -.app-rtl--wrapper { - direction: rtl; - - // Woot Tabs - .tabs-title { - &:first-child { - margin-left: var(--space-small); - margin-right: unset; - } - - &:last-child { - margin-left: unset; - margin-right: var(--space-small); - } - } - - // woot tables - table, - thead, - th { - text-align: right; - } - - // Table footer - .footer { - .page-meta { - direction: initial; - } - } - - // Wizard box - .wizard-box { - direction: initial; - } - - // Conversation details - .conversation-details-wrap { - .conversation-panel { - // Message text - .text-content { - p { - unicode-bidi: plaintext; - } - - ul { - padding-left: unset; - padding-right: var(--space-two); - } - - li { - text-align: right; - } - } - - // Message items and actions - li { - &.right { - .sender--info { - padding: var(--space-small) var(--space-smaller) - var(--space-smaller) 0; - } - - .context-menu-wrap { - margin-left: 0; - margin-right: auto; - } - } - } - } - - // Conversation footer - .conversation-footer { - .preview-item { - direction: initial; - } - } - - // Custom attributes section in conversation sidebar - .conversation-sidebar-wrap .checkbox-wrap { - .checkbox { - margin-left: var(--space-small); - } - } - - // Conversation sidebar close button - .close-button--rtl { - transform: rotate(180deg); - } - - // Resolve actions button - .resolve-actions { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } - } - - // Conversation list - .conversations-list-wrap { - border-right: 0; - - .conversation { - .conversation--meta { - left: $space-normal; - right: unset; - - .unread { - margin-left: unset; - margin-right: auto; - } - } - - .assignee-label { - margin-left: 0; - margin-right: var(--space-one); - } - - .show-more--button { - margin: unset; - transform: rotate(180deg); - } - } - - // Basic filter dropdown - .basic-filter { - left: 0; - right: unset; - } - - // Bulk actions - .bulk-action__container { - .triangle { - left: var(--triangle-position); - right: unset; - } - - .bulk-action__agents { - left: var(--space-small); - right: unset; - } - - .labels-container { - left: var(--space-small); - right: unset; - - .label-checkbox { - margin: 0 0 0 var(--space-one); - } - } - - .actions-container { - left: var(--space-small); - right: unset; - } - - .bulk-action__teams { - left: var(--space-small); - right: unset; - } - } - } - - // Contact notes - .card.note-wrap { - .time-stamp { - unicode-bidi: plaintext; - } - } - - // Help center - .article-container .row--article-block { - td:last-child { - direction: initial; - } - } - - .portal-popover__container .portal { - .actions-container { - margin-left: unset; - margin-right: var(--space-one); - } - } - - .edit-article--container { - .header-right--wrap { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } - - .header-left--wrap { - .back-button { - direction: initial; - } - } - - .article--buttons { - .dropdown-pane { - left: 0; - position: absolute; - right: unset; - } - } - - .sidebar-button { - transform: rotate(180deg); - } - } - - .article-settings--container { - border-left: 0; - border-right: 1px solid var(--color-border-light); - flex-direction: row-reverse; - margin-left: 0; - margin-right: var(--space-normal); - padding-left: 0; - padding-right: var(--space-normal); - } - - .category-list--container .header-left--wrap { - direction: initial; - justify-content: flex-end; - } - - // Toggle switch - .toggle-button { - &.small { - span { - &.active { - transform: translate(var(--space-minus-small), var(--space-zero)); - } - } - } - - span { - --minus-space-one-point-five: -0.9375rem; - - &.active { - transform: translate( - var(--minus-space-one-point-five), - var(--space-zero) - ); - } - } - } - - // Widget builder - .widget-builder-container .widget-preview { - direction: initial; - } - - // Modal - .modal-container { - text-align: right; - - .modal-footer { - button { - margin-left: 0; - margin-right: var(--space-small); - } - } - } - - // Other changes - - .colorpicker--chrome { - direction: initial; - } - - .mention--box { - direction: initial; - } - - .contact--details .contact--bio { - direction: ltr; - } - - .merge-contacts .child-contact-wrap { - direction: ltr; - } - - .contact--form .input-group { - direction: initial; - } -} diff --git a/app/javascript/dashboard/assets/scss/_variables.scss b/app/javascript/dashboard/assets/scss/_variables.scss deleted file mode 100644 index b50643bd4..000000000 --- a/app/javascript/dashboard/assets/scss/_variables.scss +++ /dev/null @@ -1,100 +0,0 @@ -// Font sizes -$font-size-nano: 0.5rem; -$font-size-micro: 0.675rem; -$font-size-mini: 0.75rem; -$font-size-small: 0.875rem; -$font-size-default: 1rem; -$font-size-medium: 1.125rem; -$font-size-large: 1.375rem; -$font-size-big: 1.5rem; -$font-size-bigger: 1.75rem; -$font-size-mega: 2.125rem; -$font-size-giga: 2.5rem; - -// spaces -$zero: 0; -$space-micro: 0.125rem; -$space-smaller: 0.25rem; -$space-small: 0.5rem; -$space-one: 0.675rem; -$space-slab: 0.75rem; -$space-normal: 1rem; -$space-two: 1.25rem; -$space-medium: 1.5rem; -$space-large: 2rem; -$space-larger: 3rem; -$space-jumbo: 4rem; -$space-mega: 6.25rem; - -// font-weight -$font-weight-feather: 100; -$font-weight-light: 300; -$font-weight-normal: 400; -$font-weight-medium: 500; -$font-weight-bold: 600; -$font-weight-black: 700; - -//Navbar -$nav-bar-width: 14.375rem; -$header-height: 3.5rem; - -$woot-logo-padding: $space-large $space-two; - -// Colors -$color-woot: #1f93ff; -$color-gray: #6e6f73; -$color-light-gray: #999a9b; - -$color-border: var(--s-75); -$color-border-light: var(--s-50); -$color-border-dark: var(--s-100); - -$color-background: var(--s-50); -$color-background-light: var(--s-25); - -$color-white: #fff; -$color-body: #3c4858; -$color-heading: #1f2d3d; -$color-extra-light-blue: #f5f7f9; - -$primary-color: $color-woot; -$secondary-color: #5d7592; -$success-color: #44ce4b; -$warning-color: #ffc532; -$alert-color: #ff382d; - -$masked-bg: rgba(0, 0, 0, .4); - -// Color-palettes - -$color-primary-light: #c7e3ff; -$color-primary-dark: darken($color-woot, 20%); - -// Thumbnail -$thumbnail-radius: 2.5rem; - -// chat-header -$conv-header-height: 2.5rem; - -// Inbox List - -$inbox-thumb-size: 3rem; - - -// Snackbar default -$woot-snackbar-bg: #323232; -$woot-snackbar-button: #ffeb3b; - -$swift-ease-out-duration: .4s !default; -$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default; -$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default; - -// Ionicons -$ionicons-font-path: '~ionicons/fonts'; - -// Transitions -$transition-ease-in: all 0.250s ease-in; - -:root { - --dashboard-app-tabs-height: 2.4375rem; -} diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 5728dd51b..27764d150 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -1,42 +1,148 @@ +// scss-lint:disable SpaceAfterPropertyColon @import 'tailwindcss/base'; @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; -@import 'shared/assets/fonts/plus-jakarta'; -@import 'shared/assets/stylesheets/animations'; -@import 'shared/assets/stylesheets/colors'; -@import 'shared/assets/stylesheets/spacing'; -@import 'shared/assets/stylesheets/font-size'; -@import 'shared/assets/stylesheets/font-weights'; -@import 'shared/assets/stylesheets/shadows'; -@import 'shared/assets/stylesheets/border-radius'; -@import 'shared/assets/stylesheets/z-index'; +@import 'shared/assets/fonts/InterDisplay/inter-display'; +@import 'shared/assets/fonts/inter'; -@import 'variables'; +// Next Colors +@import 'next-colors'; -@import 'mixins'; -@import 'helper-classes'; -@import 'formulate'; -@import 'date-picker'; +// Base styles for elements +@import 'base'; -@import 'layout'; -@import 'animations'; -@import 'rtl'; +// Plugins +@import 'plugins/date-picker'; -@import 'widgets/base'; -@import 'widgets/buttons'; -@import 'widgets/conversation-view'; -@import 'widgets/tabs'; -@import 'widgets/woot-tables'; - -@import 'plugins/multiselect'; -@import 'plugins/dropdown'; -@import '~shared/assets/stylesheets/ionicons'; +html, +body { + font-family: + 'Inter', + -apple-system, + system-ui, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Tahoma, + Arial, + sans-serif !important; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + height: 100%; + margin: 0; + padding: 0; + width: 100%; +} .tooltip { - @apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900; + @apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96; } -.hide { - @apply hidden; +#app { + @apply h-full w-full; +} + +.custom-dashed-border { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +.dark .custom-dashed-border { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E"); +} + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + /** + * ============================================================================ + * TYPOGRAPHY UTILITIES + * ============================================================================ + * + * | Class | Use Case | + * |--------------------|----------------------------------------------------| + * | .text-body-main |

, , general body text | + * | .text-body-para |

for paragraphs, larger text blocks | + * | .text-heading-1 |

, page titles, panel headers | + * | .text-heading-2 |

, section headings, card titles | + * | .text-heading-3 |

, card headings, breadcrumbs, subsections | + * | .text-label |