From 77db0d07018f9df71fb85621c72a880d90b668e0 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 25 Apr 2024 18:58:20 -0700 Subject: [PATCH 1/4] feat: Add configurable interval for IMAP sync (#9302) --- app/jobs/inboxes/fetch_imap_emails_job.rb | 10 +++---- app/services/imap/base_fetch_email_service.rb | 11 ++++---- .../inboxes/fetch_imap_emails_job_spec.rb | 19 ++++++++++---- .../microsoft_fetch_email_service_spec.rb | 26 +++++++++++++++++++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index c528b9d6a..e8a85b418 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -3,13 +3,13 @@ require 'net/imap' class Inboxes::FetchImapEmailsJob < MutexApplicationJob queue_as :scheduled_jobs - def perform(channel) + def perform(channel, interval = 1) return unless should_fetch_email?(channel) key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) with_lock(key, 5.minutes) do - process_email_for_channel(channel) + process_email_for_channel(channel, interval) end rescue *ExceptionList::IMAP_EXCEPTIONS => e Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" @@ -28,11 +28,11 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob channel.imap_enabled? && !channel.reauthorization_required? end - def process_email_for_channel(channel) + def process_email_for_channel(channel, interval) inbound_emails = if channel.microsoft? - Imap::MicrosoftFetchEmailService.new(channel: channel).perform + Imap::MicrosoftFetchEmailService.new(channel: channel, interval: interval).perform else - Imap::FetchEmailService.new(channel: channel).perform + Imap::FetchEmailService.new(channel: channel, interval: interval).perform end inbound_emails.map do |inbound_mail| process_mail(inbound_mail, channel) diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 1af3bdb5d..09332092c 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -1,7 +1,7 @@ require 'net/imap' class Imap::BaseFetchEmailService - pattr_initialize [:channel!] + pattr_initialize [:channel!, :interval] def fetch_emails # Override this method @@ -99,10 +99,10 @@ class Imap::BaseFetchEmailService end # Sends a SEARCH command to search the mailbox for messages that were - # created between yesterday and today and returns message sequence numbers. + # created between yesterday (or given date) and today and returns message sequence numbers. # Return def fetch_available_mail_sequence_numbers - imap_client.search(['SINCE', yesterday]) + imap_client.search(['SINCE', since]) end def build_imap_client @@ -123,7 +123,8 @@ class Imap::BaseFetchEmailService Mail.read_from_string(raw_email_content) end - def yesterday - (Time.zone.today - 1).strftime('%d-%b-%Y') + def since + previous_day = Time.zone.today - (interval || 1).to_i + previous_day.strftime('%d-%b-%Y') end end diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb index f40e39151..62816b99d 100644 --- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -41,18 +41,27 @@ RSpec.describe Inboxes::FetchImapEmailsJob do context 'when the channel is regular imap' do it 'calls the imap fetch service' do fetch_service = double - allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_return(fetch_service) + allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service) allow(fetch_service).to receive(:perform).and_return([]) described_class.perform_now(imap_email_channel) expect(fetch_service).to have_received(:perform) end + + it 'calls the imap fetch service with the correct interval' do + fetch_service = double + allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 4).and_return(fetch_service) + allow(fetch_service).to receive(:perform).and_return([]) + + described_class.perform_now(imap_email_channel, 4) + expect(fetch_service).to have_received(:perform) + end end context 'when the channel is Microsoft' do it 'calls the Microsoft fetch service' do fetch_service = double - allow(Imap::MicrosoftFetchEmailService).to receive(:new).with(channel: microsoft_imap_email_channel).and_return(fetch_service) + allow(Imap::MicrosoftFetchEmailService).to receive(:new).with(channel: microsoft_imap_email_channel, interval: 1).and_return(fetch_service) allow(fetch_service).to receive(:perform).and_return([]) described_class.perform_now(microsoft_imap_email_channel) @@ -62,7 +71,7 @@ RSpec.describe Inboxes::FetchImapEmailsJob do context 'when IMAP connection errors out' do it 'mark the connection for authorization required' do - allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_raise(Errno::ECONNREFUSED) + allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_raise(Errno::ECONNREFUSED) allow(Redis::Alfred).to receive(:incr) expect(Redis::Alfred).to receive(:incr).with("AUTHORIZATION_ERROR_COUNT:channel_email:#{imap_email_channel.id}") @@ -80,14 +89,14 @@ RSpec.describe Inboxes::FetchImapEmailsJob do allow(Imap::ImapMailbox).to receive(:new).and_return(mailbox) allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) - allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_return(fetch_service) + allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service) allow(fetch_service).to receive(:perform).and_return([inbound_mail]) end it 'calls the mailbox to create emails' do allow(mailbox).to receive(:process) - expect(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel).and_return(fetch_service) + expect(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service) expect(fetch_service).to receive(:perform).and_return([inbound_mail]) expect(mailbox).to receive(:process).with(inbound_mail, imap_email_channel) diff --git a/spec/services/imap/microsoft_fetch_email_service_spec.rb b/spec/services/imap/microsoft_fetch_email_service_spec.rb index 3a13ce0c6..a4a0a62d1 100644 --- a/spec/services/imap/microsoft_fetch_email_service_spec.rb +++ b/spec/services/imap/microsoft_fetch_email_service_spec.rb @@ -50,5 +50,31 @@ RSpec.describe Imap::MicrosoftFetchEmailService do end end end + + context 'when the interval is passed during an IMAP Sync' do + it 'fetches the emails based on the interval specified in the job' do + travel_to '26.10.2020 10:00'.to_datetime do + email_object = create_inbound_email_from_fixture('only_text.eml') + email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id) + + allow(imap).to receive(:search).with(%w[SINCE 18-Oct-2020]).and_return([1]) + allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header]) + allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:logout) + + result = described_class.new(channel: microsoft_channel, interval: 8).perform + + expect(refresh_token_service).to have_received(:access_token) + + expect(result.length).to eq 1 + expect(result[0].message_id).to eq email_object.message_id + expect(imap).to have_received(:search).with(%w[SINCE 18-Oct-2020]) + expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') + expect(imap).to have_received(:fetch).with(1, 'RFC822') + expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.") + end + end + end end end From ffd47081bde73bc33ea90abcfd6bb5a4ea27d144 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 25 Apr 2024 22:49:10 -0700 Subject: [PATCH 2/4] chore(cleanup): Delete sentiment feature (#9304) - The feature is unused, removing it for now, will bring it back with better models later. --- .env.example | 3 - Gemfile | 3 - Gemfile.lock | 40 +++--- app/models/conversation.rb | 1 - app/models/message.rb | 5 - .../jobs/enterprise/sentiment_analysis_job.rb | 57 --------- enterprise/app/models/enterprise/message.rb | 5 - .../enterprise/sentiment_analysis_helper.rb | 45 ------- .../enterprise/sentiment_analysis_job_spec.rb | 117 ------------------ spec/enterprise/models/conversation_spec.rb | 30 ----- spec/enterprise/models/message_spec.rb | 20 --- 11 files changed, 14 insertions(+), 312 deletions(-) delete mode 100644 enterprise/app/jobs/enterprise/sentiment_analysis_job.rb delete mode 100644 enterprise/app/models/enterprise/message.rb delete mode 100644 enterprise/app/models/enterprise/sentiment_analysis_helper.rb delete mode 100644 spec/enterprise/jobs/enterprise/sentiment_analysis_job_spec.rb delete mode 100644 spec/enterprise/models/message_spec.rb diff --git a/.env.example b/.env.example index 2b4ba9795..55b998f19 100644 --- a/.env.example +++ b/.env.example @@ -249,9 +249,6 @@ AZURE_APP_SECRET= ## 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 diff --git a/Gemfile b/Gemfile index 0dac6e95f..65cb4de6b 100644 --- a/Gemfile +++ b/Gemfile @@ -175,9 +175,6 @@ gem 'pgvector' # Convert Website HTML to Markdown gem 'reverse_markdown' -# Sentiment analysis -gem 'informers' - ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 55021a04b..542910f5d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,7 +151,6 @@ GEM base64 (0.1.1) bcrypt (3.1.20) bindex (0.8.1) - blingfire (0.1.8) bootsnap (1.16.0) msgpack (~> 1.2) brakeman (5.4.1) @@ -316,15 +315,15 @@ GEM google-cloud-translate-v3 (0.6.0) gapic-common (>= 0.17.1, < 2.a) google-cloud-errors (~> 1.0) - google-protobuf (3.25.2) - google-protobuf (3.25.2-arm64-darwin) - google-protobuf (3.25.2-x86_64-darwin) - google-protobuf (3.25.2-x86_64-linux) + google-protobuf (3.25.3) + google-protobuf (3.25.3-arm64-darwin) + google-protobuf (3.25.3-x86_64-darwin) + google-protobuf (3.25.3-x86_64-linux) googleapis-common-protos (1.4.0) google-protobuf (~> 3.14) googleapis-common-protos-types (~> 1.2) grpc (~> 1.27) - googleapis-common-protos-types (1.11.0) + googleapis-common-protos-types (1.14.0) google-protobuf (~> 3.18) googleauth (1.5.2) faraday (>= 0.17.3, < 3.a) @@ -335,14 +334,17 @@ GEM signet (>= 0.16, < 2.a) groupdate (6.2.1) activesupport (>= 5.2) - grpc (1.54.3) - google-protobuf (~> 3.21) + grpc (1.62.0) + google-protobuf (~> 3.25) googleapis-common-protos-types (~> 1.0) - grpc (1.54.3-x86_64-darwin) - google-protobuf (~> 3.21) + grpc (1.62.0-arm64-darwin) + google-protobuf (~> 3.25) googleapis-common-protos-types (~> 1.0) - grpc (1.54.3-x86_64-linux) - google-protobuf (~> 3.21) + grpc (1.62.0-x86_64-darwin) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-x86_64-linux) + google-protobuf (~> 3.25) googleapis-common-protos-types (~> 1.0) haikunator (1.1.1) hairtrigger (1.0.0) @@ -371,10 +373,6 @@ GEM image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - informers (0.2.0) - blingfire (>= 0.1.7) - numo-narray - onnxruntime (>= 0.5.1) io-console (0.6.0) irb (1.7.2) reline (>= 0.3.6) @@ -500,7 +498,6 @@ GEM racc (~> 1.4) nokogiri (1.16.4-x86_64-linux) racc (~> 1.4) - numo-narray (0.9.2.1) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) snaky_hash (~> 2.0) @@ -529,14 +526,6 @@ GEM omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) omniauth (~> 2.0) - onnxruntime (0.7.6) - ffi - onnxruntime (0.7.6-arm64-darwin) - ffi - onnxruntime (0.7.6-x86_64-darwin) - ffi - onnxruntime (0.7.6-x86_64-linux) - ffi openssl (3.1.0) orm_adapter (0.5.0) os (1.1.4) @@ -892,7 +881,6 @@ DEPENDENCIES hashie html2text! image_processing - informers jbuilder json_refs json_schemer diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 4d2ce55ba..a99d7f227 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -310,5 +310,4 @@ class Conversation < ApplicationRecord end Conversation.include_mod_with('Concerns::Conversation') -Conversation.include_mod_with('SentimentAnalysisHelper') Conversation.prepend_mod_with('Conversation') diff --git a/app/models/message.rb b/app/models/message.rb index 465c7e988..eb43c20ea 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -269,7 +269,6 @@ class Message < ApplicationRecord reopen_conversation notify_via_mail set_conversation_activity - update_message_sentiments dispatch_create_events send_reply execute_message_template_hooks @@ -406,10 +405,6 @@ class Message < ApplicationRecord conversation.update_columns(last_activity_at: created_at) # rubocop:enable Rails/SkipsModelValidations end - - def update_message_sentiments - # override in the enterprise ::Enterprise::SentimentAnalysisJob.perform_later(self) - end end Message.prepend_mod_with('Message') diff --git a/enterprise/app/jobs/enterprise/sentiment_analysis_job.rb b/enterprise/app/jobs/enterprise/sentiment_analysis_job.rb deleted file mode 100644 index e85a89ed8..000000000 --- a/enterprise/app/jobs/enterprise/sentiment_analysis_job.rb +++ /dev/null @@ -1,57 +0,0 @@ -class Enterprise::SentimentAnalysisJob < ApplicationJob - queue_as :low - - def perform(message) - return if message.account.locale != 'en' || !valid_incoming_message?(message) - - save_message_sentiment(message) - rescue StandardError => e - Rails.logger.error("Sentiment Analysis Error for message #{message.id}: #{e}") - ChatwootExceptionTracker.new(e, account: message.account).capture_exception - end - - def save_message_sentiment(message) - # We are truncating the data here to avoind the OnnxRuntime::Error - # Indices element out of data bounds, idx=512 must be within the inclusive range [-512,511] - # While gathering the maningfull node the Array/tensor index is going out of bound - - text = message.content&.truncate(2900) - return if model.blank? - - sentiment = model.predict(text) - message.sentiment = sentiment.merge(value: label_val(sentiment)) - - message.save! - end - - # Model initializes OnnxRuntime::Model, with given file for inference session and to create the tensor - def model - model_file = save_and_open_sentiment_file - - return if File.empty?(model_file) - - Informers::SentimentAnalysis.new(model_file) - end - - def label_val(sentiment) - sentiment[:label] == 'positive' ? 1 : -1 - end - - def valid_incoming_message?(message) - message.incoming? && message.content.present? && !message.private? - end - - # returns the sentiment file from vendor folder else download it to the path from AWS-S3 - def save_and_open_sentiment_file - model_path = ENV.fetch('SENTIMENT_FILE_PATH', nil) - - sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx') - - return sentiment_file if File.exist?(sentiment_file) - - source_file = Down.download(model_path) # Download file from AWS-S3 - File.rename(source_file, sentiment_file) # Change the file path - - sentiment_file - end -end diff --git a/enterprise/app/models/enterprise/message.rb b/enterprise/app/models/enterprise/message.rb deleted file mode 100644 index 11c7044e5..000000000 --- a/enterprise/app/models/enterprise/message.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Enterprise::Message - def update_message_sentiments - ::Enterprise::SentimentAnalysisJob.perform_later(self) if ENV.fetch('SENTIMENT_FILE_PATH', nil).present? - end -end diff --git a/enterprise/app/models/enterprise/sentiment_analysis_helper.rb b/enterprise/app/models/enterprise/sentiment_analysis_helper.rb deleted file mode 100644 index 6c26d5c07..000000000 --- a/enterprise/app/models/enterprise/sentiment_analysis_helper.rb +++ /dev/null @@ -1,45 +0,0 @@ -module Enterprise::SentimentAnalysisHelper - extend ActiveSupport::Concern - - included do - def opening_sentiments - records = incoming_messages.first(average_message_count) - average_sentiment(records) - end - - def closing_sentiments - return unless resolved? - - records = incoming_messages.last(average_message_count) - average_sentiment(records) - end - - def average_sentiment(records) - { - label: average_sentiment_label(records), - score: average_sentiment_score(records) - } - end - - private - - def average_sentiment_label(records) - value = records.pluck(:sentiment).sum { |a| a['value'].to_i } - value.negative? ? 'negative' : 'positive' - end - - def average_sentiment_score(records) - total = records.pluck(:sentiment).sum { |a| a['score'].to_f } - total / average_message_count - end - - def average_message_count - # incoming_messages.count >= 10 ? 5 : ((incoming_messages.count / 2) - 1) - 5 - end - - def incoming_messages - messages.incoming.where(private: false) - end - end -end diff --git a/spec/enterprise/jobs/enterprise/sentiment_analysis_job_spec.rb b/spec/enterprise/jobs/enterprise/sentiment_analysis_job_spec.rb deleted file mode 100644 index bb2a89395..000000000 --- a/spec/enterprise/jobs/enterprise/sentiment_analysis_job_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'rails_helper' - -RSpec.describe Enterprise::SentimentAnalysisJob do - context 'when account locale set to english language' do - let(:account) { create(:account, locale: 'en') } - let(:message) { build(:message, content_type: nil, account: account) } - - context 'when update the message sentiments' do - let(:model_path) { Rails.root.join('vendor/db/sentiment-analysis.onnx') } - let(:model) { double } - - before do - allow(Informers::SentimentAnalysis).to receive(:new).with(model_path).and_return(model) - allow(model).to receive(:predict).and_return({ label: 'positive', score: '0.6' }) - end - - it 'with incoming message' do - with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do - message.update(message_type: :incoming) - - described_class.perform_now(message) - - expect(message.sentiment).not_to be_empty - end - end - - it 'update sentiment label for positive message' do - with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do - message.update(message_type: :incoming, content: 'I like your product') - - described_class.perform_now(message) - - expect(message.sentiment).not_to be_empty - expect(message.sentiment['label']).to eq('positive') - expect(message.sentiment['value']).to eq(1) - end - end - - it 'update sentiment label for negative message' do - with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do - message.update(message_type: :incoming, content: 'I did not like your product') - allow(model).to receive(:predict).and_return({ label: 'negative', score: '0.6' }) - - described_class.perform_now(message) - - expect(message.sentiment).not_to be_empty - expect(message.sentiment['label']).to eq('negative') - expect(message.sentiment['value']).to eq(-1) - end - end - end - - context 'with download sentiment files' do - let(:model_path) { nil } - let(:model) { double } - - before do - allow(Informers::SentimentAnalysis).to receive(:new).with(model_path).and_return(model) - allow(model).to receive(:predict).and_return({ label: 'positive', score: '0.6' }) - end - - it 'fetch saved file in the server' do - with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do - message.update(message_type: :incoming, content: 'I did not like your product') - - described_class.new(message).save_and_open_sentiment_file - - sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx') - expect(File).to exist(sentiment_file) - end - end - - it 'fetch file from the storage' do - with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do - message.update(message_type: :incoming, content: 'I did not like your product') - allow(File).to receive(:exist?).and_return(false) - allow(Down).to receive(:download).and_return('./sentiment-analysis.onnx') - allow(File).to receive(:rename).and_return(100) - - described_class.new(message).save_and_open_sentiment_file - - sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx') - expect(sentiment_file).to be_present - end - end - end - - context 'when does not update the message sentiments' do - it 'with outgoing message' do - message.update(message_type: :outgoing) - - described_class.perform_now(message) - - expect(message.sentiment).to be_empty - end - - it 'with private message' do - message.update(private: true) - - described_class.perform_now(message) - - expect(message.sentiment).to be_empty - end - end - end - - context 'when account locale is not set to english language' do - let(:account) { create(:account, locale: 'es') } - let(:message) { build(:message, content_type: nil, account: account) } - - it 'does not update the message sentiments' do - described_class.perform_now(message) - - expect(message.sentiment).to be_empty - end - end -end diff --git a/spec/enterprise/models/conversation_spec.rb b/spec/enterprise/models/conversation_spec.rb index e9fe811ca..9585274dd 100644 --- a/spec/enterprise/models/conversation_spec.rb +++ b/spec/enterprise/models/conversation_spec.rb @@ -36,36 +36,6 @@ RSpec.describe Conversation, type: :model do # end end - describe 'conversation sentiments' do - include ActiveJob::TestHelper - - let(:conversation) { create(:conversation, additional_attributes: { referer: 'https://www.chatwoot.com/' }) } - - before do - 10.times do - message = create(:message, conversation_id: conversation.id, account_id: conversation.account_id, message_type: 'incoming') - message.update(sentiment: { 'label': 'positive', score: '0.4' }) - end - end - - it 'returns opening sentiments' do - sentiments = conversation.opening_sentiments - expect(sentiments[:label]).to eq('positive') - end - - it 'returns closing sentiments if conversation is not resolved' do - sentiments = conversation.closing_sentiments - expect(sentiments).to be_nil - end - - it 'returns closing sentiments if it is resolved' do - conversation.resolved! - - sentiments = conversation.closing_sentiments - expect(sentiments[:label]).to eq('positive') - end - end - describe 'sla_policy' do let(:account) { create(:account) } let(:conversation) { create(:conversation, account: account) } diff --git a/spec/enterprise/models/message_spec.rb b/spec/enterprise/models/message_spec.rb deleted file mode 100644 index 5fd423163..000000000 --- a/spec/enterprise/models/message_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -require Rails.root.join 'spec/models/concerns/liquidable_shared.rb' - -RSpec.describe Message do - context 'with sentiment analysis' do - let(:message) { build(:message, message_type: :incoming, content_type: nil, account: create(:account)) } - - it 'calls SentimentAnalysisJob' do - with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do - allow(Enterprise::SentimentAnalysisJob).to receive(:perform_later).and_return(:perform_later).with(message) - - message.save! - - expect(Enterprise::SentimentAnalysisJob).to have_received(:perform_later) - end - end - end -end From 47f8b2cd0caf75871f797003295c2459c9e21900 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 26 Apr 2024 15:41:02 +0530 Subject: [PATCH 3/4] refactor: handling keyboard shortcuts (#9242) * fix: Resolve and go next keyboard shortcuts doesn't work * refactor: use buildHotKeys instead of hasPressedCommandPlusAltAndEKey * feat: install tinykeys * refactor: use tinykeys * test: update buildKeyEvents * fix: remove stray import * feat: handle action list globally * feat: allow configuring `allowOnFocusedInput` * chore: Navigate chat list item * chore: Navigate dashboard * feat: Navigate editor top panel * feat: Toggle file upload * chore: More keyboard shortcuts * chore: Update mention selection mixin * chore: Phone input * chore: Clean up * chore: Clean up * chore: Dropdown and editor * chore: Enter key to send and clean up * chore: Rename mixin * chore: Review fixes * chore: Removed unused shortcut from modal * fix: Specs --------- Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../dashboard/components/ChatList.vue | 61 ++++----- .../components/buttons/ResolveAction.vue | 80 ++++++------ .../dashboard/components/layout/Sidebar.vue | 59 ++++----- .../components/widgets/AIAssistanceButton.vue | 24 ++-- .../components/widgets/ChatTypeTabs.vue | 25 ++-- .../components/widgets/LabelSelector.vue | 33 +++-- .../components/widgets/WootWriter/Editor.vue | 28 +++-- .../widgets/WootWriter/FullEditor.vue | 4 +- .../widgets/WootWriter/ReplyBottomPanel.vue | 18 +-- .../widgets/WootWriter/ReplyTopPanel.vue | 30 ++--- .../widgets/conversation/ConversationCard.vue | 2 +- .../conversation/ConversationHeader.vue | 15 +-- .../widgets/conversation/MessagesView.vue | 15 +-- .../widgets/conversation/ReplyBox.vue | 56 ++++++--- .../widgets/conversation/TagAgents.vue | 7 +- .../conversation/components/GalleryView.vue | 47 +++---- .../components/widgets/forms/PhoneInput.vue | 72 ++++++----- .../widgets/mentions/MentionBox.vue | 4 +- .../mentions/mentionSelectionKeyboardMixin.js | 61 ++++++--- .../mentionSelectionKeyboardMixin.spec.js | 99 ++++++++------- .../components/widgets/modal/constants.js | 6 - .../dashboard/i18n/locale/en/settings.json | 1 - .../modules/notes/components/AddNote.vue | 25 ++-- .../conversation/labels/LabelBox.vue | 41 +++--- .../components/ArticleSearch/Header.vue | 24 ++-- .../ArticleSearch/SearchPopover.vue | 25 ++-- .../portal/components/SearchSuggestions.vue | 7 +- .../components/ui/dropdown/DropdownMenu.vue | 68 +++++----- .../shared/helpers/KeyboardHelpers.js | 117 +----------------- .../helpers/specs/KeyboardHelpers.spec.js | 13 +- .../shared/mixins/eventListenerMixins.js | 24 ---- .../mixins/keyboardEventListenerMixins.js | 63 ++++++++++ .../views/playground/Index.vue | 22 ++-- .../widget/components/Form/PhoneInput.vue | 13 +- package.json | 1 + yarn.lock | 5 + 36 files changed, 599 insertions(+), 596 deletions(-) delete mode 100644 app/javascript/shared/mixins/eventListenerMixins.js create mode 100644 app/javascript/shared/mixins/keyboardEventListenerMixins.js diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 26c057f98..e7fae6e01 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -185,7 +185,7 @@ import ConversationBasicFilter from './widgets/conversation/ConversationBasicFil import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ConversationItem from './ConversationItem.vue'; import timeMixin from '../mixins/time'; -import eventListenerMixins from 'shared/mixins/eventListenerMixins'; +import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import conversationMixin from '../mixins/conversations'; import wootConstants from 'dashboard/constants/globals'; import advancedFilterTypes from './widgets/conversation/advancedFilterItems'; @@ -199,11 +199,6 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import countries from 'shared/constants/countries'; import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper'; - -import { - hasPressedAltAndJKey, - hasPressedAltAndKKey, -} from 'shared/helpers/KeyboardHelpers'; import { conversationListPageURL } from '../helper/URLHelper'; import { isOnMentionsView, @@ -228,7 +223,7 @@ export default { mixins: [ timeMixin, conversationMixin, - eventListenerMixins, + keyboardEventListenerMixins, alertMixin, filterMixin, uiSettingsMixin, @@ -691,30 +686,40 @@ export default { lastConversationIndex, }; }, - handleKeyEvents(e) { - if (hasPressedAltAndJKey(e)) { - const { allConversations, activeConversationIndex } = - this.getKeyboardListenerParams(); - if (activeConversationIndex === -1) { - allConversations[0].click(); - } - if (activeConversationIndex >= 1) { - allConversations[activeConversationIndex - 1].click(); - } + handlePreviousConversation() { + const { allConversations, activeConversationIndex } = + this.getKeyboardListenerParams(); + if (activeConversationIndex === -1) { + allConversations[0].click(); } - if (hasPressedAltAndKKey(e)) { - const { - allConversations, - activeConversationIndex, - lastConversationIndex, - } = this.getKeyboardListenerParams(); - if (activeConversationIndex === -1) { - allConversations[lastConversationIndex].click(); - } else if (activeConversationIndex < lastConversationIndex) { - allConversations[activeConversationIndex + 1].click(); - } + if (activeConversationIndex >= 1) { + allConversations[activeConversationIndex - 1].click(); } }, + handleNextConversation() { + const { + allConversations, + activeConversationIndex, + lastConversationIndex, + } = this.getKeyboardListenerParams(); + if (activeConversationIndex === -1) { + allConversations[lastConversationIndex].click(); + } else if (activeConversationIndex < lastConversationIndex) { + allConversations[activeConversationIndex + 1].click(); + } + }, + getKeyboardEvents() { + return { + 'Alt+KeyJ': { + action: () => this.handlePreviousConversation(), + allowOnFocusedInput: true, + }, + 'Alt+KeyK': { + action: () => this.handleNextConversation(), + allowOnFocusedInput: true, + }, + }; + }, resetAndFetchData() { this.appliedFilter = []; this.resetBulkActions(); diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 84e33cc42..9a70a7f19 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -91,12 +91,7 @@ import { mapGetters } from 'vuex'; import { mixin as clickaway } from 'vue-clickaway'; import alertMixin from 'shared/mixins/alertMixin'; import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue'; -import eventListenerMixins from 'shared/mixins/eventListenerMixins'; -import { - hasPressedAltAndEKey, - hasPressedCommandPlusAltAndEKey, - hasPressedAltAndMKey, -} from 'shared/helpers/KeyboardHelpers'; +import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; @@ -114,7 +109,7 @@ export default { WootDropdownMenu, CustomSnoozeModal, }, - mixins: [clickaway, alertMixin, eventListenerMixins], + mixins: [clickaway, alertMixin, keyboardEventListenerMixins], props: { conversationId: { type: [String, Number], required: true } }, data() { return { @@ -159,37 +154,52 @@ export default { bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation); }, methods: { - async handleKeyEvents(e) { + getKeyboardEvents() { + return { + 'Alt+KeyM': { + action: () => this.$refs.arrowDownButton?.$el.click(), + allowOnFocusedInput: true, + }, + 'Alt+KeyE': this.resolveOrToast, + '$mod+Alt+KeyE': async event => { + const { all, activeIndex, lastIndex } = this.getConversationParams(); + await this.resolveOrToast(); + + if (activeIndex < lastIndex) { + all[activeIndex + 1].click(); + } else if (all.length > 1) { + all[0].click(); + document.querySelector('.conversations-list').scrollTop = 0; + } + + event.preventDefault(); + }, + }; + }, + getConversationParams() { const allConversations = document.querySelectorAll( '.conversations-list .conversation' ); - if (hasPressedAltAndMKey(e)) { - if (this.$refs.arrowDownButton) { - this.$refs.arrowDownButton.$el.click(); - } - } - if (hasPressedAltAndEKey(e)) { - const activeConversation = document.querySelector( - 'div.conversations-list div.conversation.active' - ); - const activeConversationIndex = [...allConversations].indexOf( - activeConversation - ); - const lastConversationIndex = allConversations.length - 1; - try { - await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED); - } catch (error) { - // error - } - if (hasPressedCommandPlusAltAndEKey(e)) { - if (activeConversationIndex < lastConversationIndex) { - allConversations[activeConversationIndex + 1].click(); - } else if (allConversations.length > 1) { - allConversations[0].click(); - document.querySelector('.conversations-list').scrollTop = 0; - } - e.preventDefault(); - } + + const activeConversation = document.querySelector( + 'div.conversations-list div.conversation.active' + ); + const activeConversationIndex = [...allConversations].indexOf( + activeConversation + ); + const lastConversationIndex = allConversations.length - 1; + + return { + all: allConversations, + activeIndex: activeConversationIndex, + lastIndex: lastConversationIndex, + }; + }, + async resolveOrToast() { + try { + await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED); + } catch (error) { + // error } }, onCmdSnoozeConversation(snoozeType) { diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 1290ea602..0f3a1a857 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -1,5 +1,5 @@ diff --git a/app/javascript/v3/components/Form/ProfileAvatar.vue b/app/javascript/v3/components/Form/ProfileAvatar.vue new file mode 100644 index 000000000..fb802d35d --- /dev/null +++ b/app/javascript/v3/components/Form/ProfileAvatar.vue @@ -0,0 +1,84 @@ + + diff --git a/app/javascript/v3/helpers/CommonHelper.js b/app/javascript/v3/helpers/CommonHelper.js index cdd913769..a4b55bdd5 100644 --- a/app/javascript/v3/helpers/CommonHelper.js +++ b/app/javascript/v3/helpers/CommonHelper.js @@ -1,3 +1,9 @@ export const replaceRouteWithReload = url => { window.location = url; }; + +export const userInitial = name => { + const parts = name.split(/[ -]/).filter(Boolean); + let initials = parts.map(part => part[0].toUpperCase()).join(''); + return initials.slice(0, 2); +}; diff --git a/app/javascript/v3/helpers/specs/CommonHelper.spec.js b/app/javascript/v3/helpers/specs/CommonHelper.spec.js new file mode 100644 index 000000000..58d86006c --- /dev/null +++ b/app/javascript/v3/helpers/specs/CommonHelper.spec.js @@ -0,0 +1,10 @@ +import { userInitial } from '../CommonHelper'; + +describe('#userInitial', () => { + it('returns the initials of the user', () => { + expect(userInitial('John Doe')).toEqual('JD'); + expect(userInitial('John')).toEqual('J'); + expect(userInitial('John-Doe')).toEqual('JD'); + expect(userInitial('John Doe Smith')).toEqual('JD'); + }); +}); diff --git a/tailwind.config.js b/tailwind.config.js index afe0b3e9b..9e551985b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,17 +1,5 @@ -const { - blue, - blueDark, - green, - greenDark, - yellow, - yellowDark, - slate, - slateDark, - red, - redDark, - violet, - violetDark, -} = require('@radix-ui/colors'); +const { slateDark } = require('@radix-ui/colors'); +import { colors } from './theme/colors'; const defaultTheme = require('tailwindcss/defaultTheme'); module.exports = { darkMode: 'class', @@ -41,163 +29,7 @@ module.exports = { 'modal-backdrop-light': 'rgba(0, 0, 0, 0.4)', 'modal-backdrop-dark': 'rgba(0, 0, 0, 0.6)', current: 'currentColor', - woot: { - 25: blue.blue2, - 50: blue.blue3, - 75: blue.blue4, - 100: blue.blue5, - 200: blue.blue7, - 300: blue.blue8, - 400: blueDark.blue11, - 500: blueDark.blue10, - 600: blueDark.blue9, - 700: blueDark.blue8, - 800: blueDark.blue6, - 900: blueDark.blue2, - }, - green: { - 50: greenDark.green12, - 100: green.green6, - 200: green.green7, - 300: green.green8, - 400: greenDark.green10, - 500: greenDark.green9, - 600: green.green10, - 700: green.green11, - 800: greenDark.green7, - 900: greenDark.green6, - }, - yellow: { - 50: yellow.yellow2, - 100: yellow.yellow3, - 200: yellow.yellow5, - 300: yellowDark.yellow10, - 400: yellowDark.yellow9, - 500: yellowDark.yellow11, - 600: yellow.yellow8, - 700: yellowDark.yellow7, - 800: yellowDark.yellow2, - 900: yellowDark.yellow1, - }, - slate: { - 25: slate.slate2, - 50: slate.slate3, - 75: slate.slate4, - 100: slate.slate5, - 200: slate.slate7, - 300: slate.slate8, - 400: slateDark.slate11, - 500: slateDark.slate10, - 600: slate.slate11, - 700: slateDark.slate8, - 800: slateDark.slate4, - 900: slateDark.slate1, - }, - black: { - 50: slate.slate2, - 100: slateDark.slate12, - 200: slate.slate7, - 300: slate.slate8, - 400: slateDark.slate11, - 500: slate.slate9, - 600: slateDark.slate9, - 700: slateDark.slate8, - 800: slateDark.slate7, - 900: slateDark.slate2, - }, - red: { - 50: redDark.red12, - 100: red.red6, - 200: red.red8, - 300: redDark.red11, - 400: redDark.red10, - 500: red.red9, - 600: red.red10, - 700: red.red11, - 800: redDark.red8, - 900: red.red12, - }, - violet: { - 50: violet.violet1, - 100: violetDark.violet12, - 200: violet.violet6, - 300: violet.violet8, - 400: violet.violet11, - 500: violet.violet9, - 600: violetDark.violet8, - 700: violetDark.violet7, - 800: violetDark.violet6, - 900: violet.violet12, - }, - primary: { - 25: 'rgb(var(--color-primary-25) / )', - 50: 'rgb(var(--color-primary-50) / )', - 75: 'rgb(var(--color-primary-75) / )', - 100: 'rgb(var(--color-primary-100) / )', - 200: 'rgb(var(--color-primary-200) / )', - 300: 'rgb(var(--color-primary-300) / )', - 400: 'rgb(var(--color-primary-400) / )', - 500: 'rgb(var(--color-primary-500) / )', - 600: 'rgb(var(--color-primary-600) / )', - 700: 'rgb(var(--color-primary-700) / )', - 800: 'rgb(var(--color-primary-800) / )', - 900: 'rgb(var(--color-primary-900) / )', - }, - ash: { - 25: 'rgb(var(--color-ash-25) / )', - 50: 'rgb(var(--color-ash-50) / )', - 75: 'rgb(var(--color-ash-75) / )', - 100: 'rgb(var(--color-ash-100) / )', - 200: 'rgb(var(--color-ash-200) / )', - 300: 'rgb(var(--color-ash-300) / )', - 400: 'rgb(var(--color-ash-400) / )', - 500: 'rgb(var(--color-ash-500) / )', - 600: 'rgb(var(--color-ash-600) / )', - 700: 'rgb(var(--color-ash-700) / )', - 800: 'rgb(var(--color-ash-800) / )', - 900: 'rgb(var(--color-ash-900) / )', - }, - teal: { - 25: 'rgb(var(--color-teal-25) / )', - 50: 'rgb(var(--color-teal-50) / )', - 100: 'rgb(var(--color-teal-100) / )', - 200: 'rgb(var(--color-teal-200) / )', - 300: 'rgb(var(--color-teal-300) / )', - 400: 'rgb(var(--color-teal-400) / )', - 500: 'rgb(var(--color-teal-500) / )', - 600: 'rgb(var(--color-teal-600) / )', - 700: 'rgb(var(--color-teal-700) / )', - 800: 'rgb(var(--color-teal-800) / )', - 900: 'rgb(var(--color-teal-900) / )', - }, - amber: { - 25: 'rgb(var(--color-amber-25) / )', - 50: 'rgb(var(--color-amber-50) / )', - 75: 'rgb(var(--color-amber-75) / )', - 100: 'rgb(var(--color-amber-100) / )', - 200: 'rgb(var(--color-amber-200) / )', - 300: 'rgb(var(--color-amber-300) / )', - 400: 'rgb(var(--color-amber-400) / )', - 500: 'rgb(var(--color-amber-500) / )', - 600: 'rgb(var(--color-amber-600) / )', - 700: 'rgb(var(--color-amber-700) / )', - 800: 'rgb(var(--color-amber-800) / )', - 900: 'rgb(var(--color-amber-900) / )', - }, - ruby: { - 25: 'rgb(var(--color-ruby-25) / )', - 50: 'rgb(var(--color-ruby-50) / )', - 75: 'rgb(var(--color-ruby-75) / )', - 100: 'rgb(var(--color-ruby-100) / )', - 200: 'rgb(var(--color-ruby-200) / )', - 300: 'rgb(var(--color-ruby-300) / )', - 400: 'rgb(var(--color-ruby-400) / )', - 500: 'rgb(var(--color-ruby-500) / )', - 600: 'rgb(var(--color-ruby-600) / )', - 700: 'rgb(var(--color-ruby-700) / )', - 800: 'rgb(var(--color-ruby-800) / )', - 900: 'rgb(var(--color-ruby-900) / )', - }, + ...colors, body: slateDark.slate7, }, keyframes: { diff --git a/theme/colors.js b/theme/colors.js new file mode 100644 index 000000000..1ca8fb6a6 --- /dev/null +++ b/theme/colors.js @@ -0,0 +1,285 @@ +const { + blue, + blueDark, + green, + greenDark, + yellow, + yellowDark, + slate, + slateDark, + red, + redDark, + violet, + violetDark, +} = require('@radix-ui/colors'); +export const colors = { + woot: { + 25: blue.blue2, + 50: blue.blue3, + 75: blue.blue4, + 100: blue.blue5, + 200: blue.blue7, + 300: blue.blue8, + 400: blueDark.blue11, + 500: blueDark.blue10, + 600: blueDark.blue9, + 700: blueDark.blue8, + 800: blueDark.blue6, + 900: blueDark.blue2, + }, + green: { + 50: greenDark.green12, + 100: green.green6, + 200: green.green7, + 300: green.green8, + 400: greenDark.green10, + 500: greenDark.green9, + 600: green.green10, + 700: green.green11, + 800: greenDark.green7, + 900: greenDark.green6, + }, + yellow: { + 50: yellow.yellow2, + 100: yellow.yellow3, + 200: yellow.yellow5, + 300: yellowDark.yellow10, + 400: yellowDark.yellow9, + 500: yellowDark.yellow11, + 600: yellow.yellow8, + 700: yellowDark.yellow7, + 800: yellowDark.yellow2, + 900: yellowDark.yellow1, + }, + slate: { + 25: slate.slate2, + 50: slate.slate3, + 75: slate.slate4, + 100: slate.slate5, + 200: slate.slate7, + 300: slate.slate8, + 400: slateDark.slate11, + 500: slateDark.slate10, + 600: slate.slate11, + 700: slateDark.slate8, + 800: slateDark.slate4, + 900: slateDark.slate1, + }, + black: { + 50: slate.slate2, + 100: slateDark.slate12, + 200: slate.slate7, + 300: slate.slate8, + 400: slateDark.slate11, + 500: slate.slate9, + 600: slateDark.slate9, + 700: slateDark.slate8, + 800: slateDark.slate7, + 900: slateDark.slate2, + }, + red: { + 50: redDark.red12, + 100: red.red6, + 200: red.red8, + 300: redDark.red11, + 400: redDark.red10, + 500: red.red9, + 600: red.red10, + 700: red.red11, + 800: redDark.red8, + 900: red.red12, + }, + violet: { + 50: violet.violet1, + 100: violetDark.violet12, + 200: violet.violet6, + 300: violet.violet8, + 400: violet.violet11, + 500: violet.violet9, + 600: violetDark.violet8, + 700: violetDark.violet7, + 800: violetDark.violet6, + 900: violet.violet12, + }, + primary: { + 25: 'rgb(var(--color-primary-25) / )', + 50: 'rgb(var(--color-primary-50) / )', + 75: 'rgb(var(--color-primary-75) / )', + 100: 'rgb(var(--color-primary-100) / )', + 200: 'rgb(var(--color-primary-200) / )', + 300: 'rgb(var(--color-primary-300) / )', + 400: 'rgb(var(--color-primary-400) / )', + 500: 'rgb(var(--color-primary-500) / )', + 600: 'rgb(var(--color-primary-600) / )', + 700: 'rgb(var(--color-primary-700) / )', + 800: 'rgb(var(--color-primary-800) / )', + 900: 'rgb(var(--color-primary-900) / )', + }, + ash: { + 25: 'rgb(var(--color-ash-25) / )', + 50: 'rgb(var(--color-ash-50) / )', + 75: 'rgb(var(--color-ash-75) / )', + 100: 'rgb(var(--color-ash-100) / )', + 200: 'rgb(var(--color-ash-200) / )', + 300: 'rgb(var(--color-ash-300) / )', + 400: 'rgb(var(--color-ash-400) / )', + 500: 'rgb(var(--color-ash-500) / )', + 600: 'rgb(var(--color-ash-600) / )', + 700: 'rgb(var(--color-ash-700) / )', + 800: 'rgb(var(--color-ash-800) / )', + 900: 'rgb(var(--color-ash-900) / )', + }, + teal: { + 25: 'rgb(var(--color-teal-25) / )', + 50: 'rgb(var(--color-teal-50) / )', + 100: 'rgb(var(--color-teal-100) / )', + 200: 'rgb(var(--color-teal-200) / )', + 300: 'rgb(var(--color-teal-300) / )', + 400: 'rgb(var(--color-teal-400) / )', + 500: 'rgb(var(--color-teal-500) / )', + 600: 'rgb(var(--color-teal-600) / )', + 700: 'rgb(var(--color-teal-700) / )', + 800: 'rgb(var(--color-teal-800) / )', + 900: 'rgb(var(--color-teal-900) / )', + }, + amber: { + 25: 'rgb(var(--color-amber-25) / )', + 50: 'rgb(var(--color-amber-50) / )', + 75: 'rgb(var(--color-amber-75) / )', + 100: 'rgb(var(--color-amber-100) / )', + 200: 'rgb(var(--color-amber-200) / )', + 300: 'rgb(var(--color-amber-300) / )', + 400: 'rgb(var(--color-amber-400) / )', + 500: 'rgb(var(--color-amber-500) / )', + 600: 'rgb(var(--color-amber-600) / )', + 700: 'rgb(var(--color-amber-700) / )', + 800: 'rgb(var(--color-amber-800) / )', + 900: 'rgb(var(--color-amber-900) / )', + }, + ruby: { + 25: 'rgb(var(--color-ruby-25) / )', + 50: 'rgb(var(--color-ruby-50) / )', + 75: 'rgb(var(--color-ruby-75) / )', + 100: 'rgb(var(--color-ruby-100) / )', + 200: 'rgb(var(--color-ruby-200) / )', + 300: 'rgb(var(--color-ruby-300) / )', + 400: 'rgb(var(--color-ruby-400) / )', + 500: 'rgb(var(--color-ruby-500) / )', + 600: 'rgb(var(--color-ruby-600) / )', + 700: 'rgb(var(--color-ruby-700) / )', + 800: 'rgb(var(--color-ruby-800) / )', + 900: 'rgb(var(--color-ruby-900) / )', + }, + grass: { + 25: 'rgb(var(--color-green-25) / )', + 50: 'rgb(var(--color-green-50) / )', + 75: 'rgb(var(--color-green-75) / )', + 100: 'rgb(var(--color-green-100) / )', + 200: 'rgb(var(--color-green-200) / )', + 300: 'rgb(var(--color-green-300) / )', + 400: 'rgb(var(--color-green-400) / )', + 500: 'rgb(var(--color-green-500) / )', + 600: 'rgb(var(--color-green-600) / )', + 700: 'rgb(var(--color-green-700) / )', + 800: 'rgb(var(--color-green-800) / )', + 900: 'rgb(var(--color-green-900) / )', + }, + mint: { + 25: 'rgb(var(--color-mint-25) / )', + 50: 'rgb(var(--color-mint-50) / )', + 75: 'rgb(var(--color-mint-75) / )', + 100: 'rgb(var(--color-mint-100) / )', + 200: 'rgb(var(--color-mint-200) / )', + 300: 'rgb(var(--color-mint-300) / )', + 400: 'rgb(var(--color-mint-400) / )', + 500: 'rgb(var(--color-mint-500) / )', + 600: 'rgb(var(--color-mint-600) / )', + 700: 'rgb(var(--color-mint-700) / )', + 800: 'rgb(var(--color-mint-800) / )', + 900: 'rgb(var(--color-mint-900) / )', + }, + sky: { + 25: 'rgb(var(--color-sky-25) / )', + 50: 'rgb(var(--color-sky-50) / )', + 75: 'rgb(var(--color-sky-75) / )', + 100: 'rgb(var(--color-sky-100) / )', + 200: 'rgb(var(--color-sky-200) / )', + 300: 'rgb(var(--color-sky-300) / )', + 400: 'rgb(var(--color-sky-400) / )', + 500: 'rgb(var(--color-sky-500) / )', + 600: 'rgb(var(--color-sky-600) / )', + 700: 'rgb(var(--color-sky-700) / )', + 800: 'rgb(var(--color-sky-800) / )', + 900: 'rgb(var(--color-sky-900) / )', + }, + indigo: { + 25: 'rgb(var(--color-indigo-25) / )', + 50: 'rgb(var(--color-indigo-50) / )', + 75: 'rgb(var(--color-indigo-75) / )', + 100: 'rgb(var(--color-indigo-100) / )', + 200: 'rgb(var(--color-indigo-200) / )', + 300: 'rgb(var(--color-indigo-300) / )', + 400: 'rgb(var(--color-indigo-400) / )', + 500: 'rgb(var(--color-indigo-500) / )', + 600: 'rgb(var(--color-indigo-600) / )', + 700: 'rgb(var(--color-indigo-700) / )', + 800: 'rgb(var(--color-indigo-800) / )', + 900: 'rgb(var(--color-indigo-900) / )', + }, + iris: { + 25: 'rgb(var(--color-iris-25) / )', + 50: 'rgb(var(--color-iris-50) / )', + 75: 'rgb(var(--color-iris-75) / )', + 100: 'rgb(var(--color-iris-100) / )', + 200: 'rgb(var(--color-iris-200) / )', + 300: 'rgb(var(--color-iris-300) / )', + 400: 'rgb(var(--color-iris-400) / )', + 500: 'rgb(var(--color-iris-500) / )', + 600: 'rgb(var(--color-iris-600) / )', + 700: 'rgb(var(--color-iris-700) / )', + 800: 'rgb(var(--color-iris-800) / )', + 900: 'rgb(var(--color-iris-900) / )', + }, + purple: { + 25: 'rgb(var(--color-violet-25) / )', + 50: 'rgb(var(--color-violet-50) / )', + 75: 'rgb(var(--color-violet-75) / )', + 100: 'rgb(var(--color-violet-100) / )', + 200: 'rgb(var(--color-violet-200) / )', + 300: 'rgb(var(--color-violet-300) / )', + 400: 'rgb(var(--color-violet-400) / )', + 500: 'rgb(var(--color-violet-500) / )', + 600: 'rgb(var(--color-violet-600) / )', + 700: 'rgb(var(--color-violet-700) / )', + 800: 'rgb(var(--color-violet-800) / )', + 900: 'rgb(var(--color-violet-900) / )', + }, + pink: { + 25: 'rgb(var(--color-pink-25) / )', + 50: 'rgb(var(--color-pink-50) / )', + 75: 'rgb(var(--color-pink-75) / )', + 100: 'rgb(var(--color-pink-100) / )', + 200: 'rgb(var(--color-pink-200) / )', + 300: 'rgb(var(--color-pink-300) / )', + 400: 'rgb(var(--color-pink-400) / )', + 500: 'rgb(var(--color-pink-500) / )', + 600: 'rgb(var(--color-pink-600) / )', + 700: 'rgb(var(--color-pink-700) / )', + 800: 'rgb(var(--color-pink-800) / )', + 900: 'rgb(var(--color-pink-900) / )', + }, + orange: { + 25: 'rgb(var(--color-orange-25) / )', + 50: 'rgb(var(--color-orange-50) / )', + 75: 'rgb(var(--color-orange-75) / )', + 100: 'rgb(var(--color-orange-100) / )', + 200: 'rgb(var(--color-orange-200) / )', + 300: 'rgb(var(--color-orange-300) / )', + 400: 'rgb(var(--color-orange-400) / )', + 500: 'rgb(var(--color-orange-500) / )', + 600: 'rgb(var(--color-orange-600) / )', + 700: 'rgb(var(--color-orange-700) / )', + 800: 'rgb(var(--color-orange-800) / )', + 900: 'rgb(var(--color-orange-900) / )', + }, +};