Compare commits

...
Author SHA1 Message Date
Shivam Mishra 870cd8d3c0 fix(captain): add activity message when resolve tool is used
- Add resolve tool to Captain assistant agent_tools
- Add Current.tool_name to track active tool execution
- Set tool_name in resolve tool before resolving conversation
- Add tool_resolved i18n key for tool-based resolution
- Refactor ActivityMessageHandler for better readability

Fixes issue where conversations resolved via the resolve tool would
silently change status with no timeline/audit entry.
2025-12-10 15:38:10 +05:30
Shivam Mishra 292f6f1b18 feat: include resovle tool for the primary agent 2025-12-10 15:23:28 +05:30
Shivam MishraandGitHub 39d6fa06ea Merge branch 'develop' into feat/resolve-tool 2025-12-09 18:20:30 +05:30
Shivam Mishra 05de508b7c feat: add tool to resolve a conversation 2025-12-09 18:19:33 +05:30
Shivam Mishra bb9f07d662 feat: add a resolve tool 2025-12-09 18:17:27 +05:30
Vinay KeerthiandGitHub f2eaa845dc fix: Preserve multiple consecutive newlines in text-based messaging channels (#13032)
## Description

This PR fixes an issue where multiple consecutive newlines (blank lines
for visual spacing) were being collapsed in text-based messaging
channels like WhatsApp, Instagram, and SMS.

When users send messages via API with intentional spacing using multiple
newlines (e.g., `\n\n\n\n`), the markdown renderer was following
standard Markdown spec and collapsing them into single blank lines.
While this is correct for document formatting, messaging platforms like
WhatsApp and Instagram support and preserve multiple blank lines for
visual spacing.

The fix adds preprocessing to preserve multiple consecutive newlines
(3+) by converting them to placeholder tokens before CommonMarker
processing, then restoring the exact number of newlines in the final
output.

## Changes

- Added `preserve_multiple_newlines` and `restore_multiple_newlines`
helper methods to `MarkdownRendererService`
- Updated `render_whatsapp` to preserve multiple consecutive newlines
- Updated `render_instagram` to preserve multiple consecutive newlines
- Updated `render_plain_text` (affects SMS, Twilio SMS, Twitter) to
preserve multiple consecutive newlines
- Updated `render_line` to preserve multiple consecutive newlines
- HTML-based renderers (Email, Telegram, WebWidget) remain unchanged as
they handle spacing via HTML tags

## 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?

Added comprehensive test coverage:
- 3 new tests for multi-newline preservation across WhatsApp, Instagram,
and SMS channels
- All 56 tests passing (up from 53)

Testing scenarios:
- Single newlines preserved: `"Line 1\nLine 2"` remains `"Line 1\nLine
2"`
- Multiple newlines preserved: `"Para 1\n\n\n\nPara 2"` remains `"Para
1\n\n\n\nPara 2"`
- Standard paragraph breaks (2 newlines) work as before
- Markdown formatting (bold, italic, links) continues to work correctly
- Backward compatibility maintained for all channels

## 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
2025-12-09 17:45:27 +05:30
Sivin VargheseandGitHub 863c033699 fix: Strip unsupported markdown formatting from canned responses (#13028)
# Pull Request Template

## Description

This PR fixes, 
1. **Issue with canned response insertion** - Canned responses with
formatting (bold, italic, code, lists, etc.) were not being inserted
into channels that don't support that formatting.
Now unsupported markdown syntax is automatically stripped based on the
channel's schema before insertion.
2. **Make image node optional** - Images are now stripped while paste.
https://github.com/chatwoot/prosemirror-schema/pull/36/commits/9e269fca04db07eb1dc09ebfab051c5ae70124d7
3. Enable **bold** and _italic_ for API channel

Fixes
https://linear.app/chatwoot/issue/CW-6091/editor-breaks-when-inserting-canned-response

## Type of change

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

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/9a5215dfef2949fcaa3871f51bdec4bb


## 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
2025-12-09 09:15:35 +05:30
Vinay KeerthiandGitHub 141dfc3321 fix: preserve newlines and formatting in Twilio WhatsApp messages (#13022)
## Description

This PR fixes an issue where Twilio WhatsApp messages were losing
newlines and markdown formatting. The problem had two root causes:

1. Text-based renderers (WhatsApp, Instagram, SMS) were converting
newlines to spaces when processing plain text without markdown list
markers
2. Twilio WhatsApp channels were incorrectly using the plain text
renderer instead of the WhatsApp renderer, stripping all markdown
formatting

The fix updates the markdown rendering system to:
- Preserve newlines by overriding the `softbreak` method in WhatsApp,
Instagram, and PlainText renderers
- Detect Twilio WhatsApp channels (via the `medium` field) and route
them to use the WhatsApp renderer
- Maintain backward compatibility with existing code

## 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?

Added comprehensive test coverage:
- 3 new tests for newline preservation in WhatsApp, Instagram, and SMS
channels
- 4 new tests for Twilio WhatsApp specific behavior (medium detection,
formatting preservation, backward compatibility)
- All 53 tests passing (up from 50)

Manual testing verified:
- Twilio WhatsApp messages with plain text preserve newlines
- Twilio WhatsApp messages with markdown preserve formatting (bold,
italic, links)
- Regular WhatsApp, Instagram, and SMS channels continue to work
correctly
- Backward compatibility maintained when channel parameter is not
provided

## 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
2025-12-08 22:04:30 +05:30
aa21c15d0e fix: hide linear card when not enabled (#12918)
The Linear card now only appears in conversation sidebar when:

- The linear_integration feature flag is enabled for the account
- LINEAR_CLIENT_ID is configured (inferred from the integration existing
in the store)

This matches the backend behavior: if LINEAR_CLIENT_ID is not set, the
integration is filtered out of the API response, so it won't exist in
the store.

In addition, I discovered that Settings/Integrations page showed Linear
card even if it was disabled but Linear client_id set. Now the Linear
card shows only if both conditions are met.

Fixes #12909 

## How Has This Been Tested?

#### Before


https://github.com/user-attachments/assets/cd21b881-5332-48f8-b230-662abc256ba2


#### After



https://github.com/user-attachments/assets/d794cc2e-19d6-4545-b2ef-3af054c2ac81



---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-08 20:49:50 +05:30
Tanmay Deep SharmaandGitHub 3051da1e44 fix: add renewal identification for credit flow (#12999) 2025-12-08 18:35:33 +05:30
399c91adaa feat: Standardize rich editor across all channels (#12600)
# Pull Request Template

## Description

This PR includes,

1. **Channel-specific formatting and menu options** for the rich reply
editor.
2. **Removal of the plain reply editor** and full **standardization** on
the rich reply editor across all channels.
3. **Fix for multiple canned responses insertion:**
* **Before:** The plain editor only allowed inserting canned responses
at the beginning of a message, making it impossible to combine multiple
canned responses in a single reply. This caused inconsistent behavior
across the app.
* **Solution:** Replaced the plain reply editor with the rich
(ProseMirror) editor to ensure a unified experience. Agents can now
insert multiple canned responses at any cursor position.
4. **Floating editor menu** for the reply box to improve accessibility
and overall user experience.
5. **New Strikethrough formatting option** added to the editor menu.

---

**Editor repo PR**:
https://github.com/chatwoot/prosemirror-schema/pull/36

Fixes https://github.com/chatwoot/chatwoot/issues/12517,
[CW-5924](https://linear.app/chatwoot/issue/CW-5924/standardize-the-editor),
[CW-5679](https://linear.app/chatwoot/issue/CW-5679/allow-inserting-multiple-canned-responses-in-a-single-message)

## Type of change

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

## How Has This Been Tested?

### Screenshot
**Dark**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/47748e6c-380f-44a3-9e3b-c27e0c830bd0"
/>

**Light**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/6746cf32-bf63-4280-a5bd-bbd42c3cbe84"
/>


## 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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
2025-12-08 14:43:45 +05:30
eb759255d8 perf: update the logic to purchase credits (#12998)
## Description

- Replaces Stripe Checkout session flow with direct card charging for AI
credit top-ups
- Adds a two-step confirmation modal (select package → confirm purchase)
for better UX
- Creates Stripe invoice directly and charges the customer's default
payment method immediately

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Using the specs
- UI manual test cases

<img width="945" height="580" alt="image"
src="https://github.com/user-attachments/assets/52bdad46-cd0e-4927-b13f-54c6b6353bcc"
/>

<img width="945" height="580" alt="image"
src="https://github.com/user-attachments/assets/231bc7e9-41ac-440d-a93d-cba45a4d3e3e"
/>


## 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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-08 10:52:17 +05:30
Sojan JoseandGitHub cc86b8c7f1 fix: stream attachment handling in workers (#12870)
We’ve been watching Sidekiq workers climb from ~600 MB at boot to
1.4–1.5 GB after an hour whenever attachment-heavy jobs run. This PR is
an experiment to curb that growth by streaming attachments instead of
loading the whole blob into Ruby: reply-mailer inline attachments,
Telegram uploads, and audio transcriptions now read/write in chunks. If
this keeps RSS stable in production we’ll keep it; otherwise we’ll roll
it back and keep digging
2025-12-05 13:02:53 -08:00
a971ff00f8 fix: ruby_llm version conflicts with ai-agents (#13011)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
2025-12-05 10:52:13 +05:30
67dc21ea5f fix: Hardcoded 500 in AI api error response(#13005)
## 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 false new relic alerts set due to hardcoding an error code

## Type of change


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.

Before
<img width="776" height="666" alt="image"
src="https://github.com/user-attachments/assets/f086890d-eaf1-4e83-b383-fe3675b24159"
/>

the 500 was hardcoded. 
RubyLLM doesn't send any error codes, so i removed the error code
argument and just pass the error message

Langfuse gets just the error message

<img width="883" height="700" alt="image"
src="https://github.com/user-attachments/assets/fc8c3907-b9a5-4c87-bfc6-8e05cfe9c8b0"
/>

local logs only show error
<img width="1434" height="200" alt="image"
src="https://github.com/user-attachments/assets/716c6371-78f0-47b8-88a4-03e4196c0e9a"
/>

Better fix is to handle each case and show the user wherever necessary

## 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: aakashb95 <aakash@chatwoot.com>
2025-12-04 20:32:46 +05:30
Muhsin KelothandGitHub efc3b5e7d4 fix: Handle Instagram API error codes properly in message processing (#13002)
### Problem

Instagram webhook processing was failing with:

> TypeError: no implicit conversion of String into Integer

This occurred when handling **echo messages** (outgoing messages) that:

* Contained `unsupported_type` attachments, and
* Were sent to a recipient user that could **not** be fetched via the
Instagram API.

In these cases, the webhook job crashed while trying to create or find
the contact for the recipient.

### Root Cause

The Instagram message service does not correctly handle Instagram API
error code **100**:

> "Object with ID does not exist, cannot be loaded due to missing
permissions, or does not support this operation"

When this error occurred during `fetch_instagram_user`:

1. The method fell through to exception tracking without an explicit
return.
2. `ChatwootExceptionTracker.capture_exception` returned `true`.
3. As a result, `fetch_instagram_user` effectively returned `true`
instead of a hash or empty hash.
4. `ensure_contact` then called `find_or_create_contact(true)` because
`true.present?` is `true`.
5. `find_or_create_contact` crashed when it tried to access
`true['id']`.

So the chain was:

```txt
fetch_instagram_user -> returns true
ensure_contact -> find_or_create_contact(true)
find_or_create_contact -> true['id'] -> TypeError
```

**Example Webhook Payload**

```
{
  "object": "instagram",
  "entry": [{
    "time": 1764822592663,
    "id": "17841454414819988",
    "messaging": [{
      "sender": { "id": "17841454414819988" },     // Business account
      "recipient": { "id": "1170166904857608" },   // User that can't be fetched
      "timestamp": 1764822591874,
      "message": {
        "attachments": [{
          "type": "unsupported_type",
          "payload": { "url": "https://..." }
        }],
        "is_echo": true
      }
    }]
  }]
}
```

**Corresponding Instagram API error:**

```
{
  "error": {
    "message": "The requested user cannot be found.",
    "type": "IGApiException",
    "code": 100,
    "error_subcode": 2534014
  }
}
```

**Debug Logs (Before Fix)**

```
[InstagramUserFetchError]: Unsupported get request. Object with ID '17841454414819988' does not exist... 100
[DEBUG] result: true
[DEBUG] result.present?: true
[DEBUG] find_or_create_contact called
[DEBUG] user: true
[DEBUG] Invalid user parameter - expected hash with id, got TrueClass: true
```

### Solution

### 1\. Handle Error Code 100 Explicitly

We now treat Instagram API error code **100** as a valid case for
creating an “unknown” contact, similar to how we already handle error
code `9010`:

```
# Handle error code 100: Object doesn't exist or missing permissions
# This typically occurs when trying to fetch a user that doesn't exist
# or has privacy restrictions. We can safely create an unknown contact.
return unknown_user(ig_scope_id) if error_code == 100
```

This ensures:

* `fetch_instagram_user` returns a valid hash for unknown users.
* `ensure_contact` can proceed safely without crashing.

### 2\. Prevent Exception Tracker Results from Leaking Through

For any **unhandled** error codes, we now explicitly return an empty
hash after logging the exception:

```
exception = StandardError.new(
  "#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})"
)
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception

# Explicitly return empty hash for any unhandled error codes
# This prevents the exception tracker result (true/false) from being returned.
{}
```

This guarantees that `fetch_instagram_user` always returns either:

* A valid user hash,
* An “unknown” user hash
* An empty hash
Fixes
https://linear.app/chatwoot/issue/CW-6068/typeerror-no-implicit-conversion-of-string-into-integer-typeerror
2025-12-04 18:53:50 +05:30
eed2eaceb0 feat: Migrate ruby llm captain (#12981)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-04 18:26:10 +05:30
Muhsin KelothandGitHub 0a17976913 fix: Filter out unsupported ephemeral message attachments (#13003)
Fixes
https://linear.app/chatwoot/issue/CW-6070/argumenterror-ephemeral-is-not-a-valid-file-type-argumenterror

**Problem**
The instagram webhooks containing ephemeral (disappearing) message were
causing ArgumentError exceptions because this attachment type is not
supported and was not in the enum validation.

**Solution**
- Added ephemeral to the unsupported_file_type? filter
- Ephemeral attachments are now silently filtered out before processing,
following the same pattern as existing unsupported types (template,
unsupported_type)
2025-12-04 16:09:04 +05:30
57904a56a0 chore: update vulnerable packages (#12996)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-12-04 15:28:12 +05:30
Vishnu NarayananandGitHub 728a5a6710 fix: handle missing AccountUser in inbox_member API (#12993)
Fixes
https://linear.app/chatwoot/issue/CW-6065/actionviewtemplateerror-undefined-method-availability-status-for-nil
2025-12-04 13:32:51 +05:30
87fe1e9ad7 feat: migrate editor to ruby-llm (#12961)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-04 12:51:35 +05:30
Muhsin KelothandGitHub 5c3b85334b feat: Add support for shared post and story attachment types in Instagram messages (#12997)
When users share Instagram posts or stories via DM, Instagram sends
webhooks with type `ig_post` and `ig_story` attachments. The system was
failing on these types because they weren't defined in the file_types.
This PR fixes the issue by handling all shared types and rendering them
on the front end.

**Shared post**

<img width="2154" height="1828" alt="CleanShot 2025-12-03 at 16 29
14@2x"
src="https://github.com/user-attachments/assets/7e731171-4904-43a6-abeb-b1db2c262742"
/>

**Shared status**
<img width="1702" height="1676" alt="CleanShot 2025-12-03 at 16 10
25@2x"
src="https://github.com/user-attachments/assets/6a151233-ce47-429d-b7c2-061514b20e05"
/>


Fixes
https://linear.app/chatwoot/issue/CW-5441/argumenterror-ig-story-is-not-a-valid-file-type-argumenterror
2025-12-04 05:20:47 +05:30
Muhsin KelothandGitHub e6a7e836a0 fix: Add support for ig_post attachment type in Instagram messages (#12992)
Fixes
https://linear.app/chatwoot/issue/CW-6055/argumenterror-ig-post-is-not-a-valid-file-type-argumenterror

This PR fixes an issue where Instagram sends webhooks with both "type":
"share" and "type": "ig_post" attachments when users share Instagram
posts in direct messages. The system was failing on the ig_post type
because it wasn't defined, causing ArgumentError exceptions.


https://github.com/user-attachments/assets/577b8ebd-80e3-4c11-95f5-d8a8c3e16534
2025-12-03 10:27:16 +05:30
b269cca0bf feat: Add AI credit topup flow for Stripe (#12988)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-12-02 17:53:44 -08:00
Sivin VargheseandGitHub 1df5fd513a fix: Reduce unnecessary label suggestion API calls (#12978) 2025-12-01 13:36:34 +05:30
Muhsin KelothandGitHub 4627aad56d fix: Enable reply editor for API channels with templates (#12973) 2025-12-01 10:13:38 +05:30
f23d95e004 feat: Add Pinia support and relocate store factory (#12854)
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-11-28 16:31:59 +05:30
1ef945de7b feat: Instrument captain (#12949)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
2025-11-28 15:12:55 +05:30
Sivin VargheseandGitHub 92fc286ecb fix: Resolve z-index issue in assistant switcher (#12969) 2025-11-27 10:35:42 +05:30
Sivin VargheseandGitHub dedb0426fb chore: Improve pagination with compact number formatting and pluralization (#12962) 2025-11-27 10:32:34 +05:30
Vinay KeerthiandGitHub eb0410af53 fix: handle string return values in MailPresenter#from method (#12966) 2025-11-27 10:31:56 +05:30
3ded13c8d5 chore(revert): switch label suggestions back gpt 4 mini (#12959)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-11-27 10:27:11 +05:30
f1079574e3 fix: Disable SSL verification for LINE webhooks in development (#12960)
## Summary

- Fixes SSL certificate verification errors when testing LINE webhooks
in development environments
- Configures the LINE Bot API client to skip SSL verification only in
development mode

## Background

When testing LINE webhooks locally on macOS, the LINE Bot SDK was
failing with:
```
OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get certificate CRL)
```

This occurs because the LINE Bot SDK tries to fetch user profiles via
HTTPS, and OpenSSL on macOS development environments may not have proper
access to Certificate Revocation Lists (CRLs).

## Changes

- Added `http_options` configuration to the LINE Bot client in
`Channel::Line#client`
- Sets `verify_mode: OpenSSL::SSL::VERIFY_NONE` only when
`Rails.env.development?` is true
- Production environments continue to use full SSL verification for
security

## Test Plan

- [x] Send a LINE message to trigger webhook in development
- [x] Verify webhook is processed without SSL errors
- [x] Confirm change only applies in development mode

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-11-26 22:38:26 +05:30
Muhsin KelothandGitHub 73ad27e94c fix: Show WhatsApp templates only for inboxes with templates (#12965)
Fixed the logic to show WhatsApp templates option only for inboxes that have templates available.
2025-11-26 21:01:31 +05:30
Muhsin KelothandGitHub 6a83cad69d fix: Prevent WhatsApp campaign duplicate messages by updating status immediately (#12955)
Fixes
https://linear.app/chatwoot/issue/CW-5918/whatsapp-campaigns-running-in-parallel-and-duplicating-messages
2025-11-26 15:25:49 +05:30
Gabriel JablonskiandGitHub 7e0507e3b5 fix: invalid language tag in heatmap component in reports page (#12952)
## Description

This PR fixes a `RangeError: Invalid language tag` that occurs in the
Heatmap report when using locales with underscores (e.g., `pt_BR`,
`zh_TW`).

The issue was caused by passing the raw locale string from `vue-i18n`
(which uses underscores for some regions) directly to
`Intl.DateTimeFormat`. The `Intl` API expects BCP 47 language tags which
use hyphens (e.g., `pt-BR`).

This change sanitizes the locale string by replacing underscores with
hyphens before creating the `DateTimeFormat` instance.

Fixes #12951
2025-11-26 13:24:29 +05:30
Vinay KeerthiandGitHub 94a9e4e067 chore: Add custom RuboCop cop to enforce one class per file (#12947) 2025-11-26 12:53:04 +05:30
Shivam MishraandGitHub 7b20ecd27b chore: Revert pagination formatting and pluralization (#12954) 2025-11-25 21:36:13 +05:30
Gabriel JablonskiandGitHub e635122ff6 fix: add presence validation for account name (#12636)
## Description

When a user tries creating a new account through the Super Admin
dashboard, and they forget to fill in the account name, they're faced
with an ugly error (generic "Something went wrong" on production).

This PR simply adds the `validates :name, presence: true` model
validation on `Account` model, which is translated as a proper error
message on the Super Admin UI.
2025-11-25 20:11:15 +05:30
Aakash BakhleandGitHub fa07b9158a fix: switch label suggestions to gpt-5-nano (#12945) 2025-11-25 19:36:47 +05:30
Sivin VargheseandGitHub 46eb6f39f3 fix: Resolve z-index issue in assistant switcher (#12940) 2025-11-25 18:47:31 +05:30
Sivin VargheseandGitHub f0538b25ed fix: Reactive assistantId issue in Captain Inbox after route changes (#12939) 2025-11-25 18:47:18 +05:30
Shivam MishraandGitHub 389b864020 fix: click event for billing support (#12942) 2025-11-25 14:53:27 +05:30
Sivin VargheseandGitHub 26778156dc chore: Improve pagination with compact number formatting and pluralization (#12921)
# Pull Request Template

## Description

This PR enhances the pagination component with standardized number
formatting and improved i18n handling.

**Includes:**

* Added `formatCompactNumber` and `formatFullNumber` helpers using
`Intl.NumberFormat`.
    * `< 1,000`: show exact value (e.g., `999`)
    * `1,000–999,999`: show compact format (`1k`, `1k+`)
    * `1,000,000+`: show in millions with one decimal (e.g., `1.2M`)
* Updated `PaginationFooter` to use the new formatters for all displayed
numbers.
  * Added proper pluralization to pagination i18n strings.

Fixes
https://linear.app/chatwoot/issue/CW-5999/better-display-of-numbers

## Type of change

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

## How Has This Been Tested?

### Screenshoots

**Before**
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/9fcf8baa-ae32-4a8a-85b0-24002fd863db"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/3d7138b7-133e-4ae6-b55f-67eff73ff1cc"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/1bbf7070-0681-492d-9308-a33874052d28"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/4e441672-26aa-4e66-965e-9edb807eaa72"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/11836702-1b74-4834-8932-31c20adc2db8"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/d37971bc-09af-4238-8601-ccc2ae69dbe7"
/>


**After**
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/8eaf2a23-beea-486b-b555-37f8b36ab904"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/f44f508a-e39d-45cb-afd8-98deb26920f8"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/d3b90711-bd7e-44ee-8bb3-48e45b799420"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/30dca6cd-f2be-4dcb-8596-924326ebf8c0"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/58896699-1f05-46c9-88cb-908318e71476"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/ea0d91b0-077b-4d72-81a7-d38d17742da6"
/>




## 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
2025-11-24 17:49:24 -08:00
48627da0f9 feat: outbound voice call essentials (#12782)
- Enables outbound voice calls in voice channel . We are only caring
about wiring the logic to trigger outgoing calls to the call button
introduced in previous PRs. We will connect it to call component in
subsequent PRs

ref: #11602 

## Screens

<img width="2304" height="1202" alt="image"
src="https://github.com/user-attachments/assets/b91543a8-8d4e-4229-bd80-9727b42c7b0f"
/>

<img width="2304" height="1200" alt="image"
src="https://github.com/user-attachments/assets/1a1dad2a-8cb2-4aa2-9702-c062416556a7"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
2025-11-24 17:47:00 -08:00
Sivin VargheseandGitHub 6a712b7592 fix: Update Arcade embed aspect ratio (#12923) 2025-11-24 20:22:27 +05:30
e9c60aec04 feat: Add support for Langfuse LLM Tracing via OTEL (#12905)
This PR adds LLM instrumentation on langfuse for ai-editor feature

## Type of change
New feature (non-breaking change which adds functionality)

Needs langfuse account and env vars to be set

## How Has This Been Tested?

I configured personal langfuse credentials and instrumented the app,
traces can be seen in langfuse.
each conversation is one session. 
<img width="1683" height="714" alt="image"
src="https://github.com/user-attachments/assets/3fcba1c9-63cf-44b9-a355-fd6608691559"
/>
<img width="1446" height="172" alt="image"
src="https://github.com/user-attachments/assets/dfa6e98f-4741-4e04-9a9e-078d1f01e97b"
/>


## 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
- [ ] 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

---------

Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-11-21 16:31:45 -08:00
Vishnu NarayananandGitHub a8e9acfae9 fix: shopify and leadsquared specs in ci (#12926)
fix: shopify and leadsquared specs in ci
2025-11-21 17:01:03 +05:30
Vishnu NarayananandGitHub f5908b0f8a fix: test failures due to parallelisation in ci (#12924) 2025-11-21 16:33:29 +05:30
224 changed files with 8982 additions and 2874 deletions
+1
View File
@@ -220,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
# MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY=
+2
View File
@@ -99,3 +99,5 @@ CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.pnpm-store/*
local/
+1 -1
View File
@@ -1 +1 @@
20.5.1
23.7.0
+1 -1
View File
@@ -39,7 +39,7 @@ exclude_patterns = [
"**/target/**",
"**/templates/**",
"**/testdata/**",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
]
test_patterns = [
+12 -1
View File
@@ -7,6 +7,8 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength:
Max: 150
@@ -40,6 +42,12 @@ Style/SymbolArray:
Style/OpenStructUse:
Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
@@ -87,7 +95,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -205,6 +213,9 @@ UseFromEmail:
CustomCopLocation:
Enabled: true
Style/OneClassPerFile:
Enabled: true
AllCops:
NewCops: enable
Exclude:
+5
View File
@@ -10,6 +10,9 @@
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Code Style
@@ -37,6 +40,8 @@
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
+8 -3
View File
@@ -162,7 +162,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe'
gem 'stripe', '~> 18.0'
## - helper gems --##
## to populate db with sample data
@@ -191,11 +191,16 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.4.3'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
### Gems required only in specific deployment environments ###
@@ -210,7 +215,7 @@ group :production do
end
group :development do
gem 'annotate'
gem 'annotaterb'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
+33 -11
View File
@@ -126,11 +126,11 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
@@ -625,6 +625,25 @@ GEM
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
opentelemetry-api (1.7.0)
opentelemetry-common (0.23.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.31.1)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.10.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.36.0)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
@@ -800,7 +819,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.5.1)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -809,7 +828,7 @@ GEM
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
ruby_llm-schema (0.1.0)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -909,7 +928,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
stripe (18.0.1)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -998,8 +1017,8 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.4.3)
annotate
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
@@ -1073,6 +1092,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-saml
opensearch-ruby
opentelemetry-exporter-otlp
opentelemetry-sdk
pg
pg_search
pgvector
@@ -1098,6 +1119,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1118,7 +1140,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe
stripe (~> 18.0)
telephone_number
test-prof
tidewave
+2
View File
@@ -103,3 +103,5 @@ class ContactInboxBuilder
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
+3
View File
@@ -138,6 +138,7 @@ class Messages::MessageBuilder
private: @private,
sender: sender,
content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
@@ -222,3 +223,5 @@ class Messages::MessageBuilder
})
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
end
def file_type_params(attachment)
# Handle different URL field names for different attachment types
url = case attachment['type'].to_sym
when :ig_story
attachment['payload']['story_media_url']
else
attachment['payload']['url']
end
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
external_url: url,
remote_file_url: url
}
end
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
message.save!
end
def fetch_ig_story_link(attachment)
message = attachment.message
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
message.content_attributes[:image_type] = 'ig_story'
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
message.save!
end
def fetch_ig_post_link(attachment)
message = attachment.message
message.content_attributes[:image_type] = 'ig_post'
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
message.save!
end
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
end
+6
View File
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
});
}
updateContactLabels(contactId, labels) {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
}
export default new EnterpriseAccountAPI();
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
expect(accountAPI).toHaveProperty('createTopupCheckout');
expect(accountAPI).toHaveProperty('getLimits');
});
describe('API calls', () => {
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
{ action_type: 'undelete' }
);
});
it('#createTopupCheckout with credits', () => {
accountAPI.createTopupCheckout(1000);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits: 1000 }
);
});
it('#createTopupCheckout with different credit amounts', () => {
const creditAmounts = [1000, 2500, 6000, 12000];
creditAmounts.forEach(credits => {
accountAPI.createTopupCheckout(credits);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits }
);
});
});
it('#getLimits', () => {
accountAPI.getLimits();
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
});
});
});
@@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient {
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:contact-id="contactId"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
@@ -1,15 +1,18 @@
<script setup>
import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -18,10 +21,17 @@ const props = defineProps({
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
@@ -32,20 +42,51 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const dialogRef = ref(null);
const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
const onClick = () => {
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
const path = frontendURL(
conversationUrl({
accountId,
id: conversationId,
})
);
router.push({ path });
}
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
const apiError = error?.message;
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
}
};
const onClick = async () => {
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
}
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
const [inbox] = voiceInboxes.value;
await startCall(inbox.id);
};
const onPickInbox = () => {
// Placeholder until actual call wiring happens
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
const onPickInbox = async inbox => {
dialogRef.value?.close();
await startCall(inbox.id);
};
</script>
@@ -55,6 +96,8 @@ const onPickInbox = () => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:disabled="isInitiatingCall"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
:size="size"
@@ -19,7 +19,6 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
@@ -102,7 +101,6 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
@@ -139,19 +137,6 @@ watch(
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-1 !mr-0;
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
@@ -118,6 +118,7 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
class="z-0"
/>
<div class="flex flex-col gap-2">
@@ -108,6 +108,7 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
class="z-0"
/>
<Editor
@@ -116,6 +117,7 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
class="z-0"
/>
<Editor
@@ -126,6 +128,7 @@ watch(
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
class="z-0"
/>
<div class="flex flex-col gap-2">
@@ -299,7 +299,12 @@ const componentToRender = computed(() => {
return DyteBubble;
}
if (props.contentAttributes.imageType === 'story_mention') {
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
return InstagramStoryBubble;
}
@@ -476,7 +481,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex w-full message-bubble-container mb-2"
class="flex mb-2 w-full message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -1,41 +1,102 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { useMessageContext } from '../provider.js';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
const { contentAttributes } = useMessageContext();
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const status = computed(() => data.value?.status);
const direction = computed(() => data.value?.call_direction);
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
useVoiceCallStatus(status, direction);
const containerRingClass = computed(() => {
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
:class="containerRingClass"
>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bubbleIconBg"
:class="bgColor"
>
<span class="text-xl" :class="bubbleIconName" />
<Icon
class="size-5"
:icon="iconName"
:class="{
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
}"
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
</div>
</div>
</div>
@@ -49,6 +49,8 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};
export const CONTENT_TYPES = {
@@ -73,3 +75,16 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
};
export const VOICE_CALL_DIRECTION = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -24,6 +25,7 @@ const props = defineProps({
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
@@ -43,21 +45,27 @@ const changePage = newPage => {
};
const currentPageInformation = computed(() => {
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
return t(
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
translationKey,
{
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
}
startItem: formatFullNumber(startItem.value),
endItem: formatFullNumber(endItem.value),
totalItems: formatCompactNumber(props.totalItems),
},
Number(props.totalItems)
);
});
const pageInfo = computed(() => {
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
return t(
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
{
currentPage: '',
totalPages: formatCompactNumber(totalPages.value),
},
Number(totalPages.value)
);
});
</script>
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
/>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ currentPage }}
{{ formatFullNumber(currentPage) }}
</span>
<span class="truncate">
{{ pageInfo }}
</span>
<span class="truncate">{{ pageInfo }}</span>
</div>
<Button
icon="i-lucide-chevron-right"
@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
@@ -53,6 +51,9 @@ import {
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -75,7 +76,6 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
@@ -103,22 +103,34 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions = []
) => {
const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: props.channelType || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
schema: messageSchema,
schema,
placeholder,
methods,
plugins,
enabledMenuOptions,
enabledMenuOptions: editorMenuOptions.value,
}),
});
};
@@ -153,6 +165,8 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -174,12 +188,6 @@ const shouldShowCannedResponses = computed(() => {
);
});
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
});
function createSuggestionPlugin({
trigger,
minChars = 0,
@@ -400,6 +408,38 @@ function setToolbarPosition() {
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
// Calculate coords and final position
const coords = getSelectionCoords(editorView, selection, rect);
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
wrapper.style.setProperty('--selection-left', `${left}px`);
wrapper.style.setProperty(
'--selection-right',
`${rect.width - left - width}px`
);
wrapper.style.setProperty('--selection-top', `${top}px`);
}
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
const wrapper = editorRoot.value;
if (!wrapper) return;
wrapper.classList.toggle('has-selection', hasSelection);
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -529,7 +569,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
@@ -596,6 +638,7 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -761,15 +804,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
@apply flex flex-col;
@apply flex flex-col gap-3;
.ProseMirror-menubar {
min-height: 1.25rem !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
.ProseMirror-menu-active {
@apply bg-n-slate-5 dark:bg-n-solid-3;
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center justify-center;
.ProseMirror-icon {
@apply size-4 flex items-center justify-center flex-shrink-0;
svg {
@apply size-full;
}
}
}
}
.ProseMirror-menubar:not(:has(*)) {
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
}
> .ProseMirror {
@@ -860,4 +921,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
.ProseMirror p:last-child {
margin-bottom: 10px !important;
}
.ProseMirror-menubar {
display: none; // Hide by default
}
&.has-selection {
// Hide menu completely when it has no items
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
width: fit-content !important;
position: absolute !important;
// Default/LTR: position from left
top: var(--selection-top);
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style>
@@ -78,10 +78,6 @@ export default {
type: Boolean,
default: false,
},
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
@@ -130,7 +126,6 @@ export default {
emits: [
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
@@ -325,18 +320,8 @@ export default {
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -83,15 +83,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const callStatus = computed(
() => props.chat.additional_attributes?.call_status
);
const callDirection = computed(
() => props.chat.additional_attributes?.call_direction
);
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
useVoiceCallStatus(callStatus, callDirection);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
@@ -317,20 +312,13 @@ const deleteConversation = () => {
>
{{ currentContact.name }}
</h4>
<div
v-if="callStatus"
<VoiceCallStatus
v-if="voiceCallData.status"
key="voice-status-row"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<span
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
:class="[voiceIconColor]"
/>
<span class="mx-1">
{{ $t(voiceLabelKey) }}
</span>
</div>
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
@@ -258,8 +258,6 @@ export default {
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
@@ -291,6 +289,10 @@ export default {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -45,8 +43,6 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -61,7 +57,6 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
@@ -69,7 +64,6 @@ export default {
ReplyEmailHead,
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
@@ -86,7 +80,6 @@ export default {
setup() {
const {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -97,7 +90,6 @@ export default {
return {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -115,10 +107,7 @@ export default {
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -159,34 +148,32 @@ export default {
!this.is360DialogWhatsAppChannel
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
showWhatsappTemplates() {
return this.isAWhatsAppCloudChannel && !this.isPrivate;
// We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
this.inboxId
);
return !!(templates && templates.length) && !this.isPrivate;
},
showContentTemplates() {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
if (
this.currentChat.can_reply ||
this.isAWhatsAppChannel ||
this.isAPIInbox
) {
return this.isOnPrivateNote;
}
return true;
},
isReplyRestricted() {
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
return (
!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox)
);
},
inboxId() {
return this.currentChat.inbox_id;
@@ -288,9 +275,6 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
@@ -330,21 +314,11 @@ export default {
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.signatureToApply;
return !!this.messageSignature;
},
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() {
return this.currentChat.id;
},
@@ -371,12 +345,6 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal;
@@ -442,7 +410,7 @@ export default {
return;
}
if (canReply || this.isAWhatsAppChannel) {
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@@ -469,25 +437,7 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
message() {
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -500,7 +450,7 @@ export default {
mounted() {
this.getFromDraft();
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
// working even if the editor is focussed.
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCAndToEmailsFromLastChat();
@@ -537,45 +487,17 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
@@ -643,8 +565,8 @@ export default {
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
? appendSignature(message, this.messageSignature)
: removeSignature(message, this.messageSignature);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -660,7 +582,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -703,9 +624,6 @@ export default {
},
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput.$el.blur();
}
if (!data.length || !data[0]) {
return;
}
@@ -839,7 +757,7 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
message = appendSignature(message, this.signatureToApply);
message = appendSignature(message, this.messageSignature);
}
const updatedMessage = replaceVariablesInMessage({
@@ -861,41 +779,24 @@ export default {
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
this.updateEditorSelectionWith = content;
this.onFocus();
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
this.message = appendSignature(this.message, this.signatureToApply);
this.message = appendSignature(this.message, this.messageSignature);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -913,19 +814,15 @@ export default {
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
}
},
toggleAudioRecorderPlayPause() {
if (!this.isRecordingAudio) {
return;
}
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
if (!this.$refs.audioRecorderInput) return;
if (!this.recordingAudioState) {
this.$refs.audioRecorderInput.stopRecording();
} else if (this.isRecorderAudioStopped) {
} else {
this.$refs.audioRecorderInput.playPause();
}
},
@@ -934,9 +831,6 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1183,13 +1077,6 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1213,33 +1100,17 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="signatureToApply"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
@typing-off="onTypingOff"
@@ -1289,7 +1160,6 @@ export default {
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
@@ -1302,7 +1172,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
@@ -1356,10 +1225,6 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1379,9 +1244,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -0,0 +1,77 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
messagePreviewClass: { type: [String, Array, Object], default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<Icon
class="inline-block -mt-0.5 align-middle size-4"
:icon="iconName"
:class="statusColor"
/>
<span class="mx-1" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -1,5 +1,5 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -9,6 +9,7 @@ export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -34,6 +35,8 @@ export function useCaptain() {
return null;
});
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
@@ -46,5 +49,6 @@ export function useCaptain() {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
};
}
@@ -1,161 +0,0 @@
import { computed, unref } from 'vue';
const CALL_STATUSES = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
NO_ANSWER: 'no-answer',
BUSY: 'busy',
FAILED: 'failed',
COMPLETED: 'completed',
CANCELED: 'canceled',
};
const CALL_DIRECTIONS = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
/**
* Composable for handling voice call status display logic
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
* @returns {Object} UI properties for displaying call status
*/
export function useVoiceCallStatus(statusRef, directionRef) {
const status = computed(() => unref(statusRef)?.toString());
const direction = computed(() => unref(directionRef)?.toString());
// Status group helpers
const isFailedStatus = computed(() =>
[
CALL_STATUSES.NO_ANSWER,
CALL_STATUSES.BUSY,
CALL_STATUSES.FAILED,
].includes(status.value)
);
const isEndedStatus = computed(() =>
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
);
const isOutbound = computed(
() => direction.value === CALL_DIRECTIONS.OUTBOUND
);
const labelKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
}
if (s === CALL_STATUSES.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (s === CALL_STATUSES.NO_ANSWER) {
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.RINGING) {
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
}
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const bubbleIconName = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
}
if (isFailedStatus.value) {
return 'i-ph-phone-x-fill';
}
// For ringing/completed/canceled show direction when possible
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
});
const bubbleIconBg = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return 'bg-n-teal-9';
}
if (isFailedStatus.value) {
return 'bg-n-ruby-9';
}
if (isEndedStatus.value) {
return 'bg-n-slate-11';
}
// default (e.g., ringing)
return 'bg-n-teal-9 animate-pulse';
});
const listIconColor = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
return 'text-n-teal-9';
}
if (isFailedStatus.value) {
return 'text-n-ruby-9';
}
if (isEndedStatus.value) {
return 'text-n-slate-11';
}
return 'text-n-teal-9';
});
return {
status,
labelKey,
subtextKey,
bubbleIconName,
bubbleIconBg,
listIconColor,
};
}
+212 -25
View File
@@ -1,23 +1,143 @@
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'imageUpload',
];
// Formatting rules for different contexts (channels and special contexts)
// marks: inline formatting (strong, em, code, link, strike)
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
export const FORMATTING = {
// Channel formatting
'Channel::Email': {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::WebWidget': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::TwitterProfile': {
marks: [],
nodes: [],
menu: [],
},
'Channel::TwilioSms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Sms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Whatsapp': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'strong',
'em',
'code',
'bulletList',
'orderedList',
'strike',
'undo',
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::MessageSignature': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
},
'Context::InboxSettings': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
};
// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
@@ -33,14 +153,81 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
/**
* Markdown formatting patterns for stripping unsupported formatting.
*
* Maps camelCase type names to ProseMirror snake_case schema names.
* Order matters: codeBlock before code to avoid partial matches.
*/
export const MARKDOWN_PATTERNS = [
// --- BLOCK NODES ---
{
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
patterns: [
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
],
},
{
type: 'blockquote', // PM: blockquote, eg: > quote
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
},
{
type: 'bulletList', // PM: bullet_list, eg: - item
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
},
{
type: 'orderedList', // PM: ordered_list, eg: 1. item
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
},
{
type: 'heading', // PM: heading, eg: ## Heading
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
},
{
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
},
{
type: 'image', // PM: image, eg: ![alt](url)
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
},
{
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
patterns: [
{ pattern: /\\\n/g, replacement: '\n' },
{ pattern: / {2,}\n/g, replacement: '\n' },
],
},
// --- INLINE MARKS ---
{
type: 'strong', // PM: strong, eg: **bold** or __bold__
patterns: [
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
{ pattern: /__(.+?)__/g, replacement: '$1' },
],
},
{
type: 'em', // PM: em, eg: *italic* or _italic_
patterns: [
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$1' },
],
},
{
type: 'strike', // PM: strike, eg: ~~strikethrough~~
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
},
{
type: 'code', // PM: code, eg: `inline code`
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
},
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
+136 -2
View File
@@ -5,6 +5,8 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import camelcaseKeys from 'camelcase-keys';
/**
* The delimiter used to separate the signature from the rest of the body.
@@ -283,6 +285,47 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
* that don't support certain formatting (e.g., API channels don't support bold).
*
* @param {string} content - The markdown content to sanitize
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
* @returns {string} - Content with unsupported formatting stripped
*/
export function stripUnsupportedFormatting(content, schema) {
if (!content || typeof content !== 'string') return content;
if (!schema) return content;
let sanitizedContent = content;
// Get supported marks and nodes from the schema
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
// We use camelcase-keys to normalize node names for comparison
const supportedMarks = Object.keys(schema.marks || {});
const nodeKeys = Object.keys(schema.nodes || {});
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
// Process each formatting type in order (codeBlock before code is important!)
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
// Check if this format type is supported by the schema
const isMarkSupported = supportedMarks.includes(type);
const isNodeSupported = supportedNodes.includes(type);
// If not supported, strip the formatting
if (!isMarkSupported && !isNodeSupported) {
patterns.forEach(({ pattern, replacement }) => {
sanitizedContent = sanitizedContent.replace(pattern, replacement);
});
}
});
return sanitizedContent;
}
/**
* Content Node Creation Helper Functions for
* - mention
@@ -313,8 +356,17 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'cannedResponse': {
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(
content,
state.schema
);
return new MessageMarkdownTransformer(state.schema).parse(
sanitizedContent
);
}
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -389,3 +441,85 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
}
/**
* Menu Positioning Helpers
* Handles floating menu bar positioning for text selection in the editor.
*/
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
/**
* Calculate selection coordinates with bias to handle line-wraps correctly.
* @param {EditorView} editorView - ProseMirror editor view
* @param {Selection} selection - Current text selection
* @param {DOMRect} rect - Container bounding rect
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
*/
export function getSelectionCoords(editorView, selection, rect) {
const start = editorView.coordsAtPos(selection.from, 1);
const end = editorView.coordsAtPos(selection.to, -1);
const selTop = Math.min(start.top, end.top);
const spaceAbove = selTop - rect.top;
const onTop =
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
return { start, end, selTop, onTop };
}
/**
* Calculate anchor position based on selection visibility and RTL direction.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {number} Anchor x-position for menu
*/
export function getMenuAnchor(coords, rect, isRtl) {
const { start, end, onTop } = coords;
if (!onTop) return end.left;
// If start of selection is visible, align to text. Else stick to container edge.
if (start.top >= rect.top) return isRtl ? start.right : start.left;
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
}
/**
* Calculate final menu position (left, top) within container bounds.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {{left: number, top: number, width: number}}
*/
export function calculateMenuPosition(coords, rect, isRtl) {
const { start, end, selTop, onTop } = coords;
const anchor = getMenuAnchor(coords, rect, isRtl);
// Calculate Left: shift by width if RTL, then make relative to container
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
// Ensure menu stays within container bounds
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
// Calculate Top: align to selection or bottom of selection
const top = onTop
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
return { left, top, width: MENU_CONFIG.W };
}
/* End Menu Positioning Helpers */
@@ -1,15 +1,11 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
// Mock the node that will be returned by parse
const mockNode = { textContent: updatedMessage };
const { node } = getContentNode(
replaceVariablesInMessage.mockReturnValue(updatedMessage);
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
};
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
const result = getContentNode(
editorView,
'cannedResponse',
content,
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
editorView.state.schema
);
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
expect(result.node).toBe(mockNode);
expect(result.node.textContent).toBe(updatedMessage);
// When textContent matches updatedMessage, from should remain unchanged
expect(result.from).toBe(from);
expect(result.to).toBe(to);
});
});
@@ -9,7 +9,13 @@ import {
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
@@ -258,15 +264,11 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined();
});
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
const docNode = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('Hello')]),
]);
it('should insert text node at cursor position', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, docNode, 0);
insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -626,3 +628,329 @@ describe('getContentNode', () => {
});
});
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns signature formatting for Context::MessageSignature', () => {
const result = getFormattingForEditor('Context::MessageSignature');
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
});
it('returns widget builder formatting for Context::InboxSettings', () => {
const result = getFormattingForEditor('Context::InboxSettings');
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
});
});
describe('fallback behavior', () => {
it('returns default formatting for unknown channel type', () => {
const result = getFormattingForEditor('Channel::Unknown');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for null channel type', () => {
const result = getFormattingForEditor(null);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for undefined channel type', () => {
const result = getFormattingForEditor(undefined);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for empty string', () => {
const result = getFormattingForEditor('');
expect(result).toEqual(FORMATTING['Context::Default']);
});
});
describe('return value structure', () => {
it('always returns an object with marks, nodes, and menu properties', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toHaveProperty('marks');
expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('menu');
expect(Array.isArray(result.marks)).toBe(true);
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.menu)).toBe(true);
});
});
});
describe('stripUnsupportedFormatting', () => {
describe('when schema supports all formatting', () => {
const fullSchema = {
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
};
it('preserves all formatting when schema supports it', () => {
const content = '**bold** and *italic* and `code`';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves links when schema supports them', () => {
const content = 'Check [this link](https://example.com)';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
});
describe('when schema has no formatting support (eg:SMS channel)', () => {
const emptySchema = {
marks: {},
nodes: {},
};
it('strips bold formatting', () => {
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
'bold text'
);
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
'bold text'
);
});
it('strips italic formatting', () => {
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
'italic text'
);
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
'italic text'
);
});
it('strips inline code formatting', () => {
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
'inline code'
);
});
it('strips strikethrough formatting', () => {
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
'strikethrough'
);
});
it('strips links but keeps text', () => {
expect(
stripUnsupportedFormatting(
'Check [this link](https://example.com)',
emptySchema
)
).toBe('Check this link');
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
).toBe('item 1\nitem 2');
expect(
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
).toBe('item 1\nitem 2');
});
it('strips ordered list markers', () => {
expect(
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
).toBe('first\nsecond');
});
it('strips code block markers', () => {
expect(
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
).toBe('code here\n');
});
it('strips blockquote markers', () => {
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
'quoted text'
);
});
it('handles complex content with multiple formatting types', () => {
const content =
'**Bold** and *italic* with `code` and [link](url)\n- list item';
const expected = 'Bold and italic with code and link\nlist item';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
});
describe('when schema has partial support', () => {
const partialSchema = {
marks: { strong: {}, em: {} },
nodes: {},
};
it('preserves supported marks and strips unsupported ones', () => {
const content = '**bold** and `code`';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** and code'
);
});
it('strips unsupported nodes but keeps supported marks', () => {
const content = '**bold** text\n- list item';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** text\nlist item'
);
});
});
describe('edge cases', () => {
it('returns content unchanged if content is empty', () => {
expect(stripUnsupportedFormatting('', {})).toBe('');
});
it('returns content unchanged if content is null', () => {
expect(stripUnsupportedFormatting(null, {})).toBe(null);
});
it('returns content unchanged if content is undefined', () => {
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
});
it('returns content unchanged if schema is null', () => {
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
});
it('handles nested formatting correctly', () => {
const emptySchema = { marks: {}, nodes: {} };
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
expect(
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
).toBe('bold and italic');
});
});
});
describe('Menu positioning helpers', () => {
const mockEditorView = {
coordsAtPos: vi.fn((pos, bias) => {
// Return different coords based on position
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
return { top: 100, bottom: 120, left: 150, right: 200 };
}),
};
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
describe('getSelectionCoords', () => {
it('returns selection coordinates with onTop flag', () => {
const selection = { from: 0, to: 10 };
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
expect(result).toHaveProperty('start');
expect(result).toHaveProperty('end');
expect(result).toHaveProperty('selTop');
expect(result).toHaveProperty('onTop');
});
});
describe('getMenuAnchor', () => {
it('returns end.left when menu is below selection', () => {
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
});
it('returns start.left for LTR when menu is above and visible', () => {
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
});
it('returns start.right for RTL when menu is above and visible', () => {
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
});
});
describe('calculateMenuPosition', () => {
it('returns bounded left and top positions', () => {
const coords = {
start: { top: 100, bottom: 120, left: 50 },
end: { top: 100, bottom: 120, left: 150 },
selTop: 100,
onTop: false,
};
const result = calculateMenuPosition(coords, wrapperRect, false);
expect(result).toHaveProperty('left');
expect(result).toHaveProperty('top');
expect(result).toHaveProperty('width', 300);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
});
@@ -26,7 +26,7 @@
},
"COMPANIES_LAYOUT": {
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} companies"
"SHOWING": "Showing {startItem} {endItem} of {totalItems} company | Showing {startItem} {endItem} of {totalItems} companies"
}
}
}
@@ -1,7 +1,7 @@
{
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} pages"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} item | Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} page | {currentPage} of {totalPages} pages"
},
"COMBOBOX": {
"PLACEHOLDER": "Select an option...",
@@ -18,7 +18,8 @@
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
@@ -376,7 +377,7 @@
}
},
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contact | Showing {startItem} - {endItem} of {totalItems} contacts"
},
"FILTER": {
"NAME": "Name",
@@ -196,7 +196,6 @@
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
@@ -399,15 +399,39 @@
"DESCRIPTION": "Manage usage and credits for Captain AI.",
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
"RESPONSES": "Credits",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
"REFRESH_CREDITS": "Refresh"
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
"TOPUP": {
"BUY_CREDITS": "Buy more credits",
"MODAL_TITLE": "Buy AI Credits",
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
"CREDITS": "CREDITS",
"ONE_TIME": "one-time",
"POPULAR": "Most Popular",
"NOTE_TITLE": "Note:",
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
"TITLE": "Confirm Purchase",
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
"GO_BACK": "Go Back",
"CONFIRM_PURCHASE": "Confirm Purchase"
}
}
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
@@ -13,6 +13,8 @@ import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponent
const store = useStore();
const dialogType = ref('');
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
const assistantUiFlags = useMapGetter('captainAssistants/getUIFlags');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
const isFetchingAssistant = computed(() => assistantUiFlags.value.fetchingItem);
@@ -47,10 +49,9 @@ const handleCreateClose = () => {
selectedInbox.value = null;
};
const assistantId = Number(route.params.assistantId);
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId,
assistantId: assistantId.value,
})
);
</script>
@@ -34,7 +34,7 @@ const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistantId = Number(route.params.assistantId);
const selectedAssistantId = computed(() => route.params.assistantId);
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
@@ -45,7 +45,7 @@ const backUrl = computed(() => ({
name: 'captain_assistants_responses_index',
params: {
accountId: route.params.accountId,
assistantId: selectedAssistantId,
assistantId: selectedAssistantId.value,
},
}));
@@ -125,8 +125,8 @@ const updateURLWithFilters = (page, search) => {
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
if (selectedAssistantId) {
filterParams.assistantId = selectedAssistantId;
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
@@ -1,11 +1,10 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { debounce } from '@chatwoot/utils';
import { useCompaniesStore } from 'dashboard/stores/companies';
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
@@ -13,13 +12,18 @@ import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/Com
const DEFAULT_SORT_FIELD = 'created_at';
const DEBOUNCE_DELAY = 300;
const store = useStore();
const companiesStore = useCompaniesStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const companies = computed(() => companiesStore.getCompaniesList);
const meta = computed(() => companiesStore.getMeta);
const uiFlags = computed(() => companiesStore.getUIFlags);
const searchQuery = computed(() => route.query?.search || '');
const searchValue = ref(searchQuery.value);
const pageNumber = computed(() => Number(route.query?.page) || 1);
@@ -46,10 +50,6 @@ const sortState = reactive({
const activeSort = computed(() => sortState.activeSort);
const activeOrdering = computed(() => sortState.activeOrdering);
const companies = useMapGetter('companies/getCompaniesList');
const meta = useMapGetter('companies/getMeta');
const uiFlags = useMapGetter('companies/getUIFlags');
const isFetchingList = computed(() => uiFlags.value.fetchingList);
const buildSortAttr = () =>
@@ -89,13 +89,13 @@ const fetchCompanies = async (page, search, sort) => {
}
if (currentSearch) {
await store.dispatch('companies/search', {
await companiesStore.search({
search: currentSearch,
page: currentPage,
sort: currentSort,
});
} else {
await store.dispatch('companies/get', {
await companiesStore.get({
page: currentPage,
sort: currentSort,
});
@@ -5,7 +5,9 @@ import {
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
@@ -52,12 +54,22 @@ const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const { isCloudFeatureEnabled } = useAccount();
const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
);
const isLinearIntegrationEnabled = computed(
const isLinearClientIdConfigured = computed(() => {
return !!linearIntegration.value?.id;
});
const isLinearConnected = computed(
() => linearIntegration.value?.enabled || false
);
@@ -238,7 +250,13 @@ onMounted(() => {
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div v-else-if="element.name === 'linear_issues'">
<div
v-else-if="
element.name === 'linear_issues' &&
isLinearFeatureEnabled &&
isLinearClientIdConfigured
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
@@ -247,7 +265,7 @@ onMounted(() => {
value => toggleSidebarUIState('is_linear_issues_open', value)
"
>
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
<LinearSetupCTA v-if="!isLinearConnected" />
<LinearIssuesList v-else :conversation-id="conversationId" />
</AccordionItem>
</div>
@@ -282,6 +282,7 @@ export default {
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
@@ -11,6 +11,7 @@ import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
@@ -23,6 +24,7 @@ const {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
@@ -32,6 +34,7 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
@@ -45,6 +48,11 @@ const planName = computed(() => {
return customAttributes.value.plan_name;
});
const canPurchaseCredits = computed(() => {
const plan = planName.value?.toLowerCase();
return plan && plan !== 'hacker';
});
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
@@ -71,8 +79,9 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
fetchLimits();
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
};
const handleBillingPageLogic = async () => {
@@ -119,6 +128,15 @@ const onToggleChatWindow = () => {
}
};
const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
const handleTopupSuccess = () => {
// Refresh limits to show updated credit balance
fetchLimits();
};
onMounted(handleBillingPageLogic);
</script>
@@ -178,9 +196,27 @@ onMounted(handleBillingPageLogic);
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
<template #action>
<ButtonV4 sm faded slate disabled>
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
</ButtonV4>
<div class="flex gap-2">
<ButtonV4
sm
flushed
slate
icon="i-lucide-refresh-cw"
:is-loading="isFetchingLimits"
@click="fetchLimits"
>
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
</ButtonV4>
<ButtonV4
v-if="canPurchaseCredits"
sm
solid
blue
@click="openPurchaseCreditsModal"
>
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
</ButtonV4>
</div>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<BillingMeter
@@ -217,12 +253,16 @@ onMounted(handleBillingPageLogic);
solid
slate
icon="i-lucide-life-buoy"
@open="onToggleChatWindow"
@click="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
</ButtonV4>
</BillingHeader>
</section>
<PurchaseCreditsModal
ref="purchaseCreditsModalRef"
@success="handleTopupSuccess"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,101 @@
<script setup>
defineProps({
credits: {
type: Number,
required: true,
},
amount: {
type: Number,
required: true,
},
currency: {
type: String,
default: 'usd',
},
isSelected: {
type: Boolean,
default: false,
},
isPopular: {
type: Boolean,
default: false,
},
name: {
type: String,
required: true,
},
});
const emit = defineEmits(['select']);
const formatCredits = credits => {
return credits.toLocaleString();
};
const formatAmount = (amount, currency) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
};
</script>
<template>
<label
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
:class="[
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
]"
>
<input
type="radio"
:name="name"
:value="credits"
:checked="isSelected"
class="sr-only"
@change="emit('select')"
/>
<span
v-if="isPopular"
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
:class="
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
"
>
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
</span>
<div
v-if="isSelected"
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
{{ formatCredits(credits) }}
</span>
<span
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
>
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
</span>
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
{{ formatAmount(amount, currency) }}
<span class="text-sm text-n-slate-11 ml-0.5">{{
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
}}</span>
</span>
</label>
</template>
@@ -0,0 +1,220 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
const dialogRef = ref(null);
const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
if (!selectedOption.value) return '';
return selectedOption.value.credits.toLocaleString();
});
const dialogTitle = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
});
const dialogDescription = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
: '';
});
const dialogWidth = computed(() => {
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
};
const goBackToSelectStep = () => {
currentStep.value = STEP_SELECT;
};
const handlePurchase = async () => {
if (!selectedOption.value) return;
isLoading.value = true;
try {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
close();
emit('success', response.data);
useAlert(
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
credits: response.data.credits,
})
);
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="dialogTitle"
:description="dialogDescription"
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
<template v-else>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
credits: formattedCredits,
amount: formattedAmount,
})
}}
</p>
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
<p class="text-sm text-n-amber-11">
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
</p>
</div>
</div>
</template>
<template #footer>
<!-- Step 1 Footer -->
<div
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
class="w-full"
@click="close"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
<!-- Step 2 Footer -->
<div v-else class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
class="w-full"
:disabled="isLoading"
@click="goBackToSelectStep"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
class="w-full"
:is-loading="isLoading"
@click="handlePurchase"
/>
</div>
</template>
</Dialog>
</template>
@@ -110,6 +110,7 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
@@ -114,6 +114,7 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -81,7 +80,6 @@ export default {
selectedTabIndex: 0,
selectedPortalSlug: '',
showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
@@ -626,7 +624,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
/>
<label v-if="isAWebWidgetInbox" class="pb-4">
@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -76,7 +75,6 @@ export default {
checked: false,
},
],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -337,7 +335,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>
<label>
@@ -5,7 +5,6 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -24,7 +23,6 @@ export default {
channelWelcomeTagline: '',
greetingEnabled: false,
greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -147,7 +145,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -12,7 +11,6 @@ const props = defineProps({
});
const emit = defineEmits(['updateSignature']);
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
watch(
() => props.messageSignature ?? '',
@@ -34,7 +32,7 @@ const updateSignature = () => {
class="message-editor h-[10rem] !px-3"
is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
channel-type="Context::MessageSignature"
:enable-suggestions="false"
show-image-resize-toolbar
/>
@@ -52,11 +52,12 @@ const dayMenuItemConfigs = computed(() => [
},
]);
const resolvedLocale = computed(
() =>
const resolvedLocale = computed(() => {
const currentLocale =
locale.value ||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
);
(typeof navigator !== 'undefined' ? navigator.language : 'en');
return currentLocale.replace('_', '-');
});
const monthFormatter = computed(
() =>
@@ -1,5 +1,5 @@
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainAssistant',
@@ -1,5 +1,5 @@
import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,5 +1,5 @@
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotMessages',
@@ -1,5 +1,5 @@
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotThreads',
@@ -1,5 +1,5 @@
import CaptainCustomTools from 'dashboard/api/captain/customTools';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,5 +1,5 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainDocument',
@@ -1,5 +1,5 @@
import CaptainInboxes from 'dashboard/api/captain/inboxes';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,5 +1,5 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
@@ -1,5 +1,5 @@
import CaptainScenarios from 'dashboard/api/captain/scenarios';
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
@@ -1,94 +0,0 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
createRecord,
deleteRecord,
getRecords,
showRecord,
updateRecord,
} from './storeFactoryHelper';
export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase();
return {
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
SET: `SET_${capitalizedName}`,
ADD: `ADD_${capitalizedName}`,
EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`,
UPSERT: `UPSERT_${capitalizedName}`,
};
};
export const createInitialState = () => ({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
export const createGetters = () => ({
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
getRecord: state => id =>
state.records.find(record => record.id === Number(id)) || {},
getUIFlags: state => state.uiFlags,
getMeta: state => state.meta,
});
export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
},
[mutationTypes.SET]: MutationHelpers.set,
[mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy,
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
});
export const createCrudActions = (API, mutationTypes) => ({
get: getRecords(mutationTypes, API),
show: showRecord(mutationTypes, API),
create: createRecord(mutationTypes, API),
update: updateRecord(mutationTypes, API),
delete: deleteRecord(mutationTypes, API),
});
export const createStore = options => {
const { name, API, actions, getters, mutations } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
return {
namespaced: true,
state: createInitialState(),
getters: {
...createGetters(),
...(getters || {}),
},
mutations: {
...createMutations(mutationTypes),
...(mutations || {}),
},
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
},
};
};
@@ -1,380 +0,0 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
generateMutationTypes,
createInitialState,
createGetters,
createMutations,
createCrudActions,
createStore,
} from './storeFactory';
vi.mock('dashboard/store/utils/api', () => ({
throwErrorMessage: vi.fn(),
}));
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
set: vi.fn(),
create: vi.fn(),
update: vi.fn(),
destroy: vi.fn(),
setSingleRecord: vi.fn(),
}));
describe('storeFactory', () => {
describe('generateMutationTypes', () => {
it('generates correct mutation types with capitalized name', () => {
const result = generateMutationTypes('test');
expect(result).toEqual({
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
SET: 'SET_TEST',
ADD: 'ADD_TEST',
EDIT: 'EDIT_TEST',
DELETE: 'DELETE_TEST',
SET_META: 'SET_TEST_META',
UPSERT: 'UPSERT_TEST',
});
});
});
describe('createInitialState', () => {
it('returns the correct initial state structure', () => {
const result = createInitialState();
expect(result).toEqual({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
});
});
describe('createGetters', () => {
it('returns getters with correct implementations', () => {
const getters = createGetters();
const state = {
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
uiFlags: { fetchingList: true },
meta: { totalCount: 10, page: 1 },
};
expect(getters.getRecords(state)).toEqual([
{ id: 3 },
{ id: 2 },
{ id: 1 },
]);
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
expect(getters.getRecord(state)(4)).toEqual({});
expect(getters.getUIFlags(state)).toEqual({
fetchingList: true,
});
expect(getters.getMeta(state)).toEqual({
totalCount: 10,
page: 1,
});
});
});
describe('createMutations', () => {
it('creates mutations with correct implementations', () => {
const mutationTypes = generateMutationTypes('test');
const mutations = createMutations(mutationTypes);
const state = { uiFlags: { fetchingList: false } };
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
expect(state.uiFlags).toEqual({ fetchingList: true });
const metaState = { meta: {} };
mutations[mutationTypes.SET_META](metaState, {
total_count: '10',
page: '2',
});
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
expect(mutations[mutationTypes.UPSERT]).toBe(
MutationHelpers.setSingleRecord
);
});
});
describe('createCrudActions', () => {
let API;
let commit;
let mutationTypes;
let actions;
beforeEach(() => {
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
commit = vi.fn();
mutationTypes = generateMutationTypes('test');
actions = createCrudActions(API, mutationTypes);
});
describe('get action', () => {
it('handles successful API response', async () => {
const payload = [{ id: 1 }];
const meta = { total_count: 10, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual(payload);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('show action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.show.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('create action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.create.mockResolvedValue({ data });
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.create.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('update action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.update.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('delete action', () => {
it('handles successful API response', async () => {
API.delete.mockResolvedValue({});
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(API.delete).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual(1);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.delete.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
});
describe('createStore', () => {
it('creates a complete store with default options', () => {
const API = {};
const store = createStore({ name: 'test', API });
expect(store.namespaced).toBe(true);
expect(store.state).toEqual(createInitialState());
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
]);
expect(Object.keys(store.mutations)).toEqual([
'SET_TEST_UI_FLAG',
'SET_TEST_META',
'SET_TEST',
'ADD_TEST',
'EDIT_TEST',
'DELETE_TEST',
'UPSERT_TEST',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
]);
});
it('creates a store with custom actions and getters', () => {
const API = {};
const customGetters = { customGetter: () => 'custom' };
const customActions = () => ({
customAction: () => 'custom',
});
const store = createStore({
name: 'test',
API,
getters: customGetters,
actions: customActions,
});
expect(store.getters).toHaveProperty('customGetter');
expect(store.actions).toHaveProperty('customAction');
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
'customGetter',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
'customAction',
]);
});
});
});
@@ -1,77 +0,0 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
export const getRecords =
(mutationTypes, API) =>
async ({ commit }, params = {}) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
};
export const showRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
};
export const createRecord =
(mutationTypes, API) =>
async ({ commit }, dataObj) => {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
};
export const updateRecord =
(mutationTypes, API) =>
async ({ commit }, { id, ...updateObj }) => {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
};
export const deleteRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
};
@@ -1,4 +1,4 @@
import { createStore } from './storeFactory';
import { createStore } from '../storeFactory';
import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
-2
View File
@@ -14,7 +14,6 @@ import bulkActions from './modules/bulkActions';
import campaigns from './modules/campaigns';
import cannedResponse from './modules/cannedResponse';
import categories from './modules/helpCenterCategories';
import companies from './modules/companies';
import contactConversations from './modules/contactConversations';
import contactLabels from './modules/contactLabels';
import contactNotes from './modules/contactNotes';
@@ -78,7 +77,6 @@ export default createStore({
campaigns,
cannedResponse,
categories,
companies,
contactConversations,
contactLabels,
contactNotes,
@@ -18,6 +18,7 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingLimits: false,
},
};
@@ -141,11 +142,14 @@ export const actions = {
},
limits: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},
@@ -1,29 +0,0 @@
import CompanyAPI from 'dashboard/api/companies';
import { createStore } from 'dashboard/store/captain/storeFactory';
import camelcaseKeys from 'camelcase-keys';
export default createStore({
name: 'Company',
API: CompanyAPI,
getters: {
getCompaniesList: state => {
return camelcaseKeys(state.records, { deep: true });
},
},
actions: mutationTypes => ({
search: async ({ commit }, { search, page, sort }) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const {
data: { payload, meta },
} = await CompanyAPI.search(search, page, sort);
commit(mutationTypes.SET, payload);
commit(mutationTypes.SET_META, meta);
} catch (error) {
// Error
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
},
}),
});
@@ -302,4 +302,22 @@ export const actions = {
clearContactFilters({ commit }) {
commit(types.CLEAR_CONTACT_FILTERS);
},
initiateCall: async ({ commit }, { contactId, inboxId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
try {
const response = await ContactAPI.initiateCall(contactId, inboxId);
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
return response.data;
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else if (error.response?.data?.error) {
throw new ExceptionWithMessage(error.response.data.error);
} else {
throw new Error(error);
}
}
},
};
@@ -17,6 +17,7 @@ const state = {
isDeleting: false,
isExporting: false,
isImporting: false,
isInitiatingCall: false,
},
sortOrder: [],
appliedFilters: [],
@@ -194,7 +194,6 @@ export const mutations = {
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
@@ -225,7 +224,6 @@ export const mutations = {
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {
@@ -359,4 +359,83 @@ describe('#actions', () => {
).rejects.toThrow(Error);
});
});
describe('#initiateCall', () => {
const contactId = 123;
const inboxId = 456;
it('sends correct mutations if API is success', async () => {
const mockResponse = {
data: {
conversation_id: 789,
status: 'initiated',
},
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.initiateCall(
{ commit },
{ contactId, inboxId }
);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
expect(result).toEqual(mockResponse.data);
});
it('sends correct actions if API returns error with message', async () => {
const errorMessage = 'Failed to initiate call';
axios.post.mockRejectedValue({
response: {
data: {
message: errorMessage,
},
},
});
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(ExceptionWithMessage);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
it('sends correct actions if API returns error with error field', async () => {
const errorMessage = 'Call initiation error';
axios.post.mockRejectedValue({
response: {
data: {
error: errorMessage,
},
},
});
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(ExceptionWithMessage);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
it('sends correct actions if API returns generic error', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
});
});
@@ -753,7 +753,6 @@ describe('#mutations', () => {
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(emitter.emit).toHaveBeenCalledWith('FETCH_LABEL_SUGGESTIONS');
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
@@ -0,0 +1,226 @@
/**
* Universal Store Factory
*
* This factory creates stores for both Vuex and Pinia, allowing gradual
* migration from Vuex to Pinia without breaking existing functionality.
*
* @module storeFactory
* @see https://pinia.vuejs.org/ - Pinia documentation
* @see https://vuex.vuejs.org/ - Vuex documentation
*/
import { defineStore } from 'pinia';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
// Vuex helpers
createRecord,
deleteRecord,
getRecords,
showRecord,
updateRecord,
// Pinia helpers
piniaGetRecords,
piniaShowRecord,
piniaCreateRecord,
piniaUpdateRecord,
piniaDeleteRecord,
} from './storeFactoryHelper';
export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase();
return {
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
SET: `SET_${capitalizedName}`,
ADD: `ADD_${capitalizedName}`,
EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`,
UPSERT: `UPSERT_${capitalizedName}`,
};
};
export const createInitialState = () => ({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
export const createGetters = () => ({
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
getRecord: state => id =>
state.records.find(record => record.id === Number(id)) || {},
getUIFlags: state => state.uiFlags,
getMeta: state => state.meta,
});
export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
},
[mutationTypes.SET]: MutationHelpers.set,
[mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy,
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
});
export const createCrudActions = (API, mutationTypes) => ({
get: getRecords(mutationTypes, API),
show: showRecord(mutationTypes, API),
create: createRecord(mutationTypes, API),
update: updateRecord(mutationTypes, API),
delete: deleteRecord(mutationTypes, API),
});
/**
* Create Vuex store with standard CRUD operations
*
* @param {Object} options - Store configuration
* @param {string} options.name - Store name
* @param {Object} options.API - API client
* @param {Object} [options.getters] - Custom getters
* @param {Function} [options.actions] - Custom actions function
* @param {Object} [options.mutations] - Custom mutations
* @returns {Object} Vuex module configuration
*/
export const createVuexStore = options => {
const { name, API, actions, getters, mutations } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
return {
namespaced: true,
state: createInitialState(),
getters: {
...createGetters(),
...(getters || {}),
},
mutations: {
...createMutations(mutationTypes),
...(mutations || {}),
},
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
},
};
};
/**
* Create Pinia store with standard CRUD operations
*
* @param {Object} options - Store configuration
* @param {string} options.name - Store name
* @param {Object} options.API - API client
* @param {Object} [options.getters] - Custom getters
* @param {Function} [options.actions] - Custom actions function
* @returns {Function} Pinia store composable
*/
export const createPiniaStore = options => {
const { name, API, actions, getters } = options;
return defineStore(name.toLowerCase(), {
state: createInitialState,
getters: {
...createGetters(),
...(getters || {}),
},
actions: {
setUIFlag(data) {
this.uiFlags = {
...this.uiFlags,
...data,
};
},
setMeta(meta) {
this.meta = {
...this.meta,
totalCount: Number(meta.total_count || meta.totalCount || 0),
page: Number(meta.page || 1),
};
},
async get(params) {
return piniaGetRecords(this, API, params);
},
async show(id) {
return piniaShowRecord(this, API, id);
},
async create(obj) {
return piniaCreateRecord(this, API, obj);
},
async update(payload) {
return piniaUpdateRecord(this, API, payload);
},
async delete(id) {
return piniaDeleteRecord(this, API, id);
},
...(actions ? actions() : {}),
},
});
};
/**
* Universal Store Factory - Main Entry Point
*
* Creates either a Vuex or Pinia store based on the 'type' parameter.
* Defaults to Vuex for backward compatibility.
*
* @param {Object} options - Store configuration
* @param {string} options.name - Store name
* @param {Object} options.API - API client for CRUD operations
* @param {string} [options.type='vuex'] - Store type: 'vuex' or 'pinia'
* @param {Object} [options.getters] - Custom getters
* @param {Function} [options.actions] - Custom actions function
* @param {Object} [options.mutations] - Custom mutations (Vuex only)
*
* @returns {Object|Function} Vuex module or Pinia store composable
*
* @example
* Create Vuex store (default)
* export default createStore({
* name: 'Company',
* API: CompanyAPI,
* });
*
* @example
* Create Pinia store
* export const useCompaniesStore = createStore({
* name: 'Company',
* type: 'pinia',
* API: CompanyAPI,
* });
*/
export const createStore = options => {
const { type = 'vuex' } = options;
if (type === 'pinia') {
return createPiniaStore(options);
}
return createVuexStore(options);
};
@@ -0,0 +1,761 @@
import { setActivePinia, createPinia } from 'pinia';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
generateMutationTypes,
createInitialState,
createGetters,
createMutations,
createCrudActions,
createStore,
} from './storeFactory';
vi.mock('dashboard/store/utils/api', () => ({
throwErrorMessage: vi.fn(),
}));
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
set: vi.fn(),
create: vi.fn(),
update: vi.fn(),
destroy: vi.fn(),
setSingleRecord: vi.fn(),
}));
describe('storeFactory', () => {
describe('createStore with type parameter', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('creates Vuex store by default', () => {
const API = {};
const store = createStore({ name: 'test', API });
expect(store.namespaced).toBe(true);
expect(store.state).toBeDefined();
expect(store.mutations).toBeDefined();
});
it('creates Pinia store when type is "pinia"', () => {
const API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
});
const store = useTestStore();
expect(store.records).toBeDefined();
expect(store.get).toBeTypeOf('function');
expect(store.setUIFlag).toBeTypeOf('function');
});
it('creates Vuex store when type is "vuex"', () => {
const API = {};
const store = createStore({
name: 'test',
API,
type: 'vuex',
});
expect(store.namespaced).toBe(true);
expect(store.state).toBeDefined();
});
});
describe('generateMutationTypes', () => {
it('generates correct mutation types with capitalized name', () => {
const result = generateMutationTypes('test');
expect(result).toEqual({
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
SET: 'SET_TEST',
ADD: 'ADD_TEST',
EDIT: 'EDIT_TEST',
DELETE: 'DELETE_TEST',
SET_META: 'SET_TEST_META',
UPSERT: 'UPSERT_TEST',
});
});
});
describe('createInitialState', () => {
it('returns the correct initial state structure', () => {
const result = createInitialState();
expect(result).toEqual({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
});
});
describe('createGetters', () => {
it('returns getters with correct implementations', () => {
const getters = createGetters();
const state = {
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
uiFlags: { fetchingList: true },
meta: { totalCount: 10, page: 1 },
};
expect(getters.getRecords(state)).toEqual([
{ id: 3 },
{ id: 2 },
{ id: 1 },
]);
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
expect(getters.getRecord(state)(4)).toEqual({});
expect(getters.getUIFlags(state)).toEqual({
fetchingList: true,
});
expect(getters.getMeta(state)).toEqual({
totalCount: 10,
page: 1,
});
});
});
describe('createMutations', () => {
it('creates mutations with correct implementations', () => {
const mutationTypes = generateMutationTypes('test');
const mutations = createMutations(mutationTypes);
const state = { uiFlags: { fetchingList: false } };
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
expect(state.uiFlags).toEqual({ fetchingList: true });
const metaState = { meta: {} };
mutations[mutationTypes.SET_META](metaState, {
total_count: '10',
page: '2',
});
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
expect(mutations[mutationTypes.UPSERT]).toBe(
MutationHelpers.setSingleRecord
);
});
});
describe('createCrudActions', () => {
let API;
let commit;
let mutationTypes;
let actions;
beforeEach(() => {
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
commit = vi.fn();
mutationTypes = generateMutationTypes('test');
actions = createCrudActions(API, mutationTypes);
});
describe('get action', () => {
it('handles successful API response', async () => {
const payload = [{ id: 1 }];
const meta = { total_count: 10, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual(payload);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('show action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.show.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('create action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.create.mockResolvedValue({ data });
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.create.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('update action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.update.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('delete action', () => {
it('handles successful API response', async () => {
API.delete.mockResolvedValue({});
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(API.delete).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual(1);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.delete.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
});
describe('createStore - Vuex type', () => {
it('creates a complete Vuex store with default options', () => {
const API = {};
const store = createStore({ name: 'test', API, type: 'vuex' });
expect(store.namespaced).toBe(true);
expect(store.state).toEqual(createInitialState());
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
]);
expect(Object.keys(store.mutations)).toEqual([
'SET_TEST_UI_FLAG',
'SET_TEST_META',
'SET_TEST',
'ADD_TEST',
'EDIT_TEST',
'DELETE_TEST',
'UPSERT_TEST',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
]);
});
it('creates a Vuex store with custom actions and getters', () => {
const API = {};
const customGetters = { customGetter: () => 'custom' };
const customActions = () => ({
customAction: () => 'custom',
});
const store = createStore({
name: 'test',
API,
type: 'vuex',
getters: customGetters,
actions: customActions,
});
expect(store.getters).toHaveProperty('customGetter');
expect(store.actions).toHaveProperty('customAction');
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
'customGetter',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
'customAction',
]);
});
it('creates a Vuex store with custom mutations', () => {
const API = {};
const customMutations = {
CUSTOM_MUTATION: state => {
state.custom = true;
},
};
const store = createStore({
name: 'test',
API,
type: 'vuex',
mutations: customMutations,
});
expect(store.mutations).toHaveProperty('CUSTOM_MUTATION');
});
});
describe('createStore - Pinia type', () => {
let API;
beforeEach(() => {
setActivePinia(createPinia());
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
});
it('creates a Pinia store with correct structure', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
});
const store = useTestStore();
expect(store.records).toEqual([]);
expect(store.meta).toEqual({});
expect(store.uiFlags).toEqual({
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
});
});
it('has standard getters', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
});
const store = useTestStore();
store.records = [{ id: 2 }, { id: 1 }, { id: 3 }];
store.meta = { totalCount: 10, page: 1 };
store.uiFlags = { fetchingList: true };
expect(store.getRecords).toEqual([{ id: 3 }, { id: 2 }, { id: 1 }]);
expect(store.getRecord(2)).toEqual({ id: 2 });
expect(store.getRecord(4)).toEqual({});
expect(store.getUIFlags).toEqual({ fetchingList: true });
expect(store.getMeta).toEqual({ totalCount: 10, page: 1 });
});
it('has custom getters', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
getters: {
customGetter: state => state.records.length,
},
});
const store = useTestStore();
store.records = [{ id: 1 }, { id: 2 }];
expect(store.customGetter).toBe(2);
});
describe('setUIFlag action', () => {
it('updates UI flags correctly', () => {
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.setUIFlag({ fetchingList: true });
expect(store.uiFlags.fetchingList).toBe(true);
store.setUIFlag({ creatingItem: true });
expect(store.uiFlags.fetchingList).toBe(true);
expect(store.uiFlags.creatingItem).toBe(true);
});
});
describe('setMeta action', () => {
it('updates meta with snake_case input', () => {
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.setMeta({ total_count: '10', page: '2' });
expect(store.meta.totalCount).toBe(10);
expect(store.meta.page).toBe(2);
});
it('updates meta with camelCase input', () => {
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.setMeta({ totalCount: 15, page: 3 });
expect(store.meta.totalCount).toBe(15);
expect(store.meta.page).toBe(3);
});
});
describe('get action', () => {
it('fetches records successfully', async () => {
const payload = [{ id: 1, name: 'Test' }];
const meta = { total_count: 1, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
await store.get({ page: 1 });
expect(API.get).toHaveBeenCalledWith({ page: 1 });
expect(store.records).toEqual(payload);
expect(store.meta.totalCount).toBe(1);
expect(store.meta.page).toBe(1);
expect(store.uiFlags.fetchingList).toBe(false);
});
it('handles API response without meta', async () => {
const data = [{ id: 1, name: 'Test' }];
API.get.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
await store.get();
expect(store.records).toEqual(data);
});
it('handles errors and resets UI flags', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.get();
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(store.uiFlags.fetchingList).toBe(false);
expect(result).toBe('Error thrown');
});
});
describe('show action', () => {
it('fetches and upserts a record', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.show(1);
expect(API.show).toHaveBeenCalledWith(1);
expect(store.records).toContainEqual(data);
expect(result).toEqual(data);
expect(store.uiFlags.fetchingItem).toBe(false);
});
it('updates existing record', async () => {
const data = { id: 1, name: 'Updated' };
API.show.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1, name: 'Original' }];
await store.show(1);
expect(store.records).toHaveLength(1);
expect(store.records[0].name).toBe('Updated');
});
it('handles payload wrapper', async () => {
const record = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data: { payload: record } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.show(1);
expect(result).toEqual(record);
expect(store.records).toContainEqual(record);
});
});
describe('create action', () => {
it('creates a new record', async () => {
const data = { id: 1, name: 'New' };
API.create.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.create({ name: 'New' });
expect(API.create).toHaveBeenCalledWith({ name: 'New' });
expect(store.records).toContainEqual(data);
expect(result).toEqual(data);
expect(store.uiFlags.creatingItem).toBe(false);
});
it('handles payload wrapper', async () => {
const record = { id: 1, name: 'New' };
API.create.mockResolvedValue({ data: { payload: record } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
const result = await store.create({ name: 'New' });
expect(result).toEqual(record);
expect(store.records).toContainEqual(record);
});
});
describe('update action', () => {
it('updates an existing record', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1, name: 'Original' }];
const result = await store.update({ id: 1, name: 'Updated' });
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(store.records[0].name).toBe('Updated');
expect(result).toEqual(data);
expect(store.uiFlags.updatingItem).toBe(false);
});
it('handles payload wrapper', async () => {
const record = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data: { payload: record } });
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1, name: 'Original' }];
const result = await store.update({ id: 1, name: 'Updated' });
expect(result).toEqual(record);
expect(store.records[0]).toEqual(record);
});
});
describe('delete action', () => {
it('deletes a record', async () => {
API.delete.mockResolvedValue({});
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
const store = useTestStore();
store.records = [{ id: 1 }, { id: 2 }, { id: 3 }];
const result = await store.delete(2);
expect(API.delete).toHaveBeenCalledWith(2);
expect(store.records).toHaveLength(2);
expect(store.records).not.toContainEqual({ id: 2 });
expect(result).toBe(2);
expect(store.uiFlags.deletingItem).toBe(false);
});
});
describe('custom actions', () => {
it('includes custom actions', async () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
actions: () => ({
customAction() {
return 'custom result';
},
}),
});
const store = useTestStore();
const result = store.customAction();
expect(result).toBe('custom result');
});
it('custom actions can access store state', () => {
const useTestStore = createStore({
name: 'test',
API,
type: 'pinia',
actions: () => ({
getRecordCount() {
return this.records.length;
},
}),
});
const store = useTestStore();
store.records = [{ id: 1 }, { id: 2 }];
expect(store.getRecordCount()).toBe(2);
});
});
});
});
@@ -0,0 +1,203 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
// ============================================================================
// VUEX HELPERS
// ============================================================================
export const getRecords =
(mutationTypes, API) =>
async ({ commit }, params = {}) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
};
export const showRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
};
export const createRecord =
(mutationTypes, API) =>
async ({ commit }, dataObj) => {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
};
export const updateRecord =
(mutationTypes, API) =>
async ({ commit }, { id, ...updateObj }) => {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
};
export const deleteRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
};
// ============================================================================
// PINIA HELPERS
// ============================================================================
/**
* Get records from API and update Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Object} params - Query parameters
*/
export const piniaGetRecords = async (store, API, params = {}) => {
store.setUIFlag({ fetchingList: true });
try {
const response = await API.get(params);
const { data } = response;
store.records = data.payload || data;
if (data.meta) {
store.setMeta(data.meta);
}
return data.payload || data;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ fetchingList: false });
}
};
/**
* Show single record from API and upsert to Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Number|String} id - Record ID
*/
export const piniaShowRecord = async (store, API, id) => {
store.setUIFlag({ fetchingItem: true });
try {
const response = await API.show(id);
const { data } = response;
const record = data.payload || data;
// Upsert logic
const index = store.records.findIndex(r => r.id === record.id);
if (index !== -1) {
store.records[index] = record;
} else {
store.records.push(record);
}
return record;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ fetchingItem: false });
}
};
/**
* Create new record via API and add to Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Object} dataObj - Data to create
*/
export const piniaCreateRecord = async (store, API, dataObj) => {
store.setUIFlag({ creatingItem: true });
try {
const response = await API.create(dataObj);
const { data } = response;
const record = data.payload || data;
store.records.push(record);
return record;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ creatingItem: false });
}
};
/**
* Update existing record via API and update in Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Object} payload - Update payload with id
*/
export const piniaUpdateRecord = async (store, API, { id, ...updateObj }) => {
store.setUIFlag({ updatingItem: true });
try {
const response = await API.update(id, updateObj);
const { data } = response;
const record = data.payload || data;
const index = store.records.findIndex(r => r.id === record.id);
if (index !== -1) {
store.records[index] = record;
}
return record;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ updatingItem: false });
}
};
/**
* Delete record via API and remove from Pinia store
* @param {Object} store - Pinia store instance (this context)
* @param {Object} API - API client
* @param {Number|String} id - Record ID to delete
*/
export const piniaDeleteRecord = async (store, API, id) => {
store.setUIFlag({ deletingItem: true });
try {
await API.delete(id);
store.records = store.records.filter(record => record.id !== id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
store.setUIFlag({ deletingItem: false });
}
};
@@ -0,0 +1,31 @@
import CompanyAPI from 'dashboard/api/companies';
import { createStore } from 'dashboard/store/storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import camelcaseKeys from 'camelcase-keys';
export const useCompaniesStore = createStore({
name: 'companies',
type: 'pinia',
API: CompanyAPI,
getters: {
getCompaniesList: state => {
return camelcaseKeys(state.records, { deep: true });
},
},
actions: () => ({
async search({ search, page, sort }) {
this.setUIFlag({ fetchingList: true });
try {
const {
data: { payload, meta },
} = await CompanyAPI.search(search, page, sort);
this.records = payload;
this.setMeta(meta);
} catch (error) {
throwErrorMessage(error);
} finally {
this.setUIFlag({ fetchingList: false });
}
},
}),
});
+4
View File
@@ -16,6 +16,7 @@ import createAxios from 'dashboard/helper/APIHelper';
import commonHelpers, { isJSONValid } from 'dashboard/helper/commons';
import { sync } from 'vuex-router-sync';
import { createPinia } from 'pinia';
import router, { initalizeRouter } from 'dashboard/routes';
import store from 'dashboard/store';
import constants from 'dashboard/constants/globals';
@@ -41,9 +42,12 @@ const i18n = createI18n({
sync(store, router);
const pinia = createPinia();
const app = createApp(App);
app.use(i18n);
app.use(store);
app.use(pinia);
app.use(router);
// [VITE] Disabled this, need to renable later
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useLocale } from '../useLocale';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
vi.mock('vue-i18n');
describe('useLocale', () => {
beforeEach(() => {
vi.mocked(useI18n).mockReturnValue({
locale: ref('en-US'),
});
});
describe('resolvedLocale', () => {
it('should return normalized locale for valid hyphen-based tags', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
expect(resolvedLocale.value).toBe('en-US');
});
it('should normalize underscore-based locale tags to hyphens', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize pt_BR to pt-BR
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
});
it('should normalize zh_CN to zh-CN or fall back to zh', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize zh_CN to zh-CN or fall back to zh
expect(resolvedLocale.value).toMatch(/^zh(-CN)?$/);
});
it('should normalize en_US to en-US or fall back to en', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize en_US to en-US or fall back to en
expect(resolvedLocale.value).toMatch(/^en(-US)?$/);
});
it('should fall back to base language when specific locale not supported', () => {
// Use a specific locale that might not be fully supported
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should return either pt-BR or pt (base language)
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
});
it('should fall back to English for completely unsupported locales', () => {
const mockLocale = ref('xx-YY');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should fall back to 'en'
expect(resolvedLocale.value).toBe('en');
});
it('should handle null locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should fall back to 'en'
expect(resolvedLocale.value).toBe('en');
});
it('should handle undefined locale gracefully', () => {
const mockLocale = ref(undefined);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should fall back to 'en'
expect(resolvedLocale.value).toBe('en');
});
it('should handle base language code without region', () => {
const mockLocale = ref('pt');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should work with base language
expect(resolvedLocale.value).toBe('pt');
});
it('should handle multiple underscores in locale tag', () => {
const mockLocale = ref('zh_Hans_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should normalize all underscores to hyphens
expect(resolvedLocale.value).toMatch(/^zh(-Hans-CN|-Hans|-CN)?$/);
});
it('should be reactive to locale changes', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
expect(resolvedLocale.value).toBe('en-US');
// Change locale
mockLocale.value = 'pt_BR';
// Should update reactively
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
});
it('should work with common locales', () => {
const testCases = [
{ input: 'de-DE', expected: /^de(-DE)?$/ },
{ input: 'fr-FR', expected: /^fr(-FR)?$/ },
{ input: 'es-ES', expected: /^es(-ES)?$/ },
{ input: 'ja-JP', expected: /^ja(-JP)?$/ },
{ input: 'ko-KR', expected: /^ko(-KR)?$/ },
{ input: 'ar-SA', expected: /^ar(-SA)?$/ },
{ input: 'hi-IN', expected: /^hi(-IN)?$/ },
{ input: 'ru-RU', expected: /^ru(-RU)?$/ },
{ input: 'tr-TR', expected: /^tr(-TR)?$/ },
];
testCases.forEach(({ input, expected }) => {
const mockLocale = ref(input);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
expect(resolvedLocale.value).toMatch(expected);
});
});
});
describe('locale (raw)', () => {
it('should expose raw locale value', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { locale } = useLocale();
// Raw locale should be unchanged
expect(locale.value).toBe('pt_BR');
});
it('should be reactive', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { locale } = useLocale();
expect(locale.value).toBe('en-US');
mockLocale.value = 'pt-BR';
expect(locale.value).toBe('pt-BR');
});
});
describe('Intl API compatibility', () => {
it('should work with Intl.NumberFormat', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should not throw error
expect(() => {
new Intl.NumberFormat(resolvedLocale.value).format(1234567);
}).not.toThrow();
});
it('should work with Intl.DateTimeFormat', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should not throw error
expect(() => {
new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
}).not.toThrow();
});
it('should work with Intl.Collator', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { resolvedLocale } = useLocale();
// Should not throw error
expect(() => {
new Intl.Collator(resolvedLocale.value).compare('a', 'b');
}).not.toThrow();
});
});
});
@@ -0,0 +1,364 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useNumberFormatter } from '../useNumberFormatter';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
vi.mock('vue-i18n');
describe('useNumberFormatter', () => {
beforeEach(() => {
vi.mocked(useI18n).mockReturnValue({
locale: ref('en-US'),
});
});
describe('formatCompactNumber', () => {
it('should return exact numbers for values under 1,000', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(0)).toBe('0');
expect(formatCompactNumber(1)).toBe('1');
expect(formatCompactNumber(42)).toBe('42');
expect(formatCompactNumber(999)).toBe('999');
});
it('should return "Xk" for exact thousands and "Xk+" for values with remainder', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1000)).toBe('1k');
expect(formatCompactNumber(1020)).toBe('1k+');
expect(formatCompactNumber(1500)).toBe('1k+');
expect(formatCompactNumber(1999)).toBe('1k+');
expect(formatCompactNumber(2000)).toBe('2k');
expect(formatCompactNumber(15000)).toBe('15k');
expect(formatCompactNumber(15500)).toBe('15k+');
expect(formatCompactNumber(999999)).toBe('999k+');
});
it('should return millions/billion/trillion format for values 1,000,000 and above', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1000000)).toBe('1M');
expect(formatCompactNumber(1000001)).toBe('1.0M');
expect(formatCompactNumber(1200000)).toBe('1.2M');
expect(formatCompactNumber(1234000)).toBe('1.2M');
expect(formatCompactNumber(2500000)).toBe('2.5M');
expect(formatCompactNumber(10000000)).toBe('10M');
expect(formatCompactNumber(1000000000)).toBe('1B');
expect(formatCompactNumber(1100000000)).toBe('1.1B');
expect(formatCompactNumber(10000000000)).toBe('10B');
expect(formatCompactNumber(11000000000)).toBe('11B');
expect(formatCompactNumber(1000000000000)).toBe('1T');
expect(formatCompactNumber(1100000000000)).toBe('1.1T');
expect(formatCompactNumber(10000000000000)).toBe('10T');
expect(formatCompactNumber(11000000000000)).toBe('11T');
});
it('should handle edge cases gracefully', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(null)).toBe('0');
expect(formatCompactNumber(undefined)).toBe('0');
expect(formatCompactNumber(NaN)).toBe('0');
expect(formatCompactNumber('string')).toBe('0');
});
it('should handle negative numbers', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(-500)).toBe('-500');
expect(formatCompactNumber(-1000)).toBe('-1k');
expect(formatCompactNumber(-1500)).toBe('-1k+');
expect(formatCompactNumber(-2000)).toBe('-2k');
expect(formatCompactNumber(-1200000)).toBe('-1.2M');
});
it('should format with en-US locale', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1500)).toBe('1k+');
});
it('should format with de-DE locale', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(2000)).toBe('2k');
});
it('should format with fr-FR locale (compact notation)', () => {
const mockLocale = ref('fr-FR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(1000000);
expect(result).toMatch(/1\s*M/); // French uses space before M
});
it('should format with ja-JP locale', () => {
const mockLocale = ref('ja-JP');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(999)).toBe('999');
});
it('should format with ar-SA locale (Arabic numerals)', () => {
const mockLocale = ref('ar-SA');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(5000);
expect(result).toContain('k');
expect(typeof result).toBe('string');
});
it('should format with es-ES locale', () => {
const mockLocale = ref('es-ES');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(7500)).toBe('7k+');
});
it('should format with hi-IN locale', () => {
const mockLocale = ref('hi-IN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(100000)).toBe('100k');
});
it('should format with ru-RU locale', () => {
const mockLocale = ref('ru-RU');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(3000)).toBe('3k');
});
it('should format with ko-KR locale (uses 만 for 10,000)', () => {
const mockLocale = ref('ko-KR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(2500000);
expect(result).toContain('만'); // Korean uses 만 (10,000) as a unit, so 2,500,000 should contain 만
});
it('should format with pt-BR locale', () => {
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(8888)).toBe('8k+');
});
it('should handle underscore-based locale tags (pt_BR)', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should normalize pt_BR to pt-BR and work correctly
expect(formatCompactNumber(8888)).toBe('8k+');
expect(formatCompactNumber(1000000)).toBe('1\u00a0mi');
});
it('should handle underscore-based locale tags (zh_CN)', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should normalize zh_CN to zh-CN and work correctly
expect(formatCompactNumber(999)).toBe('999');
expect(formatCompactNumber(5000)).toBe('5k');
});
it('should handle underscore-based locale tags (en_US)', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should normalize en_US to en-US and work correctly
expect(formatCompactNumber(1500)).toBe('1k+');
expect(formatCompactNumber(1000000)).toBe('1M');
});
it('should handle null/undefined locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should fall back to 'en' locale
expect(formatCompactNumber(1500)).toBe('1k+');
});
it('should fall back to base language when specific locale not supported', () => {
// Simulate a case where pt-BR might not be fully supported but pt is
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should work with either pt-BR or pt fallback
const result = formatCompactNumber(1500);
expect(result).toMatch(/1k\+/);
});
it('should fall back to English for completely unsupported locales', () => {
// Use a completely made-up locale
const mockLocale = ref('xx-YY');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should fall back to 'en' and work
expect(formatCompactNumber(1500)).toBe('1k+');
expect(formatCompactNumber(1000000)).toBe('1M');
});
it('should handle edge case with only base language code', () => {
const mockLocale = ref('pt');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
// Should work with base language
expect(formatCompactNumber(2000)).toBe('2k');
});
});
describe('formatFullNumber', () => {
it('should format numbers with locale-specific formatting', () => {
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1000)).toBe('1,000');
expect(formatFullNumber(1234567)).toBe('1,234,567');
expect(formatFullNumber(1234567890)).toBe('1,234,567,890');
expect(formatFullNumber(1234567890123)).toBe('1,234,567,890,123');
});
it('should handle edge cases gracefully', () => {
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(null)).toBe('0');
expect(formatFullNumber(undefined)).toBe('0');
expect(formatFullNumber(NaN)).toBe('0');
expect(formatFullNumber('string')).toBe('0');
});
it('should format with en-US locale (comma separator)', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should format with de-DE locale (period separator)', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(9876543)).toBe('9.876.543');
});
it('should format with es-ES locale (period separator)', () => {
const mockLocale = ref('es-ES');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(5555555)).toBe('5.555.555');
});
it('should format with zh-CN locale', () => {
const mockLocale = ref('zh-CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1000000)).toBe('1,000,000');
});
it('should format with ar-EG locale (Arabic numerals, RTL)', () => {
const mockLocale = ref('ar-EG');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(7654321);
// Arabic locale uses Eastern Arabic numerals (٠-٩)
// Just verify it's formatted (length should be reasonable)
expect(result.length).toBeGreaterThan(6);
});
it('should format with fr-FR locale (narrow no-break space)', () => {
const mockLocale = ref('fr-FR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(3333333);
expect(result).toContain('3');
expect(result).toContain('333');
});
it('should format with hi-IN locale (Indian numbering system)', () => {
const mockLocale = ref('hi-IN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(9999999)).toBe('99,99,999');
});
it('should format with th-TH locale', () => {
const mockLocale = ref('th-TH');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(4444444)).toBe('4,444,444');
});
it('should format with tr-TR locale', () => {
const mockLocale = ref('tr-TR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(6666666)).toBe('6.666.666');
});
it('should format with pt-PT locale (space separator)', () => {
const mockLocale = ref('pt-PT');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(2222222);
// Portuguese (Portugal) uses narrow no-break space as separator
expect(result).toMatch(/2[\s\u202f]222[\s\u202f]222/);
});
it('should handle underscore-based locale tags (pt_BR)', () => {
const mockLocale = ref('pt_BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should normalize pt_BR to pt-BR and work correctly
expect(formatFullNumber(1234567)).toBe('1.234.567');
});
it('should handle underscore-based locale tags (zh_CN)', () => {
const mockLocale = ref('zh_CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should normalize zh_CN to zh-CN and work correctly
expect(formatFullNumber(1000000)).toBe('1,000,000');
});
it('should handle underscore-based locale tags (en_US)', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should normalize en_US to en-US and work correctly
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should handle null/undefined locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should fall back to 'en' locale
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should fall back to base language when specific locale not supported', () => {
// Simulate a case where pt-BR might not be fully supported but pt is
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should work with either pt-BR or pt fallback
const result = formatFullNumber(1234567);
// Portuguese uses period as thousands separator
expect(result).toMatch(/1[.,\s]234[.,\s]567/);
});
it('should fall back to English for completely unsupported locales', () => {
// Use a completely made-up locale
const mockLocale = ref('xx-YY');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should fall back to 'en' and work
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should handle edge case with only base language code', () => {
const mockLocale = ref('pt');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
// Should work with base language
const result = formatFullNumber(1000000);
expect(result).toMatch(/1[.,\s]000[.,\s]000/);
});
});
});
@@ -0,0 +1,57 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* Composable for locale resolution and validation
* Provides a normalized, validated locale that works with Intl APIs
*/
export function useLocale() {
const { locale } = useI18n();
/**
* Resolves and validates the current locale for use with Intl APIs
*
* Handles multiple fallback scenarios:
* 1. Normalizes underscore-based tags (pt_BR pt-BR, zh_CN zh-CN)
* 2. Falls back to base language if specific locale unsupported (pt-BR pt)
* 3. Falls back to English if base language unsupported (xx-YY en)
*
* @returns {string} Valid BCP 47 locale tag for Intl APIs
*
* @example
* const { resolvedLocale } = useLocale();
* new Intl.NumberFormat(resolvedLocale.value).format(1234);
* new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
*/
const resolvedLocale = computed(() => {
// Handle null/undefined locale
if (!locale.value) return 'en';
// Normalize underscore to hyphen (pt_BR → pt-BR, zh_CN → zh-CN)
const normalized = locale.value.replace(/_/g, '-');
// Check if the specific locale is supported (e.g., pt-BR, zh-CN)
const supportedLocales = Intl.NumberFormat.supportedLocalesOf([normalized]);
if (supportedLocales.length > 0) {
return normalized;
}
// If specific locale not supported, try base language (pt-BR → pt, zh-CN → zh)
const baseLocale = normalized.split('-')[0];
const baseSupportedLocales = Intl.NumberFormat.supportedLocalesOf([
baseLocale,
]);
if (baseSupportedLocales.length > 0) {
return baseLocale;
}
// If base language also not supported, fall back to English
return 'en';
});
return {
resolvedLocale,
// Also expose the raw locale for cases where you need it
locale,
};
}
@@ -0,0 +1,67 @@
import { useLocale } from './useLocale';
/**
* Composable for number formatting with i18n locale support
* Provides methods to format numbers in compact and full display formats
*/
export function useNumberFormatter() {
const { resolvedLocale } = useLocale();
/**
* Formats numbers for display with clean, minimal formatting
* - Up to 1,000: show exact number (e.g., 999)
* - 1,000 to 999,999: show as "Xk" for exact thousands or "Xk+" for remainder (e.g., 1000 "1k", 1500 "1k+")
* - 1,000,000+: show in millions with 1 decimal place (e.g., 1,234,000 "1.2M")
*
* Uses browser-native Intl.NumberFormat with proper i18n locale support
*
* @param {number} num - The number to format
* @returns {string} Formatted number string
*/
const formatCompactNumber = num => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
// For numbers between -1000 and 1000 (exclusive), show exact number with locale formatting
if (Math.abs(num) < 1000) {
return new Intl.NumberFormat(resolvedLocale.value).format(num);
}
// For numbers with absolute value above 1,000,000, show in millions with 1 decimal place
if (Math.abs(num) >= 1000000) {
const millions = num / 1000000;
return new Intl.NumberFormat(resolvedLocale.value, {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
minimumFractionDigits: millions % 1 === 0 ? 0 : 1,
}).format(num);
}
// For numbers with absolute value between 1,000 and 1,000,000, show as "Xk" or "Xk+" using floor value
// For negative numbers, we want to floor towards zero (truncate), not towards negative infinity
const thousands = num >= 0 ? Math.floor(num / 1000) : Math.ceil(num / 1000);
const remainder = Math.abs(num) % 1000;
const suffix = remainder === 0 ? 'k' : 'k+';
return `${new Intl.NumberFormat(resolvedLocale.value).format(thousands)}${suffix}`;
};
/**
* Format a number for full display with locale-specific formatting
* @param {number} num - The number to format
* @returns {string} Formatted number string with full precision and locale formatting (e.g., 1,234,567)
*/
const formatFullNumber = num => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
return new Intl.NumberFormat(resolvedLocale.value).format(num);
};
return {
formatCompactNumber,
formatFullNumber,
};
}
@@ -4,7 +4,6 @@ export const BUS_EVENTS = {
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
SCROLL_TO_MESSAGE: 'SCROLL_TO_MESSAGE',
MESSAGE_SENT: 'MESSAGE_SENT',
FETCH_LABEL_SUGGESTIONS: 'FETCH_LABEL_SUGGESTIONS',
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',
WEBSOCKET_RECONNECT: 'WEBSOCKET_RECONNECT',
+36 -15
View File
@@ -30,21 +30,15 @@ class DataImportJob < ApplicationJob
def parse_csv_and_build_contacts
contacts = []
rejected_contacts = []
# Ensuring that importing non utf-8 characters will not throw error
data = @data_import.import_file.download
utf8_data = data.force_encoding('UTF-8')
# Ensure that the data is valid UTF-8, preserving valid characters
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
csv = CSV.parse(clean_data, headers: true)
csv.each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
with_import_file do |file|
csv_reader(file).each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
end
@@ -75,7 +69,7 @@ class DataImportJob < ApplicationJob
end
def generate_csv_data(rejected_contacts)
headers = CSV.parse(@data_import.import_file.download, headers: true).headers
headers = csv_headers
headers << 'errors'
return if rejected_contacts.blank?
@@ -99,4 +93,31 @@ class DataImportJob < ApplicationJob
def send_import_failed_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
def csv_headers
header_row = nil
with_import_file do |file|
header_row = csv_reader(file).first
end
header_row&.headers || []
end
def csv_reader(file)
file.rewind
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
CSV.new(StringIO.new(clean_data), headers: true)
end
def with_import_file
temp_dir = Rails.root.join('tmp/imports')
FileUtils.mkdir_p(temp_dir)
@data_import.import_file.open(tmpdir: temp_dir) do |file|
file.binmode
yield file
end
end
end
@@ -0,0 +1,51 @@
# Handles attachment processing for ConversationReplyMailer flows.
module ConversationReplyMailerAttachmentHelper
private
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
current_total_size = handle_attachment_inline(current_total_size, attachment)
end
end
def read_blob_content(blob)
buffer = +''
blob.open do |file|
while (chunk = file.read(64.kilobytes))
buffer << chunk
end
end
buffer
end
def handle_attachment_inline(current_total_size, attachment)
blob = attachment.file.blob
return current_total_size if blob.blank?
file_size = blob.byte_size
attachment_name = attachment.file.filename.to_s
if current_total_size + file_size <= 20.megabytes
content = read_blob_content(blob)
mail.attachments[attachment_name] = {
mime_type: attachment.file.content_type || 'application/octet-stream',
content: content
}
@options[:attachments] << { name: attachment_name }
current_total_size + file_size
else
@large_attachments << attachment
current_total_size
end
end
end
@@ -1,4 +1,6 @@
module ConversationReplyMailerHelper
include ConversationReplyMailerAttachmentHelper
def prepare_mail(cc_bcc_enabled)
@options = {
to: to_emails,
@@ -27,34 +29,6 @@ module ConversationReplyMailerHelper
mail(@options)
end
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
raw_data = attachment.file.download
attachment_name = attachment.file.filename.to_s
file_size = raw_data.bytesize
# Attach files directly until we hit 20MB total
# After reaching 20MB, send remaining files as links
if current_total_size + file_size <= 20.megabytes
mail.attachments[attachment_name] = raw_data
@options[:attachments] << { name: attachment_name }
current_total_size += file_size
else
@large_attachments << attachment
end
end
end
private
def oauth_smtp_settings
+1
View File
@@ -48,6 +48,7 @@ class Account < ApplicationRecord
check_for_column: false
}.freeze
validates :name, presence: true
validates :domain, length: { maximum: 100 }
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
+1 -1
View File
@@ -40,7 +40,7 @@ class Attachment < ApplicationRecord
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8, :ig_reel => 9 }
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11 }
def push_event_data
return unless file_type
+2
View File
@@ -40,6 +40,8 @@ class Channel::Line < ApplicationRecord
config.channel_id = line_channel_id
config.channel_secret = line_channel_secret
config.channel_token = line_channel_token
# Skip SSL verification in development to avoid certificate issues
config.http_options = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if Rails.env.development?
end
end
end
+21 -12
View File
@@ -130,30 +130,39 @@ class Channel::Telegram < ApplicationRecord
def convert_markdown_to_telegram_html(text)
# ref: https://core.telegram.org/bots/api#html-style
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
# Escape HTML entities first to prevent HTML injection
# This ensures only markdown syntax is converted, not raw HTML
escaped_text = CGI.escapeHTML(text)
# convert markdown to html
html = CommonMarker.render_html(text).strip
# Parse markdown with extensions:
# - strikethrough: support ~~text~~
# - hardbreaks: preserve all newlines as <br>
html = CommonMarker.render_html(escaped_text, [:HARDBREAKS], [:strikethrough]).strip
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert paragraph breaks to double newlines to preserve them
# CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
# converted escaped br tags to \n
stripped_html.gsub('&lt;br&gt;', "\n")
# Remove opening and closing <p> tags
html_with_breaks = html_with_breaks.gsub(%r{</?p>}, '')
# Sanitize to only allowed tags
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html_with_breaks, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert <br /> tags to newlines for Telegram
stripped_html.gsub(%r{<br\s*/?>}, "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
text_payload = convert_markdown_to_telegram_html(text)
# text is already converted to HTML by MessageContentPresenter
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
text: text_payload,
text: text,
reply_markup: reply_markup,
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
+1 -1
View File
@@ -53,7 +53,7 @@ class Integrations::App
when 'slack'
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
+4
View File
@@ -30,4 +30,8 @@ class AccountPolicy < ApplicationPolicy
def toggle_deletion?
@account_user.administrator?
end
def topup_checkout?
@account_user.administrator?
end
end
+1 -1
View File
@@ -125,7 +125,7 @@ class MailPresenter < SimpleDelegator
def from
# changing to downcase to avoid case mismatch while finding contact
(@mail.reply_to.presence || @mail.from).map(&:downcase)
Array.wrap(@mail.reply_to.presence || @mail.from).map(&:downcase)
end
def sender_name
+12 -5
View File
@@ -1,11 +1,18 @@
class MessageContentPresenter < SimpleDelegator
def outgoing_content
return content unless should_append_survey_link?
content_to_send = if should_append_survey_link?
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
else
content
end
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
Messages::MarkdownRendererService.new(
content_to_send,
conversation.inbox.channel_type,
conversation.inbox.channel
).render
end
private
@@ -71,3 +71,5 @@ class Contacts::ContactableInboxesService
end
end
end
Contacts::ContactableInboxesService.prepend_mod_with('Contacts::ContactableInboxesService')

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