Compare commits

...
Author SHA1 Message Date
Muhsin 8801a7b09a feat(inboxes): show connection action indicators 2026-07-16 15:53:28 +04:00
Tanmay Deep SharmaandGitHub 2891a72cb9 feat(whatsapp): enable reconfigure for embedded signup inboxes (#15038) 2026-07-16 16:18:58 +05:30
522e3c4d3f feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)
This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.

## What changed

- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.

## Existing paid-account rollout

Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.

```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

total = accounts.count
enabled = 0
skipped = 0

puts "Enabling api_and_webhooks for #{total} paid account(s)..."

accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
  if account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    account.enable_features!('api_and_webhooks')
    enabled += 1
  end

  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end

puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```

For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:

```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```

## How to test

- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.


### Screenshots

## Cloud

<img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x"
src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b"
/>

<img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x"
src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 13:43:49 +04:00
0a25a0ef66 fix(whatsapp): log manual transfer only for embedded signup migrations (#15032)
The `[WHATSAPP_MANUAL_TRANSFER] success` log introduced in #14975 to
track embedded signup → manual migrations was firing on any WhatsApp
credential change — including routine `api_key` rotations on inboxes
that were already manually configured — inflating the migration count.
The log now fires only for the actual migration, and uses a new tag so
log searches don't match the older over-counted entries.

## How to reproduce

1. On a manually configured WhatsApp Cloud inbox, update the API key
from inbox settings → Configuration.
2. Before this change, the app log records a `[WHATSAPP_MANUAL_TRANSFER]
success` line even though no migration happened; after this change it
stays silent.
3. Switching an embedded signup inbox to manual setup still logs the
migration (now as `[WHATSAPP_EMBEDDED_TO_MANUAL] success`).

## What changed

- `Channel::Whatsapp#log_credentials_transfer` now keys off the
migration's unique signal — `provider_config['source']` changing from
`embedded_signup` to absent — instead of diffing credential keys.
- Renamed the log tag from `WHATSAPP_MANUAL_TRANSFER` to
`WHATSAPP_EMBEDDED_TO_MANUAL` (success and failure lines) so the
corrected entries are searchable without matching pre-fix false
positives.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 13:35:20 +04:00
Sivin VargheseandGitHub 0fccb7dacd feat: stage edits to published articles as drafts (#14842) 2026-07-16 12:44:10 +05:30
PranavandGitHub f1a07657e9 fix(whatsapp): restore embedded signup reauthorization banner (#15035)
Show the reauthorization banner whenever an embedded signup inbox has a
reauthorization error instead of suppressing it when the manual
migration flow is enabled. Reword the manual migration banner and dialog
as a recommendation to reconnect with your own Meta app, and switch
their styling from amber warning to blue info.
2026-07-16 12:28:30 +05:30
9cb6c501b5 fix: html/body background not applied in appearance mode (#13989)
# Pull Request Template

## Description

This PR fixes the white background bleed visible in the widget, article
viewer, and Help Center when dark mode is active.
This change was previously merged but later reverted due to a background
being added for the unread bubble.

Reverted PR: https://github.com/chatwoot/chatwoot/pull/13955,
https://github.com/chatwoot/chatwoot/pull/13981

**What was happening**

While scrolling, the `<body>` element retained a white background in
dark mode. This occurred because dark mode classes were only applied to
inner container elements, not the root.

**What changed**

* **Widget:** Updated the `useDarkMode` composable to sync the `dark`
class to `<html>` using `watchEffect`, allowing `<body>` to inherit dark
theme variables. Also added background styles to `html`, `body`, and
`#app` in `woot.scss`.
* **Help center portal:** Moved `bg-white dark:bg-slate-900` from
`<main>` to `<body>` in the portal layout so the entire page background
responds correctly to dark mode, including within the widget iframe.
* **ArticleViewer:** Replaced hardcoded `bg-white` with `bg-n-solid-1`
to ensure better theming.


Fixes
https://linear.app/chatwoot/issue/CW-6704/widget-body-colour-not-implemented

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screencasts

### Before

**Widget**


https://github.com/user-attachments/assets/e0224ad1-81a6-440a-a824-e115fb806728

**Help center**


https://github.com/user-attachments/assets/40a8ded5-5360-474d-9ec5-fd23e037c845



### After

**Widget**


https://github.com/user-attachments/assets/dd37cc68-99fc-4d60-b2ae-cf41f9d4d38c

<img width="347" height="265" alt="image"
src="https://github.com/user-attachments/assets/89460b36-c8ed-4579-9737-c258c6fe5da5"
/>


**Help center**


https://github.com/user-attachments/assets/bc998c4e-ef77-46fa-ac7f-4ea16d912ce3




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 12:21:01 +05:30
Vishnu NarayananandGitHub ab01ab7853 fix(perf): back off conversation meta polling when the count is unknown (#15031)
## Description

The conversation sidebar counts (`GET /conversations/meta`) are polled
on a debounced cadence selected from the last known conversation count
(`allCount` in the `conversationStats` store).

`allCount` starts at `0` and is only set after a **successful** meta
response. When the meta query is slow or failing under load, `allCount`
stays `0`, which selected the fastest cadence, so every client polled as
aggressively as possible exactly when the endpoint was already
struggling. On large accounts with many concurrent agents this
multiplies into sustained load that keeps the query slow, a
self-reinforcing loop.

This PR makes two changes:

1. **Treat an unknown count (`0`) as a large account** and poll at the
slowest cadence instead of the fastest (`getMetaDebounceKey`, extracted
as a pure, testable function). This breaks the failure loop.
2. **Raise the debounce intervals across all tiers** so counts are
polled less frequently under sustained activity:
   - fast: max 1 poll / 2s -> 1 / 5s
   - mid: 1 / 10s -> 1 / 20s
   - slow (large/unknown accounts): 1 / 20s -> 1 / 30s

First-load counts still render immediately (the debounce fires on the
leading edge for the very first call), so the higher intervals only
affect the sustained poll rate, not initial render.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-07-16 11:41:28 +05:30
Aakash BakhleandGitHub 6d74ff9477 fix(captain): make assistant switcher scrollable (#15027) 2026-07-16 10:44:22 +05:30
354c2cab6b fix(captain): handle resolved conversation context (#14433)
# Pull Request Template

## Description

Fixes: https://github.com/chatwoot/chatwoot/issues/13880

Uses approaches discussed from:
https://github.com/chatwoot/chatwoot/pull/13883

Activity messages pertaining to resolve are included along with an
instruction for the LLM to choose whether to consider them or not along

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and with specs


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-15 23:30:17 +05:30
Aakash BakhleandGitHub 9c68eed676 fix(captain): send custom tool API key headers (#15008)
Captain custom tools configured with API key authentication now send the
configured key in the requested HTTP header. Existing tools begin
working without needing to be recreated or reconfigured, while
credentials remain protected across redirects.

## How to reproduce

1. Create a Captain custom tool using API Key authentication.
2. Configure `X-API-Key` as the header name and save the tool.
3. Invoke the tool and inspect the incoming request.
4. Before this change, the API key header is absent; after this change,
the configured endpoint receives it.

## What changed

The UI persists API key authentication as `name` and `key`, but the
request builder also required an unused `location: header` property. The
request builder now treats API key authentication as header-based,
matching the only mode exposed by the UI.

Custom authentication headers are also registered as sensitive with
`SafeFetch`. They are retained for the configured endpoint and
same-origin redirects, but stripped when a redirect crosses origins to
prevent credential leakage. Factory, request, and redirect specs cover
the real UI payload and both public and private-network fetch paths.
2026-07-15 22:43:30 +05:30
Aakash BakhleandGitHub 6d38b4d39c fix(captain): improve complex migration instructions (#15002)
Improves Captain V1 → V2 migration for complex legacy instructions so
mandatory triggers, workflows, language rules, and escalation behavior
remain active while query-dependent product knowledge is prepared as
pending FAQ candidates.

## What changed

- Added explicit preservation rules for mandatory triggers, verification
steps, escalation conditions, exceptions, and language behavior.
- Added an auditor that checks the draft and fixes any issues before
manual review.
- Kept the existing migration application contract and schema limits
unchanged
- Added focused regression coverage for the complex-prompt classifier
contract.

## How to reproduce

Generate a migration draft for an assistant with dense legacy
instructions containing mandatory handoff triggers, verification rules,
product facts, and multi-step workflows. The resulting draft should keep
actions active, place query-dependent facts in FAQ candidates, and avoid
silently dropping or reversing source requirements.

Focused Captain migration specs and RuboCop checks pass locally.
2026-07-15 14:51:51 +05:30
Aakash BakhleandGitHub fd625981e9 fix(captain): keep custom tools available in Captain V2 (#15015)
Captain V2 assistants can now use every enabled custom tool from their
account through the main assistant. The change keeps existing custom
tool access when an assistant has no migrated scenarios, so switching
from V1 does not remove the capability without warning.

## How to reproduce

1. Create and enable an account custom tool.
2. Use an assistant with no custom instructions and no generated
scenarios.
3. Enable Captain V2 for the account.
4. Before this change, the main assistant receives only FAQ lookup and
handoff. After this change, it also receives the enabled account custom
tool.

## What changed

The main V2 assistant now loads enabled custom tools through its account
association. Scenario agents still load only the tools named in their
scenario instructions. The account custom tool limit keeps the added
tool count bounded.

Focused model coverage verifies enabled tools, disabled tools, account
isolation, FAQ lookup, and handoff. Existing V1 assistant, V2 scenario,
and V2 runner coverage passes. RuboCop passes.
2026-07-15 11:35:57 +05:30
49c442751d fix: require admin for dashboard app mutations (#14831)
## Summary

Restricts account-wide Dashboard App creation, updates, and deletion to
administrators while keeping read access available to authenticated
account users.

## Why

Dashboard Apps are account-level integrations displayed in conversation
views. Agents should be able to use them, but only administrators should
be able to change their configuration.

## What changed

- authorize Dashboard App actions through `DashboardAppPolicy`
- allow index and show access for authenticated account users
- restrict create, update, and destroy actions to administrators
- add request coverage for administrator and agent mutation behavior

## Validation

`bundle exec rspec
spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb`

17 examples, 0 failures.

`bundle exec rubocop
app/controllers/api/v1/accounts/dashboard_apps_controller.rb
app/policies/dashboard_app_policy.rb`

2 files inspected, no offenses detected.



---------

Co-authored-by: Gaurav Singhal <gauravsinghal@Gauravs-Mac-mini.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-14 23:02:13 -07:00
13db36609d fix: hide agent bot access tokens from agents (#14830)
## Summary

Keeps Agent Bot list and show access available to agents while
restricting account bot access tokens to administrators.

## Why

Agents need Agent Bot metadata for existing product workflows, but the
bot access token can be replayed against bot-authorized APIs and should
not be exposed to them.

## What changed

- serialize `access_token` only for administrators
- verify agents can read Agent Bot metadata without receiving the token
- verify administrators still receive the token from index and show
responses

## Validation

`bundle exec rspec
spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb`

27 examples, 0 failures.

Related follow-up:
[CW-7595](https://linear.app/chatwoot/issue/CW-7595/standardize-one-time-credential-disclosure-across-chatwoot-apis)



---------

Co-authored-by: Gaurav Singhal <gauravsinghal@Gauravs-Mac-mini.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-14 20:00:39 -07:00
Muhsin KelothandGitHub 8b4f3e226e revert: "fix(meta): disable Instagram replies on Cloud during restriction" (#15020)
Reverts chatwoot/chatwoot#15005
2026-07-14 14:59:41 -07:00
Aakash BakhleandGitHub 2ac55c8728 fix(captain): honor mandatory handoff guidelines (#15003)
Ensures Captain follows explicit mandatory-transfer rules from active
Response Guidelines and Guardrails instead of allowing the generic
consent-first fallback to override those rules.

## What changed

- Made explicit transfer requirements take precedence over generic
consent-first handoff defaults only when their condition matches.
- Added explicit Response Guideline and Guardrail transfer rules to the
human-handoff protocol.
- Added focused prompt regression coverage.

## How to reproduce

Configure a Response Guideline or Guardrail that requires immediate
transfer for a specific condition, then send a request matching that
condition. Captain should invoke the human-handoff path without asking
the user to consent again. Unmatched requests continue to use the
existing consent-first fallback.

The assistant prompt renderer, agent prompt context, and focused
regression specs pass locally.
2026-07-14 19:38:43 +05:30
9328f8739c fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010)
Deleting a manually-configured WhatsApp Cloud inbox left its
phone-number-level webhook override still pointing at Chatwoot on Meta's
side. The number kept routing inbound events to us after the inbox was
gone, which blocked the customer's own app — subscribed separately on
the same WABA — from receiving messages, since the phone-level override
takes priority over the app-level subscription. Deleting the inbox now
releases the override, as it already did for embedded-signup inboxes.

## What changed

The setup and teardown paths gated on opposite halves of the same
condition. `Channel::Whatsapp#should_auto_setup_webhooks?` sets the
override for `whatsapp_cloud` inboxes where `source !=
'embedded_signup'` (i.e. manual ones), while
`Whatsapp::WebhookTeardownService#should_teardown_webhook?` only cleared
it when `source == 'embedded_signup'`. The two sets are disjoint, so
manual inboxes were exactly the ones that set an override on create and
never cleared it on destroy. Embedded-signup inboxes were unaffected
because `EmbeddedSignupService` calls `setup_webhooks` explicitly.

Dropping the `source` check from the teardown guard is the whole fix.
Manual `whatsapp_cloud` channels can't persist without `api_key`,
`phone_number_id` and `business_account_id` (`validate_provider_config`
verifies all three against Meta), so the remaining presence guards and
both API calls have everything they need. The WABA-level `DELETE
/subscribed_apps` now also fires for manual inboxes when the last one on
a WABA is removed, which is symmetric with manual setup subscribing the
app in the first place; the token only unsubscribes the app it belongs
to, so a customer's separate app subscription is untouched.

This fixes the leak going forward. Numbers already stranded still need
the override cleared with the customer's own token, since we no longer
hold their `api_key` once the inbox is deleted.

## How to reproduce

1. Create a WhatsApp Cloud inbox using manual API keys (not embedded
signup).
2. Confirm the override is set: `GET
/v22.0/{phone_number_id}?fields=webhook_configuration` shows
`phone_number` pointing at your Chatwoot install.
3. Delete the inbox.
4. Before this change, the override still points at Chatwoot. After it,
`webhook_configuration` no longer carries the phone-level override and
events fall back to the WABA/app-level subscription.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-14 15:05:27 +05:30
3e03f8da1e chore(whatsapp): log warning when Cloud API template sync fails (#15004)
WhatsApp Cloud API template sync currently fails silently — if the Graph
API call errors (expired token, rate limit, permission issue), the
channel simply keeps its stale templates with no trace in the logs. This
adds a warning log when the template fetch fails, so failed syncs are
visible and debuggable.

## What changed

- `Whatsapp::Providers::WhatsappCloudService#fetch_whatsapp_templates`
now logs a warning with the account id, inbox id, HTTP status code, and
Meta's error message when the response is not successful.
- The inbox id uses safe navigation since sync also runs from the
channel's `after_create` callback, before the inbox record exists.
- The request URL is intentionally not logged, as it contains the access
token as a query param.

## How to reproduce

1. Set up a WhatsApp Cloud inbox with an invalid/expired `api_key`.
2. Trigger a template sync (Inbox settings → sync templates, or wait for
the scheduler).
3. Previously nothing was logged; now a `[WHATSAPP] Template sync failed
for account ... inbox ...` warning appears in the Rails logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-14 13:35:10 +04:00
280756b483 fix(meta): disable Instagram replies on Cloud during restriction (#15005)
Agents can no longer send replies in Instagram conversations on Chatwoot
Cloud while the temporary Meta platform restriction is active. The reply
box locks into Private Note mode — the same behavior as an expired
24-hour reply window — so teams can still collaborate internally, with
the existing amber restriction banner above the conversation explaining
why. Self-hosted installations are unaffected.

Follow-up to #14974.


## How to test

1. On a Chatwoot Cloud environment (`isOnChatwootCloud` true), open any
Instagram conversation.
2. The composer should be locked to Private Note mode: the Reply/Private
Note toggle is disabled, and sending creates a private note — even for
conversations within the 24-hour reply window.
3. Switching between conversations should keep the composer in Private
Note mode for Instagram conversations.
4. On a self-hosted environment, Instagram conversations should behave
as before (reply allowed within the messaging window).

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-14 13:30:19 +04:00
Aakash BakhleandGitHub 102f19fe41 feat(captain): add FAQ suggestion data model (1/3) (#14977)
Resolved conversations need a separate suggestion layer so repeated FAQ
signals can be grouped without creating untrusted knowledge entries.
This PR adds the persistence foundation only; it introduces no
user-facing behavior by itself.

## Closes

-
[CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions)
(stacked PR 1/3; the issue is complete after the full stack lands)

## What changed

- Added `captain_faq_suggestions` with question, answer, embedding,
source count, and review status.
- Added `captain_faq_observations` to retain conversation-level signals.
- Added Captain assistant, account, and conversation associations.
- Added vector and lookup indexes for semantic grouping.

## How to test

This layer has no standalone UI behavior. Apply the migration and
confirm Captain assistants can persist open FAQ suggestions with
attached conversation observations.
2026-07-14 14:31:08 +05:30
1b6a80d84d fix(captain): improve conversation completion evaluation (#14967)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes
https://linear.app/chatwoot/issue/AI-136/check-conversation-status-while-auto-resolving

- After 60mins of inactivity, we run a job that decides if pending
conversations are resolvable or need handoff
- the prompt was a bit conservative and didn't have conversation state
context

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally ran a sample eval 

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-14 11:45:40 +05:30
Shivam MishraandGitHub 9c444315a6 feat: add api_and_webhooks feature flag reconciled from billing plan (#14972)
This introduces a new `api_and_webhooks` account feature flag that will
control access to the token-authenticated API and account webhooks. The
flag is part of the Startup plan features, so paid plans — including
trials of paid plans — get it through the billing reconcile, while
accounts on the default (Hacker) plan don't, with
`manually_managed_features` available as a per-account override. The
flag defaults to enabled, and nothing enforces it yet, so this PR is
behavior-neutral — enforcement lands in a follow-up.

## What changed

- Added `api_and_webhooks` to `features.yml` (first flag on the
`feature_flags_ext_1` column, default enabled).
- Added the flag to `STARTUP_PLAN_FEATURES` in
`Enterprise::Billing::ReconcilePlanFeaturesService`, so all paid tiers
get it and the default plan loses it on reconcile.
- Added the flag to the manually manageable features list so it can be
granted per account via Super Admin.

```rb
# Enables the api_and_webhooks feature for all existing accounts and marks it
# as manually managed so cloud billing reconciles never strip it.
#
# NOT committed to source control — run manually on production.
#
# Usage:
#   bundle exec rails runner enable_api_and_webhooks.rb
#   ACCOUNT_ID=123 bundle exec rails runner enable_api_and_webhooks.rb
#
# Idempotent: accounts already grandfathered are skipped; safe to re-run.

probe = Internal::Accounts::InternalAttributesService.new(Account.new)
abort 'api_and_webhooks is not in valid_feature_list — deploy the feature flag PR first.' unless probe.valid_feature_list.include?('api_and_webhooks')

account_id = ENV.fetch('ACCOUNT_ID', nil)
accounts = account_id.present? ? Account.where(id: account_id) : Account.all

abort "Account with ID #{account_id} not found" if account_id.present? && accounts.empty?

total = accounts.count
puts "Grandfathering api_and_webhooks for #{total} account(s)..."
puts "Started at: #{Time.current}"

updated = 0
skipped = 0
errored = 0

accounts.find_each(batch_size: 500) do |account|
  service = Internal::Accounts::InternalAttributesService.new(account)
  features = service.manually_managed_features

  if features.include?('api_and_webhooks') && account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    service.manually_managed_features = features + ['api_and_webhooks'] unless features.include?('api_and_webhooks')
    account.enable_features!('api_and_webhooks')
    updated += 1
  end

  processed = updated + skipped + errored
  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
rescue StandardError => e
  errored += 1
  puts "Account #{account.id}: FAILED - #{e.message}"
end

puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
```
2026-07-13 18:20:36 +05:30
df7f137657 feat: add captain sessions model [CW-7485] (#14970)
This adds a `captain_sessions` table to log every Captain run, starting
with Assistant Responses and Copilot Responses. Each session records the
assistant, model, credits consumed, the FAQs/documents/scenario that
contributed to the response, and the full run context — giving customers
visibility into how a response was generated and giving us durable stats
on credit, FAQ, and document usage (which today only exist as ephemeral
trace metadata and an aggregate account counter).

## What changed

- New `Captain::Session` model with a `session_type` enum (`assistant`,
`copilot`). The subject (`Conversation` / `CopilotThread`) and result
(`Message` / `CopilotMessage`) classes are inferred from the session
type, so the table stores plain `subject_id` / `result_id` ids.
`result_id` is nullable so failed runs that still consumed credits can
be logged.
- Composite indexes on `[session_type, subject_id]`, `[session_type,
result_id]`, and `[account_id, session_type, created_at]` for lookup and
usage-stats queries.
- Factory and model specs.

This PR is schema + model only; the writer/instrumentation that records
sessions from the assistant and copilot flows will follow.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-13 18:18:20 +05:30
Vishnu NarayananandGitHub 056b5eb89d fix: avoid full scan in IMAP email dedup on large inboxes (#14981)
## Description

`Imap::BaseFetchEmailService#email_already_present?` used
`find_by(source_id:)`, which inherits `Message`'s `default_scope {
order(created_at: :asc) }`, adding an `ORDER BY created_at ASC LIMIT 1`
to what is only a presence check.

On inboxes with a large message history, that `ORDER BY` lets Postgres
satisfy the sort by walking `index_messages_on_created_at` instead of
the selective `index_messages_on_source_id`. For a not-yet-seen
`source_id` (every new email) it can scan the whole table before
returning, taking seconds per message. The dedup loop runs with no IMAP
activity in between, so the idle socket is dropped by the mail server
and the fetch job aborts with `closed stream`. The inbox then stops
ingesting mail entirely, while smaller inboxes on the same server keep
working.

`exists?` issues `SELECT 1 ... LIMIT 1` with no `ORDER BY`, so the
planner uses `index_messages_on_source_id` regardless of table size. No
schema change is required. The fix lives in the shared base class, so it
covers both the IMAP and Microsoft fetch paths.

Fixes #14682
2026-07-13 18:15:25 +05:30
08260f3be7 feat: show crawled document details and FAQ counts in Captain (#14863)
- Add a document details view that surfaces crawled content, source
metadata, and generated FAQ counts.
- Rename the document card action to open details and show the FAQ count
inline in the list.
- Return `responses_count` from the documents API efficiently and expose
document content in the show payload.
- Update related Captain copy to reflect the new details-oriented flow. 


**Preview**
<img width="1640" height="1596" alt="CleanShot 2026-06-26 at 09 25
15@2x"
src="https://github.com/user-attachments/assets/0c408fae-7d37-422a-8869-ece466292cb1"
/>

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-13 17:47:00 +05:30
11c65f3b9a feat: Intercom import workflow (#14922)
## Description

Adds an admin-only Intercom import workflow under Settings > Data.
Admins can connect an Intercom access token, start named historical
contact/conversation imports, monitor active and previous import runs,
review paginated skip/error logs, download skip logs, and route imported
conversations into source-bucket API inboxes that can be renamed later.

The import path stores durable source mappings, batches Intercom
contact/conversation pages through Sidekiq, records already-imported
records as skipped, and writes historical messages without normal
outbound delivery callbacks. The PR also includes the Intercom import
PRD/TDD document for review context.

Closes
[CW-7519](https://linear.app/chatwoot/issue/CW-7519/explore-intercom-import)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [x] This change requires a documentation update

## How Has This Been Tested?

Tested importing using actual data through integration.

Screenshots:

<img width="1800" height="948" alt="Screenshot 2026-07-02 at 10 48
48 PM"
src="https://github.com/user-attachments/assets/e74d9ed6-0bca-47de-b6ef-e589afcddfde"
/>
<img width="1800" height="1008" alt="Screenshot 2026-07-02 at 10 49
03 PM"
src="https://github.com/user-attachments/assets/1bd12fdb-0a47-4287-ac1d-ea308e70a9cd"
/>
<img width="1800" height="1005" alt="Screenshot 2026-07-02 at 10 49
21 PM"
src="https://github.com/user-attachments/assets/3d8145f5-1794-4cc3-b3fa-de5cd80e6ca3"
/>
<img width="1800" height="1002" alt="Screenshot 2026-07-02 at 10 49
38 PM"
src="https://github.com/user-attachments/assets/6f818efd-4193-43c2-84eb-66970dca4490"
/>


Passed locally:

```sh
eval "$(rbenv init -)" && bundle exec rspec spec/models/data_import_spec.rb spec/jobs/data_import_job_spec.rb spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/jobs/data_imports/intercom/import_jobs_spec.rb spec/services/data_imports/intercom/importer_spec.rb spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb spec/services/data_imports/intercom/source_bucket_spec.rb
```

```sh
eval "$(rbenv init -)" && bundle exec rubocop app/controllers/api/v1/accounts/data_imports_controller.rb app/controllers/api/v1/accounts/integrations/intercom_controller.rb app/jobs/data_imports/intercom app/models/data_import.rb app/models/data_import_error.rb app/models/data_import_item.rb app/models/data_import_mapping.rb app/models/integrations/hook.rb app/policies/data_import_policy.rb app/policies/hook_policy.rb app/services/data_imports/intercom db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb db/migrate/20260702000001_create_data_import_items.rb db/migrate/20260702000002_create_data_import_mappings.rb db/migrate/20260702000003_create_data_import_errors.rb spec/jobs/data_imports/intercom spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/services/data_imports/intercom
```

```sh
pnpm exec eslint app/javascript/dashboard/api/dataImports.js app/javascript/dashboard/api/integrations.js app/javascript/dashboard/routes/dashboard/settings/data/Index.vue app/javascript/dashboard/routes/dashboard/settings/data/Show.vue app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js app/javascript/dashboard/routes/dashboard/settings/integrations/Intercom.vue app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js app/javascript/dashboard/routes/dashboard/settings/settings.routes.js app/javascript/dashboard/components-next/sidebar/Sidebar.vue app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
```

```sh
git diff --check
```

Note: the RSpec boot logs the existing local `chatwoot_dev` purge
warning because other database sessions are open, then continues and
completes with 52 examples, 0 failures.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 15:25:07 +05:30
8fc5c7a5c8 feat: add captain general guidelines migration helpers (#14909)
This PR adds internal tooling and planning docs for migrating existing
Captain assistant instructions into the new General Guidelines
structure.


**Summary**

This PR adds a controlled migration path for moving existing Captain V1
assistant instructions into the structured Captain architecture.

It introduces a classifier that reads the current `config.instructions`
and produces reviewed migration drafts with separate sections for:

- assistant description / business context
- response guidelines
- guardrails
- scenario candidates
- conversation messages
- FAQ/document candidates
- needs-review items

The migration is intentionally staged. It only targets V1-style
assistants that still have custom instructions, are connected to
inboxes, and do not already have structured response guidelines,
guardrails, or scenario records.

When applied, the task writes the extracted business context to the
assistant description, response guidelines to `response_guidelines`,
guardrails to `guardrails`, and stores scenario candidates / FAQ
candidates / review notes under `config["assistant_migration"]`.
Scenario candidates are also flattened into response guidelines for now
so customer behavior is preserved before we create real
`Captain::Scenario` records in a later rollout.

The applier stores the original assistant values under migration
metadata so conversation message config can be restored if needed. It
does not create scenario records yet.

**How to generate drafts**

For specific assistant IDs:

```bash
bundle exec rake captain:assistant_migration:generate \
  IDS=546,636,819 \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For the first 50 eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For all eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

**How to apply drafts**

Dry run first:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=true
```

Apply changes:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=false
```

**How to restore conversation messages**

If extracted `welcome_message`, `handoff_message`, or
`resolution_message` need to be reverted to their pre-migration values:

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=true
```

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=false
```

**Notes**

- `LIMIT=0` means no limit.
- `generate` overwrites the output file.
- The apply task skips assistants that are no longer V1 migration
candidates.
- This PR does not create `Captain::Scenario` records; scenario
candidates are staged in assistant config for a future migration.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-07-13 14:56:09 +05:30
Shivam MishraandGitHub 7caa4e0bbc fix: pin pnpm version in CircleCI node orb install step (#14999)
CI runs were failing because the CircleCI Node orb's pnpm auto-install
step matches the wrong "version" field in pnpm's package.json, producing
an invalid install command since pnpm 11.12.0.

Ref: https://github.com/CircleCI-Public/node-orb/issues/262

### What changed
- Pinned `pnpm-version: 10.2.0` (matching `packageManager` in
`package.json`) on all three `node/install-pnpm` steps in
`.circleci/config.yml`, bypassing the orb's broken version
auto-detection.
2026-07-13 14:48:47 +05:30
Tanmay Deep SharmaandGitHub 9c10fe4eb1 fix: default assignment age exclusion (#14998)
## Description
Auto-assignment was skipping the 7-day staleness check entirely for
inboxes that don't have an assignment policy attached. Those inboxes
would pull unassigned conversations of any age off the backlog and hand
them to agents — including conversations untouched for months — while
the activity log still credited "Default Policy" for the assignment.
This makes the default behaviour match what that label implies: with no
policy configured, conversations with no activity in the last 7 days are
now excluded, the same window a freshly created policy uses.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-13 14:01:47 +05:30
Shivam MishraandGitHub 6b3800e25b fix: pre-chat form contact custom attributes lost for existing contacts (#14956)
Contact custom attributes filled in the pre-chat form (e.g. a CPF field)
were silently lost when the visitor's email or phone matched an existing
contact. The widget sent the attributes in a separate request that raced
the conversation create: creating the conversation merges the widget
contact into the existing contact, so the attribute update landed on the
destroyed contact and vanished without an error. New visitors were
unaffected, which made the bug hard to spot.

The fix sends contact custom attributes inside the same request that
identifies the contact. The widget conversation create endpoint now
accepts `contact.custom_attributes` and applies them through
`ContactIdentifyAction` in the same transaction as the merge, so the
submitted values always land on the surviving contact. The campaign path
folds the attributes into the existing contact update call the same way.

<details>
<summary>Reproduction script (rails runner, concurrent
threads)</summary>

```ruby
# Concurrent reproduction of the pre-chat form race that loses contact
# custom attributes when the visitor's email matches an existing contact.
#
# The old widget fired two unawaited requests on pre-chat submit. Each
# iteration replays them as real concurrent threads:
#   - create thread = POST /widget/conversations: resolves the widget contact,
#     then runs ContactIdentifyAction (which merges the widget contact into the
#     existing contact) inside a transaction held open while the conversation
#     and message are created.
#   - patch thread = PATCH /widget/contact: resolves the widget contact via its
#     contact inbox and applies the custom attributes through
#     ContactIdentifyAction, exactly like Widget::ContactsController#update.
#
# Run with: bundle exec rails runner confirm_prechat_race.rb
# Creates throwaway contacts on Account.first and deletes them afterwards.

ITERATIONS = 20
CPF = '123.456.789-09'.freeze

account = Account.first!
inbox = account.inboxes.first!
losses = 0

ITERATIONS.times do |i|
  existing = account.contacts.create!(name: 'Existing Contact', email: "race-existing-#{SecureRandom.hex(6)}@example.com")
  temp = account.contacts.create!(name: 'Widget Visitor')
  contact_inbox = ContactInbox.create!(contact: temp, inbox: inbox, source_id: SecureRandom.uuid)

  begin
    create_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ActiveRecord::Base.transaction do
          ContactIdentifyAction.new(
            contact: widget_contact,
            params: { email: existing.email, phone_number: nil, name: 'Widget Visitor' },
            retain_original_contact_name: true,
            discard_invalid_attrs: true
          ).perform
          sleep(0.02) # conversation + message creation keeps the transaction open
        end
      end
    end

    patch_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        sleep(rand * 0.02) # network jitter between the two requests
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ContactIdentifyAction.new(
          contact: widget_contact,
          params: { custom_attributes: { cpf: CPF } },
          discard_invalid_attrs: true
        ).perform
      end
    end

    create_request.join
    patch_request.join

    survivor = existing.reload
    if survivor.custom_attributes['cpf'] == CPF
      puts "iteration #{i + 1}: CPF survived"
    else
      losses += 1
      puts "iteration #{i + 1}: CPF LOST (survivor custom_attributes: #{survivor.custom_attributes.inspect})"
    end
  ensure
    account.contacts.where(id: [existing.id, temp.id]).find_each(&:destroy!)
  end
end

puts
puts "#{losses}/#{ITERATIONS} iterations lost the CPF -- race #{losses.positive? ? 'confirmed' : 'not reproduced in this run'}"
```

Result: **20/20 iterations lose the CPF** (consistent across repeated
runs). The conversation create holds its transaction open across the
contact merge, so the fast attribute update either resolves the
soon-to-be-destroyed widget contact or blocks on its row lock and then
updates 0 rows — silently, with no error. This is why the customer sees
the loss every time, not intermittently.

Note: the script replays the old two-request flow at the service layer,
so it reproduces the loss even with this fix applied — the fix works by
removing the second request from the widget, not by changing the raced
code paths. The new request spec (`saves contact custom attributes on
the surviving contact when merged into an existing contact`) covers the
fixed contract.

</details>

## Closes

- Reported via support conversation:
https://app.chatwoot.com/app/accounts/1/conversations/84465

## How to reproduce

1. Create a website inbox with a pre-chat form that has a contact custom
attribute field (e.g. CPF).
2. Create a contact with a known email address.
3. As a visitor, open the widget and fill the pre-chat form using that
same email plus a value for the custom attribute.
4. Before: the attribute never appears on the contact profile. After: it
is saved on the existing contact, overwriting any stale value.

## What changed

- `POST /api/v1/widget/conversations` now permits
`contact.custom_attributes` and passes it to `ContactIdentifyAction`,
which deep-merges it atomically with the contact merge.
- The widget pre-chat form sends contact custom attributes inside the
conversation create payload instead of a separate `setCustomAttributes`
call; the campaign path includes them in the `contacts/update` payload.
2026-07-13 13:51:58 +05:30
b560720e08 feat: allow switching embedded signup whatsapp embedded inboxes to manual setup (#14975)
Since embedded signup has been disabled on production, WhatsApp inboxes
that were created through it have no way to be managed going forward.
This PR lets admins transfer an embedded signup inbox to a manual Cloud
API setup: the inbox settings Configuration tab now shows the webhook
verification token and a "Switch to Manual Setup" form (pre-populated
with the Phone Number ID, Business Account ID, and API key stored during
the embedded signup journey) instead of the old Reconfigure button.

Saving the form updates the channel's `provider_config` without the
`source: embedded_signup` marker, so the inbox becomes a regular
manually-configured WhatsApp Cloud inbox. The backend re-validates the
submitted credentials against Meta before accepting the change.

## How to test

1. Open the settings page of a WhatsApp inbox created via embedded
signup → Configuration tab.
2. The Reconfigure button is gone; you see the webhook verification
token and a Switch to Manual Setup form pre-filled with the stored
credentials.
3. Configure the webhook in your own Meta app using the verification
token, enter a permanent access token from that app, and click "Switch
to Manual Setup".
4. On success the page switches to the standard manual configuration
view (verify token, API key update), and messaging continues to work
with the new credentials. Invalid credentials are rejected with an
error.

## What changed

- `ConfigurationPage.vue`: replaced the embedded-signup Reconfigure
section (and the hidden reauthorize component) with the manual transfer
form.
- New `WHATSAPP_MANUAL_TRANSFER_*` translation keys.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 13:48:38 +05:30
Aakash BakhleandGitHub cc87429903 feat(captain): expand assistant description limit (#14985)
# Pull Request Template

## Description

Increases description for Captain.

Why?
We are planning to include business context in description and 255 char
limit on the column and 200 char limit on the UI are very limiting to
get proper context.

## Type of change

Improvement to accommodate business context

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-13 12:59:36 +05:30
98154bbeab fix(meta): show restriction alerts for inbox setup (#14974)
Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud
now reflect the temporary Meta restriction. Instagram is hidden from
onboarding on Cloud, while the regular Instagram inbox creation page
shows a disabled action with a status-linked amber warning. WhatsApp
embedded signup on Cloud stays visible with its connect action disabled.
WhatsApp Call setup always uses the manual WhatsApp form.

Existing Instagram conversations and Instagram inbox settings on Cloud
also show amber warning banners with the public incident link.
Self-hosted installations keep their existing Instagram, WhatsApp, and
WhatsApp Call setup behavior because the temporary restriction is based
only on the Chatwoot Cloud environment check.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-10 16:22:49 +04:00
Sivin VargheseandGitHub 03a1b1dbc1 chore: insert resolved variable value in reply editor (#14921)
# Pull Request Template

## Description

This PR makes reply editor variables insert their resolved value (for
example, the contact's name) instead of the raw `{{contact.name}}`
placeholder, matching canned response behavior. This works both when
picking a variable from the `{{` menu and when an agent manually types
out `{{contact.name}}` — it resolves the moment the closing `}}` is
typed.

If a variable has no value, the `{{placeholder}}` is kept so the backend
can still resolve it when the message is sent. Private notes are left
untouched.

For safety, a resolved value that itself contains Liquid syntax `({{ }}`
or `{% %})` also keeps its placeholder, so customer-controlled fields
can never inject Liquid into the outgoing message.

Fixes
https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. Open a conversation and add a reply.
2. Type `{{` and pick a variable that has a value (e.g. Contact name) →
it inserts the actual value.
3. Manually type `{{contact.name}}` and close the braces → it
auto-resolves to the value.
4. Insert/type a variable with no value → the `{{placeholder}}` stays;
confirm it resolves correctly on send.
5. Repeat in a private note → placeholders are left as-is.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-10 17:24:34 +05:30
Sony MathewandGitHub 848e94bcf2 fix: throttle filtered unread count rebuilds (#14980)
Reduces database pressure from filtered unread-count cache rebuilds
during high-traffic account rollouts by serving stale snapshots longer
and limiting inline saved-filter rebuild fanout.

## Closes

None

## What changed

- Increase filtered unread-count refresh throttling from 30 seconds to 5
minutes.
- Increase the stale snapshot window from 30 minutes to 1 hour.
- Reduce inline saved-filter count rebuilds per request from 10 to 3.
- Update unread-count specs to assert refresh and stale behavior through
the shared constants.

## How to test

- Enable `conversation_unread_counts` and `unread_count_for_filters` for
an account with conversation custom filters.
- Open the dashboard and verify unread-count badges still return values.
- Mutate conversations and verify stale filtered counts are served while
rebuilds are throttled, instead of repeatedly rebuilding every 30
seconds.
2026-07-10 17:23:00 +05:30
Tanmay Deep SharmaandGitHub 56dc580f50 feat: support manual setup for WhatsApp calls inbox creation (#14976)
Re-enables the "WhatsApp Calls" inbox creation option, which disappeared
when embedded signup was disabled on production. The channel card now
only requires the `channel_voice` account feature, and the creation flow
offers the manual WhatsApp Cloud API setup form. Once the channel is
created with manual credentials, calling is enabled automatically — the
same post-setup step the embedded signup flow used to perform.

When an installation has embedded signup configured, the flow still uses
it; the manual form is the fallback (and the effective path on Chatwoot
Cloud today).

## How to test

1. Enable the `channel_voice` feature on the account.
2. Go to Add Inbox → the "WhatsApp Calls" card is visible again → select
it.
3. Fill in the manual Cloud API credentials (inbox name, phone number,
phone number ID, business account ID, API key) and submit.
4. The inbox is created and calling is enabled:
`provider_config.calling_enabled` is true and the Calls tab toggle is
on. If the number isn't enrolled in the Business Calling API, an alert
explains the enable failure but the messaging inbox is still created.

## What changed

- `ChannelItem.vue`: the `whatsapp_call` card is gated only on
`channel_voice` (previously also required the embedded signup app ID).
- `CloudWhatsapp.vue`: new `enableCallingOnComplete` prop that calls the
enable-calling API after channel creation.
- `WhatsappCall.vue`: renders embedded signup when available, otherwise
the manual setup form — both with calling enabled on completion.
2026-07-10 15:24:14 +05:30
35fcd56ba9 feat: add support action to suspended account page (#14969)
Updates the suspended account page with the revised policy copy and adds
a visible Contact support action that opens the embedded Chatwoot
support widget.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 16:45:47 +04:00
Aakash BakhleandGitHub d57354c8b5 feat: tighten conversation FAQ generation prompt (#14957)
Tightens the resolved-conversation FAQ generator so it only proposes
durable, reusable FAQ candidates supported by human support-agent
messages. The implementation now sends a conversation-FAQ-specific
transcript to the LLM: customer messages plus real human support-agent
messages only, excluding bot, private, activity, and template messages.

## Closes

-
https://linear.app/chatwoot/issue/CW-7494/tighten-conversation-faq-generation-prompt

## What changed

- Added a human-only transcript builder in `ConversationFaqService`
instead of using the generic `conversation.to_llm_text` output.
- Excluded bot/agent-bot messages before the LLM call, which removes the
main bot-line leakage class deterministically.
- Preserved native-channel human replies where outgoing messages are
stored as `external_echo` without a `User` sender.
- Kept a prompt decision gate requiring each FAQ to be backed by a
complete public human-agent answer.
- Added generic no-FAQ classes for spam, wrong-service conversations,
private account/payment/order/certificate/troubleshooting cases, support
workflow mechanics, and direct-link/file/quote outputs.
- Added a separate `conversation_faq_generation` model route defaulting
to `gpt-5.2`, while keeping `document_faq_generation` on its existing
`gpt-4.1-mini` default. Conversation FAQ generation passes that feature
default ahead of the legacy global `CAPTAIN_OPEN_AI_MODEL` setting
unless an account-level override is configured.
- Kept the prompt domain-neutral so it can still generate reusable
product, service, policy, setup, and process FAQs outside SaaS contexts.

## Sampling notes

- Production Langfuse traces showed `llm.captain.conversation_faq` calls
using `gpt-4.1` in the sampled account set.
- Locally, `Llm::FeatureRouter.resolve(feature:
'conversation_faq_generation')` now resolves to `gpt-5.2`.
- Reviewed recent production `llm.captain.conversation_faq` traces
across 13+ accounts in compact form.
- Replayed 20 full traces across 10 accounts/domains, including
education, hosting, retail/auto, APIs, logistics, tax/fiscal workflows,
and Chatwoot account 1.
- Explicit `gpt-5.2` replay with human-only conversation history
returned no FAQ for 15/20 traces.
- A comparison replay with `gpt-4.1-mini` returned no FAQ for only 7/20
traces, bringing back several private/order/payment/support-workflow
cases.
- Remaining non-empty `gpt-5.2` outputs are now mostly
borderline/possibly useful human-agent-derived FAQs rather than obvious
bot-sourced answers.

## How to test

- Resolve conversations where the answer came only from the bot; no
pending FAQ should be generated.
- Resolve spam, unrelated, wrong-service, or private
payment/order/account conversations; no pending FAQ should be generated.
- Resolve conversations that require account/order/payment/login/private
verification or a human handoff; no pending FAQ should be generated.
- Resolve a conversation where a human agent gives a stable, reusable
help-center answer; the generated pending FAQ should be general and
self-contained.
2026-07-09 17:47:47 +05:30
8d2ef4ec5e feat: assistant overview drilldown reports [CW-7408] (#14920)
<img width="2616" height="1716" alt="CleanShot 2026-07-09 at 14 22
42@2x"
src="https://github.com/user-attachments/assets/504e58df-0243-4cb9-a811-62504fdcb0ac"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-09 16:25:22 +05:30
bddb561546 chore: improve account suspended message (#14968)
Updates the suspended account screen copy to use clearer policy and
safety-oriented language, while giving users a direct support path if
they believe the suspension is a mistake.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 13:01:08 +04:00
Vishnu NarayananandGitHub 20227403ce fix: return public contact inbox payload after update (#14946)
# Pull Request Template

## Description
This keeps the public inbox contact update response on the public
contact inbox serializer shape after applying the contact identify
update.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `bundle exec rspec
spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7457](https://linear.app/chatwoot/issue/CW-7457)
2026-07-09 13:50:01 +05:30
Vishnu NarayananandGitHub 0a13dbedfb fix: force account picker on microsoft email inbox OAuth (#14794)
## Description

The Microsoft authorize URL had no `prompt` parameter. With Microsoft's
default behavior, a browser carrying an active Microsoft session is
silently signed in to that account. If the same account is already
attached to an inbox in the same Chatwoot account, the callback treats
the OAuth response as a re-auth and routes the user to the existing
inbox settings page instead of letting them create the new inbox they
intended.

Adding `prompt=select_account` interrupts SSO and always presents the
Microsoft account picker, so the user explicitly chooses the mailbox
they want to connect.

This is distinct from `prompt=consent`, which was removed in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) to fix the
admin consent loop. `select_account` only interrupts SSO and does not
retrigger the consent dialog.

Closes INF-76

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

`bundle exec rspec
spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb`
(3 examples, 0 failures). The regression test introduced in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) is updated to
assert `prompt=select_account` instead of asserting absence of the
parameter.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-09 13:49:33 +05:30
Muhsin KelothandGitHub 8d554f2b74 chore(inbox): disable Instagram inbox creation (#14964)
Reverts chatwoot/chatwoot#14955
2026-07-09 10:02:02 +04:00
Sony MathewandGitHub 4854fb7b4b chore: upgrade websocket-driver (#14961)
# Pull Request Template

## Description

Updates `websocket-driver` from `0.7.7` to `0.8.2` to resolve the
bundle-audit findings for CVE-2026-54463, CVE-2026-54464,
CVE-2026-54465, and GHSA-2x63-gw47-w4mm.

This is a lockfile-only transitive dependency update through Rails
Action Cable. No application code changes are included.

Fixes # (issue)
N/A

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle check && bundle exec ruby -e 'require "action_cable"; require
"action_cable/connection/client_socket"; require "websocket/driver";
puts "actioncable=#{ActionCable::VERSION::STRING}"; puts
"websocket-driver=#{Gem.loaded_specs["websocket-driver"].version}"; puts
"driver_rack=#{WebSocket::Driver.respond_to?(:rack)}"'`
- `POSTGRES_DATABASE=chatwoot_test_3d6a RAILS_ENV=test bundle exec rails
db:prepare && POSTGRES_DATABASE=chatwoot_test_3d6a bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas (not applicable; lockfile-only change)
- [ ] I have made corresponding changes to the documentation (not
applicable; dependency security update)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable; dependency security update)
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable)
2026-07-08 15:52:40 -07:00
252 changed files with 13487 additions and 497 deletions
+6 -3
View File
@@ -77,7 +77,8 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -118,7 +119,8 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -149,7 +151,8 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
+1 -1
View File
@@ -1030,7 +1030,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title,
:description,
{ tags: [] }]
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
@@ -0,0 +1,159 @@
require 'csv'
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
data_import_ids = @data_imports.map(&:id)
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
end
def show
render_show
end
def validate_source
totals = validate_intercom_source
render json: { valid: true, totals: totals }
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def create
@data_import = creation_service.perform
unless @data_import
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def start
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
render_show
end
def abandon
@data_import.abandon!
render_show
end
def skip_logs
send_data(
skip_logs_csv,
filename: "data-import-#{@data_import.id}-skip-logs.csv",
type: 'text/csv'
)
end
def error_logs
send_data(
error_logs_csv,
filename: "data-import-#{@data_import.id}-error-logs.csv",
type: 'text/csv'
)
end
private
def ensure_data_import_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
end
def set_data_import
@data_import = Current.account.data_imports.find(params[:id])
end
def check_authorization
authorize(@data_import || DataImport)
end
def permitted_params
params.permit(:name, :source_provider, :access_token, import_types: [])
end
def creation_service
DataImports::Intercom::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
)
end
def import_types
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def validate_intercom_source
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
DataImports::Intercom::CredentialsValidator.new(
access_token: permitted_params[:access_token],
import_types: import_types
).perform
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
render :show
end
def skip_logs_csv
logs_csv(@data_import.import_errors.skip_logs)
end
def error_logs_csv
logs_csv(@data_import.import_errors.non_skip_logs)
end
def logs_csv(logs)
CSV.generate(headers: true) do |csv|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
logs.order(:created_at).find_each do |log|
csv << [
log.created_at.iso8601,
log.details['kind'],
log.source_object_type,
log.source_object_id,
log.error_code,
log.message,
log.details.to_json
]
end
end
end
end
@@ -124,8 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
end
def update_channel_feature_flags
@@ -6,7 +6,10 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
}
)
if redirect_url
@@ -1,4 +1,6 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -31,7 +33,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
render json: {
success: false,
@@ -39,10 +41,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def can_upgrade_to_embedded_signup?
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
# Reconfiguring a live embedded-signup channel requires the feature flag.
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
@@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController
permitted_params.dig(:contact, :phone_number)
end
def contact_custom_attributes
permitted_params.dig(:contact, :custom_attributes)&.to_h
end
def browser_params
{
browser_name: browser.name,
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def process_update_contact
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
@@ -18,7 +18,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
render json: contact_identify_action.perform
contact_identify_action.perform
@contact_inbox.reload
end
private
+11
View File
@@ -0,0 +1,11 @@
class DataImportErrorFinder
RESULTS_LIMIT = 5
def initialize(data_import)
@data_import = data_import
end
def import_errors
@data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT)
end
end
@@ -0,0 +1,35 @@
class DataImportSkipLogFinder
RESULTS_LIMIT = 5
SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze
attr_reader :selected_source_object_type
def initialize(data_import, params = {})
@data_import = data_import
@selected_source_object_type = valid_source_object_type(params[:skip_logs_type])
end
def skip_logs
filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT)
end
def counts_by_type
base_scope.group(:source_object_type).count
end
private
def base_scope
@base_scope ||= @data_import.import_errors.skip_logs
end
def filtered_scope
return base_scope if selected_source_object_type.blank?
base_scope.where(source_object_type: selected_source_object_type)
end
def valid_source_object_type(source_object_type)
source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type)
end
end
@@ -37,6 +37,20 @@ class CaptainAssistant extends ApiClient {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getDrilldown({ assistantId, metric, range, page, signal }) {
const requestConfig = {
params: {
metric,
range,
timezone_offset: getTimezoneOffset(),
page,
},
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
}
}
export default new CaptainAssistant();
@@ -0,0 +1,39 @@
/* global axios */
import ApiClient from './ApiClient';
class DataImportsAPI extends ApiClient {
constructor() {
super('data_imports', { accountScoped: true });
}
start(id) {
return axios.post(`${this.url}/${id}/start`);
}
abandon(id) {
return axios.post(`${this.url}/${id}/abandon`);
}
show(id, params = {}) {
return axios.get(`${this.url}/${id}`, { params });
}
validateSource(payload) {
return axios.post(`${this.url}/validate_source`, payload);
}
downloadSkipLogs(id) {
return axios.get(`${this.url}/${id}/skip_logs.csv`, {
responseType: 'blob',
});
}
downloadErrorLogs(id) {
return axios.get(`${this.url}/${id}/error_logs.csv`, {
responseType: 'blob',
});
}
}
export default new DataImportsAPI();
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
@@ -7,7 +7,9 @@ import {
ARTICLE_MENU_ITEMS,
ARTICLE_MENU_OPTIONS,
ARTICLE_STATUSES,
getArticleStatus,
} from 'dashboard/helper/portalHelper';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
@@ -53,6 +55,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
hasPendingChanges: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
@@ -68,12 +74,16 @@ const emit = defineEmits([
'articleAction',
'toggleSelect',
'hover',
'draftResolved',
'draftFailed',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
@@ -105,7 +115,18 @@ const articleMenuItems = computed(() => {
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
const draftItems = props.hasPendingChanges
? [
{
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
},
]
: [];
return [...statusItems, ...draftItems, commonItems.delete];
});
const statusTextColor = computed(() => {
@@ -153,6 +174,12 @@ const lastUpdatedAt = computed(() => {
const handleArticleAction = ({ action, value }) => {
toggleDropdown(false);
// Un-publishing an article with staged edits — confirm apply/discard first;
// the popover applies the chosen status itself.
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
emit('articleAction', { action, value, id: props.id });
};
@@ -184,6 +211,18 @@ const handleClick = id => {
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="hasPendingChanges"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
)
"
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
@@ -204,9 +243,15 @@ const handleClick = id => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
class="mt-1 end-0 top-full w-40"
@action="handleArticleAction($event)"
/>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="id"
@resolved="emit('draftResolved', $event)"
@failed="emit('draftFailed', $event)"
/>
</div>
</div>
</div>
@@ -0,0 +1,173 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
renderInlineDiff,
buildDiffBlocks,
} from 'dashboard/helper/articleDiffHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const isOpen = defineModel({ type: Boolean, default: false });
const { t } = useI18n();
const liveTitle = computed(() => props.article?.title ?? '');
const liveContent = computed(() => props.article?.content ?? '');
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
const draftContent = computed(
() => props.article?.draftContent ?? liveContent.value
);
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
const titleDiff = computed(() =>
renderInlineDiff(liveTitle.value, draftTitle.value)
);
const contentBlocks = computed(() =>
buildDiffBlocks(liveContent.value, draftContent.value)
);
const contentChanged = computed(() =>
contentBlocks.value.some(block => block.type !== 'equal')
);
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
// unsized columns so they don't collapse.
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
const DEFAULT_COL_WIDTH = 50;
const applyColumnWidths = (html, widths) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const table = doc.body.querySelector('table');
if (!table) return html;
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
const colgroup = doc.createElement('colgroup');
sized.forEach(width => {
const col = doc.createElement('col');
col.style.width = `${width}px`;
colgroup.appendChild(col);
});
table.insertBefore(colgroup, table.firstChild);
table.style.tableLayout = 'fixed';
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
return doc.body.innerHTML;
};
const renderMarkdown = markdown => {
if (!markdown) return '';
const html = new MessageFormatter(markdown).formattedMessage;
const match = markdown.match(COLWIDTHS_RE);
return match
? applyColumnWidths(html, match[1].split(',').map(Number))
: html;
};
const blockClass = type => {
if (type === 'added') {
return 'border-n-teal-9 bg-n-teal-2';
}
if (type === 'removed') {
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
}
return 'border-transparent';
};
const close = () => {
isOpen.value = false;
};
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
</script>
<template>
<TeleportWithDirection to="body">
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<aside
v-if="isOpen"
v-on-click-outside="dismissOnClickOutside"
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
>
<header
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
>
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2">
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
</h3>
</div>
<p class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="sm"
class="shrink-0 hover:text-n-slate-11"
@click="close"
/>
</header>
<div
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="titleChanged"
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
>
<span
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
>
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
</span>
<h1
class="text-lg font-semibold leading-snug text-n-slate-12"
v-html="titleDiff"
/>
</div>
<div
v-if="contentChanged"
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
>
<div
v-for="(block, index) in contentBlocks"
:key="index"
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
:class="blockClass(block.type)"
v-html="renderMarkdown(block.md)"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</div>
</aside>
</Transition>
</TeleportWithDirection>
</template>
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -9,6 +9,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
const props = defineProps({
article: {
@@ -38,32 +39,75 @@ const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
// falling back to the live value.
const effectiveTitle = () =>
props.article?.draftTitle ?? props.article?.title ?? '';
const effectiveContent = () =>
props.article?.draftContent ?? props.article?.content ?? '';
// Sync local state when navigating to a different article or on initial fetch
const hasPendingChanges = computed(
() => props.article?.draftTitle != null || props.article?.draftContent != null
);
const localTitle = ref(effectiveTitle());
const localContent = ref(effectiveContent());
const isDiffPanelOpen = ref(false);
// Autosave 500ms after the last edit. It sends both title and content so an
// edit to one never drops a recent edit to the other. `stop` cancels a queued
// save; `isPending` tells the header to wait before allowing a publish.
const {
isPending: isSaving,
start: debouncedSave,
stop: cancelSave,
} = useTimeoutFn(
() =>
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
}),
500,
{ immediate: false }
);
const syncLocalState = () => {
cancelSave();
localTitle.value = effectiveTitle();
localContent.value = effectiveContent();
};
// Reseed on article switch or once a draft is published/discarded; close the
// diff panel in the latter case since there's nothing left to compare.
watch(
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
[() => props.article?.id, hasPendingChanges],
([id, pending], [prevId, prevPending]) => {
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
if (prevPending && !pending) isDiffPanelOpen.value = false;
}
);
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
const handleSave = value => {
const scheduleSave = () => {
if (isNewArticle.value) return;
debouncedSave(value);
debouncedSave();
};
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
onBeforeUnmount(() => {
if (isNewArticle.value || !isSaving.value) return;
cancelSave();
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
});
});
const articleTitle = computed({
get: () => localTitle.value,
set: value => {
localTitle.value = value;
handleSave({ title: value });
scheduleSave();
},
});
@@ -71,7 +115,7 @@ const articleContent = computed({
get: () => localContent.value,
set: content => {
localContent.value = content;
handleSave({ content });
scheduleSave();
},
});
@@ -108,9 +152,13 @@ const handleCreateArticle = event => {
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
:pending-changes="hasPendingChanges"
:is-saving="isSaving"
@go-back="onClickGoBack"
@preview-article="previewArticle"
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
/>
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
@@ -1,8 +1,8 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store.js';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
@@ -17,6 +17,7 @@ import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
const props = defineProps({
isUpdating: {
@@ -35,9 +36,17 @@ const props = defineProps({
type: Number,
default: 0,
},
pendingChanges: {
type: Boolean,
default: false,
},
isSaving: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goBack', 'previewArticle']);
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
const { t } = useI18n();
const store = useStore();
@@ -49,9 +58,30 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
// Per-article update flag the store already maintains.
const articleUiFlags = useMapGetter('articles/uiFlags');
const isUpdatingArticle = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
const blockedWhileSaving = () => {
if (!props.isSaving && !isUpdatingArticle.value) return false;
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
return true;
};
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
const hasPendingChanges = computed(
() => isPublished.value && props.pendingChanges
);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
return statusOptions.map(option => {
const items = statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
@@ -60,6 +90,17 @@ const articleMenuItems = computed(() => {
icon,
};
});
if (hasPendingChanges.value) {
items.push({
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
});
}
return items;
});
const statusText = computed(() =>
@@ -85,8 +126,9 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
const updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
// update; omit it for a plain status change.
const performStatusUpdate = async (value, draftAction) => {
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
@@ -94,7 +136,7 @@ const updateArticleStatus = async ({ value }) => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/update', {
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
portalSlug,
articleId: props.articleId,
status,
@@ -107,12 +149,100 @@ const updateArticleStatus = async ({ value }) => {
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
} finally {
isArticlePublishing.value = false;
}
};
const updateArticleStatus = ({ value }) => {
showArticleActionMenu.value = false;
// Leaving published with unsaved draft edits — ask whether to apply or discard
// first; the popover applies the status itself once resolved.
if (hasPendingChanges.value) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
performStatusUpdate(value);
};
const publishDraftChanges = async () => {
isArticlePublishing.value = true;
const { portalSlug } = route.params;
try {
await store.dispatch('articles/publishDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
} finally {
isArticlePublishing.value = false;
}
};
const discardDraftChanges = async () => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
);
}
};
const onPrimaryAction = () => {
if (blockedWhileSaving()) return;
if (hasPendingChanges.value) {
publishDraftChanges();
} else if (props.pendingChanges) {
// Promote leftover draft edits on publish instead of republishing stale content.
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
} else {
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
}
};
const onMenuAction = event => {
showArticleActionMenu.value = false;
// Don't resolve a draft while an autosave is still in flight — it could land
// after and recreate the draft we just discarded/applied.
if (blockedWhileSaving()) return;
if (event.action === 'discard-draft') {
discardDraftChanges();
} else {
updateArticleStatus(event);
}
};
// The popover applies the draft + status itself; we just surface the outcome.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
};
const onDraftFailed = error => {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
</script>
<template>
@@ -127,13 +257,24 @@ const updateArticleStatus = async ({ value }) => {
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
<button
v-if="hasPendingChanges"
type="button"
data-diff-toggle
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
@click="emit('showDiff')"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
</button>
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
<div class="flex items-center gap-2">
<div class="relative flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
color="slate"
@@ -143,17 +284,21 @@ const updateArticleStatus = async ({ value }) => {
/>
<ButtonGroup class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
:label="
hasPendingChanges
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing
isArticlePublishing ||
(isPublished && !hasPendingChanges)
"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
@click="onPrimaryAction"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
@@ -169,11 +314,17 @@ const updateArticleStatus = async ({ value }) => {
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="updateArticleStatus($event)"
@action="onMenuAction($event)"
/>
</OnClickOutside>
</div>
</ButtonGroup>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="articleId"
@resolved="onDraftResolved"
@failed="onDraftFailed"
/>
</div>
</div>
</div>
@@ -0,0 +1,132 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { onKeyStroke } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
articleId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['resolved', 'failed']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isOpen = ref(false);
const requestedStatus = ref(null);
// Which button is in flight, so only that one shows the spinner.
const activeAction = ref(null);
const articleUiFlags = useMapGetter('articles/uiFlags');
const isLoading = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Open the confirmation for a target status; resolving it also applies that status.
const open = status => {
requestedStatus.value = status;
activeAction.value = null;
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
};
// Don't let a click-outside or Escape dismiss the popover mid-action.
const dismiss = () => {
if (!isLoading.value) close();
};
const resolve = async draftAction => {
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
try {
await store.dispatch(`articles/${draftAction}`, {
portalSlug: route.params.portalSlug,
articleId: props.articleId,
status: requestedStatus.value,
});
emit('resolved', requestedStatus.value);
close();
} catch (error) {
emit('failed', error);
}
};
const onApply = () => resolve('publishDraft');
const onDiscard = () => resolve('discardDraft');
onKeyStroke('Escape', () => {
if (isOpen.value) dismiss();
});
defineExpose({ open, close });
</script>
<template>
<div
v-show="isOpen"
v-on-click-outside="dismiss"
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
>
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
)
}}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="xs"
class="shrink-0 -me-1 -mt-1"
:disabled="isLoading"
@click="close"
/>
</div>
<div class="flex items-center justify-between gap-2">
<Button
type="button"
variant="faded"
color="ruby"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'discard'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
"
@click="onDiscard"
/>
<Button
type="button"
color="blue"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'apply'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
"
@click="onApply"
/>
</div>
</div>
</template>
@@ -5,8 +5,12 @@ import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import {
getArticleStatus,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
@@ -145,6 +149,24 @@ const updateArticlesMeta = () => {
});
};
const refreshArticleMeta = async () => {
await updateArticlesMeta();
await updatePortalMeta();
};
// The card's pending-changes popover applies the status itself; surface the result.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
refreshArticleMeta();
};
const onDraftFailed = error => {
useAlert(
error?.message ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -154,6 +176,14 @@ const handleArticleAction = async (action, { status, id }) => {
articleId: id,
});
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
} else if (action === 'discard-draft') {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: id,
});
useAlert(
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
);
} else {
await store.dispatch('articles/update', {
portalSlug,
@@ -168,15 +198,16 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateArticlesMeta();
await updatePortalMeta();
await refreshArticleMeta();
} catch (error) {
const errorMessage =
error?.message ||
(action === 'delete'
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
: getStatusMessage(status, false));
useAlert(errorMessage);
const fallbackMessage =
{
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
'discard-draft': t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
),
}[action] ?? getStatusMessage(status, false);
useAlert(error?.message || fallbackMessage);
}
};
@@ -210,10 +241,15 @@ const updateArticle = ({ action, value, id }) => {
:views="item.views || 0"
:updated-at="item.updatedAt"
:is-selected="selectedArticleIds.has(item.id)"
:has-pending-changes="
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
"
selectable
:show-selection-control="shouldShowSelectionControl(item.id)"
@open-article="openArticle"
@article-action="updateArticle"
@draft-resolved="onDraftResolved"
@draft-failed="onDraftFailed"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, item.id)"
/>
@@ -3,10 +3,15 @@ import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import {
ARTICLE_TABS,
CATEGORY_ALL,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
@@ -60,6 +65,7 @@ const emit = defineEmits([
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -227,15 +233,46 @@ const onBulkActionSuccess = message => {
};
const bulkUpdateStatus = async status => {
const selectedIds = [...selectedArticleIds.value];
const { portalSlug } = route.params;
const pendingIds = props.articles
.filter(
article =>
selectedIds.includes(article.id) &&
article.status === ARTICLE_STATUSES.PUBLISHED &&
hasPendingChanges(article)
)
.map(article => article.id);
// Publish promotes each pending draft; other status changes skip them.
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
const draftIds = isPublishing ? pendingIds : [];
const skippedCount = isPublishing ? 0 : pendingIds.length;
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
if (!articleIds.length && !draftIds.length) {
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
return;
}
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
if (articleIds.length) {
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
}
await Promise.all(
draftIds.map(articleId =>
store.dispatch('articles/publishDraft', { portalSlug, articleId })
)
);
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
if (skippedCount) {
useAlert(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
);
}
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
@@ -61,10 +61,10 @@ const triggerAction = () => {
<div>
<slot />
</div>
<div>
<div class="flex-shrink-0">
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
class="px-3 py-1 w-auto grid place-content-center rounded-lg whitespace-nowrap"
:class="buttonClass"
@click="triggerAction"
>
@@ -14,6 +14,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -61,6 +62,7 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
:disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
@@ -107,6 +107,7 @@ const onClickCancel = () => {
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -66,6 +66,10 @@ const props = defineProps({
type: Number,
default: null,
},
responsesCount: {
type: Number,
default: 0,
},
isSelected: {
type: Boolean,
default: false,
@@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
value: 'viewDetails',
action: 'viewDetails',
icon: 'i-lucide-eye',
},
];
@@ -143,6 +147,9 @@ const menuItems = computed(() => {
});
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const responsesCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
);
const displayLink = computed(() =>
isPdf.value
@@ -158,6 +165,10 @@ const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
};
const handleViewDetails = () => {
emit('action', { action: 'viewDetails', id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
@@ -177,9 +188,13 @@ const handleRetry = () => {
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
<button
type="button"
class="p-0 text-base text-left bg-transparent border-0 outline-transparent text-n-slate-12 line-clamp-1 underline-offset-2 hover:underline focus-visible:underline"
@click="handleViewDetails"
>
{{ name }}
</span>
</button>
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
@@ -228,6 +243,9 @@ const handleRetry = () => {
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<span class="text-sm shrink-0 text-n-slate-11">
{{ responsesCountLabel }}
</span>
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
@@ -213,6 +213,7 @@ const renderInstruction = instruction => () =>
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -122,6 +122,7 @@ watch(
<Editor
v-model="state.description"
:max-length="500"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
@@ -117,6 +117,7 @@ watch(
<Editor
v-model="state.description"
:max-length="500"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
@@ -0,0 +1,86 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import DocumentDetails from './DocumentDetails.vue';
const { dispatch, getterValues } = vi.hoisted(() => ({
dispatch: vi.fn(),
getterValues: {
'captainResponses/getUIFlags': { value: { fetchingList: false } },
'captainResponses/getRecords': { value: [] },
'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } },
},
}));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
useMapGetter: key => getterValues[key],
}));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
const captainDocument = {
id: 42,
name: 'FAQ source',
external_link: 'https://example.com/docs',
assistant: { id: 7 },
content: 'Document content',
pdf_document: false,
};
const DialogStub = {
name: 'Dialog',
template: '<div><slot /></div>',
};
const TabBarStub = {
name: 'TabBar',
template:
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
};
const PaginationFooterStub = {
name: 'PaginationFooter',
template:
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
};
describe('DocumentDetails', () => {
beforeEach(() => {
vi.clearAllMocks();
dispatch.mockResolvedValue([]);
});
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
const wrapper = shallowMount(DocumentDetails, {
props: { captainDocument },
global: {
directives: { dompurifyHtml: {} },
stubs: {
Dialog: DialogStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
},
},
});
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('captainResponses/get', {
page: 1,
assistantId: 7,
documentId: 42,
});
await wrapper.get('[data-test="faq-tab"]').trigger('click');
await wrapper.get('[data-test="next-page"]').trigger('click');
expect(dispatch).toHaveBeenLastCalledWith('captainResponses/get', {
page: 2,
assistantId: 7,
documentId: 42,
});
});
});
@@ -0,0 +1,374 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
isSafeHttpLink,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const TAB_KEYS = {
CONTENT: 'content',
FAQS: 'faqs',
};
const RESPONSES_PER_PAGE = 25;
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentDetails = computed(() => props.captainDocument);
const showRawContent = ref(false);
const activeTabIndex = ref(0);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const currentPage = computed(() => meta.value.page || 1);
const showPaginationFooter = computed(
() => totalCount.value > RESPONSES_PER_PAGE
);
const documentContent = computed(() => documentDetails.value?.content?.trim());
const documentContentLength = computed(
() => documentContent.value?.length || 0
);
const isPdf = computed(() => documentDetails.value?.pdf_document);
const displayUrl = computed(() => documentDetails.value?.display_url);
const externalLink = computed(() => documentDetails.value?.external_link);
const sourceHref = computed(() => displayUrl.value || externalLink.value);
const hasSafeLink = computed(() => isSafeHttpLink(sourceHref.value));
const displayLink = computed(() => {
if (isPdf.value) return formatDocumentLink(externalLink.value);
return getDocumentDisplayPath(displayUrl.value || externalLink.value);
});
const contentTabLabel = computed(() =>
isPdf.value
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
);
const tabs = computed(() => [
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
{
key: TAB_KEYS.FAQS,
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
count: totalCount.value,
},
]);
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
const isUnreadableContent = computed(() => {
if (!documentContent.value) return false;
const content = documentContent.value;
const sample = content.slice(0, 2000);
const characters = Array.from(sample);
const nonPrintableCharacters = characters.filter(character => {
const charCode = character.charCodeAt(0);
return (
(charCode <= 31 && ![9, 10, 13].includes(charCode)) ||
(charCode >= 127 && charCode <= 159)
);
});
const nonPrintableRatio =
nonPrintableCharacters.length / Math.max(characters.length, 1);
const replacementCharacterRatio =
characters.filter(character => character === '\uFFFD').length /
Math.max(characters.length, 1);
const hasPdfObjectMarkers =
content.includes(' obj') &&
content.includes(' endobj') &&
content.includes(' stream');
return (
content.startsWith('%PDF') ||
hasPdfObjectMarkers ||
nonPrintableRatio > 0.02 ||
replacementCharacterRatio > 0.05
);
});
const formattedDocumentContent = computed(() => {
if (!documentContent.value || isUnreadableContent.value) return '';
const formatter = new MessageFormatter(documentContent.value);
formatter.disableImageRendering();
return formatter.formattedMessage;
});
const updatedAtLabel = computed(() => {
if (!documentDetails.value?.updated_at) return null;
return messageTimestamp(
documentDetails.value.updated_at,
'MMM d, yyyy h:mm a'
);
});
const syncedAtLabel = computed(() => {
if (!documentDetails.value?.last_synced_at) return null;
return messageTimestamp(
documentDetails.value.last_synced_at,
'MMM d, yyyy h:mm a'
);
});
const handleClose = () => {
emit('close');
};
const handleCopyContent = async () => {
try {
await copyTextToClipboard(documentContent.value);
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_SUCCESS'));
} catch {
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_ERROR'));
}
};
const handleTabChanged = tab => {
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
};
const fetchResponses = (page = 1) => {
return store.dispatch('captainResponses/get', {
page,
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
};
const handlePageChange = page => {
fetchResponses(page);
};
onMounted(() => {
fetchResponses();
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="documentDetails.name || documentDetails.external_link"
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-6 min-h-48">
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.SOURCE') }}
</span>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center min-w-0 gap-1 text-sm text-n-slate-12 hover:underline"
>
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</a>
<span v-else class="text-sm truncate text-n-slate-12">
{{ displayLink }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.GENERATED_FAQS') }}
</span>
<span class="text-sm text-n-slate-12">
{{ totalCount }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.LAST_UPDATED') }}
</span>
<span class="text-sm text-n-slate-12">
{{
syncedAtLabel ||
updatedAtLabel ||
t('CAPTAIN.DOCUMENTS.DETAILS.NOT_AVAILABLE')
}}
</span>
</div>
</div>
</section>
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChanged"
/>
<div class="h-[32rem] overflow-y-auto">
<section
v-if="activeTabKey === TAB_KEYS.CONTENT"
class="flex flex-col gap-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h4 class="text-sm font-medium text-n-slate-12">
{{
isPdf
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TITLE')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TITLE')
}}
</h4>
<span
v-if="documentContent && !isPdf"
class="text-xs text-n-slate-10"
>
{{
t('CAPTAIN.DOCUMENTS.DETAILS.CHARACTER_COUNT', {
count: documentContentLength.toLocaleString(),
})
}}
</span>
</div>
<div
v-if="documentContent && !isPdf"
class="flex flex-wrap items-center justify-end gap-4"
>
<Button
:label="
showRawContent
? t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_PREVIEW')
: t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_RAW')
"
sm
slate
link
@click="showRawContent = !showRawContent"
/>
<Button
:label="t('CAPTAIN.DOCUMENTS.DETAILS.COPY_CONTENT')"
icon="i-lucide-copy"
sm
slate
link
@click="handleCopyContent"
/>
</div>
</div>
<div
v-if="isPdf"
class="rounded-lg border border-n-weak bg-n-alpha-1 p-4 text-sm text-n-slate-11"
>
<p class="mb-3">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.PDF_DESCRIPTION') }}
</p>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-n-blue-11 hover:underline"
>
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
<Icon icon="i-lucide-external-link" class="size-3" />
</a>
<span v-else class="inline-flex items-center gap-1 text-n-slate-12">
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
</span>
</div>
<template v-else-if="documentContent">
<div
v-if="isUnreadableContent && !showRawContent"
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.UNREADABLE_CONTENT') }}
</div>
<div
v-else
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4"
>
<pre
v-if="showRawContent || isUnreadableContent"
class="m-0 whitespace-pre-wrap break-words text-xs leading-5 text-n-slate-12"
><code>{{ documentContent }}</code></pre>
<div
v-else
v-dompurify-html="formattedDocumentContent"
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-a:text-n-blue-11 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-img:hidden"
/>
</div>
</template>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.EMPTY_CONTENT') }}
</div>
</section>
<section
v-if="activeTabKey === TAB_KEYS.FAQS"
class="flex flex-col gap-3"
>
<div v-if="responses.length" class="flex flex-col gap-3">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.EMPTY') }}
</div>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="RESPONSES_PER_PAGE"
class="!px-0"
@update:current-page="handlePageChange"
/>
</footer>
</section>
</div>
</div>
</Dialog>
</template>
@@ -1,71 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const handleClose = () => {
emit('close');
};
onMounted(() => {
store.dispatch('captainResponses/get', {
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-3 min-h-48">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
</Dialog>
</template>
@@ -0,0 +1,234 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
open: { type: Boolean, default: false },
assistantId: { type: [String, Number], default: null },
metric: { type: String, default: '' },
metricName: { type: String, default: '' },
metricValue: { type: String, default: '' },
range: { type: String, default: '' },
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open: openDrilldown,
close,
loadMore,
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const subtitle = computed(() => {
if (!meta.value.conversation_count) return '';
return t('CAPTAIN.OVERVIEW.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
count: meta.value.conversation_count,
});
});
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
if (!props.metric || !props.assistantId) return;
openDrilldown({
assistantId: props.assistantId,
metric: props.metric,
range: props.range,
});
};
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
}
};
useEventListener(document, 'keydown', onKeydown);
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
close();
restoreFocus();
return;
}
rememberActiveElement();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
);
watch(
() => [props.metric, props.range],
() => {
if (props.open) fetchDrilldown();
}
);
// The drawer's headline value is a snapshot of the previous assistant's card,
// so switching assistants (e.g. browser Back/Forward) closes it instead of
// refetching records that would no longer match the header.
watch(
() => props.assistantId,
() => {
if (props.open) closeDrawer();
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="metricValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div>
</Transition>
</TeleportWithDirection>
</template>
@@ -8,16 +8,35 @@ const props = defineProps({
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
const onActivate = () => {
if (props.clickable) emit('click');
};
</script>
<template>
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
<div
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
:class="
clickable
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
: ''
"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="onActivate"
@keydown.enter.self.prevent="onActivate"
@keydown.space.self.prevent="onActivate"
>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
@@ -88,7 +88,7 @@ const openCreateAssistantDialog = () => {
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] max-h-96 rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -114,7 +114,10 @@ const openCreateAssistantDialog = () => {
@click="openCreateAssistantDialog"
/>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<div
v-if="assistants.length > 0"
class="flex flex-col flex-1 min-h-0 gap-2 px-4 pb-3 overflow-y-auto overscroll-contain"
>
<Button
v-for="assistant in assistants"
:key="assistant.id"
@@ -22,11 +22,21 @@ const props = defineProps({
type: [Number, String],
default: 0,
},
manualMigrationRecommended: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['reviewManualMigration']);
const reauthorizationRequired = computed(() => {
return props.inbox.reauthorization_required;
});
const showManualMigrationRecommendation = computed(() => {
return props.manualMigrationRecommended && !reauthorizationRequired.value;
});
</script>
<template>
@@ -42,4 +52,14 @@ const reauthorizationRequired = computed(() => {
>
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
</div>
<button
v-else-if="showManualMigrationRecommendation"
v-tooltip.top-end="$t('SIDEBAR.WHATSAPP_MANUAL_MIGRATION')"
type="button"
:aria-label="$t('SIDEBAR.WHATSAPP_MANUAL_MIGRATION')"
class="grid place-content-center size-5 bg-n-blue-5/60 rounded-full hover:bg-n-blue-5 focus-visible:bg-n-blue-5 focus-visible:outline-none"
@click.stop.prevent="emit('reviewManualMigration')"
>
<Icon icon="i-lucide-info" class="size-3 text-n-blue-9" />
</button>
</template>
@@ -6,6 +6,8 @@ import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -43,6 +45,8 @@ const emit = defineEmits([
]);
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const { isAdmin } = useAdmin();
const router = useRouter();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
@@ -85,6 +89,39 @@ const hasFilteredUnreadCounts = computed(() => {
);
});
const hasDataImport = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.DATA_IMPORT
);
});
const hasWhatsAppManualTransfer = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
);
});
const isWhatsAppManualMigrationRecommended = inbox => {
return (
isAdmin.value &&
hasWhatsAppManualTransfer.value &&
inbox.channel_type === 'Channel::Whatsapp' &&
inbox.provider === 'whatsapp_cloud' &&
inbox.provider_config?.source === 'embedded_signup' &&
!inbox.reauthorization_required
);
};
const reviewWhatsAppManualMigration = inboxId => {
router.push(
accountScopedRoute('settings_inbox_show', {
inboxId,
})
);
};
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -451,6 +488,10 @@ const menuItems = computed(() => {
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
manualMigrationRecommended:
isWhatsAppManualMigrationRecommended(inbox),
onReviewManualMigration: () =>
reviewWhatsAppManualMigration(inbox.id),
}),
})),
},
@@ -856,6 +897,16 @@ const menuItems = computed(() => {
icon: 'i-lucide-blocks',
to: accountScopedRoute('settings_applications'),
},
...(hasDataImport.value
? [
{
name: 'Settings Data',
label: t('SIDEBAR.DATA'),
icon: 'i-lucide-database',
to: accountScopedRoute('settings_data_imports'),
},
]
: []),
{
name: 'Settings Audit Logs',
label: t('SIDEBAR.AUDIT_LOGS'),
@@ -44,6 +44,12 @@ const showChatSupport = computed(() => {
);
});
const toggleChatSupport = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const menuItems = computed(() => {
return [
{
@@ -51,9 +57,7 @@ const menuItems = computed(() => {
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
window.$chatwoot.toggle();
},
click: toggleChatSupport,
},
{
show: true,
@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
const { modalType, closeOnBackdropClick, onClose } = defineProps({
closeOnBackdropClick: { type: Boolean, default: true },
showCloseButton: { type: Boolean, default: true },
onClose: { type: Function, required: true },
onClose: { type: Function, default: null },
fullWidth: { type: Boolean, default: false },
modalType: { type: String, default: 'centered' },
size: { type: String, default: '' },
@@ -35,7 +35,7 @@ const handleMouseDown = () => {
const close = () => {
show.value = false;
emit('close');
onClose();
onClose?.();
};
const onMouseUp = () => {
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -53,19 +52,10 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
if (key === 'voice' || key === 'whatsapp_call') {
return props.enabledFeatures.channel_voice;
}
if (key === 'whatsapp_call') {
return (
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
return [
'website',
'twilio',
@@ -62,6 +62,7 @@ import {
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
createVariableInputRule,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -306,6 +307,10 @@ const plugins = computed(() => {
searchTerm: variableSearchTerm,
isAllowed: () => !props.isPrivate,
}),
createVariableInputRule({
isPrivate: () => props.isPrivate,
getVariables: () => props.variables,
}),
createSuggestionPlugin({
trigger: ':',
minChars: 2,
@@ -33,7 +33,9 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -93,6 +95,7 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -170,6 +173,12 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
@@ -455,7 +464,15 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -48,6 +48,8 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
getAgentVariables,
getContactVariables,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -393,7 +395,13 @@ export default {
contact: this.currentContact,
inbox: this.inbox,
});
return variables;
// Match the backend drops: names are Ruby-capitalized and
// {{agent.*}} is the message sender, not the assignee.
return {
...variables,
...getContactVariables(this.currentContact),
...getAgentVariables(this.currentUser),
};
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
@@ -78,7 +78,5 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
// Temporarily disables WhatsApp embedded signup and WhatsApp Call inbox
// creation. Flip to false when the channel is brought back.
export const IS_WHATSAPP_INBOX_CREATION_DISABLED = true;
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
+4
View File
@@ -7,9 +7,13 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
@@ -0,0 +1,172 @@
// Powers the "unpublished changes" preview: marks what changed between the live
// article and the draft — word by word in the title, block by block in the body.
import MarkdownIt from 'markdown-it';
// Matches the public renderer (CommonMark, no typographer). True when two
// markdown strings render the same — so blank-line/spacing-only edits don't count,
// but real changes (code indentation, smart quotes, width markers) do.
const commonmark = MarkdownIt('commonmark');
export const rendersIdentically = (a, b) =>
commonmark.render(a ?? '') === commonmark.render(b ?? '');
const INS_CLASS = '!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5';
const DEL_CLASS = '!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5';
// Detailed compare gets slow on huge texts; past this, show all old as removed
// and all new as added.
const MAX_DIFF_TOKENS = 2000;
const tokenizeWords = value => (value || '').match(/\S+/g) || [];
const escapeHtml = value =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Compares two lists in order and reports what's the same (`equal`), removed
// (`del`) or added (`ins`), keeping as much unchanged as possible. `keyOf` says
// how to compare items (title passes words, body passes blocks).
const diffSequence = (a, b, keyOf = item => item) => {
const n = a.length;
const m = b.length;
if (n > MAX_DIFF_TOKENS || m > MAX_DIFF_TOKENS) {
return [
...a.map(item => ({ type: 'del', item })),
...b.map(item => ({ type: 'ins', item })),
];
}
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] =
keyOf(a[i]) === keyOf(b[j])
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (keyOf(a[i]) === keyOf(b[j])) {
ops.push({ type: 'equal', item: a[i] });
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: 'del', item: a[i] });
i += 1;
} else {
ops.push({ type: 'ins', item: b[j] });
j += 1;
}
}
while (i < n) {
ops.push({ type: 'del', item: a[i] });
i += 1;
}
while (j < m) {
ops.push({ type: 'ins', item: b[j] });
j += 1;
}
return ops;
};
const wrapDiff = {
ins: text => `<ins class="${INS_CLASS}">${text}</ins>`,
del: text => `<del class="${DEL_CLASS}">${text}</del>`,
};
// Builds the highlighted title. Compares whole words (not single spaces) so
// repeated words/spaces don't make the highlights jump around, then rejoins
// with single spaces — a run of added/removed words shares one <ins>/<del> tag.
export const renderInlineDiff = (oldValue, newValue) => {
const ops = diffSequence(tokenizeWords(oldValue), tokenizeWords(newValue));
const segments = [];
let run = [];
let runType = null;
const flushRun = () => {
if (!run.length) return;
const text = run.map(escapeHtml).join(' ');
segments.push(wrapDiff[runType] ? wrapDiff[runType](text) : text);
run = [];
};
ops.forEach(({ type, item }) => {
if (type !== runType) flushRun();
runType = type;
run.push(item);
});
flushRun();
return segments.join(' ');
};
// A fenced code block opener: ``` or ~~~, indented up to 3 spaces (CommonMark).
const FENCE_RE = /^ {0,3}(```|~~~)/;
// A list item marker: -, *, + or "1." / "1)", indented up to 3 spaces.
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
// Split on blank lines so each paragraph, heading or list compares as one piece.
// Blank lines inside a fenced code block, or between items of the same list, are
// content — splitting there would tear a code block or list apart and render it
// with broken structure (orphaned <li>/<p>), so we keep those together.
const splitBlocks = text => {
const lines = (text || '').split('\n');
const blocks = [];
let buffer = [];
let fence = null;
let inList = false;
const flush = () => {
const block = buffer.join('\n');
if (block.trim()) blocks.push(block);
buffer = [];
inList = false;
};
lines.forEach((line, index) => {
const marker = line.match(FENCE_RE)?.[1];
if (marker && !fence) fence = marker;
else if (fence && line.trimStart().startsWith(fence)) fence = null;
if (fence) {
buffer.push(line);
return;
}
if (LIST_ITEM_RE.test(line)) inList = true;
if (line.trim() !== '') {
buffer.push(line);
return;
}
// Blank line: keep it when the current list continues on the next non-blank
// line (another item or an indented continuation); otherwise end the block.
const next = lines.slice(index + 1).find(other => other.trim() !== '');
if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
buffer.push(line);
} else {
flush();
}
});
flush();
return blocks;
};
const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
// Diffs the body block by block. Blocks match when they render to the same HTML
// (the check staging uses), so only edits that change the page show as a diff.
export const buildDiffBlocks = (oldText, newText) => {
const toBlocks = text =>
splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
};
export const hasPendingChanges = article =>
article?.draftTitle != null || article?.draftContent != null;
@@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) {
* - emoji
*/
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
const LIQUID_SYNTAX = /\{\{|\{%/;
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
export const resolveVariableText = (key, variables) => {
const value = String(variables?.[key] ?? '');
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
};
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
// name split on whitespace, each word Ruby-capitalized (rest downcased).
const getNameVariables = (prefix, name) => {
const names = (name || '')
.split(/\s+/)
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
return {
[`${prefix}.name`]: names.join(' '),
[`${prefix}.first_name`]: names[0] || '',
[`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
};
};
// {{agent.*}} values for the message sender.
export const getAgentVariables = user => ({
...getNameVariables('agent', user.name),
'agent.email': user.email,
});
// {{contact.*}} name values.
export const getContactVariables = contact =>
getNameVariables('contact', contact?.name);
// Resolves a manually typed {{variable}} to its value on the closing braces.
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
const rule = new InputRule(
/\{\{([^{}]+)\}\}$/,
(editorState, match, from, to) => {
if (isPrivate()) return null;
const [, key] = match;
const text = resolveVariableText(key, getVariables());
if (text === `{{${key}}}`) return null;
return editorState.tr.insertText(text, from, to);
}
);
return inputRules({ rules: [rule] });
};
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
@@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => {
);
}
case 'variable':
return state.schema.text(`{{${content}}}`);
return state.schema.text(content);
case 'emoji':
return state.schema.text(content);
case 'tool': {
@@ -497,8 +547,12 @@ const nodeCreators = {
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
variable: (editorView, content, from, to, variables) => ({
node: createNode(
editorView,
'variable',
resolveVariableText(content, variables)
),
from,
to,
}),
@@ -0,0 +1,170 @@
import {
renderInlineDiff,
buildDiffBlocks,
hasPendingChanges,
rendersIdentically,
} from '../articleDiffHelper';
describe('articleDiffHelper', () => {
describe('renderInlineDiff', () => {
it('returns the text unchanged when there is no difference', () => {
const result = renderInlineDiff('hello world', 'hello world');
expect(result).toBe('hello world');
expect(result).not.toContain('<ins');
expect(result).not.toContain('<del');
});
it('wraps inserted words in <ins>', () => {
const result = renderInlineDiff('hello', 'hello there');
expect(result).toContain('hello');
expect(result).toContain('<ins');
expect(result).toContain('there');
});
it('wraps removed words in <del>', () => {
const result = renderInlineDiff('hello there', 'hello');
expect(result).toContain('<del');
expect(result).toContain('there');
});
it('keeps a single removal contiguous when a word repeats', () => {
const result = renderInlineDiff(
'How to use Agent bots?',
'How How to Agent bots?'
);
expect(result).toBe(
'How <ins class="!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5">How</ins> to <del class="!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5">use</del> Agent bots?'
);
});
it('escapes markup when diffing plain text', () => {
const result = renderInlineDiff('a', 'a <b>');
expect(result).toContain('&lt;b&gt;');
expect(result).not.toContain('<b>');
});
it('treats a cleared empty string as a full deletion', () => {
const result = renderInlineDiff('gone', '');
expect(result).toContain('<del');
expect(result).toContain('gone');
});
});
describe('buildDiffBlocks', () => {
it('passes an unchanged block through as equal', () => {
const blocks = buildDiffBlocks('same para', 'same para');
expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
});
it('marks an appended block as added', () => {
const blocks = buildDiffBlocks('a', 'a\n\nb');
expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
expect(blocks).toContainEqual({ type: 'added', md: 'b' });
});
it('marks a deleted block as removed', () => {
const blocks = buildDiffBlocks('a\n\nb', 'a');
expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
});
it('emits the old block then the new block for a reworded section', () => {
const blocks = buildDiffBlocks('hello world', 'hello there');
expect(blocks).toEqual([
{ type: 'removed', md: 'hello world' },
{ type: 'added', md: 'hello there' },
]);
});
it('keeps a fenced code block whole when it contains blank lines', () => {
const code = '```\nline one\n\nline two\n```';
const blocks = buildDiffBlocks(code, code);
expect(blocks).toEqual([{ type: 'equal', md: code }]);
});
it('diffs an edited code block as one whole removed + added block', () => {
const live = '```\ncode line\n```';
const draft = '```\ncode line\n\nsd\n```';
const blocks = buildDiffBlocks(live, draft);
expect(blocks).toContainEqual({ type: 'removed', md: live });
expect(blocks).toContainEqual({ type: 'added', md: draft });
});
it('surfaces whitespace edits that change the rendered output', () => {
expect(
buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
block => block.type !== 'equal'
)
).toBe(true);
expect(
buildDiffBlocks('line one\nline two', 'line one \nline two').some(
block => block.type !== 'equal'
)
).toBe(true);
});
it('surfaces an indented code block turning into a paragraph', () => {
const blocks = buildDiffBlocks(
' curl example.com',
'curl example.com'
);
expect(blocks).toContainEqual({
type: 'removed',
md: ' curl example.com',
});
expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
});
it('keeps spacing the renderer ignores as equal', () => {
const blocks = buildDiffBlocks('a\nb', 'a \nb');
expect(blocks.every(block => block.type === 'equal')).toBe(true);
});
it('keeps a loose list with item descriptions as one block', () => {
const list =
'1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
const blocks = buildDiffBlocks(list, list);
expect(blocks).toEqual([{ type: 'equal', md: list }]);
});
});
describe('rendersIdentically', () => {
it('ignores blank-line / empty-paragraph differences', () => {
expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
});
it('counts code-block indentation changes', () => {
expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
});
it('counts smart vs straight quotes (no typographer)', () => {
expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
});
it('counts real text changes', () => {
expect(rendersIdentically('hello world', 'hello there')).toBe(false);
});
it('treats nullish input as empty', () => {
expect(rendersIdentically(null, '')).toBe(true);
expect(rendersIdentically(undefined, 'x')).toBe(false);
});
});
describe('hasPendingChanges', () => {
it('is true when a draft title or content is staged', () => {
expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
});
it('treats a cleared empty-string draft as a pending change', () => {
expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
});
it('is false with no draft columns', () => {
expect(hasPendingChanges({ title: 'live' })).toBe(false);
expect(hasPendingChanges({})).toBe(false);
expect(hasPendingChanges(null)).toBe(false);
});
});
});
@@ -94,16 +94,56 @@ describe('getContentNode', () => {
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
it('should render the resolved value directly when the variable has a value', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': 'John' }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
});
it('should resolve camelCase custom attributes and non-string values', () => {
getContentNode(
editorView,
'variable',
'contact.custom_attribute.cloudCustomer',
{ from: 0, to: 10 },
{ 'contact.custom_attribute.cloudCustomer': true }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
});
it('should keep the placeholder when the variable has no value', () => {
getContentNode(
editorView,
'variable',
'contact.email',
{ from: 0, to: 10 },
{}
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.email}}'
);
});
it('should keep the placeholder when the value contains Liquid syntax', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': '{{agent.email}}' }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.name}}'
);
});
});
@@ -11,9 +11,12 @@ import {
calculateMenuPosition,
cleanSignature,
collapseSelection,
createVariableInputRule,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
getAgentVariables,
getContactVariables,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
@@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => {
});
});
});
describe('getAgentVariables', () => {
it('builds agent variables from the user', () => {
expect(
getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
).toEqual({
'agent.name': 'John Doe',
'agent.first_name': 'John',
'agent.last_name': 'Doe',
'agent.email': 'john@example.com',
});
});
it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
const variables = getAgentVariables({ name: 'JANE doE' });
expect(variables['agent.name']).toBe('Jane Doe');
expect(variables['agent.first_name']).toBe('Jane');
expect(variables['agent.last_name']).toBe('Doe');
});
it('ignores extra whitespace between words', () => {
expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
'John Doe'
);
});
it('leaves last_name empty for single-word names', () => {
const variables = getAgentVariables({ name: 'john' });
expect(variables['agent.first_name']).toBe('John');
expect(variables['agent.last_name']).toBe('');
});
it('handles a missing name', () => {
const variables = getAgentVariables({ email: 'john@example.com' });
expect(variables['agent.name']).toBe('');
expect(variables['agent.first_name']).toBe('');
expect(variables['agent.last_name']).toBe('');
});
});
describe('getContactVariables', () => {
it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
expect(getContactVariables({ name: 'JANE doE' })).toEqual({
'contact.name': 'Jane Doe',
'contact.first_name': 'Jane',
'contact.last_name': 'Doe',
});
});
it('leaves last_name empty for single-word names', () => {
const variables = getContactVariables({ name: 'john' });
expect(variables['contact.first_name']).toBe('John');
expect(variables['contact.last_name']).toBe('');
});
it('handles a missing contact', () => {
expect(getContactVariables(undefined)['contact.name']).toBe('');
});
});
describe('createVariableInputRule', () => {
// Editor holding `{{key}` so we can simulate typing the final `}`.
const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
const plugin = createVariableInputRule({
isPrivate: () => isPrivate,
getVariables: () => variables,
});
const state = EditorState.create({
schema,
doc: schema.node('doc', null, [
schema.node('paragraph', null, [schema.text(typed)]),
]),
plugins: [plugin],
});
return new EditorView(document.body, { state });
};
// Types the closing `}`; when the rule declines, insert it like the browser would.
const typeClosingBrace = view => {
const end = view.state.doc.content.size - 1;
const handled = view.someProp('handleTextInput', fn =>
fn(view, end, end, '}')
);
if (!handled) {
view.dispatch(view.state.tr.insertText('}', end, end));
}
};
it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('John');
view.destroy();
});
it('resolves boolean/non-string values', () => {
const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
variables: { 'contact.custom_attribute.cloudCustomer': true },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('true');
view.destroy();
});
it('keeps the placeholder when the variable has no value', () => {
const view = buildView('{{contact.email}', { variables: {} });
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.email}}');
view.destroy();
});
it('keeps the placeholder when the value itself contains Liquid syntax', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': '{{agent.email}}' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
it('does not resolve inside a private note', () => {
const view = buildView('{{contact.name}', {
isPrivate: true,
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
});
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -533,6 +533,8 @@
"PUBLISHED": "Published",
"ARCHIVED": "Archived"
},
"PENDING_EDITS": "Unpublished edits",
"PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
"CATEGORY": {
"UNCATEGORISED": "Uncategorised"
}
@@ -616,6 +618,8 @@
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
"STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
@@ -763,10 +767,30 @@
},
"PREVIEW": "Preview",
"PUBLISH": "Publish",
"PUBLISH_CHANGES": "Publish changes",
"PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
"PUBLISH_CHANGES_ERROR": "Could not publish changes",
"SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
"DISCARD_CHANGES": "Discard changes",
"DISCARD_CHANGES_SUCCESS": "Changes discarded",
"DISCARD_CHANGES_ERROR": "Could not discard changes",
"PENDING_CHANGES": "Pending changes",
"VIEW_CHANGES": "View unpublished changes",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"BACK_TO_ARTICLES": "Back to articles"
},
"PENDING_CHANGES_POPOVER": {
"TITLE": "Unpublished changes",
"DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
"APPLY": "Apply changes",
"DISCARD": "Discard changes"
},
"DIFF_DIALOG": {
"TITLE": "Unpublished changes",
"DESCRIPTION": "Compare your draft against the version that's currently live.",
"TITLE_LABEL": "Title"
},
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "More properties",
"UNCATEGORIZED": "Uncategorized",
@@ -11,7 +11,9 @@
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account."
"404": "There are no inboxes attached to this account.",
"REAUTHORIZATION_REQUIRED": "Reauthorization required",
"MANUAL_SETUP_RECOMMENDED": "Manual setup recommended"
},
"CREATE_FLOW": {
"CHANNEL": {
@@ -58,7 +60,10 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore.",
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. Well restore support as soon as possible.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -320,6 +325,8 @@
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow",
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. Well restore support as soon as possible.",
"STATUS_LINK": "View status update",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
@@ -815,6 +822,15 @@
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_MANUAL_TRANSFER_TITLE": "Switch to Manual Setup",
"WHATSAPP_MANUAL_TRANSFER_SUBHEADER": "This inbox was connected through WhatsApp embedded signup, which is no longer supported. Enter the WhatsApp Cloud API credentials from your own Meta app to switch this inbox to a manual setup. Configure the webhook in your Meta app using the verification token above before switching.",
"WHATSAPP_MANUAL_TRANSFER_PHONE_NUMBER_ID_LABEL": "Phone Number ID",
"WHATSAPP_MANUAL_TRANSFER_BUSINESS_ACCOUNT_ID_LABEL": "Business Account ID",
"WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API Key",
"WHATSAPP_MANUAL_TRANSFER_API_KEY_PLACEHOLDER": "Enter a permanent access token from your Meta app",
"WHATSAPP_MANUAL_TRANSFER_BUTTON": "Switch to Manual Setup",
"WHATSAPP_MANUAL_TRANSFER_SUCCESS": "Inbox switched to manual setup successfully.",
"WHATSAPP_MANUAL_TRANSFER_ERROR": "Could not switch to manual setup. Please verify the credentials and try again.",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
@@ -832,6 +848,70 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_MANUAL_MIGRATION": {
"BANNER": {
"TITLE": "Manual setup recommended",
"DESCRIPTION": "This inbox connects through the shared Meta app used for embedded signup, which recent Meta restrictions have affected. To avoid similar issues in the future, we recommend reconnecting it with your own Meta app.",
"START": "Start manual migration",
"GUIDE": "View guide"
},
"DIALOG": {
"EYEBROW": "WhatsApp manual migration",
"TITLE": "Reconnect WhatsApp inbox",
"CLOSE": "Close",
"ACTION_REQUIRED_TITLE": "Reconnect with your own Meta app",
"ACTION_REQUIRED_DESCRIPTION": "Inboxes connected through your own Meta app are not affected by restrictions on the shared embedded signup app. This guided flow updates the WhatsApp API connection without creating a new inbox.",
"GUIDE_LINK": "Open the manual setup guide",
"PRESERVED_TITLE": "Preserved",
"PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
"UPDATED_TITLE": "Updated",
"UPDATED_DESCRIPTION": "WABA ID, phone number ID, access token, and webhook configuration.",
"WABA_ID": "WABA ID",
"WABA_PLACEHOLDER": "Enter WABA ID",
"WABA_HELP": "The WhatsApp Business Account that owns this phone number.",
"PHONE_NUMBER_ID": "Phone Number ID",
"PHONE_NUMBER_PLACEHOLDER": "Enter Phone Number ID",
"PHONE_NUMBER_HELP": "Meta's unique ID for the WhatsApp number connected to this inbox.",
"DISPLAY_PHONE_NUMBER": "Display phone number",
"DISPLAY_PHONE_NUMBER_PLACEHOLDER": "Enter display phone number",
"DISPLAY_PHONE_NUMBER_HELP": "The customer-facing WhatsApp number. This cannot be changed during migration.",
"ACCESS_TOKEN": "Permanent access token or system user token",
"ACCESS_TOKEN_PLACEHOLDER": "Paste access token",
"TOKEN_HELP_PREFIX": "The token must include",
"TOKEN_HELP_MIDDLE": "Add",
"TOKEN_HELP_SUFFIX": "for template sync and template management.",
"MESSAGING_PERMISSION": "whatsapp_business_messaging.",
"MANAGEMENT_PERMISSION": "whatsapp_business_management",
"REVIEW_TITLE": "Review before reconnecting",
"INBOX": "Inbox",
"PHONE_NUMBER": "Phone number",
"NOT_ENTERED": "Not entered",
"VERIFY_NOTICE": "Chatwoot will verify these credentials with Meta before applying any changes. If verification fails, the current configuration is left untouched.",
"BACK": "Back",
"CANCEL": "Cancel",
"CONTINUE": "Continue",
"REVIEW_MIGRATION": "Review migration",
"RECONNECT": "Reconnect WhatsApp inbox"
},
"STEPS": {
"BEFORE_YOU_START": {
"TITLE": "Before you start",
"DESCRIPTION": "This reconnects the WhatsApp API details for this inbox. Conversations, collaborators, routing, business hours, CSAT, and bot settings will be preserved."
},
"BUSINESS_DETAILS": {
"TITLE": "Business details",
"DESCRIPTION": "Enter the WhatsApp assets from the customer Meta Business account."
},
"ACCESS_TOKEN": {
"TITLE": "Access token",
"DESCRIPTION": "Paste a permanent access token or system user token with WhatsApp permissions."
},
"REVIEW_MIGRATION": {
"TITLE": "Review migration",
"DESCRIPTION": "Confirm the connection details before reconnecting this inbox. Chatwoot will verify the token, WABA, and phone number with Meta before applying changes."
}
}
},
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
@@ -31,6 +31,13 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"PAYWALL": {
"TITLE": "Webhooks are available on paid plans",
"AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
"UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "Change or cancel your plan anytime."
},
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
@@ -439,6 +446,13 @@
"HINT": "Average replies the assistant sends per conversation."
}
},
"DRILLDOWN": {
"CLOSE": "Close details",
"EMPTY": "No records found for this metric.",
"ERROR": "Could not load records. Please try again.",
"LOAD_MORE": "Load more",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations"
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
@@ -446,17 +460,6 @@
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"FLAGGED": {
"TITLE": "Response quality",
"TOTAL": "{count} flagged · {rate}"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"AXIS_START": "{count}d ago",
"AXIS_END": "Today"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
@@ -470,13 +473,6 @@
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
},
"FLAG_REASONS": {
"INCORRECT": "Incorrect info",
"INCOMPLETE": "Incomplete",
"OUTDATED": "Outdated",
"INAPPROPRIATE": "Inappropriate",
"OTHER": "Other"
}
},
"ASSISTANT_SWITCHER": {
@@ -822,6 +818,7 @@
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
@@ -881,7 +878,27 @@
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
"EMPTY": "No FAQs have been generated from this document yet."
},
"DETAILS": {
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
"SOURCE": "Source",
"GENERATED_FAQS": "Generated FAQs",
"LAST_UPDATED": "Last updated",
"NOT_AVAILABLE": "Not available",
"CONTENT_TAB": "Crawled content",
"PDF_TAB": "PDF details",
"CONTENT_TITLE": "Crawled content",
"PDF_TITLE": "PDF file",
"PDF_DESCRIPTION": "Review the original PDF source.",
"CHARACTER_COUNT": "{count} characters extracted",
"COPY_CONTENT": "Copy",
"COPY_SUCCESS": "Crawled content copied to clipboard",
"COPY_ERROR": "Could not copy crawled content",
"VIEW_RAW": "View raw",
"VIEW_PREVIEW": "View preview",
"UNREADABLE_CONTENT": "Readable content could not be extracted from this document. You can view the raw extracted content.",
"EMPTY_CONTENT": "No crawled content is available for this document yet."
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
@@ -922,7 +939,7 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"VIEW_DETAILS": "View details",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
@@ -100,6 +100,7 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
"PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
@@ -263,7 +264,7 @@
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
"MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team."
},
"NO_ACCOUNTS": {
"TITLE": "No account found",
@@ -340,6 +341,7 @@
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
"DATA": "Data",
"PROFILE_SETTINGS": "Profile Settings",
"ACCOUNT_SETTINGS": "Account Settings",
"APPLICATIONS": "Applications",
@@ -378,6 +380,7 @@
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview",
"REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",
"WHATSAPP_MANUAL_MIGRATION": "Manual setup recommended. Reconnect this WhatsApp inbox with your own Meta app.",
"HELP_CENTER": {
"TITLE": "Help Center",
"ARTICLES": "Articles",
@@ -411,6 +414,104 @@
"CAPTAIN_AI": "Captain",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
},
"DATA_IMPORTS": {
"HEADER": "Data",
"DESCRIPTION": "Bring your existing contacts and past conversations into this account from another support tool. Each import runs in the background, so you can keep working while it finishes, track its progress, and review anything that was skipped along the way.",
"LOADING": "Fetching imports",
"DEFAULT_IMPORT_NAME": "Intercom import",
"TABS": {
"IMPORT": "Import",
"EXPORT": "Export"
},
"TYPES": {
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"DRAWER": {
"TITLE": "New import",
"SOURCE": "Source",
"NAME": "Import name",
"NAME_PLACEHOLDER": "July Intercom migration",
"ACCESS_KEY": "Intercom access key",
"ACCESS_KEY_PLACEHOLDER": "Paste your Intercom access key",
"DATA_TYPES": "Data to import",
"VALIDATING": "Validating access key...",
"VALID_KEY": "Access key validated.",
"INVALID_KEY": "Could not validate this access key.",
"ACTIVE_IMPORT": "Wait for the active import to finish before starting another one.",
"CANCEL": "Cancel",
"IMPORT": "Import"
},
"EXPORT": {
"TITLE": "Exports are on the way",
"DESCRIPTION": "Export your contacts and conversations out of this account. This workflow is coming soon.",
"COMING_SOON": "Coming soon"
},
"TABLE": {
"TITLE": "Recent imports",
"EMPTY": "No imports yet",
"EMPTY_DESCRIPTION": "Start an import to bring your existing customer history into this account.",
"NEW_IMPORT": "Import",
"COUNT": "{count} imports",
"UNNAMED": "Untitled import",
"IMPORTED_COUNT": "{count} imported",
"VIEW": "View import",
"NAME": "Name",
"TYPE": "Type",
"STATUS": "Status",
"IMPORTED": "Imported",
"CREATED": "Created",
"ABANDON": "Abandon"
},
"DETAIL": {
"BACK": "Back to imports",
"ERRORS": "Errors",
"SKIP_LOGS": "Skip logs",
"SOURCE": "Source",
"IMPORT_TYPES": "Import types",
"CREATED": "Created",
"DURATION": "Duration",
"INITIATED_BY": "Started by",
"PROGRESS": "Import progress",
"PROGRESS_WITH_TOTAL": "{imported} of {total} imported",
"PROGRESS_WITHOUT_TOTAL": "{imported} imported",
"PROGRESS_OF_TOTAL": "of {total} imported",
"PROGRESS_IMPORTED": "imported",
"LAST_UPDATED_TOOLTIP": "Last updated {time}",
"NO_SKIP_LOGS": "No skipped or failed records recorded.",
"DOWNLOAD_SKIP_LOGS": "Download CSV",
"DOWNLOAD_ERROR_LOGS": "Download CSV",
"ALL_SKIP_LOGS": "All",
"KIND": "Kind",
"NO_ERRORS": "No errors recorded.",
"ERROR_CODE": "Code",
"SOURCE_OBJECT": "Source object",
"MESSAGE": "Message"
},
"MONITOR": {
"LIVE": "Live updates every {seconds}s",
"LAST_UPDATED": "Last updated {time}",
"REFRESH": "Refresh",
"REFRESHING": "Refreshing",
"STAGES": {
"unknown": "Waiting for update",
"queued": "Queued",
"contacts": "Importing contacts",
"conversations": "Importing conversations",
"finalizing": "Finalizing import",
"completed": "Completed",
"completed_with_errors": "Completed with errors",
"failed": "Failed",
"abandoned": "Abandoned"
}
},
"ALERTS": {
"IMPORT_STARTED": "Intercom import has started.",
"IMPORT_ABANDONED": "Intercom import has been abandoned.",
"IMPORT_FAILED": "Could not start the Intercom import."
}
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
@@ -10,6 +11,7 @@ import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Pay
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
@@ -17,6 +19,9 @@ import CoverageBanner from 'dashboard/components-next/captain/pageComponents/ove
const { t } = useI18n();
const route = useRoute();
// Drilldown is admin-only; the backend policy enforces the same restriction.
const { checkPermissions } = usePolicy();
const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month');
@@ -69,34 +74,38 @@ const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const metrics = computed(() => [
{
key: 'handled',
metric: 'conversations_handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
metric: 'auto_resolution_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
metric: 'handoff_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'reopen',
metric: 'reopen_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'reopen',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
@@ -109,6 +118,22 @@ const metrics = computed(() => [
),
},
]);
const drilldown = ref({ metric: '', label: '', value: '' });
const isDrilldownOpen = ref(false);
const openDrilldown = metric => {
drilldown.value = {
metric: metric.metric,
label: metric.label,
value: metric.value,
};
isDrilldownOpen.value = true;
};
const closeDrilldown = () => {
isDrilldownOpen.value = false;
};
</script>
<template>
@@ -144,6 +169,8 @@ const metrics = computed(() => [
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:clickable="canDrilldown && Boolean(metric.metric)"
@click="openDrilldown(metric)"
/>
</div>
@@ -151,6 +178,17 @@ const metrics = computed(() => [
<QuickLinks />
</div>
<AssistantDrilldownDrawer
v-if="canDrilldown"
:open="isDrilldownOpen"
:assistant-id="assistantId"
:metric="drilldown.metric"
:metric-name="drilldown.label"
:metric-value="drilldown.value"
:range="selectedRange"
@close="closeDrilldown"
/>
</template>
</PageLayout>
</template>
@@ -17,7 +17,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
@@ -51,22 +51,22 @@ const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
};
const showRelatedResponses = ref(false);
const showDocumentDetails = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const relationQuestionDialog = ref(null);
const documentDetailsDialog = ref(null);
const handleShowRelatedDocument = () => {
showRelatedResponses.value = true;
nextTick(() => relationQuestionDialog.value.dialogRef.open());
const handleShowDocumentDetails = () => {
showDocumentDetails.value = true;
nextTick(() => documentDetailsDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
nextTick(() => createDocumentDialog.value.dialogRef.open());
};
const handleRelatedResponseClose = () => {
showRelatedResponses.value = false;
const handleDocumentDetailsClose = () => {
showDocumentDetails.value = false;
};
const handleCreateDialogClose = () => {
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
nextTick(() => {
if (action === 'delete') {
handleDelete();
} else if (action === 'viewRelatedQuestions') {
handleShowRelatedDocument();
} else if (action === 'viewDetails') {
handleShowDocumentDetails();
} else if (action === 'sync') {
handleSync(id);
}
@@ -416,6 +416,7 @@ onUnmounted(() => {
:last-sync-error-code="doc.last_sync_error_code"
:sync-in-progress="doc.sync_in_progress"
:sync-stale-after-hours="syncIntervalHours"
:responses-count="doc.responses_count"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
@@ -427,11 +428,11 @@ onUnmounted(() => {
</div>
</template>
<RelatedResponses
v-if="showRelatedResponses"
ref="relationQuestionDialog"
<DocumentDetails
v-if="showDocumentDetails"
ref="documentDetailsDialog"
:captain-document="selectedDocument"
@close="handleRelatedResponseClose"
@close="handleDocumentDetailsClose"
/>
<CreateDocumentDialog
v-if="showCreateDialog"
@@ -4,8 +4,12 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import {
buildPortalArticleURL,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { rendersIdentically } from 'dashboard/helper/articleDiffHelper';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
@@ -40,13 +44,60 @@ const articleLink = computed(() => {
);
});
// On a published article, title/content edits stage into draft_* columns (kept
// off the live site). Anywhere else they save straight to the live record and
// we drop any leftover draft (e.g. left behind when the card/bulk menu moved a
// published article to draft) so a later publish can't resurrect stale content.
const stageDraftFields = values => {
if (article.value?.status !== ARTICLE_STATUSES.PUBLISHED) {
const hasStaleDraft =
article.value?.draftTitle != null || article.value?.draftContent != null;
if (!hasStaleDraft) return values;
// The editor is showing the staged draft, so promote both fields to the live
// record (the field being autosaved wins) before dropping the drafts
// otherwise saving one field would snap the other back to the old live value.
return {
...values,
title: values.title ?? article.value.draftTitle ?? article.value.title,
content:
values.content ?? article.value.draftContent ?? article.value.content,
draft_title: null,
draft_content: null,
};
}
const staged = { ...values };
['title', 'content'].forEach(field => {
if (field in staged) {
staged[`draft_${field}`] = staged[field];
delete staged[field];
}
});
// Clear the draft when it matches the live version (a revert, or a body edit
// the renderer ignores like a blank line) so it doesn't leave a "pending
// changes" badge with nothing to compare. The title is shown as raw escaped
// text, so compare it exactly; only the body is Markdown, so compare its render.
const liveTitle = article.value.title ?? '';
const liveContent = article.value.content ?? '';
const nextTitle = staged.draft_title ?? article.value.draftTitle ?? liveTitle;
const nextContent =
staged.draft_content ?? article.value.draftContent ?? liveContent;
if (nextTitle === liveTitle && rendersIdentically(liveContent, nextContent)) {
staged.draft_title = null;
staged.draft_content = null;
}
return staged;
};
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
...values,
...stageDraftFields(values),
});
isSaved.value = true;
} catch (error) {
@@ -1,5 +1,4 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -8,18 +7,20 @@ import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
!isOnChatwootCloud.value &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
instagram: () =>
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -1,6 +1,7 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
@@ -23,11 +24,17 @@ export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const { isOnChatwootCloud } = useAccount();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
if (provider === 'instagram' && isOnChatwootCloud.value) {
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
return;
}
try {
const {
data: { url },
@@ -6,21 +6,25 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary WhatsApp kill switch so these specs keep covering
// the credential-based gating it short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const mountComposable = ({
brandInfo,
inboxes = [],
isOnChatwootCloud = false,
} = {}) => {
const store = createStore({
modules: {
globalConfig: {
namespaced: true,
getters: {
get: () => ({}),
isOnChatwootCloud: () => isOnChatwootCloud,
},
},
accounts: {
namespaced: true,
getters: {
@@ -202,6 +206,22 @@ describe('useDetectedChannels', () => {
'line',
]);
});
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
const { displayedChannels } = mountComposable({
isOnChatwootCloud: true,
brandInfo: {
socials: [
{ type: 'instagram', url: 'https://instagram.com/acme' },
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
});
describe('remainingChannels', () => {
@@ -52,9 +52,11 @@ const helpURL = getHelpUrlForFeature(props.featureName);
v-if="title"
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
>
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
<slot name="title">
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
</slot>
</div>
<div
v-if="description || $slots.description || linkText || helpURL"
@@ -0,0 +1,378 @@
<script setup>
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
ref,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStoreGetters } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import NewImportDialog from './NewImportDialog.vue';
import { importSourceFor } from './importSources';
import {
POLL_INTERVAL_MS,
formatDate,
formatStatus,
importedCount,
isActiveImport,
isActiveIntercomImport,
statusDotClass,
} from './importStatus';
const { t } = useI18n();
const getters = useStoreGetters();
const router = useRouter();
const dataImports = ref([]);
const isLoading = ref(true);
const isRefreshing = ref(false);
const isPolling = ref(false);
const showImportDrawer = ref(false);
const activeTab = ref('import');
let pollTimer;
let isPageActive = false;
const accountId = getters.getCurrentAccountId;
const tabs = computed(() => [
{ key: 'import', label: t('DATA_IMPORTS.TABS.IMPORT') },
{ key: 'export', label: t('DATA_IMPORTS.TABS.EXPORT') },
]);
const activeTabIndex = computed(() =>
tabs.value.findIndex(tab => tab.key === activeTab.value)
);
const hasActiveImport = computed(() => dataImports.value.some(isActiveImport));
const hasActiveIntercomImport = computed(() =>
dataImports.value.some(isActiveIntercomImport)
);
const dataImportRoute = dataImport => ({
name: 'settings_data_import_show',
params: { accountId: accountId.value, dataImportId: dataImport.id },
});
const importTypesFor = dataImport =>
dataImport.import_types?.length
? dataImport.import_types
: [dataImport.data_type];
const importTypeLabel = dataImport =>
importTypesFor(dataImport)
.map(type => {
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
if (type === 'conversations') {
return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
}
return type;
})
.join(', ');
const fetchImports = async () => {
const response = await DataImportsAPI.get();
dataImports.value = response.data.payload || [];
};
const stopPolling = () => {
if (!pollTimer) return;
window.clearInterval(pollTimer);
pollTimer = null;
};
const refreshImportsInBackground = async () => {
if (
!isPageActive ||
isPolling.value ||
!hasActiveImport.value ||
document.hidden
) {
return;
}
isPolling.value = true;
try {
await fetchImports();
} finally {
isPolling.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const startPolling = () => {
stopPolling();
if (!isPageActive || !hasActiveImport.value) return;
pollTimer = window.setInterval(refreshImportsInBackground, POLL_INTERVAL_MS);
};
const refresh = async ({ showLoader = true } = {}) => {
if (showLoader) isLoading.value = true;
else isRefreshing.value = true;
try {
await fetchImports();
} finally {
isLoading.value = false;
isRefreshing.value = false;
if (isPageActive) {
if (hasActiveImport.value && !pollTimer) startPolling();
if (!hasActiveImport.value) stopPolling();
}
}
};
const openImport = dataImport => {
router.push(dataImportRoute(dataImport));
};
const openImportDrawer = () => {
if (!hasActiveIntercomImport.value) showImportDrawer.value = true;
};
const onImportCreated = dataImportId => {
showImportDrawer.value = false;
router.push({
name: 'settings_data_import_show',
params: { accountId: accountId.value, dataImportId },
});
};
const onTabChanged = tab => {
activeTab.value = tab.key;
};
const handleVisibilityChange = () => {
if (isPageActive && !document.hidden && hasActiveImport.value) {
refreshImportsInBackground();
}
};
onActivated(async () => {
isPageActive = true;
await refresh();
if (!isPageActive) return;
startPolling();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onDeactivated(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:loading-message="$t('DATA_IMPORTS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="$t('DATA_IMPORTS.HEADER')"
:description="$t('DATA_IMPORTS.DESCRIPTION')"
>
<template #tabs>
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="onTabChanged"
/>
</template>
<template v-if="activeTab === 'import' && dataImports.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.TABLE.COUNT', { count: dataImports.length }) }}
</span>
</template>
<template v-if="activeTab === 'import'" #actions>
<span
v-if="hasActiveImport"
class="hidden items-center gap-1.5 text-body-main text-n-slate-11 sm:inline-flex"
>
<span class="size-2 rounded-full bg-n-teal-9 animate-pulse" />
{{
$t('DATA_IMPORTS.MONITOR.LIVE', {
seconds: POLL_INTERVAL_MS / 1000,
})
}}
</span>
<Button
ghost
slate
size="sm"
icon="i-lucide-refresh-cw"
:is-loading="isRefreshing"
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
@click="refresh({ showLoader: false })"
/>
<Button
size="sm"
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
:disabled="hasActiveIntercomImport"
:title="
hasActiveIntercomImport
? $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT')
: undefined
"
@click="openImportDrawer"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div
v-if="activeTab === 'export'"
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
>
<span
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon icon="i-lucide-upload" class="size-5 text-n-slate-11" />
</span>
<div class="flex flex-col gap-1">
<h3 class="text-heading-2 text-n-slate-12">
{{ $t('DATA_IMPORTS.EXPORT.TITLE') }}
</h3>
<p class="max-w-sm text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.EXPORT.DESCRIPTION') }}
</p>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-2 py-1 text-label-small text-n-slate-11"
>
<Icon icon="i-lucide-clock" class="size-3.5" />
{{ $t('DATA_IMPORTS.EXPORT.COMING_SOON') }}
</span>
</div>
<div
v-else-if="!dataImports.length"
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
>
<span
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon icon="i-lucide-database" class="size-5 text-n-slate-11" />
</span>
<div class="flex flex-col gap-1">
<h3 class="text-heading-2 text-n-slate-12">
{{ $t('DATA_IMPORTS.TABLE.EMPTY') }}
</h3>
<p class="max-w-sm text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.TABLE.EMPTY_DESCRIPTION') }}
</p>
</div>
<Button
size="sm"
icon="i-lucide-download"
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
@click="openImportDrawer"
/>
</div>
<div v-else class="divide-y divide-n-weak border-t border-n-weak">
<div
v-for="dataImport in dataImports"
:key="dataImport.id"
class="group flex cursor-pointer items-center justify-between gap-4 py-4"
role="button"
tabindex="0"
@click="openImport(dataImport)"
@keydown.enter="openImport(dataImport)"
@keydown.space.prevent="openImport(dataImport)"
>
<div class="flex min-w-0 items-center gap-3">
<img
v-if="importSourceFor(dataImport).icon"
v-tooltip.top="importSourceFor(dataImport).label"
:src="importSourceFor(dataImport).icon"
alt=""
class="size-10 justify-center bg-n-alpha-3 rounded-xl shrink-0 object-contain border border-n-strong"
/>
<span
v-else
v-tooltip.top="importSourceFor(dataImport).label"
class="size-10 justify-center bg-n-alpha-3 rounded-xl ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<Icon
:icon="importSourceFor(dataImport).iconClass"
class="size-4"
/>
</span>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="truncate text-heading-3 text-n-slate-12">
{{ dataImport.name || $t('DATA_IMPORTS.TABLE.UNNAMED') }}
</span>
<span class="flex shrink-0 items-center gap-1.5">
<span
class="size-2 rounded-full"
:class="[
statusDotClass(dataImport.status),
{ 'animate-pulse': isActiveImport(dataImport) },
]"
/>
<span
class="whitespace-nowrap capitalize text-body-main text-n-slate-11"
>
{{ formatStatus(dataImport.status) }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center gap-2 text-body-main text-n-slate-11"
>
<span>{{ importTypeLabel(dataImport) }}</span>
<div class="h-3 w-px rounded-lg bg-n-strong" />
<span class="tabular-nums">
{{
$t('DATA_IMPORTS.TABLE.IMPORTED_COUNT', {
count: importedCount(dataImport),
})
}}
</span>
<div class="h-3 w-px rounded-lg bg-n-strong" />
<span>{{ formatDate(dataImport.created_at) }}</span>
</div>
</div>
</div>
<Button
v-tooltip.top="$t('DATA_IMPORTS.TABLE.VIEW')"
icon="i-lucide-eye"
slate
sm
class="shrink-0"
@click.stop="openImport(dataImport)"
/>
</div>
</div>
</template>
</SettingsLayout>
<NewImportDialog
:show="showImportDrawer"
:has-active-import="hasActiveIntercomImport"
@close="showImportDrawer = false"
@created="onImportCreated"
/>
</template>
@@ -0,0 +1,210 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import { IMPORT_SOURCES } from './importSources';
const props = defineProps({
show: { type: Boolean, default: false },
hasActiveImport: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'created']);
const { t } = useI18n();
const dialogRef = ref(null);
const sourceProvider = ref('intercom');
const importName = ref(t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'));
const accessToken = ref('');
const selectedImportTypes = ref(['contacts', 'conversations']);
const validationState = ref('idle');
const validationMessage = ref('');
const isCreating = ref(false);
let validationRequestId = 0;
const closeDrawer = () => emit('close');
const sourceOptions = computed(() =>
IMPORT_SOURCES.map(({ value, label }) => ({ value, label }))
);
const tokenMessageType = computed(() => {
if (validationState.value === 'valid') return 'success';
if (validationState.value === 'invalid') return 'error';
return 'info';
});
const canCreate = computed(
() =>
validationState.value === 'valid' &&
selectedImportTypes.value.length > 0 &&
!props.hasActiveImport &&
!isCreating.value
);
const validationPayload = () => ({
source_provider: sourceProvider.value,
access_token: accessToken.value.trim(),
import_types: selectedImportTypes.value,
});
const invalidateValidation = () => {
validationRequestId += 1;
validationState.value = 'idle';
validationMessage.value = '';
};
const validateSource = async () => {
if (!accessToken.value.trim() || !selectedImportTypes.value.length) {
invalidateValidation();
return;
}
validationRequestId += 1;
const requestId = validationRequestId;
validationState.value = 'validating';
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALIDATING');
try {
await DataImportsAPI.validateSource(validationPayload());
if (requestId !== validationRequestId) return;
validationState.value = 'valid';
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALID_KEY');
} catch (error) {
if (requestId !== validationRequestId) return;
validationState.value = 'invalid';
validationMessage.value =
error?.response?.data?.message || t('DATA_IMPORTS.DRAWER.INVALID_KEY');
}
};
const toggleImportType = type => {
selectedImportTypes.value = selectedImportTypes.value.includes(type)
? selectedImportTypes.value.filter(item => item !== type)
: [...selectedImportTypes.value, type];
};
const createImport = async () => {
if (!canCreate.value) return;
isCreating.value = true;
try {
const response = await DataImportsAPI.create({
...validationPayload(),
name: importName.value.trim() || t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'),
});
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_STARTED'));
emit('created', response.data.id);
} catch (error) {
useAlert(
error?.response?.data?.message || t('DATA_IMPORTS.ALERTS.IMPORT_FAILED')
);
} finally {
isCreating.value = false;
}
};
watch(accessToken, invalidateValidation);
watch(selectedImportTypes, () => {
invalidateValidation();
if (accessToken.value.trim() && selectedImportTypes.value.length) {
validateSource();
}
});
watch(
() => props.show,
show => {
if (show) {
dialogRef.value?.open();
return;
}
dialogRef.value?.close();
accessToken.value = '';
validationState.value = 'idle';
validationMessage.value = '';
}
);
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t('DATA_IMPORTS.DRAWER.TITLE')"
:confirm-button-label="$t('DATA_IMPORTS.DRAWER.IMPORT')"
:cancel-button-label="$t('DATA_IMPORTS.DRAWER.CANCEL')"
:disable-confirm-button="!canCreate"
:is-loading="isCreating || validationState === 'validating'"
width="md"
@confirm="createImport"
@close="closeDrawer"
>
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1.5 text-heading-3 text-n-slate-12">
{{ $t('DATA_IMPORTS.DRAWER.SOURCE') }}
<Select
v-model="sourceProvider"
class="!w-full [&>select]:w-full"
:options="sourceOptions"
/>
</label>
<Input
v-model="importName"
:label="$t('DATA_IMPORTS.DRAWER.NAME')"
:placeholder="$t('DATA_IMPORTS.DRAWER.NAME_PLACEHOLDER')"
/>
<Input
v-model="accessToken"
type="password"
autocomplete="off"
:label="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY')"
:placeholder="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY_PLACEHOLDER')"
:message="validationMessage"
:message-type="tokenMessageType"
@blur="validateSource"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-1.5 text-heading-3 text-n-slate-12">
{{ $t('DATA_IMPORTS.DRAWER.DATA_TYPES') }}
</legend>
<label
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
>
<Checkbox
:model-value="selectedImportTypes.includes('contacts')"
@change="toggleImportType('contacts')"
/>
{{ $t('DATA_IMPORTS.TYPES.CONTACTS') }}
</label>
<label
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
>
<Checkbox
:model-value="selectedImportTypes.includes('conversations')"
@change="toggleImportType('conversations')"
/>
{{ $t('DATA_IMPORTS.TYPES.CONVERSATIONS') }}
</label>
</fieldset>
<p
v-if="hasActiveImport"
class="rounded-lg bg-n-amber-2 px-3 py-2 text-body-main text-n-amber-11"
>
{{ $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT') }}
</p>
</div>
</Dialog>
</template>
@@ -0,0 +1,238 @@
<script setup>
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
ref,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import DataImportsAPI from 'dashboard/api/dataImports';
import { POLL_INTERVAL_MS, isActiveImport } from './importStatus';
import SettingsLayout from '../SettingsLayout.vue';
import ImportDetailHeader from './components/ImportDetailHeader.vue';
import ImportSummaryTiles from './components/ImportSummaryTiles.vue';
import ImportProgress from './components/ImportProgress.vue';
import ImportErrorsSection from './components/ImportErrorsSection.vue';
import ImportSkipLogsSection from './components/ImportSkipLogsSection.vue';
const { t } = useI18n();
const route = useRoute();
const dataImport = ref(null);
const isLoading = ref(true);
const isRefreshing = ref(false);
const isPolling = ref(false);
const isAbandoning = ref(false);
const isDownloadingErrorLogs = ref(false);
const isDownloadingSkipLogs = ref(false);
const isChangingSkipLogsType = ref(false);
const selectedSkipLogsType = ref('');
const errorsOpen = ref(true);
const skipLogsOpen = ref(true);
let pollTimer;
let isPageActive = false;
const hasActiveImport = computed(() => isActiveImport(dataImport.value));
const stopPolling = () => {
if (!pollTimer) return;
window.clearInterval(pollTimer);
pollTimer = null;
};
const fetchImport = async ({
showLoader = false,
manual = false,
requestedSkipLogsType = selectedSkipLogsType.value,
} = {}) => {
if (showLoader) {
isLoading.value = true;
} else if (manual) {
isRefreshing.value = true;
}
try {
const response = await DataImportsAPI.show(route.params.dataImportId, {
skip_logs_type: requestedSkipLogsType || undefined,
});
dataImport.value = response.data;
selectedSkipLogsType.value =
response.data.skip_logs_filters?.selected_source_object_type ||
requestedSkipLogsType ||
'';
} finally {
if (showLoader) isLoading.value = false;
if (manual) isRefreshing.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const changeSkipLogsType = async type => {
if (type === selectedSkipLogsType.value || isChangingSkipLogsType.value) {
return;
}
selectedSkipLogsType.value = type;
isChangingSkipLogsType.value = true;
try {
await fetchImport({ requestedSkipLogsType: type });
} finally {
isChangingSkipLogsType.value = false;
}
};
const refreshImportInBackground = async () => {
if (
!isPageActive ||
isPolling.value ||
!hasActiveImport.value ||
document.hidden
) {
return;
}
isPolling.value = true;
try {
await fetchImport();
} finally {
isPolling.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const abandonImport = async () => {
isAbandoning.value = true;
try {
const response = await DataImportsAPI.abandon(dataImport.value.id);
dataImport.value = response.data;
stopPolling();
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_ABANDONED'));
} finally {
isAbandoning.value = false;
}
};
const downloadCsv = (response, filename) => {
const url = window.URL.createObjectURL(
new Blob([response.data], { type: 'text/csv' })
);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
};
const downloadErrorLogs = async () => {
isDownloadingErrorLogs.value = true;
try {
const response = await DataImportsAPI.downloadErrorLogs(
dataImport.value.id
);
downloadCsv(response, `data-import-${dataImport.value.id}-error-logs.csv`);
} finally {
isDownloadingErrorLogs.value = false;
}
};
const downloadSkipLogs = async () => {
isDownloadingSkipLogs.value = true;
try {
const response = await DataImportsAPI.downloadSkipLogs(dataImport.value.id);
downloadCsv(response, `data-import-${dataImport.value.id}-skip-logs.csv`);
} finally {
isDownloadingSkipLogs.value = false;
}
};
const startPolling = () => {
stopPolling();
if (!isPageActive || !hasActiveImport.value) return;
pollTimer = window.setInterval(refreshImportInBackground, POLL_INTERVAL_MS);
};
const handleVisibilityChange = () => {
if (isPageActive && !document.hidden && hasActiveImport.value) {
refreshImportInBackground();
}
};
onActivated(async () => {
isPageActive = true;
await fetchImport({ showLoader: true });
if (!isPageActive) return;
// Collapse empty sections by default; expand the ones with records.
errorsOpen.value = Boolean(dataImport.value?.import_errors_count);
skipLogsOpen.value = Boolean(dataImport.value?.skip_logs_count);
startPolling();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onDeactivated(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:loading-message="$t('DATA_IMPORTS.LOADING')"
>
<template #header>
<ImportDetailHeader
:data-import="dataImport"
:is-refreshing="isRefreshing"
:is-abandoning="isAbandoning"
:is-polling="isPolling"
@refresh="fetchImport({ manual: true })"
@abandon="abandonImport"
/>
</template>
<template #body>
<div v-if="dataImport" class="flex flex-col gap-3">
<ImportSummaryTiles :data-import="dataImport" />
<ImportProgress
v-if="dataImport.import_types?.length"
:data-import="dataImport"
:title="$t('DATA_IMPORTS.DETAIL.PROGRESS')"
/>
<ImportErrorsSection
:data-import="dataImport"
:is-open="errorsOpen"
:is-downloading="isDownloadingErrorLogs"
@toggle="errorsOpen = !errorsOpen"
@download="downloadErrorLogs"
/>
<ImportSkipLogsSection
:data-import="dataImport"
:is-open="skipLogsOpen"
:is-downloading="isDownloadingSkipLogs"
:selected-type="selectedSkipLogsType"
:is-changing-type="isChangingSkipLogsType"
@toggle="skipLogsOpen = !skipLogsOpen"
@download="downloadSkipLogs"
@change-type="changeSkipLogsType"
/>
</div>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,128 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import {
POLL_INTERVAL_MS,
importStageKey,
isAbandonableImport,
isActiveImport,
statusDotClass as getStatusDotClass,
} from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
default: null,
},
isRefreshing: {
type: Boolean,
default: false,
},
isAbandoning: {
type: Boolean,
default: false,
},
isPolling: {
type: Boolean,
default: false,
},
});
defineEmits(['refresh', 'abandon']);
const { t } = useI18n();
const pollIntervalSeconds = POLL_INTERVAL_MS / 1000;
const title = computed(
() => props.dataImport?.name || t('DATA_IMPORTS.TABLE.UNNAMED')
);
const stageLabels = computed(() => ({
unknown: t('DATA_IMPORTS.MONITOR.STAGES.unknown'),
queued: t('DATA_IMPORTS.MONITOR.STAGES.queued'),
contacts: t('DATA_IMPORTS.MONITOR.STAGES.contacts'),
conversations: t('DATA_IMPORTS.MONITOR.STAGES.conversations'),
finalizing: t('DATA_IMPORTS.MONITOR.STAGES.finalizing'),
completed: t('DATA_IMPORTS.MONITOR.STAGES.completed'),
completed_with_errors: t('DATA_IMPORTS.MONITOR.STAGES.completed_with_errors'),
failed: t('DATA_IMPORTS.MONITOR.STAGES.failed'),
abandoned: t('DATA_IMPORTS.MONITOR.STAGES.abandoned'),
}));
const monitorTitle = computed(
() =>
stageLabels.value[importStageKey(props.dataImport)] ||
stageLabels.value.unknown
);
const statusDotClass = computed(() =>
getStatusDotClass(props.dataImport?.status)
);
const hasActiveImport = computed(() => isActiveImport(props.dataImport));
const canAbandonImport = computed(() => isAbandonableImport(props.dataImport));
</script>
<template>
<BaseSettingsHeader
:title="title"
:back-button-label="$t('DATA_IMPORTS.DETAIL.BACK')"
>
<template #title>
<div class="flex w-full items-center justify-between gap-4">
<h1 class="min-w-0 truncate text-heading-1 text-n-slate-12">
{{ title }}
</h1>
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="hasActiveImport"
outline
slate
size="sm"
icon="i-lucide-refresh-cw"
:is-loading="isRefreshing"
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
@click="$emit('refresh')"
/>
<Button
v-if="canAbandonImport"
ruby
size="sm"
:is-loading="isAbandoning"
:label="$t('DATA_IMPORTS.TABLE.ABANDON')"
@click="$emit('abandon')"
/>
</div>
</div>
</template>
<template #description>
<span class="inline-flex items-center gap-1.5 align-middle">
<span
class="size-2 rounded-full"
:class="[statusDotClass, { 'animate-pulse': hasActiveImport }]"
/>
{{ monitorTitle }}
</span>
<template v-if="hasActiveImport">
<span
class="mx-2 inline-block h-3 w-px rounded-lg bg-n-strong align-middle"
/>
<span class="text-n-teal-11">
{{
isPolling
? $t('DATA_IMPORTS.MONITOR.REFRESHING')
: $t('DATA_IMPORTS.MONITOR.LIVE', {
seconds: pollIntervalSeconds,
})
}}
</span>
</template>
</template>
</BaseSettingsHeader>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import ImportLogSection from './ImportLogSection.vue';
import { formatDate, sourceObjectLabel } from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
});
defineEmits(['toggle', 'download']);
const { t } = useI18n();
const errors = computed(() => props.dataImport?.import_errors || []);
const headers = computed(() => [
t('DATA_IMPORTS.DETAIL.ERROR_CODE'),
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
t('DATA_IMPORTS.DETAIL.MESSAGE'),
t('DATA_IMPORTS.DETAIL.CREATED'),
]);
</script>
<template>
<ImportLogSection
:title="$t('DATA_IMPORTS.DETAIL.ERRORS')"
:count="dataImport.import_errors_count"
:is-open="isOpen"
:is-downloading="isDownloading"
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_ERROR_LOGS')"
:headers="headers"
:items="errors"
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_ERRORS')"
@toggle="$emit('toggle')"
@download="$emit('download')"
>
<template #row="{ items }">
<BaseTableRow v-for="error in items" :key="error.id" :item="error">
<template #default>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ error.error_code }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ sourceObjectLabel(error) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ error.message || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="whitespace-nowrap text-body-main text-n-slate-11">
{{ formatDate(error.created_at) }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</ImportLogSection>
</template>
@@ -0,0 +1,105 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { BaseTable } from 'dashboard/components-next/table';
defineProps({
title: {
type: String,
required: true,
},
count: {
type: Number,
default: 0,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
downloadLabel: {
type: String,
default: '',
},
headers: {
type: Array,
default: () => [],
},
items: {
type: Array,
default: () => [],
},
emptyMessage: {
type: String,
default: '',
},
});
defineEmits(['toggle', 'download']);
</script>
<template>
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
<div class="flex items-center justify-between gap-3 px-4 py-3">
<button
type="button"
class="flex min-w-0 items-center gap-2 !p-0"
:aria-expanded="isOpen"
@click="$emit('toggle')"
>
<h2 class="text-heading-3 text-n-slate-12">{{ title }}</h2>
<span
v-if="count"
class="rounded-md bg-n-alpha-2 px-1.5 text-label-small tabular-nums text-n-slate-11"
>
{{ count }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="size-4 shrink-0 text-n-slate-10 transition-transform duration-200"
:class="{ '-rotate-90 rtl:rotate-90': !isOpen }"
/>
</button>
<Button
ghost
slate
xs
icon="i-lucide-download"
:is-loading="isDownloading"
:disabled="!count"
:label="downloadLabel"
@click="$emit('download')"
/>
</div>
<div
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
:class="isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="min-h-0 overflow-hidden">
<div class="border-t border-n-weak">
<slot name="filters" />
<p
v-if="!items.length"
class="px-4 py-8 text-center text-body-main text-n-slate-11"
>
{{ emptyMessage }}
</p>
<div v-else class="overflow-x-auto">
<BaseTable
class="[&_td:first-child]:ps-4 [&_th:first-child]:ps-4 [&_th]:text-n-slate-11 [&_thead]:border-t-0"
:headers="headers"
:items="items"
>
<template #row="{ items: rows }">
<slot name="row" :items="rows" />
</template>
</BaseTable>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,100 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
title: {
type: String,
required: true,
},
});
const { t } = useI18n();
const items = computed(() => {
const importTypes = props.dataImport?.import_types || [];
const groups = [];
if (importTypes.includes('contacts')) {
groups.push({ key: 'contacts', label: t('DATA_IMPORTS.TYPES.CONTACTS') });
}
if (importTypes.includes('conversations')) {
groups.push(
{ key: 'conversations', label: t('DATA_IMPORTS.TYPES.CONVERSATIONS') },
{ key: 'messages', label: t('DATA_IMPORTS.TYPES.MESSAGES') }
);
}
return groups.map(({ key, label }) => {
const stats = props.dataImport?.stats?.[key] || {};
const imported = Number(stats.imported || 0);
const hasTotal = Object.prototype.hasOwnProperty.call(stats, 'total');
const total = hasTotal ? Number(stats.total) : null;
const percent =
hasTotal && total > 0
? Math.min(100, Math.round((imported / total) * 100))
: null;
return {
key,
label,
percent,
importedLabel: imported.toLocaleString(),
caption: hasTotal
? t('DATA_IMPORTS.DETAIL.PROGRESS_OF_TOTAL', {
total: total.toLocaleString(),
})
: t('DATA_IMPORTS.DETAIL.PROGRESS_IMPORTED'),
};
});
});
// Fit the grid to the number of groups so no empty cells show.
const columnsClass = computed(() => {
if (items.value.length >= 3) return 'sm:grid-cols-3';
if (items.value.length === 2) return 'sm:grid-cols-2';
return 'sm:grid-cols-1';
});
</script>
<template>
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
<h2 class="border-b border-n-weak px-4 py-3 text-heading-3 text-n-slate-12">
{{ title }}
</h2>
<div class="grid grid-cols-1 gap-px bg-n-weak" :class="columnsClass">
<div
v-for="item in items"
:key="item.key"
class="flex flex-col gap-2 bg-n-solid-1 px-4 py-3"
>
<span class="text-label-small text-n-slate-11">{{ item.label }}</span>
<div class="flex items-end justify-between gap-2">
<span
class="text-xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ item.importedLabel }}
</span>
<span
v-if="item.percent !== null"
class="text-label-small tabular-nums text-n-slate-11"
>
{{ `${item.percent}%` }}
</span>
</div>
<div
v-if="item.percent !== null"
class="h-1.5 w-full overflow-hidden rounded-full bg-n-alpha-2"
>
<div
class="h-full rounded-full bg-n-brand transition-all duration-500"
:style="{ width: `${item.percent}%` }"
/>
</div>
<span class="text-label-small text-n-slate-10">{{ item.caption }}</span>
</div>
</div>
</section>
</template>
@@ -0,0 +1,127 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import ImportLogSection from './ImportLogSection.vue';
import { formatDate, sourceObjectLabel } from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
selectedType: {
type: String,
default: '',
},
isChangingType: {
type: Boolean,
default: false,
},
});
defineEmits(['toggle', 'download', 'changeType']);
const { t } = useI18n();
const skipLogs = computed(() => props.dataImport?.skip_logs || []);
const headers = computed(() => [
t('DATA_IMPORTS.DETAIL.KIND'),
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
t('DATA_IMPORTS.DETAIL.MESSAGE'),
t('DATA_IMPORTS.DETAIL.CREATED'),
]);
const typeOptions = computed(() => {
const counts = props.dataImport?.skip_logs_filters?.counts_by_type || {};
return [
{
value: '',
label: t('DATA_IMPORTS.DETAIL.ALL_SKIP_LOGS'),
count: props.dataImport?.skip_logs_count || 0,
},
{
value: 'contact',
label: t('DATA_IMPORTS.TYPES.CONTACTS'),
count: counts.contact || 0,
},
{
value: 'conversation',
label: t('DATA_IMPORTS.TYPES.CONVERSATIONS'),
count: counts.conversation || 0,
},
{
value: 'message',
label: t('DATA_IMPORTS.TYPES.MESSAGES'),
count: counts.message || 0,
},
];
});
</script>
<template>
<ImportLogSection
:title="$t('DATA_IMPORTS.DETAIL.SKIP_LOGS')"
:count="dataImport.skip_logs_count"
:is-open="isOpen"
:is-downloading="isDownloading"
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_SKIP_LOGS')"
:headers="headers"
:items="skipLogs"
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_SKIP_LOGS')"
@toggle="$emit('toggle')"
@download="$emit('download')"
>
<template v-if="dataImport.skip_logs_count" #filters>
<div class="flex flex-wrap gap-2 border-b border-n-weak px-4 py-3">
<Button
v-for="option in typeOptions"
:key="option.value || 'all'"
:variant="option.value === selectedType ? 'solid' : 'faded'"
color="slate"
size="xs"
:disabled="!option.count || isChangingType"
:label="`${option.label} (${option.count})`"
@click="$emit('changeType', option.value)"
/>
</div>
</template>
<template #row="{ items }">
<BaseTableRow v-for="skipLog in items" :key="skipLog.id" :item="skipLog">
<template #default>
<BaseTableCell>
<span class="capitalize text-body-main text-n-slate-12">
{{ skipLog.kind || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ sourceObjectLabel(skipLog) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ skipLog.message || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="whitespace-nowrap text-body-main text-n-slate-11">
{{ formatDate(skipLog.created_at) }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</ImportLogSection>
</template>
@@ -0,0 +1,110 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import formatDistanceStrict from 'date-fns/formatDistanceStrict';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { formatDate, isActiveImport } from '../importStatus';
import { importSourceFor } from '../importSources';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const importTypeLabel = type => {
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
if (type === 'conversations') return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
return type;
};
const importTypesLabel = computed(() => {
const importTypes = props.dataImport?.import_types?.length
? props.dataImport.import_types
: [props.dataImport?.data_type].filter(Boolean);
return importTypes.map(importTypeLabel).join(', ');
});
const runDuration = computed(() => {
const startedAt =
props.dataImport?.started_at || props.dataImport?.created_at;
if (!startedAt) return '-';
const finishedAt =
props.dataImport?.completed_at ||
props.dataImport?.abandoned_at ||
(isActiveImport(props.dataImport)
? new Date()
: props.dataImport?.updated_at);
if (!finishedAt) return '-';
return formatDistanceStrict(new Date(startedAt), new Date(finishedAt));
});
const items = computed(() => [
{
key: 'source',
icon: 'i-lucide-plug',
label: t('DATA_IMPORTS.DETAIL.SOURCE'),
value: importSourceFor(props.dataImport).label,
},
{
key: 'import_types',
icon: 'i-lucide-layers',
label: t('DATA_IMPORTS.DETAIL.IMPORT_TYPES'),
value: importTypesLabel.value || '-',
},
{
key: 'created_at',
icon: 'i-lucide-calendar',
label: t('DATA_IMPORTS.DETAIL.CREATED'),
value: formatDate(props.dataImport?.created_at),
},
{
key: 'duration',
icon: 'i-lucide-clock',
label: t('DATA_IMPORTS.DETAIL.DURATION'),
value: runDuration.value,
tooltip: t('DATA_IMPORTS.DETAIL.LAST_UPDATED_TOOLTIP', {
time: formatDate(props.dataImport?.updated_at),
}),
},
{
key: 'initiated_by',
icon: 'i-lucide-user',
label: t('DATA_IMPORTS.DETAIL.INITIATED_BY'),
value:
props.dataImport?.initiated_by?.name ||
props.dataImport?.initiated_by?.email ||
'-',
},
]);
</script>
<template>
<dl
class="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-n-weak bg-n-weak sm:grid-cols-3 lg:grid-cols-5"
>
<div
v-for="item in items"
:key="item.key"
class="flex min-w-0 flex-col gap-1 bg-n-solid-1 px-4 py-3"
>
<dt class="flex items-center gap-1.5 text-label-small text-n-slate-10">
<Icon :icon="item.icon" class="size-3.5 shrink-0" />
{{ item.label }}
</dt>
<dd
v-tooltip.top="item.tooltip"
class="truncate text-heading-3 text-n-slate-12"
>
{{ item.value }}
</dd>
</div>
</dl>
</template>
@@ -0,0 +1,34 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
import Show from './Show.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/data'),
component: SettingsWrapper,
children: [
{
path: '',
name: 'settings_data_imports',
component: Index,
meta: {
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
permissions: ['administrator'],
},
},
{
path: ':dataImportId',
name: 'settings_data_import_show',
component: Show,
meta: {
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
permissions: ['administrator'],
},
},
],
},
],
};
@@ -0,0 +1,17 @@
export const IMPORT_SOURCES = [
{
value: 'intercom',
label: 'Intercom',
icon: '/dashboard/images/integrations/intercom.png',
},
];
const DEFAULT_IMPORT_SOURCE = {
value: 'file',
label: 'File import',
iconClass: 'i-lucide-file-text',
};
export const importSourceFor = dataImport =>
IMPORT_SOURCES.find(source => source.value === dataImport?.source_provider) ||
DEFAULT_IMPORT_SOURCE;
@@ -0,0 +1,84 @@
export const POLL_INTERVAL_MS = 5000;
export const ACTIVE_IMPORT_STATUSES = ['pending', 'processing'];
export const isActiveImport = dataImport =>
ACTIVE_IMPORT_STATUSES.includes(dataImport?.status);
export const isIntercomImport = dataImport =>
dataImport?.data_type === 'intercom' &&
dataImport?.source_provider === 'intercom';
export const isActiveIntercomImport = dataImport =>
isIntercomImport(dataImport) && isActiveImport(dataImport);
export const isAbandonableImport = dataImport =>
isActiveIntercomImport(dataImport);
export const importedCount = dataImport => {
if (!isIntercomImport(dataImport)) {
return Number(dataImport?.processed_records || 0);
}
return ['contacts', 'conversations', 'messages'].reduce(
(total, key) => total + Number(dataImport?.stats?.[key]?.imported || 0),
0
);
};
export const importStageKey = dataImport => {
if (!dataImport) return 'unknown';
if (dataImport.status === 'completed') return 'completed';
if (dataImport.status === 'completed_with_errors') {
return 'completed_with_errors';
}
if (dataImport.status === 'failed') return 'failed';
if (dataImport.status === 'abandoned') return 'abandoned';
if (dataImport.status === 'pending') return 'queued';
const importTypes = dataImport.import_types?.length
? dataImport.import_types
: [dataImport.data_type];
const cursor = dataImport.cursor || {};
if (importTypes.includes('contacts') && !cursor.contacts?.completed) {
return 'contacts';
}
if (
importTypes.includes('conversations') &&
!cursor.conversations?.completed
) {
return 'conversations';
}
return 'finalizing';
};
export const formatStatus = value => value?.replaceAll('_', ' ') || '-';
export const sourceObjectLabel = record =>
[record.source_object_type, record.source_object_id]
.filter(Boolean)
.join(': ') || '-';
export const formatDate = value => {
if (!value) return '-';
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
const STATUS_DOT_CLASS = {
pending: 'bg-n-amber-9',
processing: 'bg-n-blue-9',
completed: 'bg-n-teal-9',
completed_with_errors: 'bg-n-amber-9',
failed: 'bg-n-ruby-9',
abandoned: 'bg-n-slate-9',
};
export const statusDotClass = status =>
STATUS_DOT_CLASS[status] || 'bg-n-slate-9';
@@ -0,0 +1,92 @@
import {
formatDate,
importedCount,
isActiveIntercomImport,
statusDotClass,
} from '../importStatus';
describe('importStatus', () => {
describe('isActiveIntercomImport', () => {
it('only treats pending or processing Intercom imports as active', () => {
expect(
isActiveIntercomImport({
data_type: 'intercom',
source_provider: 'intercom',
status: 'processing',
})
).toBe(true);
expect(
isActiveIntercomImport({
data_type: 'contacts',
source_provider: null,
status: 'processing',
})
).toBe(false);
expect(
isActiveIntercomImport({
data_type: 'intercom',
source_provider: 'intercom',
status: 'completed',
})
).toBe(false);
});
});
describe('importedCount', () => {
it('sums Intercom imported stats', () => {
expect(
importedCount({
data_type: 'intercom',
source_provider: 'intercom',
processed_records: 20,
stats: {
contacts: { imported: 2 },
conversations: { imported: 3 },
messages: { imported: 10 },
},
})
).toBe(15);
});
it('uses processed records for legacy imports', () => {
expect(
importedCount({
data_type: 'contacts',
source_provider: null,
processed_records: 7,
stats: {},
})
).toBe(7);
});
});
describe('statusDotClass', () => {
it('maps each status to its dot color class', () => {
expect(statusDotClass('pending')).toBe('bg-n-amber-9');
expect(statusDotClass('processing')).toBe('bg-n-blue-9');
expect(statusDotClass('completed')).toBe('bg-n-teal-9');
expect(statusDotClass('completed_with_errors')).toBe('bg-n-amber-9');
expect(statusDotClass('failed')).toBe('bg-n-ruby-9');
expect(statusDotClass('abandoned')).toBe('bg-n-slate-9');
});
it('falls back to slate for unknown or missing status', () => {
expect(statusDotClass('unknown')).toBe('bg-n-slate-9');
expect(statusDotClass(undefined)).toBe('bg-n-slate-9');
});
});
describe('formatDate', () => {
it('returns a dash for empty values', () => {
expect(formatDate(null)).toBe('-');
expect(formatDate('')).toBe('-');
expect(formatDate(undefined)).toBe('-');
});
it('formats a valid date into a readable string', () => {
const formatted = formatDate('2026-07-10T18:09:00Z');
expect(formatted).not.toBe('-');
expect(formatted).toContain('2026');
});
});
});
@@ -0,0 +1,121 @@
import { flushPromises, mount } from '@vue/test-utils';
import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import Index from '../Index.vue';
import Show from '../Show.vue';
vi.mock('dashboard/api/dataImports', () => ({
default: {
get: vi.fn(),
show: vi.fn(),
},
}));
vi.mock('dashboard/composables/store', () => ({
useStoreGetters: () => ({ getCurrentAccountId: { value: 1 } }),
}));
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal()),
useRoute: () => ({ params: { dataImportId: 1 } }),
useRouter: () => ({ push: vi.fn() }),
}));
const deferredRequest = () => {
let resolve;
const promise = new Promise(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve };
};
const mountKeptAlive = component => {
const Host = defineComponent({
setup() {
const visible = ref(true);
return { visible };
},
render() {
return h(KeepAlive, null, {
default: () => (this.visible ? h(component) : null),
});
},
});
return mount(Host, {
global: {
stubs: {
SettingsLayout: true,
BaseSettingsHeader: true,
Button: true,
Icon: true,
TabBar: true,
NewImportDialog: true,
ImportDetailHeader: true,
ImportSummaryTiles: true,
ImportProgress: true,
ImportErrorsSection: true,
ImportSkipLogsSection: true,
},
mocks: {
$t: key => key,
},
},
});
};
describe('data import polling lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('does not start list polling after the page deactivates', async () => {
const request = deferredRequest();
DataImportsAPI.get.mockReturnValue(request.promise);
const wrapper = mountKeptAlive(Index);
await nextTick();
wrapper.vm.visible = false;
await nextTick();
request.resolve({ data: { payload: [{ status: 'processing' }] } });
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(DataImportsAPI.get).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
it('does not start detail polling after the page deactivates', async () => {
const request = deferredRequest();
DataImportsAPI.show.mockReturnValue(request.promise);
const wrapper = mountKeptAlive(Show);
await nextTick();
wrapper.vm.visible = false;
await nextTick();
request.resolve({
data: {
status: 'processing',
skip_logs_filters: {},
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(DataImportsAPI.show).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
});
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, onActivated, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
@@ -15,6 +15,9 @@ import {
import ChannelName from './components/ChannelName.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const getters = useStoreGetters();
const store = useStore();
@@ -26,6 +29,31 @@ const selectedInbox = ref({});
const searchQuery = ref('');
const inboxes = useMapGetter('inboxes/getInboxes');
const accountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasWhatsAppManualTransfer = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
);
});
const isWhatsAppManualMigrationRecommended = inbox => {
return (
hasWhatsAppManualTransfer.value &&
inbox.channel_type === 'Channel::Whatsapp' &&
inbox.provider === 'whatsapp_cloud' &&
inbox.provider_config?.source === 'embedded_signup' &&
!inbox.reauthorization_required
);
};
onActivated(() => {
store.dispatch('inboxes/get');
});
const inboxesList = computed(() => {
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
@@ -142,12 +170,48 @@ const openDelete = inbox => {
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
<div class="flex flex-wrap items-center gap-2">
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
<router-link
v-if="inbox.reauthorization_required"
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Label
:label="$t('INBOX_MGMT.LIST.REAUTHORIZATION_REQUIRED')"
color="ruby"
compact
>
<template #icon>
<Icon icon="i-lucide-triangle-alert" class="size-3.5" />
</template>
</Label>
</router-link>
<router-link
v-else-if="isWhatsAppManualMigrationRecommended(inbox)"
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Label
:label="$t('INBOX_MGMT.LIST.MANUAL_SETUP_RECOMMENDED')"
color="blue"
compact
>
<template #icon>
<Icon icon="i-lucide-info" class="size-3.5" />
</template>
</Label>
</router-link>
</div>
</div>
</div>
<div class="flex gap-3 justify-end">
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -27,6 +29,8 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import AccountHealth from './components/AccountHealth.vue';
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
@@ -42,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -74,8 +80,11 @@ export default {
ColorPicker,
SelectInput,
AccountHealth,
WhatsappManualMigrationDialog,
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -107,6 +116,7 @@ export default {
isLoadingHealth: false,
healthError: null,
isRegisteringWebhook: false,
isTransferringWhatsAppToManual: false,
widgetBubblePosition: 'right',
widgetBubbleType: 'standard',
widgetBubbleLauncherTitle: '',
@@ -338,6 +348,12 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -391,6 +407,16 @@ export default {
this.healthData.throughput?.level === 'NOT_APPLICABLE'
);
},
showWhatsAppManualMigration() {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
)
);
},
widgetBuilderStorageKey() {
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
},
@@ -402,6 +428,7 @@ export default {
if (inboxChanged) {
this.syncInboxData();
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
}
}
},
@@ -412,6 +439,7 @@ export default {
this.fetchHealthData();
this.$nextTick(() => {
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
});
} else {
this.selectedFeatureFlags = newInbox?.selected_feature_flags || [];
@@ -422,8 +450,52 @@ export default {
},
mounted() {
this.fetchSharedData();
this.openWhatsAppManualMigrationIfRequested();
},
methods: {
openWhatsAppManualMigrationDialog() {
this.$refs.whatsappManualMigrationDialog?.open();
},
openWhatsAppManualMigrationIfRequested() {
if (
this.showWhatsAppManualMigration &&
this.$route.query.migration === 'whatsapp_manual'
) {
this.$nextTick(() => {
this.openWhatsAppManualMigrationDialog();
});
}
},
async transferWhatsAppToManualSetup(form) {
this.isTransferringWhatsAppToManual = true;
try {
const providerConfig = { ...(this.inbox.provider_config || {}) };
delete providerConfig.source;
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...providerConfig,
phone_number_id: form.phoneNumberId,
business_account_id: form.wabaId,
api_key: form.accessToken,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_SUCCESS')
);
this.$refs.whatsappManualMigrationDialog?.close();
} catch (error) {
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_ERROR')
);
} finally {
this.isTransferringWhatsAppToManual = false;
}
},
async copyWebhookSecret(value) {
await copyTextToClipboard(value);
useAlert(
@@ -737,6 +809,35 @@ export default {
class="mx-6 mb-4"
:class="bannerMaxWidth"
/>
<WhatsappManualMigrationBanner
v-if="showWhatsAppManualMigration"
class="mx-6 mb-6"
:class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
@@ -1305,6 +1406,13 @@ export default {
@register-webhook="registerWebhook"
/>
</div>
<WhatsappManualMigrationDialog
v-if="showWhatsAppManualMigration"
ref="whatsappManualMigrationDialog"
:inbox="inbox"
:is-loading="isTransferringWhatsAppToManual"
@reconnect="transferWhatsAppToManualSetup"
/>
</div>
</section>
</div>
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
import InboxesAPI from 'dashboard/api/inboxes';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -12,6 +13,12 @@ export default {
components: {
NextButton,
},
props: {
enableCallingOnComplete: {
type: Boolean,
default: false,
},
},
setup() {
return { v$: useVuelidate() };
},
@@ -59,6 +66,14 @@ export default {
}
);
if (this.enableCallingOnComplete) {
try {
await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
} catch (_) {
useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
}
}
router.replace({
name: 'settings_inboxes_add_agents',
params: {
@@ -165,6 +180,7 @@ export default {
<div class="w-full mt-4">
<NextButton
:disabled="uiFlags.isCreating"
:is-loading="uiFlags.isCreating"
type="submit"
solid
@@ -1,15 +1,23 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
const isInstagramConnectionRestricted = computed(() => {
return isOnChatwootCloud.value;
});
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -56,7 +64,7 @@ const requestAuthorization = async () => {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -68,11 +76,36 @@ const requestAuthorization = async () => {
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
:disabled="isRequestingAuthorization"
:disabled="
isRequestingAuthorization || isInstagramConnectionRestricted
"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
/>
<Banner
v-if="isInstagramConnectionRestricted"
color="amber"
class="w-full max-w-2xl mt-6"
>
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
<a
:href="META_RESTRICTION_STATUS_URL"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
</div>
</div>
</div>
@@ -7,11 +7,13 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -22,9 +24,12 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
const isWhatsappEmbeddedSignupRestricted = computed(() => {
return isOnChatwootCloud.value;
});
const hasWhatsappAppId = computed(() => {
return (
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -103,7 +108,11 @@ const handleManualLinkClick = () => {
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
<WhatsappEmbeddedSignup
:is-disabled="isWhatsappEmbeddedSignupRestricted"
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
:restriction-status-url="META_RESTRICTION_STATUS_URL"
/>
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
@@ -1,11 +1,11 @@
<script setup>
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
</script>
<template>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<WhatsappEmbeddedSignup enable-calling-on-complete />
<CloudWhatsapp enable-calling-on-complete />
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -17,6 +18,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
showRestrictionAlert: {
type: Boolean,
default: false,
},
restrictionStatusUrl: {
type: String,
default: '',
},
restrictionWarningText: {
type: String,
default: '',
},
});
const store = useStore();
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
if (props.isDisabled) return;
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => {
</I18nT>
</div>
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-6">
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{
restrictionWarningText ||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
}}
<a
v-if="restrictionStatusUrl"
:href="restrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating"
:disabled="isAuthenticating || isDisabled"
:is-loading="isAuthenticating"
faded
slate
@@ -0,0 +1,46 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const emit = defineEmits(['start']);
const { t } = useI18n();
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
const copy = computed(() => ({
title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
description: t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.DESCRIPTION'
),
start: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.START'),
guide: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.GUIDE'),
}));
</script>
<template>
<Banner color="blue" :action-label="copy.start" @action="emit('start')">
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-info"
class="flex-shrink-0 mt-0.5 size-4 text-n-blue-11"
/>
<div class="flex flex-col gap-0.5">
<span class="font-medium text-n-blue-12">{{ copy.title }}</span>
<span>
{{ copy.description }}
<a
:href="WHATSAPP_MANUAL_MIGRATION_GUIDE_URL"
target="_blank"
rel="noopener noreferrer"
class="underline link underline-offset-2"
>
{{ copy.guide }}
</a>
</span>
</div>
</div>
</Banner>
</template>
@@ -0,0 +1,524 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBranding } from 'shared/composables/useBranding';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['reconnect']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
const dialogRef = ref(null);
const currentStep = ref(0);
const buildForm = () => ({
wabaId: props.inbox.provider_config?.business_account_id || '',
phoneNumberId: props.inbox.provider_config?.phone_number_id || '',
displayPhoneNumber: props.inbox.phone_number || '',
accessToken: '',
});
const form = ref(buildForm());
const copy = computed(() => ({
eyebrow: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.EYEBROW`
),
title: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TITLE`),
close: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CLOSE`),
actionRequiredTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_TITLE`
),
actionRequiredDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_DESCRIPTION`
),
guideLink: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.GUIDE_LINK`
),
preservedTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_TITLE`
),
preservedDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_DESCRIPTION`
),
updatedTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_TITLE`
),
updatedDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_DESCRIPTION`
),
wabaId: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_ID`
),
wabaPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_PLACEHOLDER`
),
wabaHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_HELP`
),
phoneNumberId: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_ID`
),
phoneNumberPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_PLACEHOLDER`
),
phoneNumberHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_HELP`
),
displayPhoneNumber: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER`
),
displayPhoneNumberPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_PLACEHOLDER`
),
displayPhoneNumberHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_HELP`
),
accessToken: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN`
),
accessTokenPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN_PLACEHOLDER`
),
tokenHelpPrefix: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_PREFIX`
),
tokenHelpMiddle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_MIDDLE`
),
tokenHelpSuffix: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_SUFFIX`
),
messagingPermission: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MESSAGING_PERMISSION`
),
managementPermission: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MANAGEMENT_PERMISSION`
),
reviewTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_TITLE`
),
inbox: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.INBOX`),
phoneNumber: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER`
),
notEntered: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.NOT_ENTERED`
),
verifyNotice: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.VERIFY_NOTICE`
),
back: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.BACK`),
cancel: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CANCEL`
),
continue: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CONTINUE`
),
reviewMigration: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_MIGRATION`
),
reconnect: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.RECONNECT`
),
}));
const steps = computed(() => [
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.DESCRIPTION`
),
},
]);
const currentStepDetails = computed(() => steps.value[currentStep.value]);
const isFirstStep = computed(() => currentStep.value === 0);
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
const guideUrl = WHATSAPP_MANUAL_MIGRATION_GUIDE_URL;
const hasBusinessDetails = computed(
() => form.value.wabaId.trim() && form.value.phoneNumberId.trim()
);
const hasAccessToken = computed(() => form.value.accessToken.trim());
const canContinue = computed(() => {
if (currentStep.value === 1) return hasBusinessDetails.value;
if (currentStep.value === 2) return hasAccessToken.value;
if (isLastStep.value) {
return hasBusinessDetails.value && hasAccessToken.value;
}
return true;
});
const open = () => {
currentStep.value = 0;
form.value = buildForm();
dialogRef.value?.open();
};
const close = () => dialogRef.value?.close();
const goBack = () => {
if (!isFirstStep.value) currentStep.value -= 1;
};
const goNext = () => {
if (!isLastStep.value) currentStep.value += 1;
};
const reconnect = () => {
if (!canContinue.value) return;
emit('reconnect', {
wabaId: form.value.wabaId.trim(),
phoneNumberId: form.value.phoneNumberId.trim(),
accessToken: form.value.accessToken.trim(),
});
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
:show-confirm-button="false"
:show-cancel-button="false"
overflow-y-auto
>
<div class="flex flex-col gap-5 h-[24rem]">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="mb-1 text-sm font-medium text-n-slate-11">
{{ copy.eyebrow }}
</p>
<h3 class="m-0 text-xl font-semibold text-n-slate-12">
{{ copy.title }}
</h3>
<p class="mt-2 mb-0 text-sm text-n-slate-11 min-h-[2.5rem]">
{{ replaceInstallationName(currentStepDetails.description) }}
</p>
</div>
<NextButton
v-tooltip="copy.close"
type="button"
variant="ghost"
color="slate"
size="sm"
icon="i-lucide-x"
:aria-label="copy.close"
@click="close"
/>
</div>
<div
class="grid flex-1 gap-6 overflow-y-auto min-h-0 grid-cols-[11rem,1fr]"
>
<ol class="flex flex-col p-0 m-0 list-none">
<li
v-for="(step, index) in steps"
:key="step.title"
class="relative flex gap-3"
>
<div class="relative flex flex-col items-center">
<span
class="z-10 grid text-xs font-semibold transition-colors rounded-full size-6 place-content-center"
:class="
index < currentStep
? 'bg-n-teal-9 text-white'
: index === currentStep
? 'bg-n-blue-9 text-white'
: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-container -outline-offset-1'
"
>
<Icon
v-if="index < currentStep"
icon="i-lucide-check"
class="size-3.5"
/>
<span v-else>{{ index + 1 }}</span>
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 my-1 rounded-full w-0.5"
:class="index < currentStep ? 'bg-n-teal-9' : 'bg-n-slate-4'"
/>
</div>
<span
class="text-sm leading-6 truncate min-w-0"
:class="[
index < steps.length - 1 ? 'pb-6' : '',
index === currentStep
? 'font-medium text-n-slate-12'
: 'text-n-slate-11',
]"
>
{{ step.title }}
</span>
</li>
</ol>
<div class="min-w-0">
<section v-if="currentStep === 0" class="flex flex-col gap-5">
<div
class="flex gap-3 p-3 border rounded-xl border-n-weak bg-n-alpha-2"
>
<span
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-blue-3 text-n-blue-11"
>
<Icon icon="i-lucide-info" class="size-4" />
</span>
<div>
<h4 class="mt-0 mb-1 text-base font-medium text-n-slate-12">
{{ copy.actionRequiredTitle }}
</h4>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.actionRequiredDescription }}
</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<div class="flex items-center gap-2">
<Icon icon="i-lucide-check" class="size-4 text-n-teal-11" />
<p class="m-0 text-sm font-medium text-n-slate-12">
{{ copy.preservedTitle }}
</p>
</div>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.preservedDescription }}
</p>
</div>
<div
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-refresh-cw"
class="size-4 text-n-blue-11"
/>
<p class="m-0 text-sm font-medium text-n-slate-12">
{{ copy.updatedTitle }}
</p>
</div>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.updatedDescription }}
</p>
</div>
</div>
<a
:href="guideUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-n-blue-11 hover:underline"
>
{{ copy.guideLink }}
<Icon icon="i-lucide-external-link" class="size-3.5" />
</a>
</section>
<section v-else-if="currentStep === 1" class="grid gap-4">
<div class="flex flex-col gap-1">
<Input
v-model="form.wabaId"
:label="copy.wabaId"
:placeholder="copy.wabaPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.wabaHelp }}
</span>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<Input
v-model="form.phoneNumberId"
:label="copy.phoneNumberId"
:placeholder="copy.phoneNumberPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.phoneNumberHelp }}
</span>
</div>
<div class="flex flex-col gap-1">
<Input
v-model="form.displayPhoneNumber"
disabled
:label="copy.displayPhoneNumber"
:placeholder="copy.displayPhoneNumberPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.displayPhoneNumberHelp }}
</span>
</div>
</div>
</section>
<section v-else-if="currentStep === 2" class="grid gap-4">
<TextArea
v-model="form.accessToken"
:label="copy.accessToken"
:placeholder="copy.accessTokenPlaceholder"
auto-height
min-height="6rem"
max-height="12rem"
/>
<div
class="flex gap-3 p-3 border rounded-xl bg-n-blue-3 border-n-blue-4 text-n-blue-11"
>
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4 mt-0.5" />
<p class="m-0 text-sm">
{{ copy.tokenHelpPrefix }}
<code>{{ copy.messagingPermission }}</code>
{{ copy.tokenHelpMiddle }}
<code>{{ copy.managementPermission }}</code>
{{ copy.tokenHelpSuffix }}
</p>
</div>
</section>
<section
v-else
class="flex flex-col gap-4 p-4 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<h4 class="m-0 text-base font-medium text-n-slate-12">
{{ copy.reviewTitle }}
</h4>
<dl class="grid gap-3 m-0 sm:grid-cols-2">
<div>
<dt class="text-xs text-n-slate-11">{{ copy.inbox }}</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ inbox.name }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">
{{ copy.phoneNumber }}
</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.displayPhoneNumber || inbox.phone_number }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">{{ copy.wabaId }}</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.wabaId || copy.notEntered }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">
{{ copy.phoneNumberId }}
</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.phoneNumberId || copy.notEntered }}
</dd>
</div>
</dl>
<div
class="flex gap-3 p-3 border rounded-lg bg-n-alpha-2 border-n-weak text-n-slate-11"
>
<Icon
icon="i-lucide-shield-check"
class="flex-shrink-0 size-4 mt-0.5 text-n-teal-11"
/>
<p class="m-0 text-sm">
{{ replaceInstallationName(copy.verifyNotice) }}
</p>
</div>
</section>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<NextButton
type="button"
variant="faded"
color="slate"
:disabled="isFirstStep"
@click="goBack"
>
{{ copy.back }}
</NextButton>
<div class="flex items-center gap-2">
<NextButton
type="button"
variant="ghost"
color="slate"
@click="close"
>
{{ copy.cancel }}
</NextButton>
<NextButton
v-if="!isLastStep"
type="button"
:disabled="!canContinue"
@click="goNext"
>
{{ currentStep === 2 ? copy.reviewMigration : copy.continue }}
</NextButton>
<NextButton
v-else
type="button"
color="teal"
:disabled="!canContinue"
:is-loading="isLoading"
@click="reconnect"
>
{{ copy.reconnect }}
</NextButton>
</div>
</div>
</template>
</Dialog>
</template>
@@ -1,5 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import whatsappChannel from 'dashboard/api/channel/whatsappChannel';
import inboxMixin from 'shared/mixins/inboxMixin';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
@@ -10,7 +14,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
@@ -22,7 +25,6 @@ export default {
SmtpSettings,
NextButton,
TextArea,
WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -32,29 +34,40 @@ export default {
},
},
setup() {
return { v$: useVuelidate() };
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
return { v$: useVuelidate(), runEmbeddedSignup };
},
data() {
return {
hmacMandatory: false,
allowMobileWebview: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
isSettingDefaults: false,
isReconfiguring: false,
};
},
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappAppId() {
return window.chatwootConfig?.whatsappAppId;
showWhatsAppReconfigure() {
return (
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},
isForwardingEnabled() {
return !!this.inbox.forwarding_enabled;
@@ -166,9 +179,26 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async handleReconfigure() {
if (this.$refs.whatsappReauth) {
await this.$refs.whatsappReauth.requestAuthorization();
async reconfigureWhatsApp() {
this.isReconfiguring = true;
try {
const credentials = await this.runEmbeddedSignup();
// User dismissed the Meta popup without completing signup.
if (!credentials) return;
await whatsappChannel.reauthorizeWhatsApp({
inboxId: this.inbox.id,
...credentials,
});
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUCCESS')
);
} catch (error) {
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_ERROR')
);
} finally {
this.isReconfiguring = false;
}
},
async syncTemplates() {
@@ -362,22 +392,34 @@ export default {
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsFieldSection
v-if="whatsappAppId"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsFieldSection>
<SettingsFieldSection
v-if="showWhatsAppReconfigure"
:label="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')
"
>
<div class="flex flex-col gap-1 items-start">
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
<NextButton
:is-loading="isReconfiguring"
:disabled="isReconfiguring"
@click="reconfigureWhatsApp"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</SettingsFieldSection>
</template>
<!-- Manual Setup Section -->
<template v-else>
<template v-else-if="!isEmbeddedSignupWhatsApp">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
@@ -435,12 +477,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
ref="whatsappReauth"
:inbox="inbox"
class="hidden"
/>
</div>
</template>
@@ -5,9 +5,11 @@ import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
import WebhookPaywall from './WebhookPaywall.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
@@ -20,6 +22,7 @@ export default {
NewWebhook,
EditWebhook,
WebhookRow,
WebhookPaywall,
},
setup() {
const { replaceInstallationName } = useBranding();
@@ -39,7 +42,19 @@ export default {
...mapGetters({
records: 'webhooks/getWebhooks',
uiFlags: 'webhooks/getUIFlags',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
return (
!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.API_AND_WEBHOOKS
)
);
},
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
@@ -57,9 +72,16 @@ export default {
];
},
},
watch: {
apiAndWebhooksEnabled: {
immediate: true,
handler(enabled) {
if (enabled) this.$store.dispatch('webhooks/get');
},
},
},
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get');
},
methods: {
openAddPopup() {
@@ -105,10 +127,10 @@ export default {
<template>
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:is-loading="apiAndWebhooksEnabled && uiFlags.fetchingList"
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
:no-records-found="!records.length"
:no-records-found="apiAndWebhooksEnabled && !records.length"
>
<template #header>
<BaseSettingsHeader
@@ -118,19 +140,21 @@ export default {
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
:search-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
apiAndWebhooksEnabled
? $t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
: ''
"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template v-if="records?.length" #count>
<template v-if="apiAndWebhooksEnabled && records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
}}
</span>
</template>
<template #actions>
<template v-if="apiAndWebhooksEnabled" #actions>
<NextButton
blue
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
@@ -141,7 +165,9 @@ export default {
</BaseSettingsHeader>
</template>
<template #body>
<WebhookPaywall v-if="!apiAndWebhooksEnabled" />
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
@@ -160,11 +186,19 @@ export default {
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showAddPopup"
:on-close="hideAddPopup"
>
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showEditPopup"
:on-close="hideEditPopup"
>
<EditWebhook
v-if="showEditPopup"
:id="selectedWebHook.id"
@@ -173,6 +207,7 @@ export default {
/>
</woot-modal>
<woot-delete-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
@@ -0,0 +1,27 @@
<script setup>
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const accountId = useMapGetter('getCurrentAccountId');
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div class="grid place-content-center w-full h-full max-h-[28rem] mx-auto">
<BasePaywallModal
class="mx-auto"
feature-prefix="INTEGRATION_SETTINGS.WEBHOOK"
i18n-key="PAYWALL"
is-on-chatwoot-cloud
@upgrade="openBilling"
/>
</div>
</template>
@@ -6,6 +6,7 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({
value: { type: String, default: '' },
showResetButton: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['onCopy', 'onReset']);
@@ -41,12 +42,14 @@ const onReset = () => {
}"
:type="inputType"
:model-value="value"
:disabled="disabled"
readonly
>
<template #masked>
<button
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
type="button"
:disabled="disabled"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
@@ -61,6 +64,7 @@ const onReset = () => {
type="button"
icon="i-lucide-copy"
class="rounded-xl"
:disabled="disabled"
@click="onClick"
/>
<ConfirmButton
@@ -73,6 +77,7 @@ const onReset = () => {
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
:disabled="disabled"
@click="onReset"
/>
</div>
@@ -101,7 +101,24 @@ export default {
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
if (!this.isOnChatwootCloud) return true;
return this.currentUser.accounts.some(
account => account.api_and_webhooks
);
},
accessTokenDescription() {
if (!this.apiAndWebhooksEnabled) {
return this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.PAID_PLAN_NOTE');
}
return this.replaceInstallationName(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE')
);
},
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
@@ -191,10 +208,14 @@ export default {
useAlert(this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS'));
},
async onCopyToken(value) {
if (!this.apiAndWebhooksEnabled) return;
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
async resetAccessToken() {
if (!this.apiAndWebhooksEnabled) return;
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
@@ -339,12 +360,11 @@ export default {
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
:description="accessTokenDescription"
>
<AccessToken
:value="currentUser.access_token"
:disabled="!apiAndWebhooksEnabled"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
export function useReportDrilldown() {
// `fetcher` is any `({ ...request, page, signal }) => Promise` returning the
// shared drilldown envelope (`{ data: { meta, payload } }`), so the same paging
// and abort machinery backs both the reports and Captain assistant drilldowns.
// The default is wrapped so `ReportsAPI` stays the receiver when invoked.
export function useReportDrilldown(
fetcher = params => ReportsAPI.getDrilldown(params)
) {
const activeRequest = ref(null);
const records = ref([]);
const meta = ref({});
@@ -20,17 +26,7 @@ export function useReportDrilldown() {
const isCurrentRequest = token =>
token === requestToken && !!activeRequest.value;
const requestFingerprint = request =>
JSON.stringify({
metric: request.metric,
bucketTimestamp: request.bucketTimestamp,
from: request.from,
to: request.to,
type: request.type,
id: request.id,
groupBy: request.groupBy,
businessHours: request.businessHours,
});
const requestFingerprint = request => JSON.stringify(request);
const abortActiveRequest = () => {
if (!activeRequestController) return;
@@ -55,7 +51,7 @@ export function useReportDrilldown() {
hasError.value = false;
try {
const response = await ReportsAPI.getDrilldown({
const response = await fetcher({
...request,
page,
signal: controller.signal,
@@ -26,6 +26,7 @@ import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
import data from './data/data.routes';
export default {
routes: [
@@ -57,6 +58,7 @@ export default {
...canned.routes,
...inbox.routes,
...integrations.routes,
...data.routes,
...labels.routes,
...macros.routes,
...reports.routes,
@@ -1,5 +1,6 @@
<script setup>
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { onMounted } from 'vue';
const toggleSupportWidgetVisibility = () => {
@@ -8,6 +9,12 @@ const toggleSupportWidgetVisibility = () => {
}
};
const toggleSupportWidget = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const setupListenerForWidgetEvent = () => {
window.addEventListener('chatwoot:on-message', () => {
toggleSupportWidgetVisibility();
@@ -25,6 +32,14 @@ onMounted(() => {
<EmptyState
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
/>
>
<div class="flex justify-center">
<NextButton
icon="i-lucide-life-buoy"
:label="$t('SIDEBAR_ITEMS.CONTACT_SUPPORT')"
@click="toggleSupportWidget"
/>
</div>
</EmptyState>
</div>
</template>
@@ -25,24 +25,32 @@ const fetchMetaData = async (commit, params) => {
}
};
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 2000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const debouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 5000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 7500, false, 20000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
10000,
15000,
false,
20000
30000
);
const metaDebouncers = {
default: debouncedFetchMetaData,
long: longDebouncedFetchMetaData,
superLong: superLongDebouncedFetchMetaData,
};
// allCount is 0 until a meta request succeeds; under load it stays 0, so treat
// the unknown case as a large account and poll slowest instead of fastest.
export const getMetaDebounceKey = allCount => {
if (allCount > 2000 || allCount === 0) return 'superLong';
if (allCount > 100) return 'long';
return 'default';
};
export const actions = {
get: async ({ commit, state: $state }, params) => {
if ($state.allCount > 2000) {
superLongDebouncedFetchMetaData(commit, params);
} else if ($state.allCount > 100) {
longDebouncedFetchMetaData(commit, params);
} else {
debouncedFetchMetaData(commit, params);
}
get: ({ commit, state: $state }, params) => {
metaDebouncers[getMetaDebounceKey($state.allCount)](commit, params);
},
set({ commit }, meta) {
commit(types.SET_CONV_TAB_META, meta);
@@ -96,6 +96,32 @@ export const actions = {
}
},
// Push the draft to live and clear it, optionally changing status in the same
// update. Only edited fields are sent so an untouched live value survives.
publishDraft: ({ dispatch, state }, { portalSlug, articleId, status }) => {
const article = state.articles.byId[articleId];
const payload = {
portalSlug,
articleId,
status,
draft_title: null,
draft_content: null,
};
if (article?.draftTitle != null) payload.title = article.draftTitle;
if (article?.draftContent != null) payload.content = article.draftContent;
return dispatch('update', payload);
},
// Clear the draft (optionally changing status); live content is left untouched.
discardDraft: ({ dispatch }, { portalSlug, articleId, status }) =>
dispatch('update', {
portalSlug,
articleId,
status,
draft_title: null,
draft_content: null,
}),
updateArticleMeta: async ({ commit }, { portalSlug, locale }) => {
try {
const { data } = await articlesAPI.getArticles({
@@ -150,6 +150,101 @@ describe('#actions', () => {
});
});
describe('#publishDraft', () => {
const state = {
articles: {
byId: {
1: {
id: 1,
draftTitle: 'Draft title',
draftContent: 'Draft content',
},
},
},
};
it('dispatches update promoting the edited fields and clearing the draft', async () => {
await actions.publishDraft(
{ dispatch, state },
{ portalSlug: 'room-rental', articleId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: undefined,
draft_title: null,
draft_content: null,
title: 'Draft title',
content: 'Draft content',
});
});
it('only sends the fields that were actually edited', async () => {
const partialState = {
articles: { byId: { 1: { id: 1, draftContent: 'Only content' } } },
};
await actions.publishDraft(
{ dispatch, state: partialState },
{ portalSlug: 'room-rental', articleId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: undefined,
draft_title: null,
draft_content: null,
content: 'Only content',
});
});
it('forwards a status to change it in the same update', async () => {
await actions.publishDraft(
{ dispatch, state },
{ portalSlug: 'room-rental', articleId: 1, status: 'archived' }
);
expect(dispatch).toHaveBeenCalledWith(
'update',
expect.objectContaining({
status: 'archived',
title: 'Draft title',
content: 'Draft content',
draft_title: null,
draft_content: null,
})
);
});
});
describe('#discardDraft', () => {
it('dispatches update clearing the draft columns', async () => {
await actions.discardDraft(
{ dispatch },
{ portalSlug: 'room-rental', articleId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: undefined,
draft_title: null,
draft_content: null,
});
});
it('forwards a status to change it in the same update', async () => {
await actions.discardDraft(
{ dispatch },
{ portalSlug: 'room-rental', articleId: 1, status: 'draft' }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: 'draft',
draft_title: null,
draft_content: null,
});
});
});
describe('#updateArticleMeta', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
@@ -67,8 +67,11 @@ const createMarkdownInstance = (linkify = true) => {
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
// rendered/plain output (e.g. dashboard search snippets). Strip the whole marker
// line, including any blockquote prefix, so a quoted table's `>` prefixes don't
// collapse together and break table parsing.
const COLWIDTHS_MARKER_REGEX =
/^[ \t>]*<!--cw-colwidths:[\d,]+-->[ \t]*\r?\n?/gm;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
@@ -104,6 +107,11 @@ class MessageFormatter {
return this.md.render(updatedMessage);
}
disableImageRendering() {
this.md.disable(['add-image-sizing']);
this.md.renderer.rules.image = () => '';
}
get formattedMessage() {
return this.formatMessage();
}
@@ -68,6 +68,25 @@ describe('#MessageFormatter', () => {
});
});
describe('#disableImageRendering', () => {
it('omits nested and reference images with relative URLs', () => {
const message = `Before ![nested [alt]](/relative.png)
![reference][logo]
[logo]: /logo.png
After`;
const formatter = new MessageFormatter(message);
formatter.disableImageRendering();
expect(formatter.formattedMessage).not.toContain('<img');
expect(formatter.formattedMessage).toContain('Before');
expect(formatter.formattedMessage).toContain('After');
});
});
describe('tweets', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
@@ -134,6 +153,15 @@ describe('#MessageFormatter', () => {
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
it('strips a blockquote-prefixed marker so the quoted table still renders', () => {
const message =
'> <!--cw-colwidths:120,200-->\n> | A | B |\n> | --- | --- |\n> | 1 | 2 |';
const { formattedMessage } = new MessageFormatter(message);
expect(formattedMessage).not.toContain('cw-colwidths');
expect(formattedMessage).toContain('<blockquote>');
expect(formattedMessage).toContain('<table>');
});
});
describe('#sanitize', () => {

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