Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c208dde0 | ||
|
|
f018bae231 | ||
|
|
17306e7862 | ||
|
|
a7f5003350 | ||
|
|
5fdf96e8d7 | ||
|
|
30443ca132 | ||
|
|
193933be46 | ||
|
|
a87f07619c | ||
|
|
f7fc2264c2 | ||
|
|
288a1103de | ||
|
|
71e5276cad | ||
|
|
5f16fbf590 | ||
|
|
1180c9e8b6 | ||
|
|
ac060b9cb2 | ||
|
|
cca473a27e | ||
|
|
40005b96cd | ||
|
|
459ee0cd15 | ||
|
|
ffa39aead8 | ||
|
|
79e0b2e18f | ||
|
|
cd0c9b8b04 | ||
|
|
710698155f | ||
|
|
9e88be1e86 | ||
|
|
acfa318e2a | ||
|
|
c65004f794 | ||
|
|
6f4ad3b81c | ||
|
|
99d55326cf | ||
|
|
09b22394b6 | ||
|
|
e376f998fc | ||
|
|
829014e7e0 | ||
|
|
5ddebec67c | ||
|
|
c8f5a0b2c4 | ||
|
|
039bbf3cd3 | ||
|
|
397448791e | ||
|
|
bde3368a9f | ||
|
|
68771ce928 | ||
|
|
2f21009609 | ||
|
|
06219eb785 | ||
|
|
ef79c36f78 |
@@ -93,8 +93,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
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
|
||||
|
||||
# Bundle audit
|
||||
- run:
|
||||
|
||||
@@ -68,15 +68,6 @@
|
||||
- Example: `feat(auth): add user authentication`
|
||||
- Don't reference Claude in commit messages
|
||||
|
||||
## PR Description Format
|
||||
|
||||
- Start with a short, user-facing paragraph describing the product change.
|
||||
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
|
||||
- For feature PRs, add `How to test` from a product/UX standpoint.
|
||||
- For bugfix PRs, use `How to reproduce` when helpful.
|
||||
- Optionally add a `What changed` section for implementation highlights.
|
||||
- Do not add a `How this was tested` section listing specs/commands.
|
||||
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
|
||||
@@ -268,7 +268,6 @@ group :development, :test do
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'skooma'
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+2
-13
@@ -191,7 +191,7 @@ GEM
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.5)
|
||||
connection_pool (2.5.3)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
@@ -473,12 +473,6 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
json_skooma (0.2.5)
|
||||
bigdecimal
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri-idna (~> 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
@@ -742,7 +736,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.26.4)
|
||||
redis-client (0.22.2)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
@@ -916,9 +910,6 @@ GEM
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
skooma (0.3.7)
|
||||
json_skooma (~> 0.2.5)
|
||||
zeitwerk (~> 2.6)
|
||||
slack-ruby-client (2.7.0)
|
||||
faraday (>= 2.0.1)
|
||||
faraday-mashify
|
||||
@@ -979,7 +970,6 @@ GEM
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.1.1)
|
||||
uri-idna (0.3.1)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -1153,7 +1143,6 @@ DEPENDENCIES
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
skooma
|
||||
slack-ruby-client (~> 2.7.0)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
|
||||
@@ -40,12 +40,8 @@ run:
|
||||
fi
|
||||
|
||||
force_run:
|
||||
@echo "Cleaning up Overmind processes..."
|
||||
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@rm -f ./.overmind.sock
|
||||
@rm -f tmp/pids/*.pid
|
||||
@echo "Cleanup complete"
|
||||
rm -f ./.overmind.sock
|
||||
rm -f tmp/pids/*.pid
|
||||
overmind start -f Procfile.dev
|
||||
|
||||
force_run_tunnel:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.11.2
|
||||
4.11.1
|
||||
|
||||
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save! if @contact.changed?
|
||||
@contact.save!
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
|
||||
@@ -2,17 +2,12 @@ class Messages::Messenger::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
|
||||
def process_attachment(attachment)
|
||||
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
||||
# This check handles very rare case if there are multiple files to attach with only one usupported file
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
params = attachment_params(attachment)
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
if facebook_reel?(attachment)
|
||||
update_facebook_reel_content(attachment)
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
@@ -31,7 +26,7 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = normalize_file_type(attachment['type'])
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||
@@ -105,28 +100,6 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
private
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
attachment['type'].to_sym == :reel
|
||||
end
|
||||
|
||||
def update_facebook_reel_content(attachment)
|
||||
url = attachment.dig('payload', 'url')
|
||||
return if url.blank?
|
||||
|
||||
@message.update!(content: url) if @message.content.blank?
|
||||
end
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
@@ -11,6 +11,10 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
include TimezoneHelper
|
||||
|
||||
def build
|
||||
load_data
|
||||
@@ -10,13 +9,37 @@ class V2::Reports::BaseSummaryBuilder
|
||||
private
|
||||
|
||||
def load_data
|
||||
results = data_source.summary
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
@conversations_count = results.transform_values { |data| data[:conversations_count] }
|
||||
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
|
||||
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
|
||||
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
|
||||
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
@@ -27,27 +50,7 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: nil,
|
||||
dimension_type: summary_dimension_type,
|
||||
dimension_id: nil,
|
||||
scope: nil,
|
||||
range: range,
|
||||
group_by: 'day',
|
||||
timezone: timezone_name_from_params(params[:timezone], params[:timezone_offset]),
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def summary_dimension_type
|
||||
{
|
||||
'account_id' => 'account',
|
||||
'user_id' => 'agent',
|
||||
'inbox_id' => 'inbox',
|
||||
'conversations.team_id' => 'team'
|
||||
}.fetch(group_by_key.to_s)
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,10 +3,23 @@ class V2::Reports::Conversations::BaseReportBuilder
|
||||
|
||||
private
|
||||
|
||||
def builder_class(metric)
|
||||
return unless Reports::ReportMetricRegistry.supported?(metric)
|
||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
||||
COUNT_METRICS = %w[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
V2::Reports::Timeseries::ReportBuilder
|
||||
def builder_class(metric)
|
||||
case metric
|
||||
when *AVG_METRICS
|
||||
V2::Reports::Timeseries::AverageReportBuilder
|
||||
when *COUNT_METRICS
|
||||
V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
|
||||
@@ -11,6 +11,15 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
@@ -31,4 +40,8 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,8 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
@account = account
|
||||
@params = params
|
||||
|
||||
@timezone = timezone_name_from_params(params[:timezone], params[:timezone_offset])
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||
end
|
||||
# rubocop:enable Lint/MissingSuper
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
event_date, average_time = element
|
||||
arr << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}
|
||||
metric_to_event_name[params[:metric].to_sym]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,12 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def scope
|
||||
case dimension_type.to_sym
|
||||
case params[:type].to_sym
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
@@ -21,21 +20,6 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: params[:metric],
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: params[:id],
|
||||
scope: scope,
|
||||
range: range,
|
||||
group_by: group_by,
|
||||
timezone: timezone,
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
@@ -57,12 +41,6 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_params(params[:timezone], params[:timezone_offset])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
||||
# because it converts the date to 12:00 AM server timezone.
|
||||
# The desired output should be 12:00 AM in the specified timezone.
|
||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
send("scope_for_#{metric}")
|
||||
end
|
||||
|
||||
def scope_for_conversations_count
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
end
|
||||
|
||||
def scope_for_incoming_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_outgoing_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_bot_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
||||
# It converts timestamps to the target timezone before grouping, which means
|
||||
# the same event can fall into different day buckets depending on timezone
|
||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
data_source.timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
data_source.aggregate
|
||||
end
|
||||
end
|
||||
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
Article.update_positions(params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_category, except: [:index, :create, :reorder]
|
||||
before_action :fetch_category, except: [:index, :create]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@@ -32,11 +32,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reorder
|
||||
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_category
|
||||
@@ -44,7 +39,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def related_categories_records
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -201,9 +201,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contact
|
||||
contact_scope = Current.account.contacts
|
||||
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
|
||||
@contact = contact_scope.find(params[:id])
|
||||
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
||||
end
|
||||
|
||||
def process_avatar_from_url
|
||||
|
||||
@@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def toggle_typing_status
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
|
||||
typing_status_manager.toggle_typing_status
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -4,9 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -71,6 +70,23 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -82,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -178,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@@ -112,7 +112,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
common_params.merge({
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
@@ -121,7 +120,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
common_params.merge({
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
@@ -131,7 +129,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
metric: params[:metric],
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
|
||||
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
end
|
||||
|
||||
def team
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
end
|
||||
|
||||
def label
|
||||
@@ -36,18 +36,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
until: permitted_params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
|
||||
}
|
||||
@builder_params[:timezone] = permitted_params[:timezone] if permitted_params[:timezone].present?
|
||||
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
|
||||
end
|
||||
|
||||
def render_report_with(builder_class, type: nil)
|
||||
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
|
||||
builder = builder_class.new(account: Current.account, params: builder_params)
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours, :timezone, :timezone_offset)
|
||||
params.permit(:since, :until, :business_hours)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
|
||||
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
|
||||
|
||||
def validate_bot_access_token!
|
||||
return if Current.user.is_a?(User)
|
||||
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
|
||||
return if agent_bot_accessible?
|
||||
|
||||
render_unauthorized('Access to this endpoint is not authorized for bots')
|
||||
end
|
||||
|
||||
@@ -51,7 +51,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def account_signup_allowed?
|
||||
GlobalConfigService.account_signup_enabled?
|
||||
# set it to true by default, this is the behaviour across the app
|
||||
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
|
||||
end
|
||||
|
||||
def resource_class(_mapping = nil)
|
||||
|
||||
@@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
current_sign_in_ip: Field::String,
|
||||
last_sign_in_ip: Field::String,
|
||||
confirmation_token: Field::String,
|
||||
confirmed_at: ConfirmedAtField,
|
||||
confirmed_at: Field::DateTime,
|
||||
confirmation_sent_at: Field::DateTime,
|
||||
unconfirmed_email: Field::String,
|
||||
name: Field::String.with_options(searchable: true),
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class ConfirmedAtField < Administrate::Field::DateTime
|
||||
end
|
||||
@@ -47,15 +47,11 @@ module Filters::FilterHelper
|
||||
|
||||
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
|
||||
if data_type == 'text_case_insensitive'
|
||||
ActiveRecord::Base.sanitize_sql_array(
|
||||
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
|
||||
query_hash[:attribute_key]]
|
||||
)
|
||||
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
else
|
||||
ActiveRecord::Base.sanitize_sql_array(
|
||||
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
|
||||
query_hash[:attribute_key]]
|
||||
)
|
||||
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]} "
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,7 +70,7 @@ module Filters::FilterHelper
|
||||
|
||||
def date_filter(current_filter, query_hash, filter_operator_value)
|
||||
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '') {
|
||||
let requestURL = `${this.url}/search?${buildContactParams(
|
||||
page,
|
||||
sortAttr,
|
||||
label,
|
||||
search
|
||||
)}`;
|
||||
return axios.get(requestURL, { signal: options.signal });
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
active(page = 1, sortAttr = 'name') {
|
||||
|
||||
@@ -25,12 +25,6 @@ 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();
|
||||
|
||||
@@ -9,10 +9,6 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
class ReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
@@ -27,7 +26,6 @@ class ReportsAPI extends ApiClient {
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
@@ -43,7 +41,6 @@ class ReportsAPI extends ApiClient {
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
@@ -108,8 +105,6 @@ class ReportsAPI extends ApiClient {
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,19 +68,7 @@ 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',
|
||||
{ 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 }
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,5 @@ describe('#BulkActionsAPI', () => {
|
||||
expect(categoriesAPI).toHaveProperty('create');
|
||||
expect(categoriesAPI).toHaveProperty('update');
|
||||
expect(categoriesAPI).toHaveProperty('delete');
|
||||
expect(categoriesAPI).toHaveProperty('reorder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@ import reportsAPI from '../reports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#Reports API', () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
it('creates correct instance', () => {
|
||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(reportsAPI.apiVersion).toBe('/api/v2');
|
||||
@@ -48,7 +46,6 @@ describe('#Reports API', () => {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
});
|
||||
@@ -62,7 +59,6 @@ describe('#Reports API', () => {
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
since: 1621103400,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
type: 'account',
|
||||
until: 1621621800,
|
||||
@@ -144,8 +140,6 @@ describe('#Reports API', () => {
|
||||
type: 'account',
|
||||
group_by: 'date',
|
||||
business_hours: true,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import summaryReportsAPI from '../summaryReports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#Summary Reports API', () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
it('creates correct instance', () => {
|
||||
expect(summaryReportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(summaryReportsAPI.apiVersion).toBe('/api/v2');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('includes timezone data in summary report requests', () => {
|
||||
summaryReportsAPI.getAgentReports({
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
businessHours: true,
|
||||
});
|
||||
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/summary_reports/agent',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
business_hours: true,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
class SummaryReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
@@ -15,8 +12,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -27,8 +22,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -39,8 +32,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,8 +42,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
// Base styles for elements
|
||||
@import 'base';
|
||||
|
||||
// Plugins
|
||||
@import 'plugins/date-picker';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
@@ -29,11 +28,7 @@ const handleButtonClick = () => {
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
() => emit('close'),
|
||||
// This will prevent closing the modal when the editor Create link popup is open
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
startOfMonth,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfDay,
|
||||
isSameDay,
|
||||
addHours,
|
||||
addYears,
|
||||
subYears,
|
||||
setMonth,
|
||||
setYear,
|
||||
} from 'date-fns';
|
||||
import { CALENDAR_PERIODS } from './helpers/DatePickerHelper';
|
||||
import CalendarYear from './components/CalendarYear.vue';
|
||||
import CalendarMonth from './components/CalendarMonth.vue';
|
||||
import CalendarWeek from './components/CalendarWeek.vue';
|
||||
import CalendarFooter from './components/CalendarFooter.vue';
|
||||
import TimePicker from './components/TimePicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
minDate: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['apply', 'clear']);
|
||||
|
||||
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const currentDate = ref(new Date());
|
||||
const selectedDate = ref(null);
|
||||
|
||||
const getCurrentTime = () => {
|
||||
const now = new Date();
|
||||
return {
|
||||
hour: now.getHours(),
|
||||
minute: now.getMinutes(),
|
||||
second: now.getSeconds(),
|
||||
};
|
||||
};
|
||||
|
||||
const selectedTime = ref(getCurrentTime());
|
||||
const calendarView = ref(WEEK);
|
||||
const calendarDate = ref(startOfMonth(currentDate.value));
|
||||
|
||||
const getMinTimeForToday = () => addHours(new Date(), 1);
|
||||
|
||||
const effectiveMinDate = computed(() => {
|
||||
const thresholdDay = startOfDay(getMinTimeForToday());
|
||||
if (!props.minDate) return thresholdDay;
|
||||
const propDay = startOfDay(props.minDate);
|
||||
return propDay > thresholdDay ? propDay : thresholdDay;
|
||||
});
|
||||
|
||||
const minTime = computed(() => {
|
||||
if (!selectedDate.value) return null;
|
||||
if (!isSameDay(selectedDate.value, effectiveMinDate.value)) return null;
|
||||
return getMinTimeForToday();
|
||||
});
|
||||
|
||||
const selectedDateTime = computed(() => {
|
||||
if (!selectedDate.value) return null;
|
||||
const date = new Date(selectedDate.value);
|
||||
date.setHours(
|
||||
selectedTime.value.hour,
|
||||
selectedTime.value.minute,
|
||||
selectedTime.value.second,
|
||||
0
|
||||
);
|
||||
return date;
|
||||
});
|
||||
|
||||
const selectDate = day => {
|
||||
selectedDate.value = startOfDay(day);
|
||||
if (isSameDay(day, effectiveMinDate.value)) {
|
||||
const threshold = getMinTimeForToday();
|
||||
selectedTime.value = {
|
||||
hour: threshold.getHours(),
|
||||
minute: threshold.getMinutes(),
|
||||
second: 0,
|
||||
};
|
||||
} else {
|
||||
selectedTime.value = { hour: 0, minute: 0, second: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
const moveCalendar = (direction, period = MONTH) => {
|
||||
const adjust =
|
||||
period === YEAR
|
||||
? { prev: subYears, next: addYears }
|
||||
: { prev: subMonths, next: addMonths };
|
||||
calendarDate.value = adjust[direction](calendarDate.value, 1);
|
||||
};
|
||||
|
||||
const setViewMode = (_type, mode) => {
|
||||
calendarView.value = mode;
|
||||
};
|
||||
|
||||
const openCalendar = (index, _type, period = MONTH) => {
|
||||
calendarDate.value =
|
||||
period === MONTH
|
||||
? setMonth(startOfMonth(calendarDate.value), index)
|
||||
: setYear(calendarDate.value, index);
|
||||
calendarView.value = period === MONTH ? WEEK : MONTH;
|
||||
};
|
||||
|
||||
const onApply = () => {
|
||||
if (selectedDateTime.value) {
|
||||
emit('apply', selectedDateTime.value);
|
||||
}
|
||||
};
|
||||
|
||||
const isDefaultState = computed(
|
||||
() => !selectedDate.value && calendarView.value === WEEK
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
selectedDate.value = null;
|
||||
selectedTime.value = getCurrentTime();
|
||||
calendarDate.value = startOfMonth(currentDate.value);
|
||||
calendarView.value = WEEK;
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
if (isDefaultState.value) {
|
||||
emit('clear');
|
||||
return;
|
||||
}
|
||||
resetState();
|
||||
};
|
||||
|
||||
defineExpose({ resetState });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col select-none font-inter">
|
||||
<div class="flex w-full gap-3 justify-between">
|
||||
<div class="flex justify-center py-5">
|
||||
<div class="flex flex-col items-center gap-2 min-w-[300px]">
|
||||
<CalendarYear
|
||||
v-if="calendarView === YEAR"
|
||||
:start-current-date="calendarDate"
|
||||
@select-year="openCalendar($event, null, YEAR)"
|
||||
/>
|
||||
<CalendarMonth
|
||||
v-else-if="calendarView === MONTH"
|
||||
:start-current-date="calendarDate"
|
||||
@select-month="openCalendar($event)"
|
||||
@set-view="setViewMode"
|
||||
@prev="moveCalendar('prev', YEAR)"
|
||||
@next="moveCalendar('next', YEAR)"
|
||||
/>
|
||||
<CalendarWeek
|
||||
v-else
|
||||
:current-date="currentDate"
|
||||
:start-current-date="calendarDate"
|
||||
:selected-start-date="selectedDate"
|
||||
:selected-end-date="selectedDate"
|
||||
:min-date="effectiveMinDate"
|
||||
@select-date="selectDate"
|
||||
@set-view="setViewMode"
|
||||
@prev="moveCalendar('prev')"
|
||||
@next="moveCalendar('next')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-center py-2 w-full transition-opacity ltr:border-l rtl:border-r border-n-strong"
|
||||
:class="selectedDate ? 'opacity-100' : 'opacity-40 pointer-events-none'"
|
||||
>
|
||||
<TimePicker v-model="selectedTime" :min-time="minTime" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-n-strong">
|
||||
<CalendarFooter @change="onApply" @clear="onClear" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+15
-1
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { startOfDay } from 'date-fns';
|
||||
import {
|
||||
monthName,
|
||||
yearName,
|
||||
@@ -28,6 +29,10 @@ const props = defineProps({
|
||||
selectingEndDate: Boolean,
|
||||
selectedEndDate: Date,
|
||||
hoveredEndDate: Date,
|
||||
minDate: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -41,11 +46,18 @@ const emit = defineEmits([
|
||||
const { START_CALENDAR } = CALENDAR_TYPES;
|
||||
const { MONTH } = CALENDAR_PERIODS;
|
||||
|
||||
const isDayDisabled = day => {
|
||||
if (!props.minDate) return false;
|
||||
return startOfDay(day) < startOfDay(props.minDate);
|
||||
};
|
||||
|
||||
const emitHoveredEndDate = day => {
|
||||
if (isDayDisabled(day)) return;
|
||||
emit('updateHoveredEndDate', day);
|
||||
};
|
||||
|
||||
const emitSelectDate = day => {
|
||||
if (isDayDisabled(day)) return;
|
||||
emit('selectDate', day);
|
||||
};
|
||||
const onClickPrev = () => {
|
||||
@@ -108,8 +120,10 @@ const isNextDayInRange = day => {
|
||||
|
||||
const dayClasses = day => ({
|
||||
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
|
||||
'text-n-slate-10 pointer-events-none opacity-40':
|
||||
isInCurrentMonth(day) && isDayDisabled(day),
|
||||
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
|
||||
isInCurrentMonth(day),
|
||||
isInCurrentMonth(day) && !isDayDisabled(day),
|
||||
'bg-n-brand text-white':
|
||||
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
|
||||
'bg-n-blue-4 dark:bg-n-blue-5':
|
||||
@@ -0,0 +1,430 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import {
|
||||
TIME_COLUMNS,
|
||||
TIME_PERIODS,
|
||||
TIME_FORMATS,
|
||||
} from '../helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
minTime: { type: Date, default: null },
|
||||
});
|
||||
|
||||
const model = defineModel({
|
||||
type: Object,
|
||||
default: () => ({ hour: 9, minute: 0, second: 0 }),
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { HOUR, HOUR_12, MINUTE, SECOND, PERIOD } = TIME_COLUMNS;
|
||||
const { AM, PM } = TIME_PERIODS;
|
||||
const { H24, H12 } = TIME_FORMATS;
|
||||
|
||||
const ITEM_HEIGHT = 40;
|
||||
const CENTER_ROW = 2;
|
||||
const VISIBLE_ROWS = 5;
|
||||
const SNAP_DELAY = 120;
|
||||
const SEPARATOR = ':';
|
||||
|
||||
const is24HourFormat = ref(false);
|
||||
const selectedTime = reactive({
|
||||
hour: model.value.hour,
|
||||
minute: model.value.minute,
|
||||
second: model.value.second ?? 0,
|
||||
});
|
||||
const activeIndex = reactive({});
|
||||
const dragOffset = reactive({});
|
||||
const interactionMode = reactive({});
|
||||
const touchStartY = {};
|
||||
const snapTimers = {};
|
||||
const columnRefs = reactive({});
|
||||
const focusedColumn = ref(null);
|
||||
|
||||
const period = computed(() => (selectedTime.hour >= 12 ? PM : AM));
|
||||
const showPeriod = computed(() => !is24HourFormat.value);
|
||||
|
||||
const minBound = computed(() => {
|
||||
if (!props.minTime) return null;
|
||||
const d = props.minTime;
|
||||
return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
|
||||
});
|
||||
|
||||
const toTimeNumber = (h, m, s) => h * 3600 + m * 60 + s;
|
||||
|
||||
const convertTo12Hour = hour => {
|
||||
if (hour === 0) return 12;
|
||||
return hour > 12 ? hour - 12 : hour;
|
||||
};
|
||||
|
||||
const convertTo24Hour = (hour12, timePeriod) => {
|
||||
if (timePeriod === AM) return hour12 === 12 ? 0 : hour12;
|
||||
return hour12 === 12 ? 12 : hour12 + 12;
|
||||
};
|
||||
|
||||
const isItemDisabled = (key, val) => {
|
||||
const mb = minBound.value;
|
||||
if (!mb) return false;
|
||||
const minVal = toTimeNumber(mb.h, mb.m, mb.s);
|
||||
const { hour, minute } = selectedTime;
|
||||
if (key === HOUR) return toTimeNumber(val, 0, 0) < toTimeNumber(mb.h, 0, 0);
|
||||
if (key === MINUTE) return toTimeNumber(hour, val, 0) < minVal;
|
||||
if (key === SECOND) return toTimeNumber(hour, minute, val) < minVal;
|
||||
return false;
|
||||
};
|
||||
|
||||
const formatLabel = value => String(value).padStart(2, '0');
|
||||
|
||||
const buildItems = (count, key) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
value: i,
|
||||
label: formatLabel(i),
|
||||
disabled: isItemDisabled(key, i),
|
||||
}));
|
||||
|
||||
const buildHour12Items = () =>
|
||||
Array.from({ length: 12 }, (_, i) => {
|
||||
const display = i === 0 ? 12 : i;
|
||||
return {
|
||||
value: display,
|
||||
label: formatLabel(display),
|
||||
disabled: isItemDisabled(HOUR, convertTo24Hour(display, period.value)),
|
||||
};
|
||||
});
|
||||
|
||||
const periodItems = computed(() => [
|
||||
{ value: AM, label: AM, disabled: minBound.value?.h >= 12 },
|
||||
{ value: PM, label: PM, disabled: false },
|
||||
]);
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
key: is24HourFormat.value ? HOUR : HOUR_12,
|
||||
label: t('DATE_PICKER.HOUR'),
|
||||
items: is24HourFormat.value ? buildItems(24, HOUR) : buildHour12Items(),
|
||||
},
|
||||
{
|
||||
key: MINUTE,
|
||||
label: t('DATE_PICKER.MINUTE'),
|
||||
items: buildItems(60, MINUTE),
|
||||
},
|
||||
{
|
||||
key: SECOND,
|
||||
label: t('DATE_PICKER.SECOND'),
|
||||
items: buildItems(60, SECOND),
|
||||
},
|
||||
{ key: PERIOD, label: '', items: periodItems.value },
|
||||
]);
|
||||
|
||||
const findColumn = key => columns.value.find(c => c.key === key);
|
||||
|
||||
const findNearestEnabled = (items, index) => {
|
||||
for (let d = 0; d < items.length; d += 1) {
|
||||
if (index + d < items.length && !items[index + d].disabled)
|
||||
return index + d;
|
||||
if (index - d >= 0 && !items[index - d].disabled) return index - d;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const getActiveIndex = key => {
|
||||
if (key === HOUR) return selectedTime.hour;
|
||||
if (key === HOUR_12) {
|
||||
const h = convertTo12Hour(selectedTime.hour);
|
||||
return h === 12 ? 0 : h;
|
||||
}
|
||||
if (key === PERIOD) return period.value === AM ? 0 : 1;
|
||||
return selectedTime[key];
|
||||
};
|
||||
|
||||
const getColumnTranslateY = key => {
|
||||
const idx = -(activeIndex[key] ?? 0) * ITEM_HEIGHT;
|
||||
return CENTER_ROW * ITEM_HEIGHT + idx + (dragOffset[key] ?? 0);
|
||||
};
|
||||
|
||||
const getMaxDragOffset = key => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return { maxDown: 0, maxUp: 0 };
|
||||
const currentIdx = activeIndex[key] ?? 0;
|
||||
const firstEnabled = column.items.findIndex(i => !i.disabled);
|
||||
const lastEnabled = column.items.findLastIndex(i => !i.disabled);
|
||||
if (firstEnabled === -1) return { maxDown: 0, maxUp: 0 };
|
||||
return {
|
||||
maxDown: (currentIdx - firstEnabled) * ITEM_HEIGHT,
|
||||
maxUp: (lastEnabled - currentIdx) * ITEM_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
const clampDragOffset = (key, rawOffset) => {
|
||||
const { maxDown, maxUp } = getMaxDragOffset(key);
|
||||
return Math.max(-maxUp, Math.min(maxDown, rawOffset));
|
||||
};
|
||||
|
||||
let lastEmittedSignature = '';
|
||||
const emitTimeValue = () => {
|
||||
const sig = `${selectedTime.hour}:${selectedTime.minute}:${selectedTime.second}`;
|
||||
if (sig === lastEmittedSignature) return;
|
||||
lastEmittedSignature = sig;
|
||||
model.value = { ...selectedTime };
|
||||
};
|
||||
|
||||
const applySelection = (key, item) => {
|
||||
if (key === HOUR_12) {
|
||||
selectedTime.hour = convertTo24Hour(item.value, period.value);
|
||||
} else if (key === PERIOD) {
|
||||
selectedTime.hour = convertTo24Hour(
|
||||
convertTo12Hour(selectedTime.hour),
|
||||
item.value
|
||||
);
|
||||
} else {
|
||||
selectedTime[key] = item.value;
|
||||
}
|
||||
emitTimeValue();
|
||||
};
|
||||
|
||||
const selectIndex = (key, targetIndex) => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return;
|
||||
const clamped = Math.max(0, Math.min(targetIndex, column.items.length - 1));
|
||||
const resolved = column.items[clamped]?.disabled
|
||||
? findNearestEnabled(column.items, clamped)
|
||||
: clamped;
|
||||
activeIndex[key] = resolved;
|
||||
const item = column.items[resolved];
|
||||
if (item) applySelection(key, item);
|
||||
};
|
||||
|
||||
const selectByValue = (key, value) => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return;
|
||||
const idx = column.items.findIndex(i => i.value === value && !i.disabled);
|
||||
if (idx !== -1) selectIndex(key, idx);
|
||||
};
|
||||
|
||||
const snapToNearest = key => {
|
||||
const offset = dragOffset[key] ?? 0;
|
||||
const steps = Math.round(-offset / ITEM_HEIGHT);
|
||||
dragOffset[key] = 0;
|
||||
interactionMode[key] = null;
|
||||
if (steps !== 0) selectIndex(key, (activeIndex[key] ?? 0) + steps);
|
||||
};
|
||||
|
||||
const isItemSelected = (key, val) => {
|
||||
if (key === HOUR_12) return convertTo12Hour(selectedTime.hour) === val;
|
||||
if (key === PERIOD) return period.value === val;
|
||||
return selectedTime[key] === val;
|
||||
};
|
||||
|
||||
const isColumnHidden = key => key === PERIOD && !showPeriod.value;
|
||||
|
||||
const onWheelScroll = (key, event) => {
|
||||
event.preventDefault();
|
||||
interactionMode[key] = 'wheel';
|
||||
dragOffset[key] = clampDragOffset(key, (dragOffset[key] ?? 0) - event.deltaY);
|
||||
clearTimeout(snapTimers[key]);
|
||||
snapTimers[key] = setTimeout(() => snapToNearest(key), SNAP_DELAY);
|
||||
};
|
||||
|
||||
const onTouchStart = (key, event) => {
|
||||
interactionMode[key] = 'touch';
|
||||
touchStartY[key] = event.touches[0].clientY;
|
||||
dragOffset[key] = 0;
|
||||
};
|
||||
|
||||
const onTouchMove = (key, event) => {
|
||||
event.preventDefault();
|
||||
dragOffset[key] = clampDragOffset(
|
||||
key,
|
||||
event.touches[0].clientY - touchStartY[key]
|
||||
);
|
||||
};
|
||||
|
||||
const onTouchEnd = key => snapToNearest(key);
|
||||
|
||||
const visibleColumnKeys = computed(() =>
|
||||
columns.value.filter(col => !isColumnHidden(col.key)).map(col => col.key)
|
||||
);
|
||||
|
||||
const onKeyDown = (key, event) => {
|
||||
const { key: pressed, shiftKey } = event;
|
||||
if (pressed === 'ArrowUp' || pressed === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
selectIndex(
|
||||
key,
|
||||
(activeIndex[key] ?? 0) + (pressed === 'ArrowDown' ? 1 : -1)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pressed === 'Tab') {
|
||||
const keys = visibleColumnKeys.value;
|
||||
const next = keys.indexOf(key) + (shiftKey ? -1 : 1);
|
||||
if (next >= 0 && next < keys.length) {
|
||||
event.preventDefault();
|
||||
columnRefs[keys[next]]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncAllColumns = () => {
|
||||
columns.value.forEach(col => {
|
||||
activeIndex[col.key] = getActiveIndex(col.key);
|
||||
});
|
||||
};
|
||||
|
||||
const formatTabs = [
|
||||
{ label: t('DATE_PICKER.FORMAT_24H'), value: H24 },
|
||||
{ label: t('DATE_PICKER.FORMAT_12H'), value: H12 },
|
||||
];
|
||||
const activeFormatTab = computed(() => (is24HourFormat.value ? 0 : 1));
|
||||
|
||||
const onFormatChange = tab => {
|
||||
is24HourFormat.value = tab.value === H24;
|
||||
syncAllColumns();
|
||||
};
|
||||
|
||||
watch(model, v => {
|
||||
selectedTime.hour = v.hour;
|
||||
selectedTime.minute = v.minute;
|
||||
selectedTime.second = v.second ?? 0;
|
||||
syncAllColumns();
|
||||
});
|
||||
|
||||
onMounted(syncAllColumns);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center py-2 w-full">
|
||||
<div class="mb-4">
|
||||
<TabBar
|
||||
:tabs="formatTabs"
|
||||
:initial-active-tab="activeFormatTab"
|
||||
@tab-changed="onFormatChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center z-[1] mb-1">
|
||||
<template v-for="(col, colIdx) in columns" :key="`label-${col.key}`">
|
||||
<span
|
||||
class="text-center text-xs font-medium text-n-slate-11 transition-all duration-300 ease-in-out overflow-hidden"
|
||||
:class="
|
||||
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
|
||||
"
|
||||
>
|
||||
{{ col.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="colIdx < columns.length - 1"
|
||||
class="text-lg font-520 text-transparent transition-all duration-300 ease-in-out overflow-hidden"
|
||||
:class="
|
||||
isColumnHidden(columns[colIdx + 1]?.key)
|
||||
? 'w-0 opacity-0'
|
||||
: 'opacity-100'
|
||||
"
|
||||
>
|
||||
{{ SEPARATOR }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
class="absolute inset-x-6 h-10 rounded-2xl bg-n-solid-active outline outline-1 -outline-offset-1 outline-n-weak pointer-events-none shadow-inner"
|
||||
:style="{ top: `${CENTER_ROW * ITEM_HEIGHT}px` }"
|
||||
/>
|
||||
<div class="flex items-center justify-center z-[1] relative">
|
||||
<template v-for="(col, colIdx) in columns" :key="col.key">
|
||||
<div
|
||||
:ref="
|
||||
el => {
|
||||
if (el) columnRefs[col.key] = el;
|
||||
}
|
||||
"
|
||||
:tabindex="isColumnHidden(col.key) ? -1 : 0"
|
||||
class="time-wheel relative overflow-hidden transition-all duration-300 ease-in-out outline-none"
|
||||
:class="
|
||||
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
|
||||
"
|
||||
:style="{ height: `${VISIBLE_ROWS * ITEM_HEIGHT}px` }"
|
||||
@wheel.prevent="onWheelScroll(col.key, $event)"
|
||||
@touchstart="onTouchStart(col.key, $event)"
|
||||
@touchmove.prevent="onTouchMove(col.key, $event)"
|
||||
@touchend="onTouchEnd(col.key)"
|
||||
@keydown="onKeyDown(col.key, $event)"
|
||||
@focus="focusedColumn = col.key"
|
||||
@blur="focusedColumn = null"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center"
|
||||
:class="{
|
||||
'transition-transform duration-200 ease-out':
|
||||
!interactionMode[col.key],
|
||||
'transition-transform duration-100 ease-out':
|
||||
interactionMode[col.key] === 'wheel',
|
||||
}"
|
||||
:style="{
|
||||
transform: `translateY(${getColumnTranslateY(col.key)}px)`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="item in col.items"
|
||||
:key="item.value"
|
||||
:disabled="item.disabled"
|
||||
tabindex="-1"
|
||||
class="flex items-center justify-center w-14 shrink-0 text-base font-semibold transition-colors outline-none"
|
||||
:style="{ height: `${ITEM_HEIGHT}px` }"
|
||||
:class="[
|
||||
isItemSelected(col.key, item.value)
|
||||
? 'text-n-slate-12'
|
||||
: 'text-n-slate-9',
|
||||
item.disabled
|
||||
? 'opacity-20 cursor-not-allowed'
|
||||
: 'cursor-pointer',
|
||||
isItemSelected(col.key, item.value) &&
|
||||
focusedColumn === col.key
|
||||
? 'outline outline-1 outline-n-brand -outline-offset-4 rounded-xl'
|
||||
: '',
|
||||
]"
|
||||
@click="selectByValue(col.key, item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="colIdx < columns.length - 1 && col.key !== SECOND"
|
||||
class="text-lg font-520 text-n-slate-11"
|
||||
>
|
||||
{{ SEPARATOR }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.time-wheel {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.3) 15%,
|
||||
rgba(0, 0, 0, 0.7) 30%,
|
||||
black 40%,
|
||||
black 60%,
|
||||
rgba(0, 0, 0, 0.7) 70%,
|
||||
rgba(0, 0, 0, 0.3) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.3) 15%,
|
||||
rgba(0, 0, 0, 0.7) 30%,
|
||||
black 40%,
|
||||
black 60%,
|
||||
rgba(0, 0, 0, 0.7) 70%,
|
||||
rgba(0, 0, 0, 0.3) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
+18
@@ -81,6 +81,24 @@ export const CALENDAR_PERIODS = {
|
||||
YEAR: 'year',
|
||||
};
|
||||
|
||||
export const TIME_COLUMNS = {
|
||||
HOUR: 'hour',
|
||||
HOUR_12: 'hour12',
|
||||
MINUTE: 'minute',
|
||||
SECOND: 'second',
|
||||
PERIOD: 'period',
|
||||
};
|
||||
|
||||
export const TIME_PERIODS = {
|
||||
AM: 'AM',
|
||||
PM: 'PM',
|
||||
};
|
||||
|
||||
export const TIME_FORMATS = {
|
||||
H24: '24h',
|
||||
H12: '12h',
|
||||
};
|
||||
|
||||
// Utility functions for date operations
|
||||
export const monthName = currentDate => format(currentDate, 'MMMM');
|
||||
export const yearName = currentDate => format(currentDate, 'yyyy');
|
||||
@@ -28,7 +28,7 @@ const props = defineProps({
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
@@ -113,9 +113,6 @@ watch(
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@execute-copilot-action="
|
||||
(...args) => emit('executeCopilotAction', ...args)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount || slots.actions"
|
||||
|
||||
-1
@@ -26,7 +26,6 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
+8
-12
@@ -58,22 +58,18 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
|
||||
-12
@@ -98,17 +98,6 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
deleteCategory(categoryData);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderCategories = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('categories/reorder', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -133,7 +122,6 @@ const reorderCategories = async reorderedGroup => {
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
|
||||
+16
-60
@@ -1,22 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
@@ -25,57 +17,21 @@ const handleClick = slug => {
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedPositions = localCategories.value
|
||||
.map((category, index) => category.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const reorderedGroup = localCategories.value.reduce(
|
||||
(obj, category, index) => {
|
||||
obj[category.id] = sortedPositions[index];
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
emit('reorder', reorderedGroup);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.categories,
|
||||
newCategories => {
|
||||
localCategories.value = newCategories;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Draggable
|
||||
v-model="localCategories"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
role="list"
|
||||
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none">
|
||||
<CategoryCard
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@click="handleClick(element.slug)"
|
||||
@action="handleAction($event, element)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
createContactSearcher,
|
||||
searchContacts,
|
||||
createNewContact,
|
||||
fetchContactableInboxes,
|
||||
processContactableInboxes,
|
||||
@@ -39,7 +39,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
@@ -108,17 +107,15 @@ const onContactSearch = debounce(
|
||||
isSearching.value = true;
|
||||
contacts.value = [];
|
||||
try {
|
||||
const results = await searchContacts(query);
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (results === null) return;
|
||||
contacts.value = results;
|
||||
contacts.value = await searchContacts(query);
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
isSearching.value = false;
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
},
|
||||
400,
|
||||
300,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -141,7 +138,6 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
contact = rest;
|
||||
}
|
||||
selectedContact.value = contact;
|
||||
contacts.value = [];
|
||||
if (contact?.id) {
|
||||
isFetchingInboxes.value = true;
|
||||
try {
|
||||
@@ -277,7 +273,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
{ ignore: ['div.ProseMirror-prompt'] },
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
|
||||
+3
-46
@@ -15,9 +15,6 @@ import {
|
||||
prepareWhatsAppMessagePayload,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
||||
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import ContactSelector from './ContactSelector.vue';
|
||||
import InboxSelector from './InboxSelector.vue';
|
||||
import EmailOptions from './EmailOptions.vue';
|
||||
@@ -25,7 +22,6 @@ import MessageEditor from './MessageEditor.vue';
|
||||
import ActionButtons from './ActionButtons.vue';
|
||||
import InboxEmptyState from './InboxEmptyState.vue';
|
||||
import AttachmentPreviews from './AttachmentPreviews.vue';
|
||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, default: () => [] },
|
||||
@@ -46,7 +42,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'resetContactSearch',
|
||||
'discard',
|
||||
'updateSelectedContact',
|
||||
'updateTargetInbox',
|
||||
@@ -56,8 +51,6 @@ const emit = defineEmits([
|
||||
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const copilot = useCopilotReply();
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxesDropdown = ref(false);
|
||||
const showCcEmailsDropdown = ref(false);
|
||||
@@ -164,7 +157,7 @@ const isAnyDropdownActive = computed(() => {
|
||||
});
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = value.trim().length > 1;
|
||||
showContactsDropdown.value = true;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
@@ -179,16 +172,12 @@ const handleDropdownUpdate = (type, value) => {
|
||||
};
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showBccEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showCcEmailsDropdown.value = value.trim().length >= 2;
|
||||
showCcEmailsDropdown.value = true;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showCcEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showBccEmailsDropdown.value = value.trim().length >= 2;
|
||||
showBccEmailsDropdown.value = true;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
@@ -207,7 +196,6 @@ const stripMessageFormatting = channelType => {
|
||||
|
||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
|
||||
// Strip unsupported formatting when changing the target inbox
|
||||
if (channelType) {
|
||||
@@ -234,7 +222,6 @@ const removeSignatureFromMessage = () => {
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
@@ -244,7 +231,6 @@ const removeTargetInbox = value => {
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
@@ -276,7 +262,6 @@ const handleAttachFile = files => {
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
copilot.reset(false);
|
||||
Object.assign(state, {
|
||||
message: '',
|
||||
subject: '',
|
||||
@@ -339,24 +324,6 @@ const shouldShowMessageEditor = computed(() => {
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
|
||||
|
||||
const onSubmitCopilotReply = () => {
|
||||
const acceptedMessage = copilot.accept();
|
||||
state.message = acceptedMessage;
|
||||
};
|
||||
|
||||
useKeyboardEvents({
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
|
||||
onSubmitCopilotReply();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -387,7 +354,6 @@ useKeyboardEvents({
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:has-errors="validationStates.isInboxInvalid"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
@update-inbox="removeTargetInbox"
|
||||
@toggle-dropdown="showInboxesDropdown = $event"
|
||||
@handle-inbox-action="handleInboxAction"
|
||||
@@ -416,7 +382,6 @@ useKeyboardEvents({
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
:copilot="copilot"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
@@ -426,15 +391,7 @@ useKeyboardEvents({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="isCopilotActive"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
class="h-[3.25rem] !px-4 !py-2"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
<ActionButtons
|
||||
v-else
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
|
||||
@@ -99,6 +99,7 @@ const inputClass = computed(() => {
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@focus="emit('updateDropdown', 'cc', true)"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||
@update:model-value="handleCcUpdate"
|
||||
@@ -132,6 +133,7 @@ const inputClass = computed(() => {
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@focus="emit('updateDropdown', 'bcc', true)"
|
||||
@input="emit('searchBccEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||
@update:model-value="handleBccUpdate"
|
||||
|
||||
+1
-7
@@ -1,15 +1,9 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
|
||||
>
|
||||
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
targetInbox: {
|
||||
@@ -29,10 +28,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFetchingInboxes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -76,9 +71,7 @@ const targetInboxLabel = computed(() => {
|
||||
v-on-click-outside="() => emit('toggleDropdown', false)"
|
||||
class="relative flex items-center h-7"
|
||||
>
|
||||
<Spinner v-if="isFetchingInboxes" :size="16" />
|
||||
<Button
|
||||
v-else
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
|
||||
+21
-61
@@ -3,7 +3,6 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
@@ -11,7 +10,6 @@ const props = defineProps({
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
copilot: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
@@ -22,67 +20,29 @@ const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
|
||||
|
||||
const executeCopilotAction = (action, data) => {
|
||||
if (props.copilot) {
|
||||
props.copilot.execute(action, data);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full px-4 py-4">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
|
||||
class="h-full"
|
||||
>
|
||||
<CopilotEditorSection
|
||||
v-if="isCopilotActive"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
class="!mb-0"
|
||||
@focus="() => {}"
|
||||
@blur="() => {}"
|
||||
@clear-selection="() => {}"
|
||||
@content-ready="copilot.setContentReady"
|
||||
@send="copilot.sendFollowUp"
|
||||
/>
|
||||
<Editor
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
enable-captain-tools
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+12
-35
@@ -177,42 +177,19 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
};
|
||||
|
||||
// API Calls
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
export const searchContacts = async query => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
if (!trimmed) return [];
|
||||
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
|
||||
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
|
||||
return [];
|
||||
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
} catch (error) {
|
||||
// Return null for aborted requests so callers can distinguish
|
||||
// "request was cancelled" from "no results found"
|
||||
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed);
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
};
|
||||
|
||||
export const createNewContact = async input => {
|
||||
|
||||
+7
-97
@@ -337,12 +337,7 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
describe('createContactSearcher', () => {
|
||||
let searchContacts;
|
||||
beforeEach(() => {
|
||||
searchContacts = helpers.createContactSearcher();
|
||||
});
|
||||
|
||||
describe('searchContacts', () => {
|
||||
it('searches contacts and returns camelCase results', async () => {
|
||||
const mockPayload = [
|
||||
{
|
||||
@@ -358,7 +353,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await searchContacts('john');
|
||||
const result = await helpers.searchContacts('john');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -370,56 +365,7 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array for queries shorter than 2 characters', async () => {
|
||||
const result = await searchContacts('j');
|
||||
expect(result).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array for empty or whitespace-only queries', async () => {
|
||||
expect(await searchContacts('')).toEqual([]);
|
||||
expect(await searchContacts(' ')).toEqual([]);
|
||||
expect(await searchContacts(null)).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts previous in-flight request when a new search starts', async () => {
|
||||
const mockPayload = [
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
let resolveFirst;
|
||||
const firstCall = new Promise(resolve => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
ContactAPI.search
|
||||
.mockReturnValueOnce(firstCall)
|
||||
.mockResolvedValueOnce({ data: { payload: mockPayload } });
|
||||
|
||||
// Start first search (will hang)
|
||||
const first = searchContacts('alpha');
|
||||
// Start second search (aborts first)
|
||||
const second = searchContacts('beta');
|
||||
|
||||
// Resolve the first call with CanceledError (simulating axios abort)
|
||||
const canceledError = new Error('canceled');
|
||||
canceledError.name = 'CanceledError';
|
||||
resolveFirst(Promise.reject(canceledError));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toBeNull();
|
||||
expect(secondResult).toEqual([
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
|
||||
]);
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
});
|
||||
|
||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||
@@ -451,7 +397,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await searchContacts('john');
|
||||
const result = await helpers.searchContacts('john');
|
||||
|
||||
// Should only return contacts with either email or phone number
|
||||
expect(result).toEqual([
|
||||
@@ -471,13 +417,7 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
@@ -485,7 +425,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: [] },
|
||||
});
|
||||
|
||||
const result = await searchContacts('nonexistent');
|
||||
const result = await helpers.searchContacts('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -512,7 +452,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await searchContacts('test');
|
||||
const result = await helpers.searchContacts('test');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -534,36 +474,6 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContactSearcher isolation', () => {
|
||||
it('creates isolated searcher instances that do not cancel each other', async () => {
|
||||
const searcherA = helpers.createContactSearcher();
|
||||
const searcherB = helpers.createContactSearcher();
|
||||
|
||||
const payloadA = [
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
|
||||
];
|
||||
const payloadB = [
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
ContactAPI.search
|
||||
.mockResolvedValueOnce({ data: { payload: payloadA } })
|
||||
.mockResolvedValueOnce({ data: { payload: payloadB } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
searcherA('alice'),
|
||||
searcherB('bob'),
|
||||
]);
|
||||
|
||||
expect(resultA).toEqual([
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
|
||||
]);
|
||||
expect(resultB).toEqual([
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewContact', () => {
|
||||
it('creates new contact with capitalized name', async () => {
|
||||
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
|
||||
@@ -18,18 +18,10 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -70,7 +62,6 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -80,12 +71,6 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,7 +104,7 @@ const handleEnterKey = event => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
@@ -29,12 +29,6 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -49,7 +43,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -96,17 +96,6 @@ const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Only close if the close event originated from this dialog,
|
||||
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
|
||||
const handleDialogClose = e => e.target === dialogRef.value && close();
|
||||
|
||||
// Only close on click-outside if this dialog is the topmost one.
|
||||
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
|
||||
const handleClickOutside = () => {
|
||||
const dialogs = document.querySelectorAll('dialog[open]');
|
||||
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
@@ -118,15 +107,15 @@ defineExpose({ open, close });
|
||||
<TeleportWithDirection to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl focus-within:outline-none focus-within:outline-0"
|
||||
:class="[
|
||||
maxWidthClass,
|
||||
positionClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
]"
|
||||
@close.prevent="handleDialogClose"
|
||||
@close="close"
|
||||
>
|
||||
<OnClickOutside @trigger="handleClickOutside">
|
||||
<OnClickOutside @trigger="close">
|
||||
<form
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
|
||||
@@ -81,7 +81,6 @@ const isDelivered = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
|
||||
@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
? !tags.value.length
|
||||
? isFocused.value && !tags.value.length
|
||||
: isFocused.value || !tags.value.length
|
||||
);
|
||||
|
||||
|
||||
@@ -1,77 +1,53 @@
|
||||
<script>
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DatePicker from 'dashboard/components-next/DatePicker/DatePicker.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DatePicker,
|
||||
NextButton,
|
||||
},
|
||||
emits: ['close', 'chooseTime'],
|
||||
const emit = defineEmits(['chooseTime', 'close']);
|
||||
|
||||
data() {
|
||||
return {
|
||||
snoozeTime: null,
|
||||
lang: {
|
||||
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
yearFormat: 'YYYY',
|
||||
monthFormat: 'MMMM',
|
||||
},
|
||||
};
|
||||
},
|
||||
const dialogRef = ref(null);
|
||||
const datePickerRef = ref(null);
|
||||
const today = new Date();
|
||||
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
chooseTime() {
|
||||
this.$emit('chooseTime', this.snoozeTime);
|
||||
},
|
||||
disabledDate(date) {
|
||||
// Disable all the previous dates
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return date < yesterday;
|
||||
},
|
||||
disabledTime(date) {
|
||||
// Allow only time after 1 hour
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1);
|
||||
return date < now;
|
||||
},
|
||||
},
|
||||
const onApply = dateTime => {
|
||||
dialogRef.value?.close();
|
||||
emit('chooseTime', dateTime);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
datePickerRef.value?.resetState();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
|
||||
<form
|
||||
class="modal-content w-full pt-2 px-5 pb-6"
|
||||
@submit.prevent="chooseTime"
|
||||
>
|
||||
<DatePicker
|
||||
v-model:value="snoozeTime"
|
||||
type="datetime"
|
||||
inline
|
||||
input-class="mx-input "
|
||||
:lang="lang"
|
||||
:disabled-date="disabledDate"
|
||||
:disabled-time="disabledTime"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('CONVERSATION.CUSTOM_SNOOZE.APPLY')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
width="2xl"
|
||||
@close="onDialogClose"
|
||||
>
|
||||
<DatePicker
|
||||
ref="datePickerRef"
|
||||
:min-date="today"
|
||||
@apply="onApply"
|
||||
@clear="onClear"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,6 @@ import Modal from './Modal.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Tabs from './ui/Tabs/Tabs.vue';
|
||||
import TabsItem from './ui/Tabs/TabsItem.vue';
|
||||
import DatePicker from './ui/DatePicker/DatePicker.vue';
|
||||
|
||||
const WootUIKit = {
|
||||
Code,
|
||||
@@ -37,7 +36,6 @@ const WootUIKit = {
|
||||
Spinner,
|
||||
Tabs,
|
||||
TabsItem,
|
||||
DatePicker,
|
||||
install(Vue) {
|
||||
const keys = Object.keys(this);
|
||||
keys.pop(); // remove 'install' from keys
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<script>
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
export default {
|
||||
components: { DatePicker },
|
||||
props: {
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
methods: {
|
||||
handleChange(value) {
|
||||
this.$emit('change', value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="date-picker">
|
||||
<DatePicker
|
||||
range
|
||||
confirm
|
||||
:clearable="false"
|
||||
:editable="false"
|
||||
:confirm-text="confirmText"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script>
|
||||
import addDays from 'date-fns/addDays';
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
export default {
|
||||
components: { DatePicker },
|
||||
props: {
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: Date,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
|
||||
methods: {
|
||||
handleChange(value) {
|
||||
this.$emit('change', value);
|
||||
},
|
||||
disableBeforeToday(date) {
|
||||
const yesterdayDate = addDays(new Date(), -1);
|
||||
return date < yesterdayDate;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="date-picker">
|
||||
<DatePicker
|
||||
type="datetime"
|
||||
confirm
|
||||
:clearable="false"
|
||||
:editable="false"
|
||||
:confirm-text="confirmText"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
:disabled-date="disableBeforeToday"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -107,8 +107,7 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -10,23 +10,11 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorMenuPopover: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['executeCopilotAction']);
|
||||
@@ -37,13 +25,6 @@ const { draftMessage } = useCaptain();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// When editorContent prop is passed, use it exclusively (even if empty)
|
||||
// This ensures each editor instance shows menu items based on its own content
|
||||
// Falls back to global draftMessage only when editorContent is not provided
|
||||
const effectiveContent = computed(() =>
|
||||
props.editorContent !== undefined ? props.editorContent : draftMessage.value
|
||||
);
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
@@ -61,9 +42,8 @@ const menuItems = computed(() => {
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
} else if (
|
||||
props.conversationId &&
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
effectiveContent.value
|
||||
draftMessage.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
@@ -72,7 +52,7 @@ const menuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (effectiveContent.value) {
|
||||
if (draftMessage.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
@@ -125,7 +105,7 @@ const menuItems = computed(() => {
|
||||
|
||||
const generalMenuItems = computed(() => {
|
||||
const items = [];
|
||||
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
@@ -133,10 +113,7 @@ const generalMenuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
props.conversationId &&
|
||||
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
|
||||
) {
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
@@ -199,8 +176,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
|
||||
<DropdownBody
|
||||
ref="menuRef"
|
||||
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
|
||||
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
|
||||
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
|
||||
:class="{ 'selection-menu': hasSelection }"
|
||||
:style="hasSelection ? selectionMenuStyle : {}"
|
||||
>
|
||||
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
|
||||
<div
|
||||
|
||||
@@ -202,11 +202,6 @@ const editorRoot = useTemplateRef('editorRoot');
|
||||
const imageUpload = useTemplateRef('imageUpload');
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
const isEditorMenuPopover = computed(
|
||||
() =>
|
||||
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
|
||||
);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
if (actionKey === 'improve_selection' && editorView?.state) {
|
||||
const { from, to } = editorView.state.selection;
|
||||
@@ -216,7 +211,7 @@ const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', 'improve', selectedText);
|
||||
}
|
||||
} else {
|
||||
emit('executeCopilotAction', actionKey, props.modelValue);
|
||||
emit('executeCopilotAction', actionKey);
|
||||
}
|
||||
|
||||
showSelectionMenu.value = false;
|
||||
@@ -489,7 +484,6 @@ function setToolbarPosition() {
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
if (!isEditorMenuPopover.value) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
||||
@@ -872,12 +866,8 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
v-if="showSelectionMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:is-editor-menu-popover="isEditorMenuPopover"
|
||||
:editor-content="modelValue"
|
||||
:conversation-id="conversationId"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
class="copilot-editor-menu"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
<input
|
||||
@@ -978,15 +968,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-backdrop::backdrop {
|
||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
|
||||
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
|
||||
h5 {
|
||||
@apply text-n-slate-12 mb-3;
|
||||
@apply text-n-slate-12 mb-1.5;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
@@ -1040,17 +1026,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||
}
|
||||
|
||||
// Default copilot menu position (non-popover editors like components-next/Editor)
|
||||
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
|
||||
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
|
||||
top: 1.5rem !important;
|
||||
|
||||
[dir='rtl'] & {
|
||||
left: auto !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Float editor menu
|
||||
.popover-prosemirror-menu {
|
||||
position: relative;
|
||||
|
||||
@@ -326,4 +326,26 @@ export default {
|
||||
max-height: 7.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
|
||||
h5 {
|
||||
@apply text-n-slate-12 mb-1.5;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
button {
|
||||
@apply h-8 px-3;
|
||||
|
||||
&[type='submit'] {
|
||||
@apply bg-n-brand text-white hover:bg-n-brand/90;
|
||||
}
|
||||
|
||||
&[type='button'] {
|
||||
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,10 +49,6 @@ export default {
|
||||
type: Number,
|
||||
default: () => 0,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
@@ -77,8 +73,8 @@ export default {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const showCopilotMenu = ref(false);
|
||||
|
||||
const handleCopilotAction = (actionKey, data) => {
|
||||
emit('executeCopilotAction', actionKey, data || props.editorContent);
|
||||
const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
@@ -178,8 +174,6 @@ export default {
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
:editor-content="editorContent"
|
||||
:conversation-id="conversationId"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ const onSend = () => {
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
|
||||
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
|
||||
@@ -162,8 +162,7 @@ export default {
|
||||
this.inReplyTo?.id &&
|
||||
!this.isPrivate &&
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
||||
!this.is360DialogWhatsAppChannel &&
|
||||
!this.copilot.isActive.value
|
||||
!this.is360DialogWhatsAppChannel
|
||||
);
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
@@ -1246,7 +1245,6 @@ export default {
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
@@ -1465,7 +1463,7 @@ export default {
|
||||
}
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-3 -mt-px;
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
|
||||
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
|
||||
+2
-15
@@ -65,7 +65,6 @@ export default {
|
||||
showLabelActions: false,
|
||||
showTeamsList: false,
|
||||
popoverPositions: {},
|
||||
showCustomTimeSnoozeModal: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -99,7 +98,7 @@ export default {
|
||||
methods: {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
this.$refs.snoozeModalRef?.open();
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.updateConversations('snoozed', snoozeType);
|
||||
} else {
|
||||
@@ -113,14 +112,10 @@ export default {
|
||||
this.updateConversations('resolved', null);
|
||||
},
|
||||
customSnoozeTime(customSnoozedTime) {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
if (customSnoozedTime) {
|
||||
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
},
|
||||
selectAll(e) {
|
||||
this.$emit('selectAllConversations', e.target.checked);
|
||||
},
|
||||
@@ -251,15 +246,7 @@ export default {
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
<CustomSnoozeModal ref="snoozeModalRef" @choose-time="customSnoozeTime" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'agents/getAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
|
||||
@@ -119,10 +119,6 @@ export const COPILOT_EVENTS = Object.freeze({
|
||||
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
|
||||
});
|
||||
|
||||
export const SNOOZE_EVENTS = Object.freeze({
|
||||
NLP_SNOOZE_APPLIED: 'Applied snooze via text-to-date input',
|
||||
});
|
||||
|
||||
export const GENERAL_EVENTS = Object.freeze({
|
||||
COMMAND_BAR: 'Used commandbar',
|
||||
});
|
||||
|
||||
@@ -93,23 +93,12 @@ const EN_DEFAULTS = {
|
||||
TWENTY: 'twenty',
|
||||
THIRTY: 'thirty',
|
||||
},
|
||||
ORDINALS: {
|
||||
FIRST: 'first',
|
||||
SECOND: 'second',
|
||||
THIRD: 'third',
|
||||
FOURTH: 'fourth',
|
||||
FIFTH: 'fifth',
|
||||
},
|
||||
MERIDIEM: { AM: 'am', PM: 'pm' },
|
||||
HALF: 'half',
|
||||
NEXT: 'next',
|
||||
THIS: 'this',
|
||||
AT: 'at',
|
||||
IN: 'in',
|
||||
OF: 'of',
|
||||
AFTER: 'after',
|
||||
WEEK: 'week',
|
||||
DAY: 'day',
|
||||
FROM_NOW: 'from now',
|
||||
NEXT_YEAR: 'next year',
|
||||
};
|
||||
@@ -132,13 +121,6 @@ const STRUCTURAL_WORDS = [
|
||||
'eod',
|
||||
'am',
|
||||
'pm',
|
||||
'week',
|
||||
'day',
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
'fourth',
|
||||
'fifth',
|
||||
];
|
||||
|
||||
const ENGLISH_VOCAB = new Set([
|
||||
@@ -152,6 +134,8 @@ const ENGLISH_VOCAB = new Set([
|
||||
...EN_MONTHS_LIST,
|
||||
...STRUCTURAL_WORDS,
|
||||
]);
|
||||
const ENGLISH_VOCAB_LIST = [...ENGLISH_VOCAB];
|
||||
const hasVocabPrefix = w => ENGLISH_VOCAB_LIST.some(v => v.startsWith(w));
|
||||
|
||||
// ─── Regex for token replacement ────────────────────────────────────────────
|
||||
|
||||
@@ -177,7 +161,6 @@ const CACHE_SECTIONS = [
|
||||
'RELATIVE',
|
||||
'TIME_OF_DAY',
|
||||
'WORD_NUMBERS',
|
||||
'ORDINALS',
|
||||
'MERIDIEM',
|
||||
];
|
||||
const SINGLE_KEYS = [
|
||||
@@ -186,10 +169,6 @@ const SINGLE_KEYS = [
|
||||
'THIS',
|
||||
'AT',
|
||||
'IN',
|
||||
'OF',
|
||||
'AFTER',
|
||||
'WEEK',
|
||||
'DAY',
|
||||
'FROM_NOW',
|
||||
'NEXT_YEAR',
|
||||
];
|
||||
@@ -362,32 +341,25 @@ export const generateDateSuggestions = (
|
||||
? buildReplacementPairs(translations, locale)
|
||||
: [];
|
||||
|
||||
// Try English parse first, then translated parse if we have locale pairs.
|
||||
// This avoids the problem where a single overlapping word (e.g. "in" in German)
|
||||
// would skip token translation entirely.
|
||||
// Try English first — if user types English in a non-English locale, skip translation
|
||||
const directParse = parseDateFromText(stripped, referenceDate);
|
||||
const looksEnglish =
|
||||
directParse ||
|
||||
stripped
|
||||
.split(/\s+/)
|
||||
.filter(w => !/^\d/.test(w))
|
||||
.some(w => ENGLISH_VOCAB.has(w) || (w.length >= 2 && hasVocabPrefix(w)));
|
||||
const useEnglish = !pairs.length || looksEnglish;
|
||||
|
||||
const translated = pairs.length ? replaceTokens(normalized, pairs) : null;
|
||||
const translatedParse =
|
||||
translated && translated !== stripped
|
||||
? parseDateFromText(translated, referenceDate)
|
||||
: null;
|
||||
|
||||
// Prefer direct English parse; fall back to translated parse
|
||||
const useTranslated = !directParse && !!translatedParse;
|
||||
const englishInput = useTranslated ? translated : stripped;
|
||||
const englishInput = useEnglish ? stripped : replaceTokens(normalized, pairs);
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
const exact = directParse || translatedParse;
|
||||
const exact = directParse || parseDateFromText(englishInput, referenceDate);
|
||||
if (exact) {
|
||||
seen.add(exact.unix);
|
||||
const exactLabel =
|
||||
useTranslated && pairs.length
|
||||
? reverseTokens(englishInput, pairs)
|
||||
: englishInput;
|
||||
results.push({ label: exactLabel, query: englishInput, ...exact });
|
||||
results.push({ label: normalized, query: englishInput, ...exact });
|
||||
}
|
||||
|
||||
buildSuggestionCandidates(englishInput).some(candidate => {
|
||||
@@ -396,7 +368,7 @@ export const generateDateSuggestions = (
|
||||
if (result && !seen.has(result.unix)) {
|
||||
seen.add(result.unix);
|
||||
const label =
|
||||
useTranslated && pairs.length
|
||||
!useEnglish && pairs.length
|
||||
? reverseTokens(candidate, pairs)
|
||||
: candidate;
|
||||
results.push({ label, query: candidate, ...result });
|
||||
|
||||
@@ -59,25 +59,6 @@ const UNIT_RE = `(${UNIT_NAMES})`;
|
||||
const TIME_SUFFIX_RE =
|
||||
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
|
||||
|
||||
const ORDINAL_MAP = {
|
||||
first: 1,
|
||||
second: 2,
|
||||
third: 3,
|
||||
fourth: 4,
|
||||
fifth: 5,
|
||||
sixth: 6,
|
||||
seventh: 7,
|
||||
eighth: 8,
|
||||
ninth: 9,
|
||||
tenth: 10,
|
||||
};
|
||||
const parseOrdinal = str => {
|
||||
if (ORDINAL_MAP[str]) return ORDINAL_MAP[str];
|
||||
return parseInt(str.replace(/(?:st|nd|rd|th)$/, ''), 10) || null;
|
||||
};
|
||||
const ORDINAL_WORDS = Object.keys(ORDINAL_MAP).join('|');
|
||||
const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
|
||||
|
||||
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
|
||||
|
||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||
@@ -98,7 +79,7 @@ const RELATIVE_DAY_AT_TIME_RE = new RegExp(
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
|
||||
);
|
||||
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
|
||||
`^(?:(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)|(?:same\\s+time|this\\s+time)\\s+(${RELATIVE_DAYS}))$`
|
||||
`^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$`
|
||||
);
|
||||
const NEXT_UNIT_RE = new RegExp(
|
||||
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
|
||||
@@ -147,10 +128,6 @@ const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
|
||||
// "april first week", "first week of april", "march 2nd day", "5th day of jan"
|
||||
const MONTH_ORDINAL_RE = new RegExp(
|
||||
`^(?:(${MONTH_NAMES})\\s+${ORDINAL_RE}\\s+(week|day)|${ORDINAL_RE}\\s+(week|day)\\s+of\\s+(${MONTH_NAMES}))${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const DAY_AFTER_TOMORROW_RE = new RegExp(
|
||||
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
@@ -163,17 +140,7 @@ const DURATION_AT_TIME_RE = new RegExp(
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
|
||||
const END_OF_NEXT_RE = /^end\s+of\s+(?:the\s+)?next\s+(week|month)$/;
|
||||
const START_OF_NEXT_RE =
|
||||
/^(?:beginning|start)\s+of\s+(?:the\s+)?next\s+(week|month)$/;
|
||||
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
|
||||
const EARLY_LATE_TOD_RE = new RegExp(
|
||||
`^(early|late)\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const ONE_AND_HALF_RE = new RegExp(
|
||||
`^(?:in\\s+)?(?:one\\s+and\\s+(?:a\\s+)?half|an?\\s+hour\\s+and\\s+(?:a\\s+)?half)(?:\\s+${UNIT_RE})?$`
|
||||
);
|
||||
const NEXT_BUSINESS_DAY_RE = /^next\s+(?:business|working)\s+day$/;
|
||||
|
||||
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
|
||||
const ISO_DATE_RE = new RegExp(
|
||||
@@ -188,6 +155,7 @@ const DASH_DATE_RE = new RegExp(
|
||||
const DOT_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const AMBIGUOUS_DATE_RES = [SLASH_DATE_RE, DASH_DATE_RE, DOT_DATE_RE];
|
||||
|
||||
// ─── Pattern Matchers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -209,13 +177,6 @@ const matchDuration = (text, now) => {
|
||||
: null;
|
||||
}
|
||||
|
||||
// "one and a half hours", "an hour and a half"
|
||||
const oneHalf = text.match(ONE_AND_HALF_RE);
|
||||
if (oneHalf) {
|
||||
const unit = UNIT_MAP[oneHalf[1]] || 'hours';
|
||||
return addFractionalSafe(now, unit, 1.5);
|
||||
}
|
||||
|
||||
const compound = text.match(COMPOUND_DURATION_RE);
|
||||
if (compound) {
|
||||
const a1 = parseNumber(compound[1]);
|
||||
@@ -321,7 +282,7 @@ const matchRelativeDay = (text, now) => {
|
||||
|
||||
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
|
||||
if (sameTimeMatch) {
|
||||
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]];
|
||||
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1]];
|
||||
if (offset <= 0) return null;
|
||||
return applyTimeToDate(
|
||||
add(startOfDay(now), { days: offset }),
|
||||
@@ -494,18 +455,6 @@ const matchTimeOfDay = (text, now) => {
|
||||
);
|
||||
}
|
||||
|
||||
// "early morning" → 7am, "late evening" → 21:00, "late night" → 23:00
|
||||
const earlyLate = text.match(EARLY_LATE_TOD_RE);
|
||||
if (earlyLate) {
|
||||
const tod = TIME_OF_DAY_MAP[earlyLate[2]];
|
||||
if (!tod) return null;
|
||||
const shift = earlyLate[1] === 'early' ? -1 : 2;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, tod.hours + shift, 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const match = text.match(TOD_PLAIN_RE);
|
||||
if (!match) return null;
|
||||
|
||||
@@ -570,41 +519,6 @@ const matchNamedDate = (text, now) => {
|
||||
return isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
// "april first week", "first week of april", "march 2nd day", etc.
|
||||
const mo = text.match(MONTH_ORDINAL_RE);
|
||||
if (mo) {
|
||||
// Groups: (1)month-A (2)ordinal-A (3)unit-A | (4)ordinal-B (5)unit-B (6)month-B (7)time
|
||||
const monthIdx = MONTH_MAP[mo[1] || mo[6]];
|
||||
const num = parseOrdinal(mo[2] || mo[4]);
|
||||
const unit = mo[3] || mo[5];
|
||||
const timeStr = mo[7];
|
||||
|
||||
if (!num || num < 1) return null;
|
||||
|
||||
if (unit === 'day') {
|
||||
if (num > 31) return null;
|
||||
return resolveAbsoluteDate(monthIdx, num, null, timeStr, now);
|
||||
}
|
||||
|
||||
// unit === 'week'
|
||||
if (num > 5) return null;
|
||||
const weekStartDay = (num - 1) * 7 + 1;
|
||||
let year = now.getFullYear();
|
||||
if (
|
||||
monthIdx < now.getMonth() ||
|
||||
(monthIdx === now.getMonth() && now.getDate() > weekStartDay)
|
||||
) {
|
||||
year += 1;
|
||||
}
|
||||
// Reject if weekStartDay overflows the month (e.g. feb fifth week = day 29 in non-leap)
|
||||
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
|
||||
if (weekStartDay > daysInMonth) return null;
|
||||
const d = new Date(year, monthIdx, weekStartDay);
|
||||
if (!isValid(d)) return null;
|
||||
const result = applyTimeOrDefault(d, timeStr);
|
||||
return result && isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -615,13 +529,11 @@ const buildDateWithOptionalTime = (year, month, day, timeStr) => {
|
||||
return applyTimeOrDefault(date, timeStr);
|
||||
};
|
||||
|
||||
// When both values are ≤ 12 (ambiguous), dayFirst controls the fallback:
|
||||
// dayFirst=false (slash M/D/Y) → month first
|
||||
// dayFirst=true (dash/dot D-M-Y, D.M.Y) → day first
|
||||
const disambiguateDayMonth = (a, b, dayFirst = false) => {
|
||||
// When both values are ≤ 12 (ambiguous), defaults to M/D (US format).
|
||||
const disambiguateDayMonth = (a, b) => {
|
||||
if (a > 12) return { day: a, month: b - 1 };
|
||||
if (b > 12) return { month: a - 1, day: b };
|
||||
return dayFirst ? { day: a, month: b - 1 } : { month: a - 1, day: b };
|
||||
return { month: a - 1, day: b };
|
||||
};
|
||||
|
||||
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
|
||||
@@ -640,20 +552,13 @@ const matchFormalDate = (text, now) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Slash = M/D/Y (US), Dash/Dot = D-M-Y / D.M.Y (European)
|
||||
const formats = [
|
||||
{ re: SLASH_DATE_RE, dayFirst: false },
|
||||
{ re: DASH_DATE_RE, dayFirst: true },
|
||||
{ re: DOT_DATE_RE, dayFirst: true },
|
||||
];
|
||||
let result = null;
|
||||
formats.some(({ re, dayFirst }) => {
|
||||
AMBIGUOUS_DATE_RES.some(re => {
|
||||
const m = text.match(re);
|
||||
if (!m) return false;
|
||||
const { month, day } = disambiguateDayMonth(
|
||||
parseInt(m[1], 10),
|
||||
parseInt(m[2], 10),
|
||||
dayFirst
|
||||
parseInt(m[2], 10)
|
||||
);
|
||||
result = ensureFuture(
|
||||
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
|
||||
@@ -683,41 +588,6 @@ const matchSpecial = (text, now) => {
|
||||
}
|
||||
}
|
||||
|
||||
// "end of next week", "end of next month"
|
||||
const eofNext = text.match(END_OF_NEXT_RE);
|
||||
if (eofNext) {
|
||||
if (eofNext[1] === 'week') {
|
||||
const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
|
||||
return applyTimeToDate(add(nextWeekStart, { days: 4 }), 17, 0);
|
||||
}
|
||||
if (eofNext[1] === 'month') {
|
||||
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "beginning of next week", "start of next month"
|
||||
const sofNext = text.match(START_OF_NEXT_RE);
|
||||
if (sofNext) {
|
||||
if (sofNext[1] === 'week') {
|
||||
return applyTimeToDate(
|
||||
startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }),
|
||||
9,
|
||||
0
|
||||
);
|
||||
}
|
||||
if (sofNext[1] === 'month') {
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return applyTimeToDate(nextMonth, 9, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "next business day", "next working day"
|
||||
if (NEXT_BUSINESS_DAY_RE.test(text)) {
|
||||
let d = add(startOfDay(now), { days: 1 });
|
||||
while (isSaturday(d) || isSunday(d)) d = add(d, { days: 1 });
|
||||
return applyTimeToDate(d, 9, 0);
|
||||
}
|
||||
|
||||
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
|
||||
|
||||
const weekendMatch = text.match(
|
||||
|
||||
@@ -34,7 +34,8 @@ const ALL_SUGGESTION_PHRASES = [
|
||||
'end of day',
|
||||
'end of week',
|
||||
'end of month',
|
||||
...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${tod}`),
|
||||
...TOD_NAMES.map(tod => `tomorrow ${tod}`),
|
||||
...TOD_NAMES.map(tod => `tomorrow at ${tod}`),
|
||||
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
|
||||
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
|
||||
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
|
||||
@@ -69,9 +70,8 @@ export const buildSuggestionCandidates = text => {
|
||||
const num = text.match(/^\d+(?:\.5)?/)[0];
|
||||
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim();
|
||||
const spaced = trimmed.replace(/(\d)([a-z])/i, '$1 $2');
|
||||
return spaced.length > num.length
|
||||
? candidates.filter(c => c.startsWith(spaced))
|
||||
return trimmed.length > num.length
|
||||
? candidates.filter(c => c.startsWith(trimmed))
|
||||
: candidates;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user