Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 6b7240661f fix: clear counter_cache_optimization flag for existing accounts on migration 2026-03-02 12:30:48 +05:30
Tanmay Deep Sharma f2d20905c1 Merge remote-tracking branch 'origin/perf/conversation_count' into perf/conversation_count 2026-02-27 16:33:10 +05:30
Tanmay Deep Sharma 9cd08f4075 reclaim existing deprecated feature flag 2026-02-27 12:15:57 +05:30
Tanmay Deep Sharma 62f77d1091 Merge remote-tracking branch 'origin/develop' into perf/conversation_count 2026-02-26 18:15:47 +05:30
Tanmay Deep Sharma 6d38b917c9 Merge branch 'develop' into perf/conversation_count 2026-02-26 18:13:56 +05:30
Tanmay Deep SharmaandGitHub 5f6cd48a53 Merge branch 'develop' into perf/conversation_count 2026-02-24 18:26:06 +05:30
Tanmay Deep Sharma af511b39c4 Merge remote-tracking branch 'origin/perf/conversation_count' into perf/conversation_count 2026-02-24 16:24:31 +05:30
Tanmay Deep Sharma d4992ad4c6 feat: add conversation status counter cache with per-account backfill on feature enable 2026-02-24 16:24:10 +05:30
Tanmay Deep SharmaandGitHub d752c379b0 Merge branch 'develop' into perf/conversation_count 2026-02-23 14:17:23 +05:30
Tanmay Deep Sharma d0525660ab Merge remote-tracking branch 'origin/develop' into perf/conversation_count 2026-02-18 20:47:47 +05:30
Tanmay Deep Sharma efc58eef9a fix review changes 2026-02-09 10:56:02 +05:30
Tanmay Deep Sharma 96f1aa7205 fix review changes 2026-02-04 12:44:38 +05:30
Tanmay Deep Sharma ace5c90007 use cache for only admins 2026-02-03 20:51:34 +05:30
Tanmay Deep Sharma 6e85dcbbce use counter culture and move the optimisation under feature flag 2026-02-03 20:26:03 +05:30
Tanmay Deep Sharma 6a053e975e perf(conversations): optimize /conversations/meta with counter cache for all_count 2026-02-03 14:20:35 +05:30
293 changed files with 2232 additions and 13889 deletions
+2 -2
View File
@@ -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:
-9
View File
@@ -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**:
+1 -1
View File
@@ -12,6 +12,7 @@ gem 'bootsnap', require: false
gem 'acts-as-taggable-on'
gem 'attr_extras'
gem 'browser'
gem 'counter_culture'
gem 'hashie'
gem 'jbuilder'
gem 'kaminari'
@@ -268,7 +269,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
+6 -13
View File
@@ -191,7 +191,10 @@ GEM
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
connection_pool (2.5.3)
counter_culture (3.12.1)
activerecord (>= 4.2)
activesupport (>= 4.2)
crack (1.0.0)
bigdecimal
rexml
@@ -473,12 +476,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 +739,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 +913,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 +973,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)
@@ -1051,6 +1044,7 @@ DEPENDENCIES
cld3 (~> 3.7)
climate_control
commonmarker
counter_culture
csv-safe
database_cleaner
datadog (~> 2.0)
@@ -1153,7 +1147,6 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen
+2 -6
View File
@@ -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
View File
@@ -1 +1 @@
4.11.2
4.11.1
@@ -105,19 +105,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: content_attributes,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
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
View File
@@ -1,7 +1,6 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -1,6 +1,5 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
def build
load_data
@@ -9,15 +8,9 @@ class V2::Reports::BaseSummaryBuilder
private
# Summary builders fetch all metrics in a single query, so the per-metric
# check from RollupConditions does not apply.
def metric_covered?
true
end
def load_data
@conversations_count = fetch_conversations_count
use_rollup? ? load_rollup_data : load_reporting_events_data
load_reporting_events_data
end
def load_reporting_events_data
@@ -41,46 +34,6 @@ class V2::Reports::BaseSummaryBuilder
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def load_rollup_data
dimension_type = dimension_type_to_rollup
value_col = rollup_value_column
# Fetch rollup data grouped by dimension_id
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: dimension_type,
date: rollup_date_range
).group(:dimension_id).select(
'dimension_id',
'SUM(CASE WHEN metric = \'resolutions_count\' THEN count ELSE 0 END) as resolved_count',
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as resolution_count",
"SUM(CASE WHEN metric = 'resolution_time' THEN #{value_col} ELSE 0 END) as resolution_sum_value",
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as first_response_count",
"SUM(CASE WHEN metric = 'first_response' THEN #{value_col} ELSE 0 END) as first_response_sum_value",
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as reply_count",
"SUM(CASE WHEN metric = 'reply_time' THEN #{value_col} ELSE 0 END) as reply_sum_value"
)
# Convert to the expected format
results = {}
rollup_rows.each do |row|
results[row.dimension_id] = row
end
@resolved_count = results.transform_values { |r| r.resolved_count.to_i }
@avg_resolution_time = results.transform_values do |r|
r.resolution_count.to_i.zero? ? nil : (r.resolution_sum_value.to_f / r.resolution_count.to_i)
end
@avg_first_response_time = results.transform_values do |r|
r.first_response_count.to_i.zero? ? nil : (r.first_response_sum_value.to_f / r.first_response_count.to_i)
end
@avg_reply_time = results.transform_values do |r|
r.reply_count.to_i.zero? ? nil : (r.reply_sum_value.to_f / r.reply_count.to_i)
end
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
@@ -1,75 +0,0 @@
module V2::Reports::Concerns::RollupConditions
COVERED_METRICS = {
avg_first_response_time: :first_response,
avg_resolution_time: :resolution_time,
reply_time: :reply_time,
resolutions_count: :resolutions_count,
bot_resolutions_count: :bot_resolutions_count,
bot_handoffs_count: :bot_handoffs_count
}.freeze
SUPPORTED_DIMENSIONS = %w[account agent inbox].freeze
def use_rollup?
# Condition 0: reporting_timezone must be configured on account
return false if account.reporting_timezone.blank?
# Condition 1: Feature flag must be enabled
return false unless account.feature_enabled?('reporting_events_rollup')
# Condition 2: Metric must be covered by rollup
return false unless metric_covered?
# Condition 3: Not hourly granularity
return false if params[:group_by] == 'hour'
# Condition 4: Dimension must be supported
return false unless dimension_supported?
# Condition 5: Timezone must match
timezone_matches_account?
end
private
def metric_covered?
return false if params[:metric].blank?
COVERED_METRICS.key?(params[:metric].to_sym)
end
def dimension_supported?
return true if params[:type].blank? # account dimension (no specific ID)
SUPPORTED_DIMENSIONS.include?(params[:type].to_s)
end
def timezone_matches_account?
return false if params[:timezone_offset].blank?
offset_in_seconds = params[:timezone_offset].to_f * 3600
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
account_zone&.now&.utc_offset == offset_in_seconds
end
def metric_to_rollup_metric(metric)
COVERED_METRICS[metric.to_sym]
end
def rollup_value_column
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :sum_value_business_hours : :sum_value
end
def rollup_date_range
tz = ActiveSupport::TimeZone[account.reporting_timezone]
start_date = range.first.in_time_zone(tz).to_date
# range is exclusive (since...until), so subtract a second to avoid
# including the until boundary day when it falls exactly at midnight.
end_date = (range.last - 1.second).in_time_zone(tz).to_date
start_date..end_date
end
def dimension_type_to_rollup
{ 'account' => :account, 'agent' => :agent, 'inbox' => :inbox }[params[:type].to_s]
end
end
@@ -11,6 +11,11 @@ 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
@@ -1,15 +1,5 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_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|
@@ -22,98 +12,11 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end
end
def rollup_timeseries
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup(rollup_rows)
end
def raw_aggregate_value
def aggregate_value
object_scope.average(average_value_key)
end
def rollup_aggregate_value
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
value_col = rollup_value_column
result = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).pick(Arel.sql("SUM(count), SUM(#{value_col})"))
return nil if result.blank? || result[0].to_i.zero?
result[1].to_f / result[0].to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup(rollup_rows)
grouped_data = aggregate_rollup_rows(rollup_rows, all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } })
results = grouped_data.map do |date_key, data|
value = data[:count].zero? ? 0 : data[:sum_value] / data[:count]
{ value: value, timestamp: date_key.in_time_zone(timezone).to_i, count: data[:count] }
end
results.sort_by { |h| h[:timestamp] }
end
def aggregate_rollup_rows(rollup_rows, grouped_data = {})
value_col = rollup_value_column
rollup_rows.each_with_object(grouped_data) do |row, all_grouped_data|
date_key = date_key_for_group(row.date)
all_grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
all_grouped_data[date_key][:count] += row.count
all_grouped_data[date_key][:sum_value] += row.public_send(value_col)
end
end
def date_key_for_group(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_group(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
private
def event_name
metric_to_event_name = {
@@ -1,8 +1,6 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
@@ -1,15 +1,5 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
@@ -21,92 +11,11 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
end
end
def rollup_timeseries
metric = metric_to_rollup_metric(params[:metric])
return [] if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup_counts(rollup_rows)
end
def raw_aggregate_value
def aggregate_value
object_scope.count
end
def rollup_aggregate_value
metric = metric_to_rollup_metric(params[:metric])
return 0 if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).sum(:count).to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup_counts(rollup_rows)
# Pre-fill all periods with zero to match raw path's default_value: 0 behavior
grouped_data = all_periods_in_range.index_with { 0 }
rollup_rows.each do |row|
date_key = date_key_for_period(row.date)
grouped_data[date_key] ||= 0
grouped_data[date_key] += row.count
end
results = grouped_data.map do |date_key, count|
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
end
results.sort_by { |h| h[:timestamp] }
end
def date_key_for_period(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_period(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
private
def metric
@metric ||= params[:metric]
@@ -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
@@ -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
@@ -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
@@ -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,17 +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_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_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)
+1 -1
View File
@@ -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),
-4
View File
@@ -1,4 +0,0 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end
+17 -1
View File
@@ -2,6 +2,9 @@ class ConversationFinder
attr_reader :current_user, :current_account, :params
DEFAULT_STATUS = 'open'.freeze
# Filter params that affect conversation count
# NOTE: When adding a new filter, add it here to prevent incorrect cache usage
FILTER_PARAMS = %i[inbox_id team_id labels q source_id conversation_type].freeze
SORT_OPTIONS = {
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
@@ -184,10 +187,23 @@ class ConversationFinder
end
def set_count_for_all_conversations
# Use cache only for admins with no filters other than status
use_cache = current_account.feature_enabled?(:counter_cache_optimization) &&
@is_admin &&
FILTER_PARAMS.none? { |key| params[key] } &&
params[:status] != 'all'
all_count = if use_cache
status = params[:status].presence_in(Conversation.statuses.keys) || DEFAULT_STATUS
current_account.public_send("#{status}_conversations_count")
else
@conversations.count
end
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
@conversations.count
all_count
]
end
+5 -9
View File
@@ -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)
+2 -2
View File
@@ -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();
@@ -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');
});
});
@@ -1,8 +1,6 @@
/* global axios */
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
@@ -14,7 +12,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -25,7 +22,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -36,7 +32,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -47,7 +42,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -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
@@ -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"
@@ -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);
@@ -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
@@ -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="{
@@ -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,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"
@@ -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>
@@ -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 => {
@@ -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) {
@@ -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');
};
@@ -124,9 +113,9 @@ defineExpose({ open, close });
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
);
@@ -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">
@@ -100,8 +100,6 @@ export default {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
@@ -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',
});
@@ -1,12 +0,0 @@
/**
* snoozeDateParser — Natural language date/time parser for snooze.
*
* Barrel re-export from submodules:
* - parser.js: core parsing engine (parseDateFromText)
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
* - suggestions.js: compositional suggestion engine
* - tokenMaps.js: shared token maps and utility functions
*/
export { parseDateFromText } from './parser';
export { generateDateSuggestions } from './localization';
@@ -1,408 +0,0 @@
/**
* Handles non-English input and generates the final suggestion list.
* Translates localized words to English before parsing, then converts
* suggestion labels back to the user's language for display.
*/
import {
WEEKDAY_MAP,
MONTH_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
RELATIVE_DAY_MAP,
TIME_OF_DAY_MAP,
sanitize,
stripNoise,
normalizeDigits,
} from './tokenMaps';
import { parseDateFromText } from './parser';
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
// ─── English Reference Data ─────────────────────────────────────────────────
const EN_WEEKDAYS_LIST = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
const EN_MONTHS_LIST = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const EN_DEFAULTS = {
UNITS: {
MINUTE: 'minute',
MINUTES: 'minutes',
HOUR: 'hour',
HOURS: 'hours',
DAY: 'day',
DAYS: 'days',
WEEK: 'week',
WEEKS: 'weeks',
MONTH: 'month',
MONTHS: 'months',
YEAR: 'year',
YEARS: 'years',
},
RELATIVE: {
TOMORROW: 'tomorrow',
DAY_AFTER_TOMORROW: 'day after tomorrow',
NEXT_WEEK: 'next week',
NEXT_MONTH: 'next month',
THIS_WEEKEND: 'this weekend',
NEXT_WEEKEND: 'next weekend',
},
TIME_OF_DAY: {
MORNING: 'morning',
AFTERNOON: 'afternoon',
EVENING: 'evening',
NIGHT: 'night',
NOON: 'noon',
MIDNIGHT: 'midnight',
},
WORD_NUMBERS: {
ONE: 'one',
TWO: 'two',
THREE: 'three',
FOUR: 'four',
FIVE: 'five',
SIX: 'six',
SEVEN: 'seven',
EIGHT: 'eight',
NINE: 'nine',
TEN: 'ten',
TWELVE: 'twelve',
FIFTEEN: 'fifteen',
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',
};
const STRUCTURAL_WORDS = [
'at',
'in',
'next',
'this',
'from',
'now',
'after',
'half',
'same',
'time',
'weekend',
'end',
'of',
'the',
'eod',
'am',
'pm',
'week',
'day',
'first',
'second',
'third',
'fourth',
'fifth',
];
const ENGLISH_VOCAB = new Set([
...Object.keys(WEEKDAY_MAP),
...Object.keys(MONTH_MAP),
...Object.keys(UNIT_MAP),
...Object.keys(WORD_NUMBER_MAP),
...Object.keys(RELATIVE_DAY_MAP),
...Object.keys(TIME_OF_DAY_MAP),
...EN_WEEKDAYS_LIST,
...EN_MONTHS_LIST,
...STRUCTURAL_WORDS,
]);
// ─── Regex for token replacement ────────────────────────────────────────────
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
const NUM_TOD_RE =
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
const TOD_TO_MERIDIEM = {
morning: 'am',
noon: 'pm',
afternoon: 'pm',
evening: 'pm',
night: 'pm',
};
// ─── Translation Cache ──────────────────────────────────────────────────────
const safeString = v => (v == null ? '' : String(v));
const MAX_PAIRS_CACHE = 20;
const pairsCache = new Map();
const CACHE_SECTIONS = [
'UNITS',
'RELATIVE',
'TIME_OF_DAY',
'WORD_NUMBERS',
'ORDINALS',
'MERIDIEM',
];
const SINGLE_KEYS = [
'HALF',
'NEXT',
'THIS',
'AT',
'IN',
'OF',
'AFTER',
'WEEK',
'DAY',
'FROM_NOW',
'NEXT_YEAR',
];
/** Create a string key from translations so we can cache results. */
const translationSignature = translations => {
if (!translations || typeof translations !== 'object') return 'none';
return [
...CACHE_SECTIONS.flatMap(section => {
const values = translations[section] || {};
return Object.keys(values)
.sort()
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
}),
...SINGLE_KEYS.map(
k => `${k}:${safeString(translations[k]).toLowerCase()}`
),
].join('|');
};
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
const buildReplacementPairsUncached = (translations, locale) => {
const pairs = [];
const seen = new Set();
const t = translations || {};
const addPair = (local, en) => {
const l = sanitize(safeString(local));
const e = safeString(en).toLowerCase();
const key = `${l}\0${e}`;
if (l && e && l !== e && !seen.has(key)) {
seen.add(key);
pairs.push([l, e]);
}
};
CACHE_SECTIONS.forEach(section => {
const localSection = t[section] || {};
const enSection = EN_DEFAULTS[section] || {};
Object.keys(enSection).forEach(key => {
addPair(localSection[key], enSection[key]);
});
});
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
try {
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
EN_WEEKDAYS_LIST.forEach((en, i) => {
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
});
} catch {
/* locale not supported */
}
try {
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
EN_MONTHS_LIST.forEach((en, i) => {
addPair(moFmt.format(new Date(2024, i, 1)), en);
});
} catch {
/* locale not supported */
}
pairs.sort((a, b) => b[0].length - a[0].length);
return pairs;
};
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
const buildReplacementPairs = (translations, locale) => {
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
const pairs = buildReplacementPairsUncached(translations, locale);
if (pairsCache.size >= MAX_PAIRS_CACHE)
pairsCache.delete(pairsCache.keys().next().value);
pairsCache.set(cacheKey, pairs);
return pairs;
};
// ─── Token Replacement ──────────────────────────────────────────────────────
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Swap localized words for their English versions in the text. */
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, en]) => {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
});
return r;
};
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
const filterToEnglishVocab = text =>
normalizeDigits(text)
.replace(/(\d+)h\b/g, '$1:00')
.split(/\s+/)
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
const repositionNextYear = text => {
if (!MONTH_NAME_RE.test(text)) return text;
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
/next/i.test(m) ? m : 'next year'
);
if (!/\bnext\s+year\b/i.test(r)) return r;
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
const timePart = withoutNY.match(timeRe);
if (timePart) {
const beforeTime = withoutNY.slice(0, timePart.index).trim();
r = `${beforeTime} next year ${timePart[0].trim()}`;
} else {
r = `${withoutNY} next year`;
}
return r;
};
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
const replaceTokens = (text, pairs) => {
const substituted = substituteLocalTokens(text, pairs);
const filtered = filterToEnglishVocab(substituted);
const fixed = filtered.replace(
NUM_TOD_RE,
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
);
return stripNoise(repositionNextYear(fixed));
};
/** Convert English words back to the user's language for display. */
const reverseTokens = (text, pairs) =>
pairs.reduce(
(r, [local, en]) =>
r.replace(
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
local
),
text
);
// ─── Main Suggestion Generator ──────────────────────────────────────────────
/**
* Generate snooze suggestions from what the user has typed so far.
* Works with any language if translations are provided. Returns up to 5
* unique results, each with a label, date, and unix timestamp.
*
* @param {string} text - what the user typed
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @param {{ translations?: object, locale?: string }} [options] - i18n config
* @returns {Array<{ label: string, date: Date, unix: number }>}
*/
export const generateDateSuggestions = (
text,
referenceDate = new Date(),
{ translations, locale } = {}
) => {
if (!text || typeof text !== 'string') return [];
const normalized = sanitize(text);
if (!normalized) return [];
const stripped = stripNoise(normalized);
const pairs =
locale && locale !== 'en'
? 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.
const directParse = parseDateFromText(stripped, referenceDate);
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 seen = new Set();
const results = [];
const exact = directParse || translatedParse;
if (exact) {
seen.add(exact.unix);
const exactLabel =
useTranslated && pairs.length
? reverseTokens(englishInput, pairs)
: englishInput;
results.push({ label: exactLabel, query: englishInput, ...exact });
}
buildSuggestionCandidates(englishInput).some(candidate => {
if (results.length >= MAX_SUGGESTIONS) return true;
const result = parseDateFromText(candidate, referenceDate);
if (result && !seen.has(result.unix)) {
seen.add(result.unix);
const label =
useTranslated && pairs.length
? reverseTokens(candidate, pairs)
: candidate;
results.push({ label, query: candidate, ...result });
}
return false;
});
return results;
};
@@ -1,806 +0,0 @@
/**
* Parses natural language text into a future date.
*
* Flow: clean the input → try each matcher in order → return the first future date.
* The MATCHERS order matters — see the comment above the array.
*/
import {
add,
startOfDay,
getDay,
isSaturday,
isSunday,
nextFriday,
nextSaturday,
getUnixTime,
isValid,
startOfWeek,
addWeeks,
isAfter,
isBefore,
endOfMonth,
} from 'date-fns';
import {
WEEKDAY_MAP,
MONTH_MAP,
RELATIVE_DAY_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
NEXT_WEEKDAY_FN,
TIME_OF_DAY_MAP,
TOD_HOUR_RANGE,
HALF_UNIT_DURATIONS,
sanitize,
stripNoise,
parseNumber,
parseTimeString,
applyTimeToDate,
applyTimeOrDefault,
strictDate,
futureOrNextYear,
ensureFutureOrNextDay,
inferHoursFromTOD,
addFractionalSafe,
} from './tokenMaps';
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
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)$/;
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
const DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
);
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
'(\\d{1,2}(?::\\d{2})?\\s*' +
'(?: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}))$`
);
const NEXT_UNIT_RE = new RegExp(
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
);
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
const NEXT_WEEKDAY_TOD_RE = new RegExp(
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const NEXT_WEEKDAY_RE = new RegExp(
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
);
const SAME_TIME_WEEKDAY_RE = new RegExp(
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
);
const WEEKDAY_TOD_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const WEEKDAY_TOD_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const WEEKDAY_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
);
const TIME_ONLY_MERIDIEM_RE =
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
const TOD_WITH_TIME_RE = new RegExp(
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const TOD_PLAIN_RE = new RegExp(
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
);
const ABSOLUTE_DATE_RE = new RegExp(
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
`(?:[,\\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}$`
);
const COMPOUND_DURATION_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
);
const DURATION_AT_TIME_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
'(\\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(
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
);
const SLASH_DATE_RE = new RegExp(
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DASH_DATE_RE = new RegExp(
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DOT_DATE_RE = new RegExp(
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
// ─── Pattern Matchers ───────────────────────────────────────────────────────
/** Read amount and unit from a regex match, then add to now. */
const parseDuration = (match, now) => {
if (!match) return null;
const amount = parseNumber(match[1]);
const unit = UNIT_MAP[match[2]];
if (amount == null || !unit) return null;
return addFractionalSafe(now, unit, amount);
};
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
const matchDuration = (text, now) => {
const half = text.match(HALF_UNIT_RE);
if (half) {
return HALF_UNIT_DURATIONS[half[1]]
? add(now, HALF_UNIT_DURATIONS[half[1]])
: 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]);
const u1 = UNIT_MAP[compound[2]];
const a2 = parseNumber(compound[3]);
const u2 = UNIT_MAP[compound[4]];
if (a1 == null || !u1 || a2 == null || !u2) {
return null;
}
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
}
const atTime = text.match(DURATION_AT_TIME_RE);
if (atTime) {
const amount = parseNumber(atTime[1]);
const unit = UNIT_MAP[atTime[2]];
const time = parseTimeString(atTime[3]);
if (amount == null || !unit || !time) {
return null;
}
return applyTimeToDate(
add(now, { [unit]: amount }),
time.hours,
time.minutes
);
}
return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
);
};
/** Set time on a day offset. If the result is already past, move to the next day. */
const applyTimeWithRollover = (offset, hours, minutes, now) => {
const base = add(startOfDay(now), { days: offset });
const date = applyTimeToDate(base, hours, minutes);
if (isAfter(date, now)) return date;
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
};
/** Handle "today", "tonight", "tomorrow" with optional time. */
const matchRelativeDay = (text, now) => {
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
if (dayOnlyMatch) {
const key = dayOnlyMatch[1];
const offset = RELATIVE_DAY_MAP[key];
if (key === 'tonight' || key === 'tonite') {
return ensureFutureOrNextDay(
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
now
);
}
if (offset === 1) {
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
}
return add(now, { hours: 1 });
}
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
if (dayTodTimeMatch) {
const timeParts = dayTodTimeMatch[3].split(':');
const time = inferHoursFromTOD(
dayTodTimeMatch[2],
timeParts[0],
timeParts[1]
);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
time.hours,
time.minutes,
now
);
}
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
if (dayTodMatch) {
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodMatch[1]],
hours,
minutes,
now
);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
const time = bare
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
: parseTimeString(timeRaw);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayKey],
time.hours,
time.minutes,
now
);
}
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
if (sameTimeMatch) {
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]];
if (offset <= 0) return null;
return applyTimeToDate(
add(startOfDay(now), { days: offset }),
now.getHours(),
now.getMinutes()
);
}
return null;
};
/** Find the given weekday in next week (not this week). */
const nextWeekdayInNextWeek = (dayIndex, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const date = fn(now);
const sameWeek =
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
startOfWeek(date, { weekStartsOn: 1 }).getTime();
return sameWeek ? fn(date) : date;
};
/** Handle "next friday", "next week", "next month", "next january", etc. */
const matchNextPattern = (text, now) => {
const nextUnitMatch = text.match(NEXT_UNIT_RE);
if (nextUnitMatch) {
const unit = nextUnitMatch[1];
if (unit === 'hour') return add(now, { hours: 1 });
if (unit === 'minute') return add(now, { minutes: 1 });
if (unit === 'week') {
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const nextMonthMatch = text.match(NEXT_MONTH_RE);
if (nextMonthMatch) {
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
let year = now.getFullYear();
if (monthIdx <= now.getMonth()) year += 1;
const base = new Date(year, monthIdx, 1);
return applyTimeOrDefault(base, nextMonthMatch[2]);
}
// "next monday morning", "next friday midnight" — weekday + time-of-day
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
if (nextTodMatch) {
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
if (!date) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
return applyTimeToDate(date, hours, minutes);
}
// "monday of next week", "next week monday", "next friday" — all with optional time
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
if (weekdayMatch) {
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
if (!date) return null;
return applyTimeOrDefault(date, weekdayMatch[4]);
}
return null;
};
/** Find the next occurrence of a weekday, with optional time. */
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let adjusted = timeStr;
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
const h = parseInt(timeStr, 10);
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
}
if (getDay(now) === dayIndex) {
const todayDate = applyTimeOrDefault(now, adjusted);
if (todayDate && isAfter(todayDate, now)) return todayDate;
}
return applyTimeOrDefault(fn(now), adjusted);
};
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
const matchWeekday = (text, now) => {
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
if (sameTimeWeekday) {
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const target = fn(now);
return applyTimeToDate(target, now.getHours(), now.getMinutes());
}
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
if (todTimeMatch) {
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const timeParts = todTimeMatch[3].split(':');
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
if (!time) return null;
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, time.hours, time.minutes);
return isAfter(date, now)
? date
: applyTimeToDate(fn(now), time.hours, time.minutes);
}
// "monday morning", "friday midnight", "wednesday evening", etc.
const todMatch = text.match(WEEKDAY_TOD_RE);
if (todMatch) {
const dayIndex = WEEKDAY_MAP[todMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, hours, minutes);
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
}
const match = text.match(WEEKDAY_TIME_RE);
if (!match) return null;
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
};
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
const matchTimeOnly = (text, now) => {
const match =
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
if (!match) return null;
const time = parseTimeString(match[1]);
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
};
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
const matchTimeOfDay = (text, now) => {
const todWithTime = text.match(TOD_WITH_TIME_RE);
if (todWithTime) {
const rawTime = todWithTime[2].trim();
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
let time;
if (hasMeridiem) {
time = parseTimeString(rawTime);
const range = TOD_HOUR_RANGE[todWithTime[1]];
if (!time) return null;
if (range) {
const h = time.hours === 0 ? 24 : time.hours;
if (h < range[0] || h >= range[1]) return null;
}
} else {
const parts = rawTime.split(':');
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
}
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
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;
const key = text
.replace(/^(?:later|in)\s+/, '')
.replace(/^(?:this|the)\s+/, '')
.trim();
const tod = TIME_OF_DAY_MAP[key];
if (!tod) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours, tod.minutes),
now
);
};
/** Turn month + day + optional year into a future date. */
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
let year = now.getFullYear();
if (yearStr && /next\s+year/i.test(yearStr)) {
year += 1;
} else if (yearStr) {
year = parseInt(yearStr, 10);
}
if (yearStr) {
const base = strictDate(year, month, day);
if (!base) return null;
const date = applyTimeOrDefault(base, timeStr);
return date && isAfter(date, now) ? date : null;
}
return futureOrNextYear(year, month, day, timeStr, now);
};
/** Handle "jan 15", "15 march", "december 2025". */
const matchNamedDate = (text, now) => {
const abs = text.match(ABSOLUTE_DATE_RE);
if (abs) {
return resolveAbsoluteDate(
MONTH_MAP[abs[1]],
parseInt(abs[2], 10),
abs[3],
abs[4],
now
);
}
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
if (rev) {
return resolveAbsoluteDate(
MONTH_MAP[rev[2]],
parseInt(rev[1], 10),
rev[3],
rev[4],
now
);
}
const my = text.match(MONTH_YEAR_RE);
if (my) {
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
if (!isValid(date)) return null;
const result = applyTimeToDate(date, 9, 0);
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;
};
/** Build a date from year/month/day numbers, with optional time. */
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
const date = strictDate(year, month, day);
if (!date) return null;
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) => {
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 };
};
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
const matchFormalDate = (text, now) => {
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
const isoMatch = text.match(ISO_DATE_RE);
if (isoMatch) {
return ensureFuture(
buildDateWithOptionalTime(
parseInt(isoMatch[1], 10),
parseInt(isoMatch[2], 10) - 1,
parseInt(isoMatch[3], 10),
isoMatch[4]
)
);
}
// 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 }) => {
const m = text.match(re);
if (!m) return false;
const { month, day } = disambiguateDayMonth(
parseInt(m[1], 10),
parseInt(m[2], 10),
dayFirst
);
result = ensureFuture(
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
);
return true;
});
return result;
};
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
const matchSpecial = (text, now) => {
const dat = text.match(DAY_AFTER_TOMORROW_RE);
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
const eof = text.match(END_OF_RE);
if (eof) {
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
if (eof[1] === 'week') {
const fri = applyTimeToDate(now, 17, 0);
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
return applyTimeToDate(nextFriday(now), 17, 0);
}
if (eof[1] === 'month') {
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
if (isAfter(eom, now)) return eom;
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
// "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(
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
);
if (weekendMatch) {
const isNext = weekendMatch[1] === 'next weekend';
const timeStr = weekendMatch[2];
if (isNext) {
const sat = nextSaturday(now);
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
return applyTimeOrDefault(d, timeStr);
}
if (isSaturday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
if (now.getHours() < 18) return add(now, { hours: 2 });
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
}
if (isSunday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
return add(now, { hours: 2 });
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
}
return applyTimeOrDefault(nextSaturday(now), timeStr);
}
return null;
};
// ─── Main Parser ────────────────────────────────────────────────────────────
// Order matters — first match wins. Common patterns go first.
// Do not reorder without running the spec.
const MATCHERS = [
matchDuration, // "in 2 hours", "half day", "3h30m"
matchSpecial, // "end of week", "later today", "this weekend"
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
matchNextPattern, // "next friday", "next week", "next month"
matchTimeOfDay, // "morning", "evening 6pm", "eod"
matchWeekday, // "friday", "monday 3pm", "wed morning"
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
matchNamedDate, // "jan 15", "march 20 next year"
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
];
/**
* Parse free-form text into a future date.
* Returns { date, unix } or null. Only returns dates after referenceDate.
*
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @returns {{ date: Date, unix: number } | null}
*/
export const parseDateFromText = (text, referenceDate = new Date()) => {
if (!text || typeof text !== 'string') return null;
const normalized = stripNoise(sanitize(text));
if (!normalized) return null;
const maxDate = add(referenceDate, { years: 999 });
const isValidFuture = d =>
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
let result = null;
MATCHERS.some(matcher => {
const d = matcher(normalized, referenceDate);
if (isValidFuture(d)) {
result = { date: d, unix: getUnixTime(d) };
return true;
}
return false;
});
return result;
};
@@ -1,101 +0,0 @@
/**
* Builds autocomplete suggestions as the user types a snooze date.
* Matches partial input against known phrases and ranks them by closeness.
*/
import {
UNIT_MAP,
WEEKDAY_MAP,
TIME_OF_DAY_MAP,
RELATIVE_DAY_MAP,
WORD_NUMBER_MAP,
MONTH_MAP,
HALF_UNIT_DURATIONS,
} from './tokenMaps';
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
u => u !== 'seconds'
);
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
const ALL_SUGGESTION_PHRASES = [
...Object.keys(RELATIVE_DAY_MAP),
...FULL_WEEKDAYS,
...TOD_NAMES,
'next week',
'next month',
'this weekend',
'next weekend',
'day after tomorrow',
'later today',
'end of day',
'end of week',
'end of month',
...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${tod}`),
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
...MONTH_NAMES_LONG.map(m => `${m} 1`),
];
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
const prefixMatchScore = (candidate, input) => {
if (candidate === input) return -1;
if (candidate.startsWith(input)) return 0;
const inputWords = input.split(' ');
const candidateWords = candidate.split(' ');
const lastIdx = inputWords.reduce((prev, iw) => {
if (prev === -2) return -2;
const idx = candidateWords.findIndex(
(cw, ci) => ci > prev && cw.startsWith(iw)
);
return idx === -1 ? -2 : idx;
}, -1);
if (lastIdx === -2) return -1;
return candidateWords.length - inputWords.length;
};
export const MAX_SUGGESTIONS = 5;
/** Turn user input into a ranked list of suggestion strings to try parsing. */
export const buildSuggestionCandidates = text => {
if (!text) return [];
if (/^\d/.test(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))
: candidates;
}
if (text.length >= 2 && 'half'.startsWith(text)) {
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
}
const wordNum = WORD_NUMBER_MAP[text];
if (wordNum != null && wordNum >= 1) {
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
}
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
const score = prefixMatchScore(candidate, text);
if (score >= 0) acc.push({ candidate, score });
return acc;
}, []);
scored.sort((a, b) => a.score - b.score);
const seen = new Set();
return scored.reduce((acc, { candidate }) => {
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
seen.add(candidate);
acc.push(candidate);
}
return acc;
}, []);
};
@@ -1,395 +0,0 @@
/**
* Shared lookup tables and helper functions used by the parser,
* suggestions, and localization modules.
*/
import {
add,
set,
isValid,
isAfter,
nextMonday,
nextTuesday,
nextWednesday,
nextThursday,
nextFriday,
nextSaturday,
nextSunday,
} from 'date-fns';
// ─── Token Maps ──────────────────────────────────────────────────────────────
// All keys are lowercase. Short forms and full names both work.
/** Weekday name or short form → day index (0 = Sunday). */
export const WEEKDAY_MAP = {
sunday: 0,
sun: 0,
monday: 1,
mon: 1,
tuesday: 2,
tue: 2,
tues: 2,
wednesday: 3,
wed: 3,
thursday: 4,
thu: 4,
thur: 4,
thurs: 4,
friday: 5,
fri: 5,
saturday: 6,
sat: 6,
};
/** Month name or short form → month index (0 = January). */
export const MONTH_MAP = {
january: 0,
jan: 0,
february: 1,
feb: 1,
march: 2,
mar: 2,
april: 3,
apr: 3,
may: 4,
june: 5,
jun: 5,
july: 6,
jul: 6,
august: 7,
aug: 7,
september: 8,
sep: 8,
sept: 8,
october: 9,
oct: 9,
november: 10,
nov: 10,
december: 11,
dec: 11,
};
/** Words like "today" or "tomorrow" → how many days from now. */
export const RELATIVE_DAY_MAP = {
today: 0,
tonight: 0,
tonite: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
};
/** Unit shorthand → full unit name used by date-fns. */
export const UNIT_MAP = {
m: 'minutes',
min: 'minutes',
mins: 'minutes',
minute: 'minutes',
minutes: 'minutes',
h: 'hours',
hr: 'hours',
hrs: 'hours',
hour: 'hours',
hours: 'hours',
d: 'days',
day: 'days',
days: 'days',
w: 'weeks',
wk: 'weeks',
wks: 'weeks',
week: 'weeks',
weeks: 'weeks',
mo: 'months',
month: 'months',
months: 'months',
y: 'years',
yr: 'years',
yrs: 'years',
year: 'years',
years: 'years',
};
/** English number words → their numeric value. */
export const WORD_NUMBER_MAP = {
a: 1,
an: 1,
one: 1,
couple: 2,
few: 3,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
thirty: 30,
forty: 40,
fifty: 50,
sixty: 60,
ninety: 90,
half: 0.5,
};
/** Day index → the date-fns function that finds the next occurrence. */
export const NEXT_WEEKDAY_FN = {
0: nextSunday,
1: nextMonday,
2: nextTuesday,
3: nextWednesday,
4: nextThursday,
5: nextFriday,
6: nextSaturday,
};
/** Time-of-day label → default hour and minute. */
export const TIME_OF_DAY_MAP = {
morning: { hours: 9, minutes: 0 },
noon: { hours: 12, minutes: 0 },
afternoon: { hours: 14, minutes: 0 },
evening: { hours: 18, minutes: 0 },
night: { hours: 20, minutes: 0 },
tonight: { hours: 20, minutes: 0 },
midnight: { hours: 0, minutes: 0 },
eod: { hours: 17, minutes: 0 },
'end of day': { hours: 17, minutes: 0 },
'end of the day': { hours: 17, minutes: 0 },
};
/** Allowed hour range per label — used to pick am or pm when not specified. */
export const TOD_HOUR_RANGE = {
morning: [4, 12],
noon: [11, 13],
afternoon: [12, 18],
evening: [16, 22],
night: [18, 24],
tonight: [18, 24],
midnight: [23, 25],
};
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
export const HALF_UNIT_DURATIONS = {
hour: { minutes: 30 },
day: { hours: 12 },
week: { days: 3, hours: 12 },
month: { days: 15 },
year: { months: 6 },
};
const FRACTIONAL_CONVERT = {
hours: { unit: 'minutes', factor: 60 },
days: { unit: 'hours', factor: 24 },
weeks: { unit: 'days', factor: 7 },
months: { unit: 'days', factor: 30 },
years: { unit: 'months', factor: 12 },
};
// ─── Unicode / Normalization ────────────────────────────────────────────────
// Turn non-ASCII digits and punctuation into plain ASCII so the
// parser only has to deal with standard characters.
const UNICODE_DIGIT_RANGES = [
[0x30, 0x39],
[0x660, 0x669], // Arabic-Indic
[0x6f0, 0x6f9], // Eastern Arabic-Indic
[0x966, 0x96f], // Devanagari
[0x9e6, 0x9ef], // Bengali
[0xa66, 0xa6f], // Gurmukhi
[0xae6, 0xaef], // Gujarati
[0xb66, 0xb6f], // Oriya
[0xbe6, 0xbef], // Tamil
[0xc66, 0xc6f], // Telugu
[0xce6, 0xcef], // Kannada
[0xd66, 0xd6f], // Malayalam
];
const toAsciiDigit = char => {
const code = char.codePointAt(0);
const range = UNICODE_DIGIT_RANGES.find(
([start, end]) => code >= start && code <= end
);
if (!range) return char;
return String(code - range[0]);
};
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
const ARABIC_PUNCT_MAP = {
'\u061f': '?',
'\u060c': ',',
'\u061b': ';',
'\u066b': '.',
};
const NOISE_RE =
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from|after|within)\s+)?/;
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
export const sanitize = text =>
normalizeDigits(
text
.normalize('NFKC')
.toLowerCase()
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
.replace(/[\u064b-\u065f]/g, '')
.replace(/\u00a0/g, ' ')
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
)
.replace(/[,!?;]+/g, ' ')
.replace(/\.+$/g, '')
.replace(/\s+/g, ' ')
.trim();
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
export const stripNoise = text => {
let r = text
.replace(/\ba\s+fortnight\b/g, '2 weeks')
.replace(/\bfortnight\b/g, '2 weeks')
.replace(NOISE_RE, '')
.replace(APPROX_RE, '')
.replace(/^the\s+/, '')
.replace(/\bnxt\b/g, 'next')
.replace(/\ba\s+couple\s+of\b/g, 'couple')
.replace(/\bcouple\s+of\b/g, 'couple')
.replace(/\ba\s+couple\b/g, 'couple')
.replace(/\ba\s+few\b/g, 'few')
.replace(
/\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g,
(_, h, m) =>
`${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`)
.replace(
/\b(\d+)m\b/g,
(_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\btomm?orow\b/g, 'tomorrow')
.replace(/\s+later$/, '')
.trim();
// bare unit without number: "month later" → "1 month", "week" stays
r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1');
return r;
};
// ─── Utility Functions ──────────────────────────────────────────────────────
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
export const parseNumber = str => {
if (!str) return null;
const lower = normalizeDigits(str.toLowerCase().trim());
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
const num = Number(lower);
return Number.isNaN(num) ? null : num;
};
/** Set the time on a date, clearing seconds and milliseconds. */
export const applyTimeToDate = (date, hours, minutes = 0) =>
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
export const parseTimeString = timeStr => {
if (!timeStr) return null;
const match = timeStr
.toLowerCase()
.replace(/\s+/g, '')
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
if (!match) return null;
const raw = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
const meridiem = match[3]?.replace(/\./g, '');
if (meridiem && (raw < 1 || raw > 12)) return null;
const toHours = (h, m) => {
if (m === 'pm' && h < 12) return h + 12;
if (m === 'am' && h === 12) return 0;
return h;
};
const hours = toHours(raw, meridiem);
if (hours > 23 || minutes > 59) return null;
return { hours, minutes };
};
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
if (timeStr) {
const time = parseTimeString(timeStr);
if (!time) return null;
return applyTimeToDate(date, time.hours, time.minutes);
}
return applyTimeToDate(date, defaultHours, 0);
};
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
export const strictDate = (year, month, day) => {
const date = new Date(year, month, day);
if (
!isValid(date) ||
date.getFullYear() !== year ||
date.getMonth() !== month ||
date.getDate() !== day
)
return null;
return date;
};
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
export const futureOrNextYear = (year, month, day, timeStr, now) => {
for (let i = 0; i < 9; i += 1) {
const base = strictDate(year + i, month, day);
if (base) {
const date = applyTimeOrDefault(base, timeStr);
if (!date) return null;
if (isAfter(date, now)) return date;
}
}
return null;
};
/** If the date is already past, push it to the next day. */
export const ensureFutureOrNextDay = (date, now) =>
isAfter(date, now) ? date : add(date, { days: 1 });
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
const h = parseInt(rawHour, 10);
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
const range = TOD_HOUR_RANGE[todLabel];
if (!range) return { hours: h, minutes: m };
// Try both am and pm interpretations, pick the one in range
const am = h === 12 ? 0 : h;
const pm = h === 12 ? 12 : h + 12;
const inRange = v => v >= range[0] && v < range[1];
if (inRange(am)) return { hours: am, minutes: m };
if (inRange(pm)) return { hours: pm, minutes: m };
const mid = (range[0] + range[1]) / 2;
return {
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
minutes: m,
};
};
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
export const addFractionalSafe = (date, unit, amount) => {
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
if (amount % 1 !== 0.5) return null;
const conv = FRACTIONAL_CONVERT[unit];
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
return add(date, { [unit]: Math.round(amount) });
};
+48 -102
View File
@@ -7,17 +7,11 @@ import {
startOfMonth,
isMonday,
isToday,
isSameYear,
setHours,
setMinutes,
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import {
generateDateSuggestions,
parseDateFromText,
} from 'dashboard/helper/snoozeDateParser';
import { UNIT_MAP } from 'dashboard/helper/snoozeDateParser/tokenMaps';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -39,113 +33,65 @@ export const findStartOfNextMonth = currentDate => {
});
};
export const findNextDay = currentDate => add(currentDate, { days: 1 });
export const findNextDay = currentDate => {
return add(currentDate, { days: 1 });
};
export const setHoursToNine = date =>
setSeconds(setMinutes(setHours(date, 9), 0), 0);
const SNOOZE_RESOLVERS = {
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
setHoursToNine(findStartOfNextMonth(d)),
export const setHoursToNine = date => {
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
};
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
const resolve = SNOOZE_RESOLVERS[snoozeType];
return resolve ? getUnixTime(resolve(currentDate)) : null;
};
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) return null;
const date = new Date(snoozedUntil);
if (isToday(date)) return format(date, 'h.mmaaa');
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
return format(date, 'd MMM, h.mmaaa');
};
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
const sameYear = isSameYear(snoozeDate, currentDate);
try {
const opts = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...(sameYear ? {} : { year: 'numeric' }),
};
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
} catch {
return sameYear
? format(snoozeDate, 'EEE, d MMM, h:mm a')
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
let parsedDate = null;
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
parsedDate = add(currentDate, { hours: 1 });
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
parsedDate = setHoursToNine(findNextDay(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
}
return parsedDate ? getUnixTime(parsedDate) : null;
};
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const date = new Date(snoozedUntil);
if (isToday(date)) {
return format(date, 'h.mmaaa');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
const expandUnit = (num, abbr) => {
const full = UNIT_MAP[abbr];
if (!full) return `${num} ${abbr}`;
return parseFloat(num) === 1
? `${num} ${full.replace(/s$/, '')}`
: `${num} ${full}`;
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
};
const capitalizeLabel = text => {
const expanded = text
.replace(
/^(\d+)h(\d+)m(?:in)?$/i,
(_, h, m) => `${expandUnit(h, 'h')} ${expandUnit(m, 'm')}`
)
.replace(/^(\d+(?:\.5)?)\s*([a-z]+)$/i, (_, n, u) =>
UNIT_MAP[u.toLowerCase()] ? expandUnit(n, u.toLowerCase()) : `${n} ${u}`
);
return expanded.replace(/^\w/, c => c.toUpperCase());
};
export const generateSnoozeSuggestions = (
searchText,
currentDate = new Date(),
{ translations, locale } = {}
) => {
const suggestions = generateDateSuggestions(searchText, currentDate, {
translations,
locale,
});
return suggestions.map(s => ({
date: s.date,
unixTime: s.unix,
query: s.query,
label: capitalizeLabel(s.label),
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
}));
};
const UNIT_SHORT = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
month: 'mo',
months: 'mo',
year: 'y',
years: 'y',
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) return null;
return snoozedUntil
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil
.replace(/^in\s+/i, '')
.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
(match, unit) => unitMap[unit.toLowerCase()] || match
);
return shortenTime;
};
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,6 @@ import {
setHoursToNine,
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
generateSnoozeSuggestions,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
@@ -92,26 +91,12 @@ describe('#Snooze Helpers', () => {
});
describe('snoozedReopenTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTime(null)).toEqual(null);
});
afterEach(() => {
vi.useRealTimers();
});
it('should return formatted date with year if snoozedUntil is not in current year', () => {
// Input is 09:00 UTC.
// If your environment is UTC, this will be 9.00am.
it('should return formatted date if snoozedUntil is not nil', () => {
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun 2023, 9.00am'
);
});
it('should return formatted date without year if snoozedUntil is in current year', () => {
// This uses 2024 because we mocked the system time above
expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
@@ -165,56 +150,4 @@ describe('#Snooze Helpers', () => {
expect(shortenSnoozeTime(null)).toEqual(null);
});
});
describe('generateSnoozeSuggestions label expansion', () => {
const now = new Date('2023-06-16T10:00:00');
it('expands abbreviated units: "1d" → "1 Day"', () => {
const results = generateSnoozeSuggestions('1d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 day');
});
it('expands abbreviated units: "2 d" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands abbreviated units: "1h" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1h', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('expands abbreviated units: "2min" → "2 Minutes"', () => {
const results = generateSnoozeSuggestions('2min', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 minutes');
});
it('handles singular: "1 hours" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1 hours', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('handles singular: "1 minutes" → "1 Minute"', () => {
const results = generateSnoozeSuggestions('1 minutes', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 minute');
});
it('keeps plural for non-1: "2 days" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 days', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands compound: "1h30m" → "1 Hour 30 Minutes"', () => {
const results = generateSnoozeSuggestions('1h30m', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour 30 minutes');
});
});
});
@@ -613,7 +613,7 @@
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
"CONTACT_SELECTOR": {
"LABEL": "To:",
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
"CONTACT_CREATING": "Creating contact..."
},
"INBOX_SELECTOR": {
@@ -624,9 +624,9 @@
"SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
"CC_LABEL": "Cc:",
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"CC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_LABEL": "Bcc:",
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_BUTTON": "Bcc"
},
"MESSAGE_EDITOR": {
@@ -182,7 +182,6 @@
},
"COMMAND_BAR": {
"SEARCH_PLACEHOLDER": "Search or jump to",
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
"SECTIONS": {
"GENERAL": "General",
"REPORTS": "Reports",
@@ -374,16 +374,6 @@
"ERROR_MESSAGE": "Error while deleting article"
}
},
"REORDER_ARTICLE": {
"API": {
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
}
},
"REORDER_CATEGORY": {
"API": {
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
}
},
"CREATE_ARTICLE": {
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
},
@@ -849,7 +839,7 @@
"STATUS": {
"UPLOADED": "Ready",
"PROCESSING": "Processing",
"PROCESSED": "Completed",
"PROCESSED": "Completed",
"FAILED": "Failed"
}
},
@@ -34,7 +34,6 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
@@ -78,7 +77,6 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
@@ -1,72 +0,0 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "minute",
"MINUTES": "minutes",
"HOUR": "hour",
"HOURS": "hours",
"DAY": "day",
"DAYS": "days",
"WEEK": "week",
"WEEKS": "weeks",
"MONTH": "month",
"MONTHS": "months",
"YEAR": "year",
"YEARS": "years"
},
"HALF": "half",
"NEXT": "next",
"THIS": "this",
"AT": "at",
"IN": "in",
"FROM_NOW": "from now",
"NEXT_YEAR": "next year",
"MERIDIEM": {
"AM": "am",
"PM": "pm"
},
"RELATIVE": {
"TOMORROW": "tomorrow",
"DAY_AFTER_TOMORROW": "day after tomorrow",
"NEXT_WEEK": "next week",
"NEXT_MONTH": "next month",
"THIS_WEEKEND": "this weekend",
"NEXT_WEEKEND": "next weekend"
},
"TIME_OF_DAY": {
"MORNING": "morning",
"AFTERNOON": "afternoon",
"EVENING": "evening",
"NIGHT": "night",
"NOON": "noon",
"MIDNIGHT": "midnight"
},
"WORD_NUMBERS": {
"ONE": "one",
"TWO": "two",
"THREE": "three",
"FOUR": "four",
"FIVE": "five",
"SIX": "six",
"SEVEN": "seven",
"EIGHT": "eight",
"NINE": "nine",
"TEN": "ten",
"TWELVE": "twelve",
"FIFTEEN": "fifteen",
"TWENTY": "twenty",
"THIRTY": "thirty"
},
"ORDINALS": {
"FIRST": "first",
"SECOND": "second",
"THIRD": "third",
"FOURTH": "fourth",
"FIFTH": "fifth"
},
"OF": "of",
"AFTER": "after",
"WEEK": "week",
"DAY": "day"
}
}
@@ -5,7 +5,7 @@ import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils';
import { useMapGetter } from 'dashboard/composables/store.js';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { fetchContactDetails } from '../helpers/searchHelper';
@@ -18,8 +18,6 @@ const props = defineProps({
const emit = defineEmits(['change']);
const searchContacts = createContactSearcher();
const FROM_TYPE = {
CONTACT: 'contact',
AGENT: 'agent',
@@ -121,10 +119,7 @@ const debouncedSearch = debounce(async query => {
}
try {
const contacts = await searchContacts(query, { skipMinLength: true });
// null means the request was aborted (a newer search is in-flight),
if (contacts === null) return;
const contacts = await searchContacts(query);
// Add selected contact to top if not already in results
const allContacts = selectedContact.value
@@ -135,8 +130,9 @@ const debouncedSearch = debounce(async query => {
: contacts;
searchedContacts.value = allContacts;
isSearching.value = false;
} catch {
// Ignore error
} finally {
isSearching.value = false;
}
}, 300);
@@ -191,7 +191,7 @@ onMounted(() => {
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:is-fetching="isFetching"
:show-know-more="false"
:show-pagination-footer="false"
@@ -31,8 +31,6 @@ const toggleStatus = async (status, snoozedUntil) => {
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
} else if (typeof snoozeType === 'number') {
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, snoozeType);
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
@@ -4,29 +4,16 @@ import { ref, computed, watchEffect, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useLocale } from 'shared/composables/useLocale';
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys';
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
import wootConstants from 'dashboard/constants/globals';
import {
GENERAL_EVENTS,
SNOOZE_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { generateSnoozeSuggestions } from 'dashboard/helper/snoozeHelpers';
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
import {
CMD_SNOOZE_CONVERSATION,
CMD_SNOOZE_NOTIFICATION,
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import { emitter } from 'shared/helpers/mitt';
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const store = useStore();
const { t, tm } = useI18n();
const { resolvedLocale } = useLocale();
const { t } = useI18n();
const ninjakeys = ref(null);
@@ -41,168 +28,48 @@ const { goToCommandHotKeys } = useGoToCommandHotKeys();
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const { conversationHotKeys } = useConversationHotKeys();
const SNOOZE_PARENT_IDS = [
'snooze_conversation',
'snooze_notification',
'bulk_action_snooze_conversation',
];
const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_';
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
const dynamicSnoozeActions = ref([]);
const currentCommandRoot = ref(null);
const placeholder = computed(() =>
SNOOZE_PARENT_IDS.includes(currentCommandRoot.value)
? t('COMMAND_BAR.SNOOZE_PLACEHOLDER')
: t('COMMAND_BAR.SEARCH_PLACEHOLDER')
);
const SNOOZE_PRESET_IDS = new Set(Object.values(wootConstants.SNOOZE_OPTIONS));
const hotKeys = computed(() => {
const allActions = [
...dynamicSnoozeActions.value,
...inboxHotKeys.value,
...goToCommandHotKeys.value,
...goToAppearanceHotKeys.value,
...bulkActionsHotKeys.value,
...conversationHotKeys.value,
];
// When dynamic NLP snooze suggestions exist, hide all preset snooze actions to avoid duplication
if (!dynamicSnoozeActions.value.length) return allActions;
return allActions.filter(
a => !SNOOZE_PRESET_IDS.has(a.id) || !SNOOZE_PARENT_IDS.includes(a.parent)
);
});
const hotKeys = computed(() => [
...inboxHotKeys.value,
...goToCommandHotKeys.value,
...goToAppearanceHotKeys.value,
...bulkActionsHotKeys.value,
...conversationHotKeys.value,
]);
const setCommandBarData = () => {
ninjakeys.value.data = hotKeys.value;
};
const SNOOZE_EVENT_MAP = {
snooze_conversation: CMD_SNOOZE_CONVERSATION,
snooze_notification: CMD_SNOOZE_NOTIFICATION,
bulk_action_snooze_conversation: CMD_BULK_ACTION_SNOOZE_CONVERSATION,
};
const SNOOZE_SECTION_MAP = {
snooze_conversation: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
snooze_notification: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
};
const snoozeTranslations = computed(() => {
const raw = tm('SNOOZE_PARSER');
if (!raw || typeof raw !== 'object') return {};
return JSON.parse(JSON.stringify(raw));
});
const buildDynamicSnoozeActions = (search, parentId) => {
const suggestions = generateSnoozeSuggestions(search, new Date(), {
translations: snoozeTranslations.value,
locale: resolvedLocale.value,
});
if (!suggestions.length) return [];
const busEvent = SNOOZE_EVENT_MAP[parentId];
const section = t(SNOOZE_SECTION_MAP[parentId]);
return suggestions.map((parsed, index) => ({
id: `${DYNAMIC_SNOOZE_PREFIX}${index}`,
title:
parsed.label !== parsed.formattedDate
? `${parsed.label} - ${parsed.formattedDate}`
: parsed.formattedDate,
parent: parentId,
section,
icon: ICON_SNOOZE_CONVERSATION,
keywords: search,
handler: () => {
emitter.emit(busEvent, parsed.resolve());
useTrack(SNOOZE_EVENTS.NLP_SNOOZE_APPLIED, { label: parsed.label });
},
}));
};
const resetSnoozeState = () => {
currentCommandRoot.value = null;
dynamicSnoozeActions.value = [];
};
const patchNinjaKeysOpenClose = el => {
if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') {
return;
}
const originalOpen = el.open.bind(el);
const originalClose = el.close.bind(el);
el.open = (...args) => {
const [options = {}] = args;
currentCommandRoot.value = options.parent || null;
dynamicSnoozeActions.value = [];
return originalOpen(...args);
};
el.close = (...args) => {
resetSnoozeState();
return originalClose(...args);
};
};
const onSelected = item => {
const {
detail: {
action: { title = null, section = null, id = null, children = null } = {},
} = {},
detail: { action: { title = null, section = null, id = null } = {} } = {},
} = item;
selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null;
if (Array.isArray(children) && children.length) {
currentCommandRoot.value = id;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
selectedSnoozeType.value = null;
}
useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title });
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
});
setCommandBarData();
};
const onCommandBarChange = item => {
const { detail: { search = '', actions = [] } = {} } = item;
const normalizedSearch = search.trim();
if (actions.length > 0) {
const uniqueParents = [
...new Set(actions.map(action => action.parent).filter(Boolean)),
];
if (uniqueParents.length === 1) {
currentCommandRoot.value = uniqueParents[0];
} else {
currentCommandRoot.value = null;
}
}
if (
!normalizedSearch ||
!SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '')
) {
dynamicSnoozeActions.value = [];
return;
}
dynamicSnoozeActions.value = buildDynamicSnoozeActions(
normalizedSearch,
currentCommandRoot.value
);
};
const onClosed = () => {
if (selectedSnoozeType.value !== CUSTOM_SNOOZE) {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
if (
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
) {
store.dispatch('setContextMenuChatId', null);
}
resetSnoozeState();
};
watchEffect(() => {
@@ -211,10 +78,7 @@ watchEffect(() => {
}
});
onMounted(() => {
setCommandBarData();
patchNinjaKeysOpenClose(ninjakeys.value);
});
onMounted(setCommandBarData);
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
@@ -224,7 +88,6 @@ onMounted(() => {
noAutoLoadMdIcons
hideBreadcrumbs
:placeholder="placeholder"
@change="onCommandBarChange"
@selected="onSelected"
@closed="onClosed"
/>
@@ -11,10 +11,7 @@ const store = useStore();
const pageNumber = ref(1);
const allArticles = useMapGetter('articles/allArticles');
const articlesSortedByPosition = useMapGetter(
'articles/allArticlesSortedByPosition'
);
const articles = useMapGetter('articles/allArticles');
const categories = useMapGetter('categories/allCategories');
const meta = useMapGetter('articles/getMeta');
const portalMeta = useMapGetter('portals/getMeta');
@@ -61,11 +58,6 @@ const isCategoryArticles = computed(() => {
);
});
// Use position-sorted articles for category views and categories filter view (where drag reorder is enabled)
const articles = computed(() =>
isCategoryArticles.value ? articlesSortedByPosition.value : allArticles.value
);
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
store.dispatch('articles/index', {
pageNumber: pageNumberParam || pageNumber.value,
@@ -9,7 +9,7 @@ import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryP
const store = useStore();
const route = useRoute();
const categories = useMapGetter('categories/allCategoriesSortedByPosition');
const categories = useMapGetter('categories/allCategories');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const getPortalBySlug = useMapGetter('portals/portalBySlug');
@@ -69,8 +69,6 @@ export default {
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.snoozeNotification(snoozeType);
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
@@ -160,7 +160,6 @@ export default {
@submit.prevent="updateAccount"
>
<WithLabel
name="account-name"
:has-error="v$.name.$error"
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
@@ -174,7 +173,6 @@ export default {
/>
</WithLabel>
<WithLabel
name="site-language"
:has-error="v$.locale.$error"
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
@@ -191,7 +189,6 @@ export default {
</WithLabel>
<WithLabel
v-if="featureCustomReplyDomainEnabled"
name="custom-domain"
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
>
<NextInput
@@ -214,7 +211,6 @@ export default {
</WithLabel>
<WithLabel
v-if="featureCustomReplyEmailEnabled"
name="support-email"
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
>
<NextInput
@@ -228,10 +228,7 @@ export const mutations = {
},
[types.ADD_CONVERSATION](_state, conversation) {
const exists = _state.allConversations.some(c => c.id === conversation.id);
if (!exists) {
_state.allConversations.push(conversation);
}
_state.allConversations.push(conversation);
},
[types.DELETE_CONVERSATION](_state, conversationId) {
@@ -167,17 +167,7 @@ export const actions = {
return fileUrl;
},
reorder: async (
{ commit, state },
{ portalSlug, categorySlug, reorderedGroup }
) => {
// Save old positions so we can rollback on failure
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
map[id] = state.articles.byId[id]?.position;
return map;
}, {});
// Update positions in the store immediately so subsequent mutations preserve correct positions
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
try {
await articlesAPI.reorderArticles({
portalSlug,
@@ -185,8 +175,9 @@ export const actions = {
categorySlug,
});
} catch (error) {
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
throw error;
throwErrorMessage(error);
}
return '';
},
};
@@ -22,16 +22,6 @@ export const getters = {
.filter(article => article !== undefined);
return articles;
},
allArticlesSortedByPosition: (...getterArguments) => {
const [state, _getters] = getterArguments;
const articles = state.articles.allIds
.map(id => _getters.articleById(id))
.filter(article => article !== undefined);
// Sort by position so reordered articles stay in correct order after store updates
return articles.sort(
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
);
},
articleStatus:
(...getterArguments) =>
articleId => {
@@ -64,18 +64,6 @@ export const mutations = {
...uiFlags,
};
},
[types.SET_ARTICLE_POSITIONS]: ($state, positionsHash) => {
const { byId, allIds } = $state.articles;
// Update position on each article record
Object.entries(positionsHash).forEach(([id, position]) => {
if (byId[id]) byId[id] = { ...byId[id], position };
});
// Re-sort allIds so every consumer sees the new order
allIds.sort(
(a, b) =>
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
);
},
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
const articleId = updatedArticle.id;
if ($state.articles.byId[articleId]) {
@@ -279,63 +279,4 @@ describe('#actions', () => {
).rejects.toThrow('Upload failed');
});
});
describe('#reorder', () => {
const state = {
articles: {
byId: {
1: { id: 1, title: 'Article 1', position: 10 },
2: { id: 2, title: 'Article 2', position: 20 },
3: { id: 3, title: 'Article 3', position: 30 },
},
},
};
it('commits SET_ARTICLE_POSITIONS and calls API when reorder is successful', async () => {
axios.post.mockResolvedValue({ data: {} });
const reorderedGroup = { 1: 1, 2: 2, 3: 3 };
await actions.reorder(
{ commit, state },
{
portalSlug: 'test-portal',
categorySlug: 'test-category',
reorderedGroup,
}
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_ARTICLE_POSITIONS,
reorderedGroup
);
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/portals/test-portal/articles/reorder'),
{ positions_hash: reorderedGroup, category_slug: 'test-category' }
);
});
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
const reorderedGroup = { 1: 1, 2: 2 };
await expect(
actions.reorder(
{ commit, state },
{
portalSlug: 'test-portal',
reorderedGroup,
}
)
).rejects.toEqual({ message: 'Network error' });
expect(commit).toHaveBeenCalledWith(
types.default.SET_ARTICLE_POSITIONS,
reorderedGroup
);
expect(commit).toHaveBeenCalledWith(types.default.SET_ARTICLE_POSITIONS, {
1: 10,
2: 20,
});
});
});
});
@@ -41,82 +41,4 @@ describe('#getters', () => {
it('isFetchingArticles', () => {
expect(getters.isFetching(state)).toEqual(true);
});
describe('allArticlesSortedByPosition', () => {
it('returns articles sorted by position in ascending order', () => {
const stateWithPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 3 },
2: { id: 2, title: 'Article 2', position: 1 },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([2, 3, 1]);
expect(result.map(a => a.position)).toEqual([1, 2, 3]);
});
it('places articles with null position at the end', () => {
const stateWithNullPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 1 },
2: { id: 2, title: 'Article 2', position: null },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithNullPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithNullPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
});
it('handles articles with undefined position', () => {
const stateWithUndefinedPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 1 },
2: { id: 2, title: 'Article 2' },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithUndefinedPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithUndefinedPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
});
});
});
@@ -5,7 +5,7 @@ import types from '../../../mutation-types';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = JSON.parse(JSON.stringify(article));
state = article;
});
describe('#SET_UI_FLAG', () => {
@@ -93,9 +93,9 @@ describe('#mutations', () => {
mutations[types.ADD_ARTICLE_ID](state, 3);
expect(state.articles.allIds).toEqual([1, 2, 3]);
});
it('does not add duplicate article id to state', () => {
mutations[types.ADD_ARTICLE_ID](state, 1);
expect(state.articles.allIds).toEqual([1, 2]);
it('Does not invalid article with empty data passed', () => {
mutations[types.ADD_ARTICLE_ID](state, {});
expect(state).toEqual(article);
});
});
@@ -154,53 +154,4 @@ describe('#mutations', () => {
});
});
});
describe('#SET_ARTICLE_POSITIONS', () => {
it('updates positions for articles in the store', () => {
const positionsHash = { 1: 1, 2: 2 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[1].position).toEqual(1);
expect(state.articles.byId[2].position).toEqual(2);
});
it('does not update articles that are not in the store', () => {
const positionsHash = { 999: 5 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[999]).toBeUndefined();
});
it('preserves other article properties when updating position', () => {
const originalTitle = state.articles.byId[1].title;
const positionsHash = { 1: 3 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[1].position).toEqual(3);
expect(state.articles.byId[1].title).toEqual(originalTitle);
});
it('re-sorts allIds by position after update', () => {
state.articles.byId[1].position = 1;
state.articles.byId[2].position = 2;
state.articles.allIds = [1, 2];
mutations[types.SET_ARTICLE_POSITIONS](state, { 1: 3, 2: 1 });
expect(state.articles.allIds).toEqual([2, 1]);
});
it('UPDATE_ARTICLE preserves reordered position after SET_ARTICLE_POSITIONS', () => {
mutations[types.SET_ARTICLE_POSITIONS](state, { 2: 1 });
expect(state.articles.byId[2].position).toEqual(1);
mutations[types.UPDATE_ARTICLE](state, {
id: 2,
title: 'Updated Title',
status: 'published',
});
expect(state.articles.byId[2].position).toEqual(1);
expect(state.articles.byId[2].title).toEqual('Updated Title');
});
});
});
@@ -92,23 +92,4 @@ export const actions = {
});
}
},
reorder: async ({ commit, state }, { portalSlug, reorderedGroup }) => {
// Save old positions so we can rollback on failure
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
map[id] = state.categories.byId[id]?.position;
return map;
}, {});
// Update positions in the store immediately so subsequent mutations preserve correct positions
commit(types.SET_CATEGORY_POSITIONS, reorderedGroup);
try {
await categoriesAPI.reorder({
portalSlug,
reorderedGroup,
});
} catch (error) {
commit(types.SET_CATEGORY_POSITIONS, oldPositions);
throw error;
}
},
};
@@ -21,16 +21,6 @@ export const getters = {
});
return categories;
},
allCategoriesSortedByPosition: (...getterArguments) => {
const [state, _getters] = getterArguments;
const categories = state.categories.allIds
.map(id => _getters.categoryById(id))
.filter(category => category !== undefined);
// Sort by position so reordered categories stay in correct order after store updates
return categories.sort(
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
);
},
categoriesByLocaleCode:
(...getterArguments) =>
localeCode => {
@@ -49,18 +49,6 @@ export const mutations = {
...uiFlags,
};
},
[types.SET_CATEGORY_POSITIONS]: ($state, positionsHash) => {
const { byId, allIds } = $state.categories;
// Update position on each category record
Object.entries(positionsHash).forEach(([id, position]) => {
if (byId[id]) byId[id] = { ...byId[id], position };
});
// Re-sort allIds so every consumer sees the new order
allIds.sort(
(a, b) =>
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
);
},
[types.UPDATE_CATEGORY]($state, category) {
const categoryId = category.id;
@@ -161,63 +161,4 @@ describe('#actions', () => {
]);
});
});
describe('#reorder', () => {
const state = {
categories: {
byId: {
1: { id: 1, name: 'Category 1', position: 10 },
2: { id: 2, name: 'Category 2', position: 20 },
},
},
};
it('commits SET_CATEGORY_POSITIONS and calls API when reorder is successful', async () => {
axios.post.mockResolvedValue({ data: {} });
const reorderedGroup = { 2: 1, 1: 2 };
await actions.reorder(
{ commit, state },
{
portalSlug: 'room-rental',
reorderedGroup,
}
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
reorderedGroup
);
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/portals/room-rental/categories/reorder'),
{
positions_hash: { 2: 1, 1: 2 },
}
);
});
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
const reorderedGroup = { 2: 1, 1: 2 };
await expect(
actions.reorder(
{ commit, state },
{
portalSlug: 'room-rental',
reorderedGroup,
}
)
).rejects.toEqual({ message: 'Incorrect header' });
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
reorderedGroup
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
{ 1: 10, 2: 20 }
);
});
});
});
@@ -25,82 +25,4 @@ describe('#getters', () => {
it('isFetchingCategories', () => {
expect(getters.isFetching(state)).toEqual(true);
});
describe('allCategoriesSortedByPosition', () => {
it('returns categories sorted by position in ascending order', () => {
const stateWithPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 3 },
2: { id: 2, name: 'Category 2', position: 1 },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([2, 3, 1]);
expect(result.map(c => c.position)).toEqual([1, 2, 3]);
});
it('places categories with null position at the end', () => {
const stateWithNullPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 1 },
2: { id: 2, name: 'Category 2', position: null },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithNullPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithNullPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
});
it('handles categories with undefined position', () => {
const stateWithUndefinedPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 1 },
2: { id: 2, name: 'Category 2' },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithUndefinedPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithUndefinedPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
});
});
});
@@ -4,7 +4,7 @@ import { categoriesState, categoriesPayload } from './fixtures';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = JSON.parse(JSON.stringify(categoriesState));
state = categoriesState;
});
describe('#SET_UI_FLAG', () => {
@@ -53,9 +53,9 @@ describe('#mutations', () => {
mutations[types.ADD_CATEGORY_ID](state, 3);
expect(state.categories.allIds).toEqual([1, 2, 3]);
});
it('pushes the given id to allIds', () => {
it('Does not invalid category with empty data passed', () => {
mutations[types.ADD_CATEGORY_ID](state, {});
expect(state.categories.allIds).toEqual([1, 2, {}]);
expect(state).toEqual(categoriesState);
});
});
@@ -98,40 +98,4 @@ describe('#mutations', () => {
// expect(state.categories.uiFlags).toEqual({});
// });
// });
describe('#SET_CATEGORY_POSITIONS', () => {
it('updates positions for categories in the store', () => {
const positionsHash = { 1: 1, 2: 2 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[1].position).toEqual(1);
expect(state.categories.byId[2].position).toEqual(2);
});
it('does not update categories that are not in the store', () => {
const positionsHash = { 999: 5 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[999]).toBeUndefined();
});
it('preserves other category properties when updating position', () => {
const originalName = state.categories.byId[1].name;
const positionsHash = { 1: 3 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[1].position).toEqual(3);
expect(state.categories.byId[1].name).toEqual(originalName);
});
it('re-sorts allIds by position after update', () => {
state.categories.byId[1].position = 1;
state.categories.byId[2].position = 2;
state.categories.allIds = [1, 2];
mutations[types.SET_CATEGORY_POSITIONS](state, { 1: 3, 2: 1 });
expect(state.categories.allIds).toEqual([2, 1]);
});
});
});
@@ -975,16 +975,6 @@ describe('#mutations', () => {
mutations[types.ADD_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
it('should not add a duplicate conversation', () => {
const conversation = { id: 1, messages: [] };
const state = {
allConversations: [conversation],
};
mutations[types.ADD_CONVERSATION](state, { id: 1, messages: [] });
expect(state.allConversations).toHaveLength(1);
});
});
describe('#DELETE_CONVERSATION', () => {
@@ -290,7 +290,6 @@ export default {
REMOVE_ARTICLE: 'REMOVE_ARTICLE',
REMOVE_ARTICLE_ID: 'REMOVE_ARTICLE_ID',
SET_UI_FLAG: 'SET_UI_FLAG',
SET_ARTICLE_POSITIONS: 'SET_ARTICLE_POSITIONS',
// Help Center -- Categories
ADD_CATEGORY: 'ADD_CATEGORY',
@@ -302,7 +301,6 @@ export default {
UPDATE_CATEGORY: 'UPDATE_CATEGORY',
REMOVE_CATEGORY: 'REMOVE_CATEGORY',
REMOVE_CATEGORY_ID: 'REMOVE_CATEGORY_ID',
SET_CATEGORY_POSITIONS: 'SET_CATEGORY_POSITIONS',
// Agent Bots
SET_AGENT_BOT_UI_FLAG: 'SET_AGENT_BOT_UI_FLAG',
@@ -6,12 +6,7 @@ const RECONNECT_INTERVAL = 1000;
class BaseActionCableConnector {
static isDisconnected = false;
constructor(
app,
pubsubToken,
websocketHost = '',
presenceInterval = PRESENCE_INTERVAL
) {
constructor(app, pubsubToken, websocketHost = '') {
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
this.consumer = createConsumer(websocketURL);
@@ -42,7 +37,7 @@ class BaseActionCableConnector {
setTimeout(() => {
this.subscription.updatePresence();
this.triggerPresenceInterval();
}, presenceInterval);
}, PRESENCE_INTERVAL);
};
this.triggerPresenceInterval();
}

Some files were not shown because too many files have changed in this diff Show More