Compare commits

...
Author SHA1 Message Date
Shivam Mishra a6f34cf291 feat: destroy wavesurfer before unmount 2025-01-23 18:48:49 +05:30
Sivin VargheseandGitHub b429ce0ad5 fix: Prevent template variables from becoming links (#10725)
# Pull Request Template

## Description

**Issue**
This PR fixes template variables in messages (e.g., {{customer.name}})
that were being incorrectly converted to clickable links by the
`MessageFormatter's linkify` functionality. This caused formatting
issues and broken links.

**Solution**
Added a `linkify` parameter to `MessageFormatter` to optionally disable
link conversion

## Type of change

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

## How Has This Been Tested?

**Screenshots**

**Before**
<img width="1012" alt="image"
src="https://github.com/user-attachments/assets/70abb238-b4d9-439d-9e51-c7513cf482fb"
/>


**After**
<img width="1012" alt="image"
src="https://github.com/user-attachments/assets/387acb74-674e-4b26-85cc-2d7190d256b1"
/>




## 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-01-23 18:18:14 +05:30
Sivin VargheseandGitHub d3d39a81d6 fix: Prevent duplicate chat creation in the web widget during latency (#10745) 2025-01-23 12:53:28 +05:30
be8205657e fix: Added authentication to FireCrawl API, remove unused RobinAI references (#10737)
- Fixed Firecrawl webhook payloads to ensure proper data handling and
delivery.
- Removed unused Robin AI code to improve codebase cleanliness and
maintainability.
- Implement authentication for the Firecrawl endpoint to improve
security. A key is generated to secure the webhook URLs from FireCrawl.

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-01-22 18:14:25 -08:00
Shivam MishraandGitHub 3b366f43e6 feat: setup captain limits (#10713)
This pull request introduces several changes to implement and manage
usage limits for the Captain AI service. The key changes include adding
configuration for plan limits, updating error messages, modifying
controllers and models to handle usage limits, and updating tests to
ensure the new functionality works correctly.

## Implementation Checklist

- [x] Ability to configure captain limits per check
- [x] Update response for `usage_limits` to include captain limits
- [x] Methods to increment or reset captain responses limits in the
`limits` column for the `Account` model
- [x] Check documents limit using a count query
- [x] Ensure Captain hand-off if a limit is reached
- [x] Ensure limits are enforced for Copilot Chat
- [x] Ensure limits are reset when stripe webhook comes in 
- [x] Increment usage for FAQ generation and Contact notes
- [x] Ensure documents limit is enforced

These changes ensure that the Captain AI service operates within the defined usage limits for different subscription plans, providing appropriate error messages and handling when limits are exceeded.
2025-01-23 01:23:18 +05:30
David KubešandGitHub 52362ec1ea fix: country selection (#10670) 2025-01-22 21:30:51 +05:30
Sivin VargheseandGitHub 38e46bbb6c chore: Adds loader for attachment download buttons (#10748) 2025-01-22 18:29:57 +05:30
Sivin VargheseandGitHub 137c6f8a75 fix: Context menu and its submenu boundary overflow (#10729)
# Pull Request Template

## Description

This PR adds smart positioning to prevent context menu and submenu from
overflowing screen boundaries. The context menu and its submenu now
dynamically adjust their position when there isn't enough space.


Fixes
https://linear.app/chatwoot/issue/CW-3936/assign-to-agent-ui-element-is-drawn-off-canvas
https://github.com/chatwoot/chatwoot/issues/10727

## Type of change

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

## How Has This Been Tested?
**Loom video**

**Before**

https://www.loom.com/share/6c267dde103c4f2281ed5b4d006a43b1?sid=047966f5-d6d3-4de6-ade8-270141e6e215

**After**

https://www.loom.com/share/5599c6061bb74c3ea527dd06960d3189?sid=ffb01149-bb7a-449e-b0ec-02cc191f472b


## 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
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-22 17:56:43 +05:30
PranavandGitHub 3a950dbdbb fix: Use documentable instead of document (#10743)
Fixes
https://linear.app/chatwoot/issue/CW-3950/nomethoderror-undefined-method-document-for-an-instance-of
2025-01-21 19:43:20 -08:00
451c28a7a1 feat: add prompt suggestions and June events (#10726)
This PR adds the following two features

1. Prompt suggestions to get started with Copilot Chat
2. June events for each action

![CleanShot 2025-01-20 at 21 00
52@2x](https://github.com/user-attachments/assets/d73e7982-0f78-4d85-873e-da2c16762688)

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-01-21 22:52:42 +05:30
Sivin VargheseandGitHub 0021a7d8e5 chore: Design improvements (#10732) 2025-01-21 17:03:15 +05:30
615a0c69fe chore: Help center improvements (#10712)
# Pull Request Template

## Description

Fixes https://linear.app/chatwoot/issue/CW-3913/issues-with-help-center

**Fixes included**
1. > The default locale that is selected should the portal default
locale.

Now, we update the last active locale in UI settings after changing the
selected locale from the article page header. This ensures that we see
the last active locale-based categories on the category page and
remember it when we return. Initially, it’s the default locale.
     
2. > I cannot switch to a different locale if there are no articles in
the portal

Now, the `v-if` condition that checked for the presence of articles in
portal has been removed. Additionally, the locale length checks for the
showing dropdown have been removed, allows locale switching even if
article is not preset.
     
3. > Create or updating the article is quite painful, see the video 

Removed the `quickSave` and `saveAndSyncDebounced` usage for a newly
creating article.

4. > I cannot see the articles if I delete the English locale
(irrespective of what I choose as default locale)

Now, the last active locale in UI settings will automatically update to
the default locale when the last active locale is deleted.

5. > Set a new default locale other than `en` and delete the `en` locale
preset in the portal. Then, adding a new locale will automatically set
`en` as the default locale, even if the `en` locale not preset in the
portal.

    Now, we pass default locale when we add a new locale.

6. Adds search for all dropdown menus
7. Update article count in realtime.


## Type of change

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

## How Has This Been Tested?

**Check this linear issues**
https://linear.app/chatwoot/issue/CW-3913/issues-with-help-center


## 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
- [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: Pranav <pranav@chatwoot.com>
2025-01-21 13:50:01 +05:30
PranavandGitHub 1528473dd0 fix(ce): Check the edition before running the data migration (#10728)
Fixes #10721

This PR addresses an issue where the data migration in the CE edition
was failing due to missing models. The change ensures that the migration
process runs only when the deployment is using the EE image.
2025-01-21 10:00:37 +05:30
Sivin VargheseandGitHub 05c16b49dd fix: Vite dev build fails due to sass (#10716)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-3853/vite-dev-build-fails-due-to-dart-sass

**Other issue**

| First Header  | Second Header |
| ------------- | ------------- |
|
![image](https://github.com/user-attachments/assets/e99ac2bc-0b8a-44d1-aa58-9e10e00b5ad5)
|
![image](https://github.com/user-attachments/assets/c987f70c-f5f5-4606-a498-00a6fe6b6525)
|



## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-20 14:34:33 +05:30
Sivin VargheseandGitHub a8ecbd3919 chore: Auto-fetch previous page on last item deletion (#10714)
# Pull Request Template

## Description

This PR fixes an issue where deleting the last item on the last page of
responses/documents, would show an empty state instead of loading the
previous page.

Fixes
> If you have pending responses spanning 2 or more pages .. and you
delete the last response in the last page.. instead of showing the
previous page the system show empty state.

## 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/b0e89f774ccd45dab0e8dba2c34bd1ac?sid=d9923bcd-5030-42d9-9b7f-170df5297cfd


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-20 14:34:23 +05:30
Shivam MishraandGitHub c25a75f497 feat: check if item is present (#10715) 2025-01-18 18:31:07 +05:30
5046dc5727 feat: add CSAT & Form bubble (#10711)
![CleanShot 2025-01-17 at 14 02
40@2x](https://github.com/user-attachments/assets/7a19c4d7-7548-4b6a-92bb-0ddba181c428)

![CleanShot 2025-01-17 at 14 39
50@2x](https://github.com/user-attachments/assets/4251c944-2d1f-4cb5-817a-6352a644b743)

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-17 15:30:52 +05:30
Sivin VargheseandGitHub 3da97d97be fix: Download button opens URL instead of downloading (#10710) 2025-01-17 14:34:44 +05:30
Sojan f2ff3b42e3 Merge branch 'release/4.0.1' into develop 2025-01-17 01:13:32 +05:30
Sojan 9dc493c7d4 Bump version to 4.0.1 2025-01-17 01:11:35 +05:30
Shivam MishraandGitHub 2f60301064 fix: bubble colors for email and text (#10701) 2025-01-17 00:57:22 +05:30
PranavandGitHub c20cb9535b fix: Remove backdrop-blur from Modal (#10709)
Remove backdrop-blur-[100px] from Modal as it was causing issues with
old design pages.
2025-01-17 00:50:34 +05:30
Sojan JoseandGitHub 257f59e219 chore: Update SECURITY.md (#10705)
Move vulnerability reporting to Github.
2025-01-16 10:59:33 -08:00
PranavandGitHub c0ad0b4cc1 fix: Re-enable scheduled jobs (#10708)
During Captain, scheduled jobs were disabled. This PR would re-enable them
2025-01-16 10:59:09 -08:00
Shivam MishraandGitHub 937d4f78a8 fix: rendering for woot modal (#10707) 2025-01-16 20:05:53 +05:30
Sojan a33f170171 Merge branch 'release/4.0.0' into develop 2025-01-16 17:11:51 +05:30
Sojan 789f281273 Bump version to 4.0.0 2025-01-16 17:10:55 +05:30
1ccfb4e3db fix: sentry issues (#10695)
1. Ensure audio player ref is accessible before triggering calls
([Sentry](https://chatwoot-p3.sentry.io/issues/6221981610))
2. Use correct default for attachments, this was incorrectly set to
`null` in a previous PR
([Sentry](https://chatwoot-p3.sentry.io/issues/5966738120))
3. Fix `lastNonActivityMessage` is not present
([Sentry](https://chatwoot-p3.sentry.io/issues/6116038455))
4. Fix `Alt+J` & `Alt+K` shortcuts not working
([Sentry](https://chatwoot-p3.sentry.io/issues/6075125384))

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-16 15:49:48 +05:30
79997fd86f feat: use break anywhere (#10698)
Ref:
https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap#anywhere

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-16 15:49:39 +05:30
0b4028b95d feat: Add support for the references in FAQs (#10699)
Currently, it’s unclear whether an FAQ item is generated from a
document, derived from a conversation, or added manually.

This PR resolves the issue by providing visibility into the source of
each FAQ. Users can now see whether an FAQ was generated or manually
added and, if applicable, by whom.

- Move the document_id to a polymorphic relation (documentable).
- Updated the APIs to accommodate the change.
- Update the service to add corresponding references. 
- Updated the specs.

<img width="1007" alt="Screenshot 2025-01-15 at 11 27 56 PM"
src="https://github.com/user-attachments/assets/7d58f798-19c0-4407-b3e2-748a919d14af"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-16 15:27:30 +05:30
Sivin VargheseandGitHub 88f3b4de48 chore(v4): Update colors (#10700) 2025-01-16 15:27:23 +05:30
Sivin VargheseandGitHub 542bab617b feat: Implement Empty State UI for Captain Pages (#10696) 2025-01-16 00:05:19 -08:00
PranavandGitHub 793b1f85f6 feat: Add assistant filter support to documents page (#10697)
This PR introduces support for an assistant filter on the documents page.

- Moved the existing assistant filter functionality to a standalone, reusable component.
- Updated the documents page and responses page to use the component
2025-01-15 21:41:07 -08:00
Shivam MishraandGitHub 54d629d171 fix: Update words not breaking in reply-to preview (#10694) 2025-01-15 21:16:39 -08:00
PranavandGitHub 6096932f76 feat: Add a review step for FAQs generated from conversations before using it (#10693)
This PR introduces a review step for generated FAQs, allowing a human to
validate and approve them before use in customer interactions. While
hallucinations are minimal, this step ensures accurate and reliable FAQs
for Captain to use during LLM calls when responding to customers.

- Added a status field for the FAQ
- Allow the filter on the UI.
<img width="1072" alt="Screenshot 2025-01-15 at 6 39 26 PM"
src="https://github.com/user-attachments/assets/81dfc038-31e9-40e6-8a09-586ebc4e8384"
/>
2025-01-16 09:54:34 +05:30
e3b5b30666 fix: Remove overflow-hidden on accordionItem (#10692)
This PR fixes the following problem. 

<img width="318" alt="Screenshot 2025-01-15 at 11 53 38 AM"
src="https://github.com/user-attachments/assets/44656cbe-2c29-4400-88b4-15bd270bb627"
/>

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-01-16 01:29:20 +05:30
PranavandGitHub 7469cde0b9 fix: Remove Report API calls from being called on every event (#10691)
Previously, the Reports API fetched data based on event triggers. For
example, when an event occurred on an account, the system would
automatically retrieve and display updated information. However, this
approach was designed under the assumption that reports would be
accessed by a small number of users and on an infrequent basis (e.g.,
once daily or weekly).

In scenarios where large customers have multiple team members actively
monitoring reports, this event-driven approach led to an excessive
number of requests, significantly straining the system.

This PR introduces a interval-based fetching of reports instead of the
event-driven model.
2025-01-15 11:23:00 -08:00
Shivam MishraandGitHub da488b0c32 fix: ignore subgroups without link when checking for permission (#10690) 2025-01-15 18:16:10 +05:30
7fd8b4d03a feat: update colors for v4 (#10660)
Porting changes from https://github.com/chatwoot/chatwoot/pull/10552

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-01-15 17:13:03 +05:30
Vishnu NarayananandGitHub a4c0c76fa5 fix: set cwctl pg version to 16 (#10689)
# Pull Request Template

## Description

Multiple versions of postgres were getting installed, conflicting with
the `pgvector` extension being installed. This PR sets the version to
`16` for all postgres dependencies ensuring a single version is
installed.

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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


## 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
2025-01-15 16:31:49 +05:30
Sivin VargheseandGitHub a899c2b5a4 chore: Improve new conversation form (#10687) 2025-01-15 13:10:09 +05:30
d070743383 feat(ee): Add Captain features (#10665)
Migration Guide: https://chwt.app/v4/migration

This PR imports all the work related to Captain into the EE codebase. Captain represents the AI-based features in Chatwoot and includes the following key components:

- Assistant: An assistant has a persona, the product it would be trained on. At the moment, the data at which it is trained is from websites. Future integrations on Notion documents, PDF etc. This PR enables connecting an assistant to an inbox. The assistant would run the conversation every time before transferring it to an agent.
- Copilot for Agents: When an agent is supporting a customer, we will be able to offer additional help to lookup some data or fetch information from integrations etc via copilot.
- Conversation FAQ generator: When a conversation is resolved, the Captain integration would identify questions which were not in the knowledge base.
- CRM memory: Learns from the conversations and identifies important information about the contact.

---------

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-14 16:15:47 -08:00
7b31b5ad6e fix: Fix bubble design in RTL (#10683)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-14 11:14:13 -08:00
Sivin VargheseandGitHub b3d0d466ee fix: Inbox view list lacks a border in RTL view. (#10684) 2025-01-14 11:13:16 -08:00
0c01303db0 chore: Update translations (#10549)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-14 22:00:56 +05:30
Vishnu NarayananandGitHub 91dd92e318 feat: switch to postgres docker images with pgvector support (v4.0) (#10658)
Starting v4.0, pgvector support is mandatory. This PR adds pgvector
support for new docker installations.

- Switch to postgres images with pgvector support
- Change the default postgres version from `12` to `16`
2025-01-14 19:45:42 +05:30
Vishnu NarayananandGitHub 03a938eb60 feat: cwctl install pgvector for new deployments (v4.0) (#10657)
Starting v4.0, pgvector support is mandatory. This PR adds pgvector
support for new/existing Linux installations via cwctl.

- Install `pgvector` for new deployments via cwctl
- Show a warning before upgrading to 4.0 or above for existing
installations. The user is expected to install `pgvector` based on their
version of self-hosted Postgres or managed Postgres.
2025-01-14 19:43:56 +05:30
Shivam MishraandGitHub 26187c3ebf fix: toggle for reply editor (#10680)
The reply editor toggle was a bit confusing for a few users, where it's
not clear that it's clickable. This PR updates it to make it more clear

### Pending Work

- [x] RTL view

### Preview

##### Dark Mode


https://github.com/user-attachments/assets/9e377eda-34d5-4f64-a06a-23ae822da50e

##### Light Mode


https://github.com/user-attachments/assets/84c6b23c-cd17-4805-8e63-9d7d1cc0d799
2025-01-14 16:28:29 +05:30
Sivin VargheseandGitHub bffc24d119 fix(v4): Overflow issue in conversation card (#10679) 2025-01-14 12:48:30 +05:30
Shivam MishraandGitHub dbcc55665a fix: Bubble style when grouping with next (#10677) 2025-01-14 09:05:29 +05:30
Shivam MishraandGitHub 35ca67c9ba fix: Update the design for the reply box banner UI (#10678) 2025-01-13 13:12:43 -08:00
Sojan JoseandGitHub c75ed1ef69 fix: Prevent duplicate notifications for mentions (#10675)
This PR addresses an issue where mention_notification was being
overwritten by assigned_conversation_new_message notifications.
Similarly, participating_conversation_new_message was taking priority
over assigned_conversation_new_message.

The fix ensures that additional notifications are not created in these
scenarios by establishing a priority order for notifications as follows:

1. mention_notification
2. assigned_conversation_new_message
3. participating_conversation_new_message

This change maintains consistency and prevents redundant notifications.

----

Note: In the older implementation of notifications in Chatwoot,
notification objects were created only if the user had subscribed to a
specific notification type. With the new inbox_view model, we are
changing this approach to create notification objects in all cases. This
PR updates the services to remove reliance on notification preferences,
simplifying the logic.


----

Caveat: Since we don’t create extra notifications for new messages if a
mention_notification already exists, a user subscribed to
new_message_notification push/email notifications but not to
mention_notification will not receive notifications in these cases.
2025-01-13 11:20:31 +05:30
Sojan JoseandGitHub 97235a4365 fix: Exclude notifications for blocked contacts, except mentions (#10659)
- Prevents notifications from being created for conversations or actions
involving blocked contacts.
- The exception is the conversation_mention notification type, which
will still be created when applicable.
2025-01-12 15:30:27 +05:30
Shivam MishraandGitHub 74870bec41 fix: Fix bubble overflow issue and color (#10674) 2025-01-11 10:02:24 -08:00
Shivam MishraandGitHub 0d90e7c158 fix: Teams feature flag (#10673) 2025-01-11 10:01:57 -08:00
Shivam MishraandGitHub e70f7a2550 fix: Bubble typography (#10653)
![CleanShot 2025-01-08 at 18 22
36@2x](https://github.com/user-attachments/assets/77cc99d2-1bdf-49ff-9016-57bb9c50b149)
2025-01-10 15:40:12 +05:30
Shivam MishraandGitHub 4912140893 fix: ensure the message types are same when grouping (#10661) 2025-01-10 09:32:06 +05:30
Shivam MishraandGitHub 2d44468c38 fix: attachment url in file chip (#10662)
https://github.com/user-attachments/assets/e84d94d5-876d-4c00-88be-a460f917bf3b
2025-01-10 09:31:56 +05:30
Shivam MishraandGitHub 1ac2a0949f fix: conversation footer colors (#10654) 2025-01-09 15:04:40 +05:30
Shivam MishraandGitHub ef01779d61 fix: Message grouping logic (#10656) 2025-01-09 13:24:16 +05:30
Muhsin KelothandGitHub b5f4e8155b chore: Configure iOS universal linking (#10651)
- Moved `apple-app-site-association` to
`.well-known/apple-app-site-association`
https://docs.expo.dev/linking/ios-universal-links/#create-aasa-file
- Updated the paths pattern to accept conversation links only.
2025-01-08 21:25:11 +05:30
Shivam MishraandGitHub 25258417de fix: v4 ui issues (#10652)
This PR has the following fixes

1. Remove leading `:` in inbox view push message content
2. Styles for block quotes in text
3. Better condition for checking bot message
4. Use dompurify
2025-01-08 18:17:50 +05:30
dd595675bc chore: Update reply editor design (#10575)
# Pull Request Template

## Description

This PR will update the reply message editor’s design.

**Screen recording**


https://github.com/user-attachments/assets/40f61903-6bf7-4031-9a36-9027dffc46aa

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-01-08 17:49:53 +05:30
fa8f1d9b6f fix: throttle reports api endpoint (#10620)
- Throttle reports API requests at the account level
- Throttle reports API requests at the user level for dashboard users as
well as API users

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-01-08 15:43:33 +05:30
Shivam MishraandGitHub 91b1015457 feat: fixes for bubble UI (#10643)
This PR applies the following fixes

1. Images in emails have margins, causing unnecessary gaps when 1x1px
images are loaded (ref:
[42111](https://app.chatwoot.com/app/accounts/1/conversations/42111?messageId=96215315))
2. Two adjacent `<br>` tags would add a huge gap, fixed this (ref:
[42111](https://app.chatwoot.com/app/accounts/1/conversations/42111?messageId=96215315))
3. Color for outgoing emails is wrong (ref:
[41621](https://app.chatwoot.com/app/accounts/1/conversations/41621?messageId=93560032))
4. Wrong list styles (see:
[42372](https://app.chatwoot.com/app/accounts/1/conversations/42372?messageId=96208130))
5. Wrong bubble color when outgoing message is sent by a bot or captain
6. Wrong avatar when outgoing message is sent by a bot or captain
2025-01-08 09:26:40 +05:30
Shivam MishraandGitHub c924d386f4 fix: reply-to preview not working on next bubbles (#10649) 2025-01-08 08:58:21 +05:30
Sivin VargheseandGitHub 3c93cdb8b2 feat(v4): Update Inbox view card design (#10599) 2025-01-07 21:11:54 +05:30
918f8e6f8e fix: AgentTable report showing test value for zero conversations (#10641)
# Pull Request Template

## Description

Changed to make use of nullish coalescing operator to only short circuit
in cases when the `metric` variable is not zero. This change also begs
the question as to whether the `stringToFloat` test function should
exist - to me it seems interesting to have test code embedded into
production code for the frontend?
Fixes #10640

## Type of change

Please delete options that are not relevant.

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

Tested by opening the report overview page and reviewing that this shows
`---` for agents with no conversations assigned to them.


## 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
- [x] 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-01-06 13:51:27 +05:30
Sojan JoseandGitHub 5e4e955e64 chore: Avoid contact inboxes getting deleted (#10612)
We observed some rare instances on prod where a contact inbox was
getting deleted during the contact merge action. the potential suspect
is contact inbox objects already loaded into the memory getting cleared
through the destroy association job. Reloading the object to avoid this
case !!!
2024-12-26 16:17:38 +05:30
Shivam MishraandGitHub c19d70a6a0 fix: bubble UI issues (#10608)
This PR has fixes for the following issues

- Inconsistent spacing between meta and text in text bubble
- Activity bubble overflows for longer text (for now I have truncated
it, I'll work with @absurdiya on a better solution)
- Ugly lookinh gradient for expand button on email bubble
- Email bubble overflow issues and text rendering issues
- Alignment for error message
- Minute-wise grouping not working
- Link color should not be blue
- Use `gray-3` for bubble background instead of `gray-4`
2024-12-21 13:36:46 +05:30
PranavandGitHub c52282307a feat(v4): Update team, agent summary builder to include resolution metrics (#10607)
Following https://github.com/chatwoot/chatwoot/pull/10604, this PR
introduces similar reporting features for Agents and Teams.

Updates in this PR:
- Added additional methods to the base class to avoid repetition.
- Improve reporting for Teams and Agents to include resolution count.
2024-12-20 19:16:56 +05:30
4fd9bddb9d feat(v4): Add API to fetch aggregate reports for inboxes (#10604)
The Inbox Overview section is being updated to offer a more detailed
report, showing an overall view of the account grouped by inboxes. To
view detailed reports and access specific graphs for individual inboxes,
click on the inbox name to navigate to its dedicated report page.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-19 14:47:19 -08:00
eef70b9bd7 feat: integrate new bubbles (#10550)
To test this, set the `useNextBubble` value to `true` in the
localstorage. Here's a quick command to run in the console

```js
localStorage.setItem('useNextBubble', true)
```

```js
localStorage.setItem('useNextBubble', false)
```

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-19 18:41:55 +05:30
Sojan 9279175199 Merge branch 'release/3.16.0' into develop 2024-12-17 23:03:32 +05:30
Sojan 361878d346 Bump version to 3.16.0 2024-12-17 23:02:59 +05:30
Sivin VargheseandGitHub 2a4c0dfa2a chore: Adds the ability to remove labels from label card (#10591) 2024-12-17 18:43:36 +05:30
Sivin VargheseandGitHub 6b348da807 feat(v4): Compose a new conversation from a phone number. (#10568) 2024-12-17 18:07:58 +05:30
96ae298464 fix: Dotenv::Railtie is deprecated (#10515)
https://github.com/bkeepers/dotenv/pull/468

Renames Dotenv::Railtie => Dotenv::Rails

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-17 17:20:44 +05:30
932244a1ec feat: Add support for Arcade videos on articles (#10585)
Fixes
https://linear.app/chatwoot/issue/CW-3779/add-support-for-arcade-videos-on-articles-loom-alternative

**Loom video**

https://www.loom.com/share/917bdecb4eaf4d3f9782b4fa84ee4bd4?sid=d11f0d71-0cf5-424a-9268-9d9fb3797ee2


Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-17 17:17:42 +05:30
PranavandGitHub d69571f6f8 fix: Update old data to fix login issues (#10594)
The following lines caused issues for some users, specifically those who
signed up in 2021 when audio alerts were implemented as a flag. The data
type update for the flag was not handled correctly. This PR fixes the
issue by updating it to a compatible value.


https://github.com/chatwoot/chatwoot/blob/9410b3bcbbb7aba4b790f33bea52a430bf69466c/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js#L76-L81
2024-12-17 17:17:11 +05:30
Sojan JoseandGitHub 1d88e0dd28 fix: Contact form breaks if name is empty (#10597)
- Handles the case where the form and contact display page breaks if
name is `null`
2024-12-17 17:16:50 +05:30
Shivam MishraandGitHub b34dac7bbe feat: validate query conditions (#10595)
Query conditions can take in arbitrary values, this can cause SQL
errors. This PR fixes it
2024-12-17 17:16:37 +05:30
Vishnu NarayananandGitHub e3109dbb22 chore: pin bundler version to 2.5.x in circleci (#10596) 2024-12-17 16:31:55 +05:30
Vishnu NarayananandGitHub 9410b3bcbb feat: add multiarch arm64 support in ce docker images (#10582) 2024-12-16 16:53:01 +05:30
Sivin VargheseandGitHub 9220afce6e feat: Insert captain response to reply editor (#10581) 2024-12-13 15:13:16 +05:30
19ff5bdd5e feat: Add new message bubbles (#10481)
---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-12 17:42:22 -08:00
Sivin VargheseandGitHub 67e52d7d51 fix: Chat list header overflow issue (#10577)
# Pull Request Template

## Description

This PR fixes the overflow issue with the chat list header title.

## Type of change

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

## How Has This Been Tested?

**Before**
<img width="484" alt="image"
src="https://github.com/user-attachments/assets/e46f6daf-e2f3-4f1c-ba33-00a932d7788f"
/>


**After**
<img width="484" alt="image"
src="https://github.com/user-attachments/assets/d45808aa-ebbd-47e4-be32-f643c22b363f"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-12-12 13:22:52 +05:30
Sivin VargheseandGitHub 757fac79d1 chore: Update chat list header UI (#10573) 2024-12-12 10:12:07 +05:30
Sivin VargheseandGitHub 86d37622c8 chore: Remove unused files in contact (#10570) 2024-12-11 19:29:24 -08:00
Shivam MishraandGitHub 99c699ea34 fix: links rendering in sidebar profile (#10574)
This pull request includes several changes to the `DropdownItem.vue` and `SidebarProfileMenu.vue` components to improve the handling of links.

Earlier we passed the link `/super_admin` to RouterLink directly, which would trigger validations internally and the dropdown item would not render in case of any errors. This PR fixes this by handling the native links appropriately

Fixes #10571
2024-12-11 17:42:46 -08:00
PranavandGitHub a3290bfd42 fix: Update design of the conversation sidebar, chatlist (#10572)
- Increase the sidebar and chatlist width on screens bigger than 2xl
- Fix background of the copilot sidebar
- Increase the breakpoint for hiding chatlist from 1024 to 1200
2024-12-11 08:06:21 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ccb117d36a chore(deps): bump actionpack from 7.0.8.5 to 7.0.8.7 (#10569)
Bumps [actionpack](https://github.com/rails/rails) from 7.0.8.5 to
7.0.8.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails/releases">actionpack's
releases</a>.</em></p>
<blockquote>
<h2>7.0.8.7</h2>
<h2>Active Support</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Model</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Record</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action View</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Pack</h2>
<ul>
<li>
<p>Add validation to content security policies to disallow spaces and
semicolons.
Developers should use multiple arguments, and different directive
methods instead.</p>
<p>[CVE-2024-54133]</p>
<p><em>Gannon McGibbon</em></p>
</li>
</ul>
<h2>Active Job</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Mailer</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Cable</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Storage</h2>
<ul>
<li>No changes.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rails/rails/commit/778eab826538be6da355d848aecaea2245e3b8ce"><code>778eab8</code></a>
Preparing for 7.0.8.7 release</li>
<li><a
href="https://github.com/rails/rails/commit/cb16a3bb515b5d769f73926d9757270ace691f1d"><code>cb16a3b</code></a>
Add CSP directive validation</li>
<li><a
href="https://github.com/rails/rails/commit/bc979c5cf4e29e2aba95b7b13ca53e7a02fddee2"><code>bc979c5</code></a>
Preparing for 7.0.8.6 release</li>
<li>See full diff in <a
href="https://github.com/rails/rails/compare/v7.0.8.5...v7.0.8.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actionpack&package-manager=bundler&previous-version=7.0.8.5&new-version=7.0.8.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-10 18:53:43 -08:00
PranavandGitHub 10a0333980 feat(ee): Add copilot integration (v1) to the conversation sidebar (#10566) 2024-12-10 15:36:48 -08:00
9a405d65ba fix: RemoveStaleRedisKeys service (#10562)
Fixes issues with RemoveStaleRedisKeys service

Co-authored-by: Sojan <sojan@pepalo.com>
2024-12-11 03:38:25 +08:00
Sivin VargheseandGitHub 1b0e94ec95 feat: Flag icon component (#10564) 2024-12-10 11:53:24 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aebcbb63e4 chore(deps): bump nanoid from 3.3.7 to 3.3.8 (#10565)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ai/nanoid/blob/main/CHANGELOG.md">nanoid's
changelog</a>.</em></p>
<blockquote>
<h2>3.3.8</h2>
<ul>
<li>Fixed a way to break Nano ID by passing non-integer size (by <a
href="https://github.com/myndzi"><code>@​myndzi</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ai/nanoid/commit/3044cd5e73f4cf31795f61f6e6b961c8c0a5c744"><code>3044cd5</code></a>
Release 3.3.8 version</li>
<li><a
href="https://github.com/ai/nanoid/commit/4fe34959c34e5b3573889ed4f24fe91d1d3e7231"><code>4fe3495</code></a>
Update size limit</li>
<li><a
href="https://github.com/ai/nanoid/commit/d643045f40d6dc8afa000a644d857da1436ed08c"><code>d643045</code></a>
Fix pool pollution, infinite loop (<a
href="https://redirect.github.com/ai/nanoid/issues/510">#510</a>)</li>
<li>See full diff in <a
href="https://github.com/ai/nanoid/compare/3.3.7...3.3.8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nanoid&package-manager=npm_and_yarn&previous-version=3.3.7&new-version=3.3.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-09 17:48:46 -08:00
472f6d9345 feat: Ability to lock the conversation to a single thread in API channels (#10329)
Added the possibility to mark as a single conversation in the API type
inbox. This allows the conversation builder to search for the last
conversation.
I thought about searching for the last conversation with created_at:
desc order, as is done in some channels... but I didn't change the way
the conversation is searched.

Fixes: #7726 

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-09 23:36:17 +08:00
Shivam MishraandGitHub 2ce7c8b845 fix: sidebar collapsed on reload (#10561)
When reloading a page, the sidebar item that is actively selected is
collapsed by default. This PR fixes it by expanding it on reload
2024-12-09 15:59:26 +05:30
d902bb1d6f fix: Remove duplicate contactable inbox in the conversation form (#10554)
---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-06 12:31:01 -08:00
Sivin VargheseandGitHub 1b430ffae2 fix: New compose conversation form (#10548) 2024-12-06 15:40:06 +05:30
Sivin VargheseandGitHub afb3e3e649 fix: Fix issues with contact routes in old navigation sidebar (#10547) 2024-12-05 22:46:57 -08:00
Shivam MishraandGitHub 3fd585f40b feat: Throttle meta request for large chat size (#10518)
For large accounts with huge volumes of messages, it can be very
wasteful to make the meta request so often. It also puts un-necessary
load on the DB bombarding it with so many requests. This PR fixes it by
throttling the requests to 5 seconds for all users with more than 1000
accessible chats.

### Why not cache this value in the backend?

Well, it's a bit tricky, since a user can have different permissions
over inboxes and can see different chats, maintaining a cache for each
of them is not effective, besides the requests will reach the server
anyway.
2024-12-05 22:35:30 -08:00
b116ab5ad3 feat(v4): Compose new conversation without multiple clicks (#10545)
---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-05 20:16:29 -08:00
67c90231b6 feat: Add compose conversation components (#10457)
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-05 16:31:47 -08:00
Sivin VargheseandGitHub 41106bccb7 chore: Fix issues with Contact pages (#10544) 2024-12-05 14:24:10 -08:00
cdff624a0a feat: update notification settings (#10529)
https://github.com/user-attachments/assets/52ecf3f8-0329-4268-906e-d6102338f4af

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-05 19:05:04 +05:30
Sivin VargheseandGitHub d635be4b2a fix: Fetch assignable agents in expanded layout (#10540) 2024-12-04 21:26:00 -08:00
Sivin VargheseandGitHub f7b0d5dbe2 fix: Reset contact attributes when form is updated (#10539) 2024-12-04 21:25:28 -08:00
3edc0542cc fix: Fix issue with profile picture not updating (#10532)
This PR resolves the issue with updating the profile picture in the profile settings.

**Cause of issue**
The issue can be reproduced with the old `ProfileAvatar.vue` component.
While the exact reason is unclear, it seems related to cases where the
file might be `null`.

**Solution**
Replaced the old `ProfileAvatar.vue` with `Avatar.vue` and tested it. It
works fine. I’ve attached a loom video below.

Fixes https://linear.app/chatwoot/issue/CW-3768/profile-picture-bug

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-04 15:02:29 -08:00
bf58a18af4 fix: Update contact details page errors (#10536)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-04 13:58:53 -08:00
Shivam MishraandGitHub 9b6830a610 fix: Update UI issues with sidebar (#10535)
This PR fixes a few UI issues with the sidebar

1. `z-index` issues with sidebar dropdowns
2. Move the event listener to the root of the dropdown container, it
allows more consistent behaviour of the trigger, earlier the click on
the trigger when the dropdown was open would cause the container to
re-render
3. Use `perserve-open` for the status switcher menu item in the profile
menu.
4. Use `sessionStorage` instead of `localStorage` to preserve sidebar
dropdown info. When opening the dashboard without directly going to a
specific route, any previous known item would get expanded even if it's
link was not active, this caused issues across tabs too, this fixes it.
5. Use `snakeCaseKeys` instead of `decamelize` we had two packages doing
the same thing
6. Update `vueuse` the new version is vue3 only
2024-12-04 13:48:12 -08:00
769b7171f4 feat(v4): Add new contact details screen (#10504)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-03 21:29:47 -08:00
Vishnu NarayananandGitHub d4b6f710bd chore(heroku): Switch heroku stack to the latest stack heroku-24 (#10085)
# Pull Request Template

## Description


- Heroku has marked `heroku-20` stack as EOL by April 2025
- https://help.heroku.com/NPN275RK/heroku-20-end-of-life-faq 
- https://devcenter.heroku.com/articles/heroku-24-stack

Fixes https://linear.app/chatwoot/issue/CW-3552/upgrade-heroku-stack-to-heroku-24
2024-12-02 18:53:59 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Pranav
50e7ceb19b chore(deps): bump rails-html-sanitizer from 1.6.0 to 1.6.1 (#10528)
Bumps
[rails-html-sanitizer](https://github.com/rails/rails-html-sanitizer)
from 1.6.0 to 1.6.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails-html-sanitizer/releases">rails-html-sanitizer's
releases</a>.</em></p>
<blockquote>
<h2>1.6.1 / 2024-12-02</h2>
<p>This is a performance and security release which addresses several
possible XSS vulnerabilities.</p>
<ul>
<li>
<p>The dependency on Nokogiri is updated to v1.15.7 or &gt;=1.16.8.</p>
<p>This change addresses CVE-2024-53985 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-w8gc-x259-rc7x">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-w8gc-x259-rc7x</a>).</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Disallowed tags will be pruned when they appear in foreign content
(i.e. SVG or MathML content),
regardless of the <code>prune:</code> option value. Previously,
disallowed tags were &quot;stripped&quot; unless the
gem was configured with the <code>prune: true</code> option.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53986 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-638j-pmjw-jq48">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-638j-pmjw-jq48</a>)</li>
<li>CVE-2024-53987 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-2x5m-9ch4-qgrr">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-2x5m-9ch4-qgrr</a>)</li>
</ul>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>The tags &quot;noscript&quot;, &quot;mglyph&quot;, and
&quot;malignmark&quot; will not be allowed, even if explicitly added to
the allowlist. If applications try to allow any of these tags, a warning
is emitted and the tags
are removed from the allow-list.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53988 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-cfjx-w229-hgx5">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-cfjx-w229-hgx5</a>)</li>
<li>CVE-2024-53989 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-rxv5-gxqc-xx8g">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-rxv5-gxqc-xx8g</a>)</li>
</ul>
<p>Please note that we <em>may</em> restore support for allowing
&quot;noscript&quot; in a future release. We do not
expect to ever allow &quot;mglyph&quot; or &quot;malignmark&quot;,
though, especially since browser support is minimal
for these tags.</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Improve performance by eliminating needless operations on attributes
that are being removed. <a
href="https://redirect.github.com/rails/rails-html-sanitizer/issues/188">#188</a></p>
<p><em>Mike Dalessio</em></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails-html-sanitizer/blob/main/CHANGELOG.md">rails-html-sanitizer's
changelog</a>.</em></p>
<blockquote>
<h2>1.6.1 / 2024-12-02</h2>
<p>This is a performance and security release which addresses several
possible XSS vulnerabilities.</p>
<ul>
<li>
<p>The dependency on Nokogiri is updated to v1.15.7 or &gt;=1.16.8.</p>
<p>This change addresses CVE-2024-53985 (GHSA-w8gc-x259-rc7x).</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Disallowed tags will be pruned when they appear in foreign content
(i.e. SVG or MathML content),
regardless of the <code>prune:</code> option value. Previously,
disallowed tags were &quot;stripped&quot; unless the
gem was configured with the <code>prune: true</code> option.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53986 (GHSA-638j-pmjw-jq48)</li>
<li>CVE-2024-53987 (GHSA-2x5m-9ch4-qgrr)</li>
</ul>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>The tags &quot;noscript&quot;, &quot;mglyph&quot;, and
&quot;malignmark&quot; will not be allowed, even if explicitly added to
the allowlist. If applications try to allow any of these tags, a warning
is emitted and the tags
are removed from the allow-list.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53988 (GHSA-cfjx-w229-hgx5)</li>
<li>CVE-2024-53989 (GHSA-rxv5-gxqc-xx8g)</li>
</ul>
<p>Please note that we <em>may</em> restore support for allowing
&quot;noscript&quot; in a future release. We do not
expect to ever allow &quot;mglyph&quot; or &quot;malignmark&quot;,
though, especially since browser support is minimal
for these tags.</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Improve performance by eliminating needless operations on attributes
that are being removed. <a
href="https://redirect.github.com/rails/rails-html-sanitizer/issues/188">#188</a></p>
<p><em>Mike Dalessio</em></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/5e96b19bbb934284e675109851bd82429622bb6e"><code>5e96b19</code></a>
version bump to v1.6.1</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/383cc7c17f5a6aafed79adff1c419da31a414878"><code>383cc7c</code></a>
doc: update CHANGELOG with assigned CVEs</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/a7b0cfe103ff5d8b17d77fb3660589522dd6bcad"><code>a7b0cfe</code></a>
Combine the noscript/mglyph prevention blocks</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/5658335ede931a47d02b8aa4d3b5800bd3184158"><code>5658335</code></a>
Merge branch 'h1-2509647-noscript' into
flavorjones-2024-security-fixes</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/65fb72f07e875b401bbec479c2a5a3d3b10ad338"><code>65fb72f</code></a>
Merge branch 'h1-2519936-mglyph-foster-parenting' into
flavorjones-2024-secur...</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/3fe22a8b892361c8463f50f4f4a5d08e299c62a2"><code>3fe22a8</code></a>
Merge branch 'h1-2519936-foreign-ns-confusion' into
flavorjones-2024-security...</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/d7a94c125209a9611308fbfdb7420a8786d7e80f"><code>d7a94c1</code></a>
Merge branch 'h1-2503220-nokogiri-serialization' into
flavorjones-2024-securi...</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/3fd6e650f991ce687961da45038b77504114d237"><code>3fd6e65</code></a>
doc: update CHANGELOG</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/16251735e36ebdc302e2f90f2a39cad56879414f"><code>1625173</code></a>
fix: disallow 'noscript' from safe lists</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/a0a3e8b76b696446ffc6bffcff3bc7b7c6393c72"><code>a0a3e8b</code></a>
fix: disallow 'mglyph' and 'malignmark' from safe lists</li>
<li>Additional commits viewable in <a
href="https://github.com/rails/rails-html-sanitizer/compare/v1.6.0...v1.6.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rails-html-sanitizer&package-manager=bundler&previous-version=1.6.0&new-version=1.6.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-12-02 18:23:58 -08:00
Sivin VargheseandGitHub 062587487a feat: Improve Contact list (#10522) 2024-12-02 18:23:41 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
86bd339a47 chore(deps): bump vue-i18n from 9.14.0 to 9.14.2 (#10526)
Bumps
[vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n)
from 9.14.0 to 9.14.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/intlify/vue-i18n/releases">vue-i18n's
releases</a>.</em></p>
<blockquote>
<h2>v9.14.2</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>🔒 Security Fixes</h3>
<ul>
<li>fix: XSS vulnerability with prototype pollution on AST: <a
href="https://github.com/intlify/vue-i18n/security/advisories/GHSA-9r9m-ffp6-9x4v">https://github.com/intlify/vue-i18n/security/advisories/GHSA-9r9m-ffp6-9x4v</a></li>
<li>fix: prototype pollusion on deepCopy: <a
href="https://github.com/intlify/vue-i18n/security/advisories/GHSA-hjwq-mjwj-4x6c">https://github.com/intlify/vue-i18n/security/advisories/GHSA-hjwq-mjwj-4x6c</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/intlify/vue-i18n/compare/v9.14.1...v9.14.2">https://github.com/intlify/vue-i18n/compare/v9.14.1...v9.14.2</a></p>
<h2>v9.14.1</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>fix: messages <code>deepCopy</code> mutates <code>src</code>
arguments by <a
href="https://github.com/BobbieGoede"><code>@​BobbieGoede</code></a> in
<a
href="https://redirect.github.com/intlify/vue-i18n/pull/1975">intlify/vue-i18n#1975</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/intlify/vue-i18n/compare/v9.14.0...v9.14.1">https://github.com/intlify/vue-i18n/compare/v9.14.0...v9.14.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/intlify/vue-i18n/commit/5448139375d123c90019f372404de6138ff6b588"><code>5448139</code></a>
release: v9.14.2</li>
<li><a
href="https://github.com/intlify/vue-i18n/commit/af67265181fa776dab006afbab85de6a47a34d66"><code>af67265</code></a>
release: v9.14.1</li>
<li>See full diff in <a
href="https://github.com/intlify/vue-i18n/commits/v9.14.2/packages/vue-i18n">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vue-i18n&package-manager=npm_and_yarn&previous-version=9.14.0&new-version=9.14.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-02 18:16:33 -08:00
Sivin VargheseandGitHub 0ab7accd3f feat: Contact filter preview (#10516)
# Pull Request Template

## Description

**Screenshots**
<img width="986" alt="image"
src="https://github.com/user-attachments/assets/8df44237-ec51-45d3-aed3-518cded42f5d">

<img width="986" alt="image"
src="https://github.com/user-attachments/assets/2213ce2e-2461-41f0-a05a-0f955a4d7e3a">

**Story**
<img width="992" alt="image"
src="https://github.com/user-attachments/assets/f8e25fe2-11e8-4b9b-8d0b-357f9b7b6e39">
2024-12-02 16:22:05 +05:30
Vishnu NarayananandGitHub e95680e800 feat: remove stale ONLINE_PRESENCE contact keys in redis (#9558)
50% of Redis memory size comes from ONLINE_PRESENCE keys. This PR adds a
periodic job to remove stale keys from all accounts.
2024-12-02 15:47:54 +05:30
Sivin VargheseandGitHub 1c12fbceb9 feat: New contacts advanced filter (#10514) 2024-11-29 10:55:15 +05:30
a50e4f1748 feat(v4): Update the design for the contacts list page (#10501)
---------
Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-27 20:07:20 -08:00
25c61aba25 feat(v4): Add new conversation filters component (#10502)
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-27 20:05:54 -08:00
1781 changed files with 89656 additions and 31372 deletions
+10 -2
View File
@@ -26,6 +26,12 @@ jobs:
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Add PostgreSQL repository and update
command: |
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update -y
- run:
name: Install System Dependencies
@@ -34,7 +40,9 @@ jobs:
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
redis-server \
postgresql \
postgresql-common \
postgresql-16 \
postgresql-16-pgvector \
build-essential \
git \
curl \
@@ -74,7 +82,7 @@ jobs:
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler
gem install bundler -v 2.5.16
- run:
name: Install Application Dependencies
+1
View File
@@ -51,6 +51,7 @@ exclude_patterns:
- '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'
+1 -1
View File
@@ -40,7 +40,7 @@ services:
network_mode: service:db
db:
image: postgres:latest
image: pgvector/pgvector:pg16
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
+7 -3
View File
@@ -12,6 +12,7 @@ on:
- master
tags:
- v*
# pull_request:
workflow_dispatch:
jobs:
@@ -40,7 +41,9 @@ jobs:
- name: set docker tag
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
# Replace forward slashes with hyphens in the ref name
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV
- name: replace docker tag if master
if: github.ref_name == 'master'
@@ -48,6 +51,7 @@ jobs:
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -58,6 +62,6 @@ jobs:
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
push: true
platforms: linux/amd64, linux/arm64
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
tags: ${{ env.DOCKER_TAG }}
+1 -7
View File
@@ -1,9 +1,3 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
@@ -18,7 +12,7 @@ jobs:
runs-on: ubuntu-20.04
services:
postgres:
image: postgres:15.3
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
@@ -1,89 +0,0 @@
# #
# # This workflow will run specs related to response bot
# # This can only be activated in installations Where vector extension is available.
# #
name: Run Response Bot spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-20.04
services:
postgres:
image: ankane/pgvector
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: pnpm
run: pnpm install
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Enable ResponseBotService in installation
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
# Run Response Bot specs
- name: Run backend tests
run: |
bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \
--profile=10 \
--format documentation
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
+2
View File
@@ -175,6 +175,8 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'ruby-openai'
### Gems required only in specific deployment environments ###
##############################################################
+68 -62
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
actioncable (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailbox (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.5)
actionpack (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailer (7.0.8.7)
actionpack (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.5)
actionview (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionpack (7.0.8.7)
actionview (= 7.0.8.7)
activesupport (= 7.0.8.7)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.5)
actionpack (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actiontext (7.0.8.7)
actionpack (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.5)
activesupport (= 7.0.8.5)
actionview (7.0.8.7)
activesupport (= 7.0.8.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.5)
activesupport (= 7.0.8.5)
activejob (7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.3.6)
activemodel (7.0.8.5)
activesupport (= 7.0.8.5)
activerecord (7.0.8.5)
activemodel (= 7.0.8.5)
activesupport (= 7.0.8.5)
activemodel (7.0.8.7)
activesupport (= 7.0.8.7)
activerecord (7.0.8.7)
activemodel (= 7.0.8.7)
activesupport (= 7.0.8.7)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activesupport (= 7.0.8.5)
activestorage (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activesupport (= 7.0.8.7)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.5)
activesupport (7.0.8.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -231,6 +231,7 @@ GEM
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
execjs (2.8.1)
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
@@ -452,7 +453,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.22.0)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -471,8 +472,8 @@ GEM
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.25.1)
mini_portile2 (2.8.8)
minitest (5.25.4)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -502,14 +503,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.7)
nokogiri (1.17.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin)
nokogiri (1.17.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin)
nokogiri (1.17.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
nokogiri (1.17.1-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -581,30 +582,30 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.5)
actioncable (= 7.0.8.5)
actionmailbox (= 7.0.8.5)
actionmailer (= 7.0.8.5)
actionpack (= 7.0.8.5)
actiontext (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activemodel (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
rails (7.0.8.7)
actioncable (= 7.0.8.7)
actionmailbox (= 7.0.8.7)
actionmailer (= 7.0.8.7)
actionpack (= 7.0.8.7)
actiontext (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activemodel (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
bundler (>= 1.15.0)
railties (= 7.0.8.5)
railties (= 7.0.8.7)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -684,6 +685,10 @@ GEM
rubocop-rspec (2.21.0)
rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
@@ -941,6 +946,7 @@ DEPENDENCIES
rubocop-performance
rubocop-rails
rubocop-rspec
ruby-openai
scout_apm
scss_lint
seed_dump
+4 -4
View File
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
## Reporting a Vulnerability
We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, contact security@chatwoot.com.
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible.
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible.
> Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new)
## Supported versions
| Version | Supported |
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
- Brute force attacks
- DNSSEC
If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new).
## Thanks
+1 -1
View File
@@ -1 +1 @@
3.0.0
3.1.0
+2 -2
View File
@@ -48,7 +48,7 @@
"size": "basic"
}
},
"stack": "heroku-20",
"stack": "heroku-24",
"image": "heroku/ruby",
"addons": [
{
@@ -58,7 +58,7 @@
"plan": "heroku-postgresql:essential-0"
}
],
"stack": "heroku-20",
"stack": "heroku-24",
"buildpacks": [
{
"url": "heroku/nodejs"
+1 -1
View File
@@ -54,7 +54,7 @@ class ContactMergeAction
# attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
@mergee_contact.destroy!
@mergee_contact.reload.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
+2
View File
@@ -25,6 +25,8 @@ class NotificationBuilder
def build_notification
# Create conversation_creation notification only if user is subscribed to it
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
user.notifications.create!(
notification_type: notification_type,
@@ -2,52 +2,38 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
load_data
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
end
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
def build_agent_stats(account_user)
user_id = account_user.user_id
{
id: user_id,
conversations_count: conversations_count[user_id] || 0,
resolved_conversations_count: resolved_count[user_id] || 0,
avg_resolution_time: avg_resolution_time[user_id],
avg_first_response_time: avg_first_response_time[user_id],
avg_reply_time: avg_reply_time[user_id]
}
end
def group_by_key
:user_id
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
arr << {
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -1,17 +1,50 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
def build
load_data
prepare_report
end
private
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
# Override this method
end
def prepare_report
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
params[:business_hours].present? ? :value_in_business_hours : :value
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,50 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
end
end
def build_inbox_stats(inbox)
{
id: inbox.id,
conversations_count: conversations_count[inbox.id] || 0,
resolved_conversations_count: resolved_count[inbox.id] || 0,
avg_resolution_time: avg_resolution_time[inbox.id],
avg_first_response_time: avg_first_response_time[inbox.id],
avg_reply_time: avg_reply_time[inbox.id]
}
end
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
+22 -34
View File
@@ -1,49 +1,37 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
end
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
end
end
def build_team_stats(team)
{
id: team.id,
conversations_count: conversations_count[team.id] || 0,
resolved_conversations_count: resolved_count[team.id] || 0,
avg_resolution_time: avg_resolution_time[team.id],
avg_first_response_time: avg_first_response_time[team.id],
avg_reply_time: avg_reply_time[team.id]
}
end
def group_by_key
'conversations.team_id'
end
def prepare_report
account.teams.each_with_object([]) do |team, arr|
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
}
end
end
end
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@@ -68,6 +68,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
@@ -46,6 +46,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
@@ -1,53 +0,0 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :hook
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
"api/accounts/#{hook.settings['account_id']}#{request_route}"
end
def request_url
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def with_leading_hash_on_route(request_route)
return '' if request_route.blank?
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def permitted_params
params.permit(:method, :route, body: {})
end
end
@@ -17,7 +17,7 @@ class Api::V1::ProfilesController < Api::BaseController
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
head :ok
@user.reload
end
def auto_offline
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team]
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -10,6 +10,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::TeamSummaryBuilder)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
end
private
def check_authorization
@@ -26,8 +30,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
data = builder.build
render json: data
render json: builder.build
end
def permitted_params
@@ -7,9 +7,10 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles.published
@articles_count = @articles.count
search_articles
order_by_sort_param
@articles.page(list_params[:page]) if list_params[:page].present?
@articles = @articles.page(list_params[:page]) if list_params[:page].present?
end
def show; end
@@ -44,7 +45,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort, :status)
params.permit(:query, :locale, :sort, :status, :page)
end
def permitted_params
+2
View File
@@ -22,3 +22,5 @@ class AsyncDispatcher < BaseDispatcher
]
end
end
AsyncDispatcher.prepend_mod_with('AsyncDispatcher')
@@ -2,6 +2,6 @@ require 'administrate/field/base'
class Enterprise::AccountLimitsField < Administrate::Field::Base
def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
end
end
+8
View File
@@ -81,4 +81,12 @@ module FilterHelper
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
end
@@ -0,0 +1,19 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
},
});
}
}
export default new CaptainAssistant();
@@ -0,0 +1,20 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainDocument extends ApiClient {
constructor() {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
},
});
}
}
export default new CaptainDocument();
@@ -0,0 +1,26 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainInboxes extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId } = {}) {
return axios.get(`${this.url}/${assistantId}/inboxes`);
}
create(params = {}) {
const { assistantId, inboxId } = params;
return axios.post(`${this.url}/${assistantId}/inboxes`, {
inbox: { inbox_id: inboxId },
});
}
delete(params = {}) {
const { assistantId, inboxId } = params;
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
}
}
export default new CaptainInboxes();
@@ -0,0 +1,22 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainResponses extends ApiClient {
constructor() {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
document_id: documentId,
status,
},
});
}
}
export default new CaptainResponses();
+8
View File
@@ -27,6 +27,14 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
show(id) {
return axios.get(`${this.url}/${id}?include_contact_inboxes=false`);
}
update(id, data) {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`);
}
@@ -133,6 +133,10 @@ class ConversationApi extends ApiClient {
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
}
export default new ConversationApi();
@@ -32,10 +32,6 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
}
export default new IntegrationsAPI();
@@ -72,6 +72,19 @@
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
@@ -111,6 +124,19 @@
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
@@ -122,6 +148,7 @@
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
@@ -129,10 +156,10 @@
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.1;
--white-alpha: 255, 255, 255, 0.8;
}
body.dark {
.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
@@ -147,6 +174,19 @@
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
@@ -186,6 +226,19 @@
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
@@ -195,6 +248,7 @@
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
@@ -1,7 +1,7 @@
.dropdown-pane {
@apply border rounded-lg hidden relative invisible shadow-lg border-slate-25 dark:border-slate-700 box-content p-2 w-fit z-[9999];
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999];
&.dropdown-pane--open {
@apply bg-white absolute dark:bg-slate-800 block visible;
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible;
}
}
@@ -31,7 +31,7 @@ button {
}
.button {
@apply items-center bg-woot-500 dark:bg-woot-500 px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
.button__content {
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
@@ -42,8 +42,10 @@ button {
}
}
&:hover {
@apply bg-woot-600 dark:bg-woot-600;
&:hover:not(.secondary):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
}
&:disabled,
@@ -52,23 +54,23 @@ button {
}
&.success {
@apply bg-[#44ce4b] dark:bg-[#44ce4b] text-white dark:text-white;
@apply bg-n-teal-9 text-white dark:text-white;
}
&.secondary {
@apply bg-slate-700 dark:bg-slate-600 text-white dark:text-white;
@apply bg-n-solid-3 text-white dark:text-white;
}
&.primary {
@apply bg-woot-500 dark:bg-woot-500 text-white dark:text-white;
@apply bg-n-brand text-white dark:text-white;
}
&.clear {
@apply text-woot-500 dark:text-woot-500 bg-transparent dark:bg-transparent;
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
}
&.alert {
@apply bg-red-500 dark:bg-red-500 text-white dark:text-white;
@apply bg-n-ruby-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
@@ -76,7 +78,7 @@ button {
}
&.warning {
@apply bg-[#ffc532] dark:bg-[#ffc532] text-white dark:text-white;
@apply bg-n-amber-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
@@ -115,114 +117,74 @@ button {
}
&.hollow {
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20;
&.secondary {
@apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
@apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5;
}
&.success {
@apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
@apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5;
}
&.alert {
@apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
@apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5;
}
&.warning {
@apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
}
&:hover {
@apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
&.secondary {
@apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100;
}
&.success {
@apply border-slate-100 dark:border-slate-700 text-green-800 dark:text-green-100;
}
&.alert {
@apply border-slate-100 dark:border-slate-700 text-red-700 dark:text-red-100;
}
&.warning {
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500;
}
@apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5;
}
}
// Smooth style
&.smooth {
@apply bg-woot-50 dark:bg-woot-800 text-woot-700 dark:text-woot-100 hover:text-woot-700 dark:hover:text-woot-700 hover:bg-woot-100 dark:hover:bg-woot-900;
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40;
&.secondary {
@apply bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800;
@apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5;
}
&.success {
@apply bg-green-50 dark:bg-green-700 text-green-700 dark:text-green-100 hover:bg-green-100 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
@apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5;
}
&.alert {
@apply bg-red-50 dark:bg-red-700 dark:bg-opacity-50 text-red-700 dark:text-red-100 hover:bg-red-100 dark:hover:bg-red-800 dark:hover:bg-opacity-30;
@apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5;
}
&.warning {
@apply bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-200;
@apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5;
}
}
&.clear {
@apply text-woot-500 dark:text-woot-500;
@apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30;
&.secondary {
@apply text-slate-700 dark:text-slate-100;
@apply text-n-slate-12 hover:bg-n-slate-4;
}
&.success {
@apply text-green-700 dark:text-green-100;
@apply text-n-teal-10 hover:bg-n-teal-4;
}
&.alert {
@apply text-red-700 dark:text-red-100;
@apply text-n-ruby-11 hover:bg-n-ruby-4;
}
&.warning {
@apply text-yellow-700 dark:text-yellow-600;
}
&:hover {
@apply hover:bg-woot-50 dark:hover:bg-woot-900/50 hover:text-woot-500 dark:hover:text-woot-100;
&.secondary {
@apply hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-slate-800 dark:hover:text-slate-100;
}
&.success {
@apply hover:bg-green-50 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
}
&.alert {
@apply hover:bg-red-50 dark:hover:bg-red-800 hover:text-red-700 dark:hover:text-red-100;
}
&.warning {
@apply hover:bg-yellow-100 dark:hover:bg-yellow-800 hover:text-yellow-700 dark:hover:text-yellow-600;
}
@apply text-n-amber-11 hover:bg-n-amber-4;
}
&:active {
&.secondary {
@apply active:bg-slate-100 dark:active:bg-slate-900;
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
}
}
&:focus {
&.secondary {
@apply focus:bg-slate-50 dark:focus:bg-slate-700;
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
}
}
}
@@ -3,7 +3,15 @@
}
.tabs--container--with-border {
@apply border-b border-slate-50 dark:border-slate-800/50;
@apply border-b border-n-weak;
}
.tabs--container--compact.tab--chat-type {
.tabs-title {
a {
@apply py-2 text-sm;
}
}
}
.tabs {
@@ -34,7 +42,7 @@
@apply flex-shrink-0 my-0 mx-2;
.badge {
@apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
}
&:first-child {
@@ -48,22 +56,22 @@
&:hover,
&:focus {
a {
@apply text-slate-800 dark:text-slate-100;
@apply text-n-slate-12;
}
}
a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {
a {
@apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
@apply border-b border-n-brand text-n-blue-text;
}
.badge {
@apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text;
}
}
}
@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
</span>
</div>
<div
v-dompurify-html="formatMessage(message)"
v-dompurify-html="formatMessage(message, false, false, false)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
@@ -0,0 +1,136 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
const props = defineProps({
contactId: {
type: [String, Number],
default: null,
},
});
const store = useStore();
const route = useRoute();
const showDropdown = ref(false);
// Store the currently hovered label's ID
// Using JS state management instead of CSS :hover / group hover
// This will solve the flickering issue when hovering over the last label item
const hoveredLabel = ref(null);
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
const savedLabels = computed(() => {
const availableContactLabels = contactLabels.value(props.contactId);
return allLabels.value.filter(({ title }) =>
availableContactLabels.includes(title)
);
});
const labelMenuItems = computed(() => {
return allLabels.value
?.map(label => ({
label: label.title,
value: label.id,
thumbnail: { name: label.title, color: label.color },
isSelected: savedLabels.value.some(
savedLabel => savedLabel.id === label.id
),
action: 'contactLabel',
}))
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
const fetchLabels = async contactId => {
if (!contactId) {
return;
}
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ value }) => {
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
// Find the label title for the ID (value)
const selectedLabel = allLabels.value.find(label => label.id === value);
if (!selectedLabel) return;
let updatedLabels;
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
}
await store.dispatch('contactLabels/update', {
contactId: props.contactId,
labels: updatedLabels,
});
showDropdown.value = false;
} catch (error) {
// error
}
};
const handleRemoveLabel = labelId => {
return handleLabelAction({ value: labelId });
};
watch(
() => props.contactId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchLabels(newVal);
}
}
);
onMounted(() => {
if (route.params.contactId) {
fetchLabels(route.params.contactId);
}
});
const handleMouseLeave = () => {
// Reset hover state when mouse leaves the container
// This ensures all labels return to their default state
hoveredLabel.value = null;
};
const handleLabelHover = labelId => {
// Added this to prevent flickering on when showing remove button on hover
// If the label item is at end of the line, it will show the remove button
// when hovering over the last label item
hoveredLabel.value = labelId;
};
</script>
<template>
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
<LabelItem
v-for="label in savedLabels"
:key="label.id"
:label="label"
:is-hovered="hoveredLabel === label.id"
@remove="handleRemoveLabel"
@hover="handleLabelHover(label.id)"
/>
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="handleLabelAction"
/>
</div>
</template>
@@ -1,10 +1,13 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
id: { type: Number, required: true },
@@ -14,46 +17,118 @@ const props = defineProps({
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const { t } = useI18n();
const contactsFormRef = ref(null);
const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
const contactData = ref(getInitialContactData());
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const countryDetails = computed(() => {
const attributes = props.additionalAttributes || {};
const { country, countryCode, city } = attributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
const handleFormUpdate = updatedData => {
emit('updateContact', { id: props.id, updatedData });
Object.assign(contactData.value, updatedData);
};
const onClickViewDetails = async () => {
emit('showContact', props.id);
const handleUpdateContact = () => {
emit('updateContact', contactData.value);
};
const onClickExpand = () => {
emit('toggle');
contactData.value = getInitialContactData();
};
const onClickViewDetails = () => emit('showContact', props.id);
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate text-n-slate-12">
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<template v-if="additionalAttributes?.companyName">
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.CARD.OF') }}
</span>
<span class="text-sm font-medium truncate text-n-slate-12">
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</template>
</span>
</div>
<div class="flex items-center gap-3">
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span>
<div v-if="email" class="w-px h-3 bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm text-n-slate-11">
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
@@ -70,7 +145,7 @@ const onClickViewDetails = async () => {
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="emit('toggle')"
@click="onClickExpand"
/>
<template #after>
@@ -78,22 +153,28 @@ const onClickViewDetails = async () => {
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[360px]"
leave-from-class="opacity-100 max-h-[360px]"
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
>
<div v-show="isExpanded" class="w-full">
<div class="p-6 border-t border-n-strong">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
:contact-data="{
id,
name,
email,
phoneNumber,
additionalAttributes,
}"
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
</div>
</transition>
@@ -0,0 +1,89 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
buttonLabel: {
type: String,
default: '',
},
selectedContact: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
return props.selectedContact?.name;
});
const breadcrumbItems = computed(() => {
const items = [
{
label: t('CONTACTS_LAYOUT.HEADER.BREADCRUMB.CONTACTS'),
link: '#',
},
];
if (props.selectedContact) {
items.push({
label: selectedContactName.value,
});
}
return items;
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full mx-auto max-w-[650px]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div class="w-full py-4 mx-auto max-w-[650px]">
<slot name="default" />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
</section>
</template>
@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const deleteContact = async id => {
if (!id) return;
try {
await store.dispatch('contacts/delete', id);
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.ERROR_MESSAGE'));
}
};
const handleDialogConfirm = async () => {
emit('goToContactsList');
await deleteContact(route.params.contactId || props.selectedContact.id);
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['export']);
const { t } = useI18n();
const route = useRoute();
const dialogRef = ref(null);
const segments = useMapGetter('customViews/getContactCustomViews');
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
const uiFlags = useMapGetter('contacts/getUIFlags');
const isExportingContact = computed(() => uiFlags.value.isExporting);
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegment = computed(() =>
activeSegmentId.value
? segments.value.find(view => view.id === Number(activeSegmentId.value))
: undefined
);
const exportContacts = async () => {
let query = { payload: [] };
if (activeSegmentId.value && activeSegment.value) {
query = activeSegment.value.query;
} else if (Object.keys(appliedFilters.value).length > 0) {
query = filterQueryGenerator(appliedFilters.value);
}
emit('export', {
...query,
label: route.params.label || '',
});
};
const handleDialogConfirm = async () => {
await exportContacts();
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.CONFIRM')
"
:is-loading="isExportingContact"
:disable-confirm-button="isExportingContact"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,134 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['import']);
const { t } = useI18n();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isImportingContact = computed(() => uiFlags.value.isImporting);
const dialogRef = ref(null);
const fileInput = ref(null);
const hasSelectedFile = ref(null);
const selectedFileName = ref('');
const csvUrl = '/downloads/import-contacts-sample.csv';
const handleFileClick = () => fileInput.value?.click();
const processFileName = fileName => {
const lastDotIndex = fileName.lastIndexOf('.');
const extension = fileName.slice(lastDotIndex);
const baseName = fileName.slice(0, lastDotIndex);
return baseName.length > 20
? `${baseName.slice(0, 20)}...${extension}`
: fileName;
};
const handleFileChange = () => {
const file = fileInput.value?.files[0];
hasSelectedFile.value = file;
selectedFileName.value = file ? processFileName(file.name) : '';
};
const handleRemoveFile = () => {
hasSelectedFile.value = null;
if (fileInput.value) {
fileInput.value.value = null;
}
selectedFileName.value = '';
};
const uploadFile = async () => {
if (!hasSelectedFile.value) return;
emit('import', hasSelectedFile.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.IMPORT')
"
:is-loading="isImportingContact"
:disable-confirm-button="isImportingContact"
@confirm="uploadFile"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DESCRIPTION') }}
<a
:href="csvUrl"
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-text"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
}}
</a>
</p>
</template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm text-n-slate-12 whitespace-nowrap">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
</label>
<div class="flex items-center justify-between w-full gap-2">
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
{{ selectedFileName }}
</span>
<Button
v-if="!hasSelectedFile"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
"
icon="i-lucide-upload"
color="slate"
variant="ghost"
size="sm"
class="!w-fit"
@click="handleFileClick"
/>
<div v-else class="flex items-center gap-1">
<Button
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')"
color="slate"
variant="ghost"
size="sm"
@click="handleFileClick"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
color="slate"
variant="ghost"
size="sm"
@click="handleRemoveFile"
/>
</div>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
</Dialog>
</template>
@@ -98,7 +98,7 @@ const { t } = useI18n();
:size="32"
rounded-full
/>
<div class="flex flex-col gap-1">
<div class="flex flex-col w-full min-w-0 gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>
@@ -3,7 +3,7 @@ import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
@@ -35,17 +35,17 @@ const FORM_CONFIG = {
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.country' },
COUNTRY: { field: 'additionalAttributes.countryCode' },
BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
};
const SOCIAL_CONFIG = {
FACEBOOK: 'i-ri-facebook-circle-fill',
GITHUB: 'i-ri-github-fill',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
const defaultState = {
@@ -80,6 +80,8 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
return; // Added to prevent state update for new contact form
@@ -92,14 +94,13 @@ const prepareStateBasedOnProps = () => {
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const [firstName = '', lastName = ''] = name.split(' ');
const { firstName, lastName } = splitName(name || '');
const {
description,
companyName,
countryCode,
country,
city,
description = '',
companyName = '',
countryCode = '',
country = '',
city = '',
socialProfiles = {},
} = additionalAttributes || {};
@@ -122,7 +123,7 @@ const prepareStateBasedOnProps = () => {
};
const countryOptions = computed(() =>
countries.map(({ name }) => ({ label: name, value: name }))
countries.map(({ name, id }) => ({ label: name, value: id }))
);
const editDetailsForm = computed(() =>
@@ -203,6 +204,20 @@ const getMessageType = key => {
: 'info';
};
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.country = selectedCountry?.name || '';
emit('update', state);
};
const resetValidation = () => {
v$.value.$reset();
};
const resetForm = () => {
Object.assign(state, defaultState);
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
@@ -211,6 +226,9 @@ watch(() => props.contactData, prepareStateBasedOnProps, {
// Expose state to parent component for avatar upload
defineExpose({
state,
resetValidation,
isFormInvalid,
resetForm,
});
</script>
@@ -220,11 +238,11 @@ defineExpose({
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-2 gap-4">
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.country"
v-model="state.additionalAttributes.countryCode"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
@@ -234,7 +252,7 @@ defineExpose({
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
}"
@update:model-value="emit('update', state)"
@update:model-value="handleCountrySelection"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
@@ -248,7 +266,7 @@ defineExpose({
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error)]:!border-transparent' : ''
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
}`"
class="w-full"
@input="
@@ -0,0 +1,68 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const contactsFormRef = ref(null);
const contact = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
const isCreatingContact = computed(() => uiFlags.value.isCreating);
const createNewContact = contactItem => {
contact.value = contactItem;
};
const handleDialogConfirm = async () => {
if (!contact.value) return;
emit('create', contact.value);
};
const onSuccess = () => {
contactsFormRef.value?.resetForm();
dialogRef.value.close();
};
const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef, contactsFormRef, onSuccess });
</script>
<template>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm
ref="contactsFormRef"
is-new-contact
@update="createNewContact"
/>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
"
color="blue"
:is-loading="isCreatingContact"
@click="handleDialogConfirm"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,71 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['create']);
const FILTER_TYPE_CONTACT = 1;
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isCreating = computed(() => uiFlags.value.isCreating);
const dialogRef = ref(null);
const state = reactive({
name: '',
});
const validationRules = {
name: { required },
};
const v$ = useVuelidate(validationRules, state);
const handleDialogConfirm = async () => {
const isNameValid = await v$.value.$validate();
if (!isNameValid) return;
emit('create', {
name: state.name,
filter_type: FILTER_TYPE_CONTACT,
});
state.name = '';
v$.value.$reset();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.CONFIRM')
"
:is-loading="isCreating"
:disable-confirm-button="isCreating"
@confirm="handleDialogConfirm"
>
<Input
v-model="state.name"
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.LABEL')"
:placeholder="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.PLACEHOLDER')
"
:message="
v$.name.$error
? t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR')
: ''
"
:message-type="v$.name.$error ? 'error' : 'info'"
/>
</Dialog>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['delete']);
const FILTER_TYPE_CONTACT = 'contact';
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isDeleting = computed(() => uiFlags.value.isDeleting);
const dialogRef = ref(null);
const handleDialogConfirm = async () => {
emit('delete', {
filterType: FILTER_TYPE_CONTACT,
});
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.CONFIRM')
"
:is-loading="isDeleting"
:disable-confirm-button="isDeleting"
@confirm="handleDialogConfirm"
/>
</template>
@@ -4,6 +4,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
@@ -20,7 +21,7 @@ defineProps({
},
buttonLabel: {
type: String,
required: true,
default: '',
},
activeSort: {
type: String,
@@ -30,23 +31,36 @@ defineProps({
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'search',
'filter',
'update:sort',
'message',
'add',
'import',
'export',
'createSegment',
'deleteSegment',
]);
</script>
<template>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[960px]"
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
@@ -71,12 +85,40 @@ const emit = defineEmits([
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
"
color="slate"
size="sm"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<slot name="filter" />
</div>
<Button
icon="i-lucide-list-filter"
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
icon="i-lucide-save"
color="slate"
size="sm"
variant="ghost"
@click="emit('filter')"
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView"
icon="i-lucide-trash"
color="slate"
size="sm"
variant="ghost"
@click="emit('deleteSegment')"
/>
<ContactSortMenu
:active-sort="activeSort"
@@ -90,7 +132,11 @@ const emit = defineEmits([
/>
</div>
<div class="w-px h-4 bg-n-strong" />
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
@@ -0,0 +1,311 @@
<script setup>
import { ref, computed, unref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import countries from 'shared/constants/countries';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue';
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue';
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue';
import ContactsFilter from 'dashboard/components-next/filter/ContactsFilter.vue';
const props = defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
headerTitle: { type: String, default: '' },
segmentsId: { type: [String, Number], default: 0 },
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
});
const emit = defineEmits([
'update:sort',
'search',
'applyFilter',
'clearFilters',
]);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const createNewContactDialogRef = ref(null);
const contactExportDialogRef = ref(null);
const contactImportDialogRef = ref(null);
const createSegmentDialogRef = ref(null);
const deleteSegmentDialogRef = ref(null);
const showFiltersModal = ref(false);
const appliedFilter = ref([]);
const segmentsQuery = ref({});
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const hasActiveSegments = computed(
() => props.activeSegment && props.segmentsId !== 0
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
contactImportDialogRef.value?.dialogRef.open();
const openContactExportDialog = () =>
contactExportDialogRef.value?.dialogRef.open();
const openCreateSegmentDialog = () =>
createSegmentDialogRef.value?.dialogRef.open();
const openDeleteSegmentDialog = () =>
deleteSegmentDialogRef.value?.dialogRef.open();
const onCreate = async contact => {
try {
await store.dispatch('contacts/create', contact);
createNewContactDialogRef.value?.onSuccess();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SUCCESS_MESSAGE')
);
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION';
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS_DUPLICATE`));
} else if (error.data.includes('phone_number')) {
useAlert(t(`${i18nPrefix}.PHONE_NUMBER_DUPLICATE`));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
}
}
};
const onImport = async file => {
try {
await store.dispatch('contacts/import', file);
contactImportDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.SUCCESS_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(
error.message ??
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.ERROR_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
};
const onExport = async query => {
try {
await store.dispatch('contacts/export', query);
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
);
}
};
const onCreateSegment = async payload => {
try {
const payloadData = {
...payload,
query: segmentsQuery.value,
};
const response = await store.dispatch('customViews/create', payloadData);
createSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
);
const segmentId = response?.data?.id;
if (!segmentId) return;
// Navigate to the created segment
router.push({
name: 'contacts_dashboard_segments_index',
params: { segmentId },
query: { page: 1 },
});
} catch {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR_MESSAGE')
);
}
};
const onDeleteSegment = async payload => {
try {
await store.dispatch('customViews/delete', {
id: Number(props.segmentsId),
...payload,
});
router.push({
name: 'contacts_dashboard_index',
query: {
page: 1,
},
});
deleteSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.ERROR_MESSAGE')
);
}
};
const closeAdvanceFiltersModal = () => {
showFiltersModal.value = false;
appliedFilter.value = [];
};
const clearFilters = async () => {
emit('clearFilters');
};
const onApplyFilter = async payload => {
payload = useSnakeCase(payload);
segmentsQuery.value = filterQueryGenerator(payload);
emit('applyFilter', filterQueryGenerator(payload));
showFiltersModal.value = false;
};
const onUpdateSegment = async (payload, segmentName) => {
payload = useSnakeCase(payload);
const payloadData = {
...props.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
await store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
};
const setParamsForEditSegmentModal = () => {
return {
countries,
filterTypes: contactFilterItems,
allCustomAttributes: useSnakeCase(contactAttributes.value),
};
};
const initializeSegmentToFilterModal = segment => {
const query = unref(segment)?.query?.payload;
if (!Array.isArray(query)) return;
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
const values = Array.isArray(transformed.values)
? generateValuesForEditCustomViews(
useSnakeCase(filter),
setParamsForEditSegmentModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
appliedFilter.value = [...appliedFilter.value, ...newFilters];
};
const onToggleFilters = () => {
appliedFilter.value = [];
if (hasActiveSegments.value) {
initializeSegmentToFilterModal(props.activeSegment);
} else {
appliedFilter.value = props.hasAppliedFilters
? [...appliedFilters.value]
: [
{
attributeKey: 'name',
filterOperator: 'equal_to',
values: '',
queryOperator: 'and',
attributeModel: 'standard',
},
];
}
showFiltersModal.value = true;
};
defineExpose({
onToggleFilters,
});
</script>
<template>
<ContactsHeader
:show-search="showSearch"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@add="openCreateNewContactDialog"
@import="openContactImportDialog"
@export="openContactExportDialog"
@filter="onToggleFilters"
@create-segment="openCreateSegmentDialog"
@delete-segment="openDeleteSegmentDialog"
>
<template #filter>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
</template>
</ContactsHeader>
<CreateNewContactDialog ref="createNewContactDialogRef" @create="onCreate" />
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
<DeleteSegmentDialog ref="deleteSegmentDialogRef" @delete="onDeleteSegment" />
</template>
@@ -55,7 +55,7 @@ const handleContactAction = ({ action }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
@@ -109,7 +109,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
const props = defineProps({
activeSegment: { type: Object, default: null },
});
const emit = defineEmits(['clearFilters', 'openFilter']);
const { t } = useI18n();
const route = useRoute();
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegmentQuery = computed(() => {
const query = props.activeSegment?.query?.payload;
if (!Array.isArray(query)) return [];
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values: transformed.values,
};
});
return newFilters;
});
const hasActiveSegments = computed(
() => props.activeSegment && activeSegmentId.value !== 0
);
const activeFilterQueryData = computed(() => {
return hasActiveSegments.value
? activeSegmentQuery.value
: appliedFilters.value;
});
</script>
<template>
<ActiveFilterPreview
:applied-filters="activeFilterQueryData"
:max-visible-filters="2"
:more-filters-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
count: activeFilterQueryData.length - 2,
})
"
:clear-button-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[960px] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
</template>
@@ -0,0 +1,103 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
currentPage: { type: Number, default: 1 },
totalItems: { type: Number, default: 100 },
itemsPerPage: { type: Number, default: 15 },
activeSort: { type: String, default: '' },
activeOrdering: { type: String, default: '' },
activeSegment: { type: Object, default: null },
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
});
const emit = defineEmits([
'update:currentPage',
'update:sort',
'search',
'applyFilter',
'clearFilters',
]);
const route = useRoute();
const contactListHeaderWrapper = ref(null);
const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index'
);
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
ref="contactListHeaderWrapper"
:show-search="isNotSegmentView"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:active-segment="activeSegment"
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
</section>
</template>
@@ -0,0 +1,95 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const handleDelete = async () => {
try {
await store.dispatch('contacts/deleteCustomAttributes', {
id: route.params.contactId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await store.dispatch('contacts/update', {
id: route.params.contactId,
customAttributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(() => {
return (
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
});
</script>
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -0,0 +1,129 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const searchQuery = ref('');
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
const hasContactAttributes = computed(
() => contactAttributes.value?.length > 0
);
const processContactAttributes = (
attributes,
customAttributes,
filterCondition
) => {
if (!attributes.length || !customAttributes) {
return [];
}
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
const meetsCondition = filterCondition(attributeKey, customAttributes);
if (meetsCondition) {
result.push({
...attribute,
value: customAttributes[attributeKey] ?? '',
});
}
return result;
}, []);
};
const usedAttributes = computed(() => {
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => key in custom
);
});
const unusedAttributes = computed(() => {
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => !(key in custom)
);
});
const filteredUnusedAttributes = computed(() => {
return unusedAttributes.value?.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
);
});
const unusedAttributesCount = computed(() => unusedAttributes.value?.length);
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
:key="attribute.id"
is-editing-view
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}</span>
<div class="flex-1 h-[1px] bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="attribute.id"
:attribute="attribute"
/>
</div>
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
</p>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
const { t } = useI18n();
const route = useRoute();
const conversations = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const isFetching = computed(() => uiFlags.value.isFetching);
const contactConversations = computed(() =>
conversations.value(route.params.contactId)
);
</script>
<template>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
</p>
</template>
@@ -0,0 +1,145 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useRoute } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import ContactAPI from 'dashboard/api/contacts';
import { debounce } from '@chatwoot/utils';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
const props = defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const emit = defineEmits(['goToContactsList', 'resetTab']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const state = reactive({
primaryContactId: null,
});
const uiFlags = useMapGetter('contacts/getUIFlags');
const searchResults = ref([]);
const isSearching = ref(false);
const validationRules = {
primaryContactId: { required },
};
const v$ = useVuelidate(validationRules, state);
const isMergingContact = computed(() => uiFlags.value.isMerging);
const primaryContactList = computed(
() =>
searchResults.value?.map(item => ({
value: item.id,
label: `(ID: ${item.id}) ${item.name}`,
})) ?? []
);
const onContactSearch = debounce(
async query => {
isSearching.value = true;
searchResults.value = [];
try {
const {
data: { payload },
} = await ContactAPI.search(query);
searchResults.value = payload.filter(
contact => contact.id !== props.selectedContact.id
);
isSearching.value = false;
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const resetState = () => {
if (state.primaryContactId === null) {
emit('resetTab');
}
state.primaryContactId = null;
searchResults.value = [];
isSearching.value = false;
};
const onMergeContacts = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
childId: props.selectedContact.id || route.params.contactId,
parentId: state.primaryContactId,
});
emit('goToContactsList');
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SUCCESS_MESSAGE'));
resetState();
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.ERROR_MESSAGE'));
}
};
</script>
<template>
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
</p>
</div>
<ContactMergeForm
v-model:primary-contact-id="state.primaryContactId"
:selected-contact="selectedContact"
:primary-contact-list="primaryContactList"
:is-searching="isSearching"
:has-error="!!v$.primaryContactId.$error"
:error-message="
v$.primaryContactId.$error
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
: ''
"
@search="onContactSearch"
/>
<div class="flex items-center justify-between gap-3">
<Button
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isMergingContact"
:disabled="isMergingContact"
@click="onMergeContacts"
/>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactNoteItem from './components/ContactNoteItem.vue';
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const state = reactive({
message: '',
});
const currentUser = useMapGetter('getCurrentUser');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const notes = computed(() => notesByContact.value(route.params.contactId));
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note?.user?.name || 'Bot';
};
const onAdd = content => {
if (!content) return;
const { contactId } = route.params;
store.dispatch('contactNotes/create', { content, contactId });
state.message = '';
};
const onDelete = noteId => {
if (!noteId) return;
const { contactId } = route.params;
store.dispatch('contactNotes/delete', { noteId, contactId });
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(state.message),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6 py-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
focus-on-mount
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
>
<template #actions>
<div class="flex items-center gap-3">
<Button
variant="link"
color="blue"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
class="hover:no-underline"
:is-loading="isCreatingNote"
:disabled="!state.message || isCreatingNote"
@click="onAdd(state.message)"
/>
</div>
</template>
</Editor>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="notes.length > 0">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
:note="note"
:written-by="getWrittenBy(note)"
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
</p>
</div>
</template>
@@ -28,13 +28,13 @@ const handleDelete = () => {
<template>
<div
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar
:name="note.user.name"
:src="note.user.thumbnail"
:name="note?.user?.name || 'Bot'"
:src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
:size="16"
rounded-full
/>
@@ -1,5 +1,8 @@
<script setup>
import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
@@ -22,6 +25,14 @@ defineProps({
default: '',
},
});
const emit = defineEmits(['create']);
const createNewContactDialogRef = ref(null);
const onClick = () => {
createNewContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
@@ -45,6 +56,10 @@ defineProps({
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
<CreateNewContactDialog
ref="createNewContactDialogRef"
@create="emit('create', $event)"
/>
</div>
</template>
</EmptyStateLayout>
@@ -0,0 +1,200 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
const props = defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const confirmDeleteContactDialogRef = ref(null);
const avatarFile = ref(null);
const avatarUrl = ref('');
const contactsFormRef = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const contactData = ref({});
const getInitialContactData = () => {
if (!props.selectedContact) return {};
return { ...props.selectedContact };
};
onMounted(() => {
Object.assign(contactData.value, getInitialContactData());
});
const createdAt = computed(() => {
return contactData.value?.createdAt
? dynamicTime(contactData.value.createdAt)
: '';
});
const lastActivityAt = computed(() => {
return contactData.value?.lastActivityAt
? dynamicTime(contactData.value.lastActivityAt)
: '';
});
const avatarSrc = computed(() => {
return avatarUrl.value ? avatarUrl.value : contactData.value?.thumbnail;
});
const handleFormUpdate = updatedData => {
Object.assign(contactData.value, updatedData);
};
const updateContact = async () => {
try {
const { customAttributes, ...basicContactData } = contactData.value;
await store.dispatch('contacts/update', basicContactData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact.id
);
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.ERROR_MESSAGE'));
}
};
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
const handleAvatarUpload = async ({ file, url }) => {
avatarFile.value = file;
avatarUrl.value = url;
try {
await store.dispatch('contacts/update', {
...contactsFormRef.value?.state,
avatar: file,
isFormData: true,
});
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.SUCCESS_MESSAGE'));
} catch {
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.ERROR_MESSAGE'));
}
};
const handleAvatarDelete = async () => {
try {
if (props.selectedContact && props.selectedContact.id) {
await store.dispatch('contacts/deleteAvatar', props.selectedContact.id);
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.SUCCESS_MESSAGE'));
}
avatarFile.value = null;
avatarUrl.value = '';
contactData.value.thumbnail = null;
} catch (error) {
useAlert(
error.message
? error.message
: t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.ERROR_MESSAGE')
);
}
};
</script>
<template>
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
:size="72"
allow-upload
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact?.name }}
</h3>
<div class="flex flex-col gap-1.5">
<span
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<div class="flex flex-col items-start gap-6">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
is-details-view
@update="handleFormUpdate"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="updateContact"
/>
</div>
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({ contacts: { type: Array, required: true } });
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const updateContact = async updatedData => {
try {
await store.dispatch('contacts/update', updatedData);
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM';
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS.DUPLICATE`));
} else if (error.data.includes('phone_number')) {
useAlert(t(`${i18nPrefix}.PHONE_NUMBER.DUPLICATE`));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
}
}
};
const onClickViewDetails = async id => {
const routeTypes = {
contacts_dashboard_segments_index: ['contacts_edit_segment', 'segmentId'],
contacts_dashboard_labels_index: ['contacts_edit_label', 'label'],
};
const [name, paramKey] = routeTypes[route.name] || ['contacts_edit'];
const params = {
contactId: id,
...(paramKey && { [paramKey]: route.params[paramKey] }),
};
await router.push({ name, params, query: route.query });
};
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
</script>
<template>
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
/>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -13,9 +14,15 @@ const props = defineProps({
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
@@ -19,9 +20,17 @@ const props = defineProps({
const { t } = useI18n();
const slaCardLabelRef = ref(null);
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {
@@ -38,7 +47,15 @@ const unreadMessagesCount = computed(() => {
return unreadCount;
});
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
const hasSlaThreshold = computed(() => {
return (
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
);
});
defineExpose({
hasSlaThreshold,
});
</script>
<template>
@@ -66,7 +83,11 @@ const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
: 'grid-cols-[1fr_20px]'
"
>
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
<SLACardLabel
v-show="hasSlaThreshold"
ref="slaCardLabelRef"
:conversation="conversation"
/>
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
<div class="overflow-hidden">
<CardLabels
@@ -1,6 +1,8 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useRouter, useRoute } from 'vue-router';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -28,6 +30,11 @@ const props = defineProps({
},
});
const router = useRouter();
const route = useRoute();
const cardMessagePreviewWithMetaRef = ref(null);
const currentContact = computed(() => props.contact);
const currentContactName = computed(() => currentContact.value?.name);
@@ -51,14 +58,37 @@ const lastActivityAt = computed(() => {
});
const showMessagePreviewWithoutMeta = computed(() => {
const { slaPolicyId, labels = [] } = props.conversation;
return !slaPolicyId && labels.length === 0;
const { labels = [] } = props.conversation;
return (
!cardMessagePreviewWithMetaRef.value?.hasSlaThreshold && labels.length === 0
);
});
const onCardClick = e => {
const path = frontendURL(
conversationUrl({
accountId: route.params.accountId,
id: props.conversation.id,
})
);
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
router.push({ path });
};
</script>
<template>
<div
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out rounded-xl"
role="button"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
@click="onCardClick"
>
<Avatar
:name="currentContactName"
@@ -67,7 +97,7 @@ const showMessagePreviewWithoutMeta = computed(() => {
:status="currentContactStatus"
rounded-full
/>
<div class="flex flex-col w-full gap-1">
<div class="flex flex-col w-full gap-1 min-w-0">
<div class="flex items-center justify-between h-6 gap-2">
<h4 class="text-base font-medium truncate text-n-slate-12">
{{ currentContactName }}
@@ -75,7 +105,7 @@ const showMessagePreviewWithoutMeta = computed(() => {
<div class="flex items-center gap-2">
<CardPriorityIcon :priority="conversation.priority || null" />
<div
v-tooltip.top-start="inboxName"
v-tooltip.left="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
>
<Icon
@@ -89,11 +119,12 @@ const showMessagePreviewWithoutMeta = computed(() => {
</div>
</div>
<CardMessagePreview
v-if="showMessagePreviewWithoutMeta"
v-show="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-else
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:account-labels="accountLabels"
/>
@@ -31,13 +31,17 @@ const convertObjectCamelCaseToSnakeCase = object => {
const appliedSLA = computed(() => props.conversation?.appliedSla);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
return slaStatus.value?.threshold && appliedSLA.value?.id;
});
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
chat: props.conversation,
});
};
@@ -61,6 +65,21 @@ onUnmounted(() => {
});
watch(() => props.conversation, updateSlaStatus);
// This expose is to provide context to the parent component, so that it can decided weather
// a new row has to be added to the conversation card or not
// SLACardLabel > CardMessagePreviewWithMeta > ConversationCard
//
// We need to do this becuase each SLA card has it's own SLA timer
// and it's just convenient to have this logic in the SLACardLabel component
// However this is a bit hacky, and we should change this in the future
//
// TODO: A better implementation would be to have the timer as a shared composable, just like the provider pattern
// we use across the next components. Have the calculation be done on the top ConversationCard component
// and then the value be injected to the SLACardLabel component
defineExpose({
hasSlaThreshold,
});
</script>
<template>
@@ -88,7 +88,7 @@ const handleInputUpdate = async () => {
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
'text-n-slate-12 truncate': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
@@ -127,9 +127,8 @@ const handleInputUpdate = async () => {
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 hover:text-n-brand text-n-blue-text':
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -41,10 +41,14 @@ const props = defineProps({
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
});
const emit = defineEmits(['update:modelValue']);
const slots = useSlots();
const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length);
@@ -81,7 +85,7 @@ const handleBlur = () => {
watch(
() => props.modelValue,
newValue => {
if (props.maxLength && props.showCharacterCount) {
if (props.maxLength && props.showCharacterCount && !slots.actions) {
if (characterCount.value >= props.maxLength) {
emit('update:modelValue', newValue.slice(0, props.maxLength));
}
@@ -114,17 +118,23 @@ watch(
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
/>
<div
v-if="showCharacterCount"
v-if="showCharacterCount || slots.actions"
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-n-slate-10">
<span
v-if="showCharacterCount && !slots.actions"
class="text-xs tabular-nums text-n-slate-10"
>
{{ characterCount }} / {{ maxLength }}
</span>
<slot v-else name="actions" />
</div>
</div>
<p
@@ -144,7 +154,7 @@ watch(
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
@@ -163,7 +173,7 @@ watch(
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11 !important;
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[960px] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -76,6 +76,7 @@ const togglePortalSwitcher = () => {
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
@@ -95,7 +96,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3">
<div class="w-full max-w-[960px] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
@@ -36,6 +36,8 @@ const emit = defineEmits([
const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
emit('saveArticle', value);
};
@@ -52,21 +54,32 @@ const quickSave = debounce(
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
};
const articleTitle = computed({
get: () => props.article.title,
set: value => {
quickSave({ title: value });
saveAndSyncDebounced({ title: value });
handleSave({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
quickSave({ content });
saveAndSyncDebounced({ content });
handleSave({ content });
},
});
@@ -182,6 +182,7 @@ onMounted(() => {
<OnClickOutside @trigger="openAgentsList = false">
<Button
variant="ghost"
color="slate"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
@@ -199,7 +200,8 @@ onMounted(() => {
<DropdownMenu
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -214,6 +216,7 @@ onMounted(() => {
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
class="!px-2 font-normal hover:!bg-transparent"
@click="openCategoryList = !openCategoryList"
>
@@ -229,7 +232,8 @@ onMounted(() => {
<DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52"
show-search
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -244,6 +248,7 @@ onMounted(() => {
"
icon="i-lucide-plus"
variant="ghost"
color="slate"
:disabled="isNewArticle"
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
@click="openProperties = !openProperties"
@@ -66,6 +66,7 @@ onMounted(() => {
icon="i-lucide-x"
size="sm"
variant="ghost"
color="slate"
class="hover:text-n-slate-11"
@click="emit('close')"
/>
@@ -110,7 +111,7 @@ onMounted(() => {
custom-label-class="min-w-[120px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
>
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import {
ARTICLE_TABS,
CATEGORY_ALL,
@@ -37,6 +38,7 @@ const emit = defineEmits([
const route = useRoute();
const { t } = useI18n();
const { updateUISettings } = useUISettings();
const isCategoryMenuOpen = ref(false);
const isLocaleMenuOpen = ref(false);
@@ -111,13 +113,12 @@ const localeMenuItems = computed(() => {
}));
});
const hasMoreThanOneLocaleMenuItems = computed(() => {
return localeMenuItems.value?.length > 1;
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
updateUISettings({
last_active_locale_code: value,
});
};
const handleCategoryAction = ({ value }) => {
@@ -143,7 +144,7 @@ const handleTabChange = value => {
/>
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<div v-if="hasMoreThanOneLocaleMenuItems" class="relative group">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
@@ -157,6 +158,7 @@ const handleTabChange = value => {
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
@@ -177,6 +179,7 @@ const handleTabChange = value => {
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction"
/>
@@ -99,11 +99,19 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
const updateMeta = () => {
const updatePortalMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('portals/show', { portalSlug, locale });
};
const updateArticlesMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('articles/updateArticleMeta', {
portalSlug,
locale,
});
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -127,7 +135,8 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateMeta();
await updateArticlesMeta();
await updatePortalMeta();
} catch (error) {
const errorMessage =
error?.message ||
@@ -91,10 +91,7 @@ const articlesCount = computed(() => {
});
const showArticleHeaderControls = computed(
() =>
!hasNoArticlesInPortal.value &&
!props.isCategoryArticles &&
!isSwitchingPortal.value
() => !props.isCategoryArticles && !isSwitchingPortal.value
);
const showCategoryHeaderControls = computed(
@@ -141,6 +141,7 @@ const handleBreadcrumbClick = () => {
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
@@ -49,8 +49,11 @@ const onCreate = async () => {
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: { allowed_locales: updatedLocales },
portalSlug: props.portal?.slug,
config: {
allowed_locales: updatedLocales,
default_locale: props.portal?.meta?.default_locale,
},
});
useTrack(PORTALS_EVENTS.CREATE_LOCALE, {
@@ -2,6 +2,7 @@
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -20,6 +21,7 @@ const props = defineProps({
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -56,26 +58,40 @@ const changeDefaultLocale = ({ localeCode }) => {
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: route.name,
});
};
const deletePortalLocale = ({ localeCode }) => {
const updateLastActivePortal = async localeCode => {
const { last_active_locale_code: lastActiveLocaleCode } =
uiSettings.value || {};
const defaultLocale = props.portal.meta.default_locale;
// Update UI settings only if deleting locale matches the last active locale in UI settings.
if (localeCode === lastActiveLocaleCode) {
await updateUISettings({
last_active_locale_code: defaultLocale,
});
}
};
const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
updatePortalLocales({
await updatePortalLocales({
newAllowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
await updateLastActivePortal(localeCode);
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: route.name,
@@ -108,6 +108,7 @@ const redirectToPortalHomePage = () => {
<Button
icon="i-lucide-arrow-up-right"
variant="ghost"
color="slate"
icon-lib="lucide"
size="sm"
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
@@ -133,6 +134,7 @@ const redirectToPortalHomePage = () => {
:key="index"
:label="portal.name"
variant="ghost"
color="slate"
trailing-icon
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
@@ -0,0 +1,248 @@
<script setup>
import { computed, ref, onBeforeMount } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import {
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
} from 'dashboard/helper/snoozeHelpers';
import { NOTIFICATION_TYPES_MAPPING } from 'dashboard/routes/dashboard/inbox/helpers/InboxViewHelpers';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
import InboxContextMenu from 'dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue';
const props = defineProps({
inboxItem: { type: Object, default: () => ({}) },
stateInbox: { type: Object, default: () => ({}) },
});
const emit = defineEmits([
'click',
'contextMenuOpen',
'contextMenuClose',
'markNotificationAsRead',
'markNotificationAsUnRead',
'deleteNotification',
]);
const { t } = useI18n();
const isContextMenuOpen = ref(false);
const contextMenuPosition = ref({ x: null, y: null });
const slaCardLabel = ref(null);
const getMessageClasses = {
emphasis: 'text-sm font-medium text-n-slate-11',
emphasisUnread: 'text-sm font-medium text-n-slate-12',
normal: 'text-sm font-normal text-n-slate-11',
normalUnread: 'text-sm text-n-slate-12',
};
const primaryActor = computed(() => props.inboxItem?.primaryActor);
const meta = computed(() => primaryActor.value?.meta);
const assigneeMeta = computed(() => meta.value?.sender);
const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
});
const hasSlaThreshold = computed(() => {
return slaCardLabel.value?.hasSlaThreshold && primaryActor.value?.slaPolicyId;
});
const lastActivityAt = computed(() => {
const timestamp = props.inboxItem?.lastActivityAt;
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
});
const menuItems = computed(() => [
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
{
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
},
]);
const messageClasses = computed(() => ({
emphasis: isUnread.value
? getMessageClasses.emphasisUnread
: getMessageClasses.emphasis,
normal: isUnread.value
? getMessageClasses.normalUnread
: getMessageClasses.normal,
}));
const formatPushMessage = message => {
if (message.startsWith(': ')) {
return message.slice(2);
}
return message.replace(/^([^:]+):/g, (match, name) => {
return `<span class="${messageClasses.value.emphasis}">${name}:</span>`;
});
};
const formattedMessage = computed(() => {
const messageContent = `<span class="${messageClasses.value.normal}">${formatPushMessage(props.inboxItem?.pushMessageBody || '')}</span>`;
return isUnread.value
? `<span class="inline-flex flex-shrink-0 w-2 h-2 mb-px rounded-full bg-n-iris-10 ltr:mr-1 rtl:ml-1"></span> ${messageContent}`
: messageContent;
});
const notificationDetails = computed(() => {
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
const [icon = '', color = 'text-n-blue-text'] =
NOTIFICATION_TYPES_MAPPING[type] || [];
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
});
const snoozedUntilTime = computed(() => {
const { snoozedUntil } = props.inboxItem;
if (!snoozedUntil) return null;
return shortenSnoozeTime(
dynamicTime(snoozedReopenTimeToTimestamp(snoozedUntil))
);
});
const hasLastSnoozed = computed(() => props.inboxItem?.meta?.lastSnoozedAt);
const snoozedText = computed(() => {
return !hasLastSnoozed.value
? t('INBOX.TYPES_NEXT.SNOOZED_UNTIL', {
time: shortTimestamp(snoozedUntilTime.value),
})
: t('INBOX.TYPES_NEXT.SNOOZED_ENDS');
});
const contextMenuActions = {
close: () => {
isContextMenuOpen.value = false;
contextMenuPosition.value = { x: null, y: null };
emit('contextMenuClose');
},
open: e => {
e.preventDefault();
contextMenuPosition.value = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
isContextMenuOpen.value = true;
emit('contextMenuOpen');
},
handle: key => {
const actions = {
mark_as_read: () => emit('markNotificationAsRead', props.inboxItem),
mark_as_unread: () => emit('markNotificationAsUnRead', props.inboxItem),
delete: () => emit('deleteNotification', props.inboxItem),
};
actions[key]?.();
},
};
onBeforeMount(contextMenuActions.close);
</script>
<template>
<div
role="button"
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
@contextmenu="contextMenuActions.open($event)"
@click="emit('click')"
>
<div class="flex items-start gap-2">
<Avatar
:name="assigneeMeta.name"
:src="assigneeMeta.thumbnail"
:size="20"
rounded-full
class="mt-1"
/>
<p v-dompurify-html="formattedMessage" class="mb-0 line-clamp-2" />
</div>
<div class="flex items-center justify-between h-6 gap-2">
<div class="flex items-center flex-1 min-w-0 gap-1">
<div
v-if="snoozedUntilTime || hasLastSnoozed"
class="flex items-center w-full min-w-0 gap-2 ltr:pl-1 rtl:pr-1"
>
<Icon
:icon="
!hasLastSnoozed
? 'i-lucide-alarm-clock-plus'
: 'i-lucide-alarm-clock-off'
"
class="flex-shrink-0 size-4"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
>
{{ snoozedText }}
</span>
</div>
<div
v-else-if="notificationDetails.text"
class="flex items-center w-full min-w-0 gap-2 ltr:pl-1 rtl:pr-1"
>
<Icon
:icon="notificationDetails.icon"
:class="isUnread ? notificationDetails.color : 'text-n-slate-11'"
class="flex-shrink-0 size-4"
/>
<span
class="text-xs font-medium truncate"
:class="isUnread ? notificationDetails.color : 'text-n-slate-11'"
>
{{ notificationDetails.text }}
</span>
</div>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<SLACardLabel
v-show="hasSlaThreshold"
ref="slaCardLabel"
:conversation="primaryActor"
class="[&>span]:text-xs"
:class="
!isUnread && '[&>span]:text-n-slate-11 [&>div>svg]:fill-n-slate-11'
"
/>
<div v-if="hasSlaThreshold" class="w-px h-3 rounded-sm bg-n-slate-4" />
<CardPriorityIcon
v-if="primaryActor?.priority"
:priority="primaryActor?.priority"
class="[&>svg]:size-4"
/>
<div
v-if="inboxIcon"
v-tooltip.left="inbox?.name"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
</div>
<InboxContextMenu
v-if="isContextMenuOpen"
:context-menu-position="contextMenuPosition"
:menu-items="menuItems"
@close="contextMenuActions.close"
@select-action="contextMenuActions.handle"
/>
</div>
</template>
@@ -21,7 +21,7 @@ const showDropdown = ref(false);
<template>
<div class="relative">
<button
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-[26px] outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-6 outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="showDropdown = !showDropdown"
>
@@ -1,22 +1,56 @@
<script setup>
defineProps({
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: {
type: Object,
default: null,
},
isHovered: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['remove', 'hover']);
const handleRemoveLabel = () => {
emit('remove', props.label?.id);
};
const handleMouseEnter = () => {
// Notify parent component when this label is hovered
// Added this to show the remove button with transition when hovering over the label
// This will solve the flickering issue when hovering over the last label item
emit('hover', props.label?.id);
};
</script>
<template>
<div
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
class="flex items-center px-1 py-1 overflow-hidden transition-all duration-300 ease-out rounded-md bg-n-alpha-2 h-7"
@mouseenter="handleMouseEnter"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12">
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
{{ label.title }}
</span>
<div
class="w-0 flex relative ltr:left-1 rtl:right-1 flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-out"
:class="{ 'w-6': isHovered }"
>
<Button
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
slate
xs
faded
icon="i-lucide-x"
@click="handleRemoveLabel"
/>
</div>
</div>
</template>
@@ -0,0 +1,227 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import {
searchContacts,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
});
const store = useStore();
const { t } = useI18n();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature');
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
);
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
);
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
isSearching.value = false;
} catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const resetContacts = () => {
contacts.value = [];
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
contact = await createNewContact(value);
isCreatingContact.value = false;
} catch (error) {
isCreatingContact.value = false;
return;
}
} else {
contact = rest;
}
selectedContact.value = contact;
if (contact?.id) {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
}
}
};
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
};
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
targetInbox.value = null;
resetContacts();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
params: payload,
isFromWhatsApp,
});
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
useAlert(
error instanceof ExceptionWithMessage
? error.data
: t('COMPOSE_NEW_CONVERSATION.FORM.ERROR_MESSAGE')
);
return false; // Return failure
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
};
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
};
}
},
{ immediate: true, deep: true }
);
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
class="relative"
:class="{
'z-40': showComposeNewConversation,
}"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</template>
@@ -0,0 +1,234 @@
<script setup>
import { defineAsyncComponent, ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disableSendButton: { type: Boolean, default: false },
hasSelectedInbox: { type: Boolean, default: false },
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
});
const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'insertEmoji',
'addSignature',
'removeSignature',
'attachFile',
]);
const { t } = useI18n();
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const signatureToApply = computed(() =>
props.isEmailOrWebWidgetInbox
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature)
);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
isEditorHotKeyEnabled,
} = useUISettings();
const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const setSignature = () => {
if (signatureToApply.value) {
if (sendWithSignature.value) {
emit('addSignature', signatureToApply.value);
} else {
emit('removeSignature', signatureToApply.value);
}
}
};
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
// Added this watch to dynamically set signature.
// Only targetInbox has value and is Advance Editor(used by isEmailOrWebWidgetInbox)
// Set the signature only if the inbox based flag is true
watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
});
}
);
const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
const newFile = {
resource: blob || file,
isPrivate: false,
thumb: reader.result,
blobSignedId: blob?.signed_id,
};
emit('attachFile', [...props.attachedFiles, newFile]);
};
},
});
const sendButtonLabel = computed(() => {
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
keyCode,
});
});
const keyboardEvents = {
Enter: {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
'$mod+Enter': {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<WhatsAppOptions
v-if="isWhatsappInbox"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
icon="i-lucide-smile-plus"
color="slate"
size="sm"
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
<FileUpload
v-if="isEmailOrWebWidgetInbox"
ref="uploadAttachment"
input-id="composeNewConversationAttachment"
:size="4096 * 4096"
:accept="ALLOWED_FILE_TYPES"
multiple
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
class="p-px"
@input-file="onFileUpload"
>
<Button
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10 relative"
/>
</FileUpload>
<Button
v-if="hasSelectedInbox && !isWhatsappInbox"
icon="i-lucide-signature"
color="slate"
size="sm"
class="!w-10"
@click="toggleMessageSignature"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="emit('discard')"
/>
<Button
v-if="!isWhatsappInbox"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
:disabled="isLoading || disableSendButton"
:is-loading="isLoading"
@click="emit('sendMessage')"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
</style>
@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue';
import { fileNameWithEllipsis } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update:attachments']);
const isTypeImage = file => {
const type = file.content_type || file.type;
return type.includes('image');
};
const filteredImageAttachments = computed(() => {
return props.attachments.filter(attachment =>
isTypeImage(attachment.resource)
);
});
const filteredNonImageAttachments = computed(() => {
return props.attachments.filter(
attachment => !isTypeImage(attachment.resource)
);
});
const removeAttachment = id => {
const updatedAttachments = props.attachments.filter(
attachment => attachment.resource.id !== id
);
emit('update:attachments', updatedAttachments);
};
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredImageAttachments"
:key="attachment.id"
class="relative group/image w-[72px] h-[72px]"
>
<img
class="object-cover w-[72px] h-[72px] rounded-lg"
:src="attachment.thumb"
/>
<Button
variant="ghost"
icon="i-lucide-trash"
color="slate"
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
<div
v-if="filteredNonImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredNonImageAttachments"
:key="attachment.id"
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="shrink-0 !h-5 !w-5"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,351 @@
<script setup>
import { reactive, ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
prepareNewMessagePayload,
prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue';
import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue';
const props = defineProps({
contacts: { type: Array, default: () => [] },
contactId: { type: String, default: null },
selectedContact: { type: Object, default: null },
targetInbox: { type: Object, default: null },
currentUser: { type: Object, default: null },
isCreatingContact: { type: Boolean, default: false },
isFetchingInboxes: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
isDirectUploadsEnabled: { type: Boolean, default: false },
contactConversationsUiFlags: { type: Object, default: null },
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
});
const emit = defineEmits([
'searchContacts',
'discard',
'updateSelectedContact',
'updateTargetInbox',
'clearSelectedContact',
'createConversation',
]);
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isEmailOrWebWidget:
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
isTwilioSMS:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'sms',
}));
const whatsappMessageTemplates = computed(() =>
Object.keys(props.targetInbox?.messageTemplates || {}).length
? props.targetInbox.messageTemplates
: []
);
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
subject: { required: requiredIf(inboxTypes.value.isEmail) },
}));
const v$ = useVuelidate(validationRules, {
selectedContact: computed(() => props.selectedContact),
targetInbox: computed(() => props.targetInbox),
message: computed(() => state.message),
subject: computed(() => state.subject),
});
const validationStates = computed(() => ({
isContactInvalid:
v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
isInboxInvalid: v$.value.targetInbox.$dirty && v$.value.targetInbox.$invalid,
isSubjectInvalid: v$.value.subject.$dirty && v$.value.subject.$invalid,
isMessageInvalid: v$.value.message.$dirty && v$.value.message.$invalid,
}));
const newMessagePayload = () => {
const { message, subject, ccEmails, bccEmails, attachedFiles } = state;
return prepareNewMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser: props.currentUser,
attachedFiles,
directUploadsEnabled: props.isDirectUploadsEnabled,
});
};
const contactableInboxesList = computed(() => {
return buildContactableInboxesList(props.selectedContact?.contactInboxes);
});
const showNoInboxAlert = computed(() => {
return (
props.selectedContact &&
contactableInboxesList.value.length === 0 &&
!props.contactsUiFlags.isFetchingInboxes &&
!props.isFetchingInboxes
);
});
const isAnyDropdownActive = computed(() => {
return (
showContactsDropdown.value ||
showInboxesDropdown.value ||
showCcEmailsDropdown.value ||
showBccEmailsDropdown.value
);
});
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
});
};
const handleDropdownUpdate = (type, value) => {
if (type === 'cc') {
showCcEmailsDropdown.value = value;
} else if (type === 'bcc') {
showBccEmailsDropdown.value = value;
} else {
showContactsDropdown.value = value;
}
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
};
const setSelectedContact = async ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateSelectedContact', { value, action, ...rest });
showContactsDropdown.value = false;
showInboxesDropdown.value = true;
};
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
const removeTargetInbox = value => {
v$.value.$reset();
// Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
state.message = removeSignature(state.message, signatureToRemove);
}
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
};
const onClickInsertEmoji = emoji => {
state.message += emoji;
};
const handleAddSignature = signature => {
state.message = appendSignature(state.message, signature);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
const clearForm = () => {
Object.assign(state, {
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
v$.value.$reset();
};
const handleSendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
try {
const success = await emit('createConversation', {
payload: newMessagePayload(),
isFromWhatsApp: false,
});
if (success) {
clearForm();
}
} catch (error) {
// Form will not be cleared if conversation creation fails
}
};
const handleSendWhatsappMessage = async ({ message, templateParams }) => {
const whatsappMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: whatsappMessagePayload,
isFromWhatsApp: true,
});
};
</script>
<template>
<div
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<ActionButtons
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
/>
</div>
</template>
@@ -0,0 +1,154 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { INPUT_TYPES } from 'dashboard/components-next/taginput/helper/tagInputHelper.js';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
required: true,
},
selectedContact: {
type: Object,
default: null,
},
showContactsDropdown: {
type: Boolean,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
isCreatingContact: {
type: Boolean,
required: true,
},
contactId: {
type: String,
default: null,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const inputType = ref(INPUT_TYPES.EMAIL);
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
label: email ? `${name} (${email})` : name,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
name,
email,
action: 'contact',
}));
});
const selectedContactLabel = computed(() => {
const { name, email = '', phoneNumber = '' } = props.selectedContact || {};
if (email) {
return `${name} (${email})`;
}
if (phoneNumber) {
return `${name} (${phoneNumber})`;
}
return name || '';
});
const errorClass = computed(() => {
return props.hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: '';
});
const handleInput = value => {
// Update input type based on whether input starts with '+'
// If it does, set input type to 'tel'
// Otherwise, set input type to 'email'
inputType.value = value.startsWith('+') ? INPUT_TYPES.TEL : INPUT_TYPES.EMAIL;
emit('searchContacts', value);
};
</script>
<template>
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
<div class="flex items-baseline w-full gap-3 min-h-7">
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t(`${i18nPrefix}.LABEL`) }}
</label>
<div
v-if="isCreatingContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ t(`${i18nPrefix}.CONTACT_CREATING`) }}
</span>
</div>
<div
v-else-if="selectedContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{
isCreatingContact
? t(`${i18nPrefix}.CONTACT_CREATING`)
: selectedContactLabel
}}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
:disabled="contactId"
size="xs"
@click="emit('clearSelectedContact')"
/>
</div>
<TagInput
v-else
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
:type="inputType"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
@input="handleInput"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"
/>
</div>
</div>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
contacts: { type: Array, required: true },
showCcEmailsDropdown: { type: Boolean, required: false },
showBccEmailsDropdown: { type: Boolean, required: false },
isLoading: { type: Boolean, default: false },
hasErrors: { type: Boolean, default: false },
});
const emit = defineEmits([
'searchCcEmails',
'searchBccEmails',
'updateDropdown',
]);
const i18nPrefix = `COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS`;
const showBccInput = ref(false);
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const subject = defineModel('subject', { type: String, default: '' });
const ccEmails = defineModel('ccEmails', { type: String, default: '' });
const bccEmails = defineModel('bccEmails', { type: String, default: '' });
const { t } = useI18n();
// Convert string to array for TagInput
const ccEmailsArray = computed(() =>
props.ccEmails ? props.ccEmails.split(',').map(email => email.trim()) : []
);
const bccEmailsArray = computed(() =>
props.bccEmails ? props.bccEmails.split(',').map(email => email.trim()) : []
);
const contactEmailsList = computed(() => {
return props.contacts?.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
});
// Handle updates from TagInput and convert array back to string
const handleCcUpdate = value => {
ccEmails.value = value.join(',');
};
const handleBccUpdate = value => {
bccEmails.value = value.join(',');
};
const inputClass = computed(() => {
return props.hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: '';
});
</script>
<template>
<div class="flex flex-col divide-y divide-n-strong">
<div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3">
<InlineInput
v-model="subject"
:placeholder="t(`${i18nPrefix}.SUBJECT_PLACEHOLDER`)"
:label="t(`${i18nPrefix}.SUBJECT_LABEL`)"
focus-on-mount
:custom-input-class="inputClass"
/>
</div>
<div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t(`${i18nPrefix}.CC_LABEL`) }}
</label>
<div class="flex items-center w-full gap-3 min-h-7">
<TagInput
:model-value="ccEmailsArray"
:placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate"
/>
<Button
:label="t(`${i18nPrefix}.BCC_BUTTON`)"
variant="ghost"
size="sm"
color="slate"
class="flex-shrink-0"
@click="toggleBccInput"
/>
</div>
</div>
<div
v-if="showBccInput"
class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t(`${i18nPrefix}.BCC_LABEL`) }}
</label>
<TagInput
:model-value="bccEmailsArray"
:placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,9 @@
<template>
<div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
>
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span>
</div>
</template>
@@ -0,0 +1,91 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { generateLabelForContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
targetInbox: {
type: Object,
default: null,
},
selectedContact: {
type: Object,
default: null,
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'updateInbox',
'toggleDropdown',
'handleInboxAction',
]);
const { t } = useI18n();
const targetInboxLabel = computed(() => {
return generateLabelForContactableInboxesList(props.targetInbox);
});
</script>
<template>
<div
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.LABEL') }}
</label>
<div
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="emit('updateInbox', null)"
/>
</div>
<div
v-else
v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link"
size="sm"
:color="hasErrors ? 'ruby' : 'slate'"
:disabled="!selectedContact"
class="hover:!no-underline"
@click="emit('toggleDropdown', !showInboxesDropdown)"
/>
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
</div>
</template>
@@ -0,0 +1,122 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import {
appendSignature,
extractTextFromMarkdown,
removeSignature,
} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
});
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
const state = ref({
hasSlashCommand: false,
showMentions: false,
mentionSearchKey: '',
});
const plainTextSignature = computed(() =>
extractTextFromMarkdown(props.messageSignature)
);
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, plainTextSignature.value)
: '';
// Check if message starts with slash
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Update slash command and mentions state
state.value = {
...state.value,
hasSlashCommand: startsWithSlash,
showMentions: startsWithSlash,
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
};
},
{ immediate: true }
);
const hideMention = () => {
state.value.showMentions = false;
};
const replaceText = async message => {
// Only append signature on replace if sendWithSignature is true
const finalMessage = props.sendWithSignature
? appendSignature(message, plainTextSignature.value)
: message;
await nextTick();
modelValue.value = finalMessage;
};
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<template v-if="isEmailOrWebWidgetInbox">
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
/>
</template>
<template v-else>
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/>
</TextArea>
</template>
</div>
</template>
@@ -0,0 +1,126 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
const props = defineProps({
messageTemplates: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
});
const filteredTemplates = computed(() => {
return whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="w-full pt-2 text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplateParser
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>
@@ -0,0 +1,176 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const templateName = computed(() => {
return props.template?.name || '';
});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
});
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const processedStringWithVariableHighlight = computed(() => {
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
return variables.reduce((result, variable) => {
const variableKey = processVariable(variable);
const value = processedParams.value[variableKey] || variable;
return result.replace(
variable,
`<span class="break-all text-n-slate-12">${value}</span>`
);
}, templateString.value);
});
const rules = computed(() => {
const paramRules = {};
Object.keys(processedParams.value).forEach(key => {
paramRules[key] = { required: requiredIf(true) };
});
return {
processedParams: paramRules,
};
});
const v$ = useVuelidate(rules, { processedParams });
const getFieldErrorType = key => {
if (!v$.value.processedParams[key]?.$error) return 'info';
return 'error';
};
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const sendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
onMounted(() => {
generateVariables();
});
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
{ templateName: templateName }
)
}}
</span>
<p
class="mb-0 text-sm text-n-slate-11"
v-html="processedStringWithVariableHighlight"
/>
<span
v-if="Object.keys(processedParams).length"
class="text-sm font-medium text-n-slate-12"
>
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
)
}}
</span>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="flex items-center w-full gap-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{ key }}
</span>
<Input
v-model="processedParams[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(key)"
/>
</div>
<div class="flex items-end justify-between w-full gap-3 h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="emit('back')"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup>
import { ref } from 'vue';
import { contacts, activeContact, emailInbox, currentUser } from './fixtures';
import ComposeNewConversationForm from '../ComposeNewConversationForm.vue';
const selectedContact = ref(activeContact);
const targetInbox = ref(emailInbox);
// Event handlers
const onSearchContacts = query => {
console.log('Searching contacts:', query);
};
const onUpdateSelectedContact = contact => {
console.log('Selected contact updated:', contact);
};
const onUpdateTargetInbox = inbox => {
console.log('Target inbox updated:', inbox);
targetInbox.value = inbox;
console.log('Target inbox updated:', inbox);
};
const onClearSelectedContact = () => {
console.log('Contact cleared');
};
const onCreateConversation = payload => {
console.log('Creating conversation:', payload);
};
const onDiscard = () => {
console.log('Form discarded');
};
</script>
<template>
<Story
title="Components/Compose/ComposeNewConversationForm"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With all props">
<div class="h-[600px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="false"
is-fetching-inboxes
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
<Variant title="With no target inbox">
<div class="h-[200px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="{ ...selectedContact, contactInboxes: [] }"
:target-inbox="null"
:is-creating-contact="false"
:is-fetching-inboxes="false"
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,168 @@
export const contacts = [
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: 'offline',
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
];
export const activeContact = {
email: 'ozler@chatwoot.com',
id: 29,
label: 'Abraham Ozlers (ozler@chatwoot.com)',
name: 'Abraham Ozlers',
thumbnail: {
name: 'Abraham Ozlers',
src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
},
contactInboxes: [
{
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
},
{
id: 8,
label: 'PaperLayer WhatsApp',
name: 'PaperLayer WhatsApp',
sourceId: '123456',
phoneNumber: '+1223233434',
channelType: 'Channel::Whatsapp',
messageTemplates: [
{
id: '1',
name: 'shipment_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, great news! Your order {{2}} has shipped.\n\nTracking #: {{3}}\nEstimated delivery: {{4}}\n\nWe will provide updates until delivery.',
type: 'BODY',
example: {
bodyText: [['John', '#12345', 'ZK4539O2311J', 'Jan 1, 2024']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/',
text: 'Track shipment',
type: 'URL',
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'shipment_confirmation_2',
},
{
id: '2',
name: 'otp_test',
status: 'APPROVED',
category: 'AUTHENTICATION',
language: 'en_US',
components: [
{
text: 'Use code *{{1}}* to authorize your transaction.',
type: 'BODY',
example: {
bodyText: [['123456']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp{{1}}',
text: 'Copy code',
type: 'URL',
example: [
'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp123456',
],
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'verify_transaction_1',
messageSendTtlSeconds: 900,
},
{
id: '3',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameterFormat: 'POSITIONAL',
},
],
},
{
id: 9,
label: 'PaperLayer API',
name: 'PaperLayer API',
email: '',
channelType: 'Channel::Api',
},
],
};
export const emailInbox = {
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
};
export const currentUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
@@ -0,0 +1,221 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
const CHANNEL_PRIORITY = {
'Channel::Email': 1,
'Channel::Whatsapp': 2,
'Channel::Sms': 3,
'Channel::TwilioSms': 4,
'Channel::WebWidget': 5,
'Channel::Api': 6,
};
export const generateLabelForContactableInboxesList = ({
name,
email,
channelType,
phoneNumber,
}) => {
if (channelType === INBOX_TYPES.EMAIL) {
return `${name} (${email})`;
}
if (
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return `${name} (${phoneNumber})`;
}
return name;
};
const transformInbox = ({
name,
id,
email,
channelType,
phoneNumber,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
});
export const compareInboxes = (a, b) => {
// Channels that have no priority defined should come at the end.
const priorityA = CHANNEL_PRIORITY[a.channelType] || 999;
const priorityB = CHANNEL_PRIORITY[b.channelType] || 999;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(transformInbox).sort(compareInboxes);
};
export const getCapitalizedNameFromEmail = email => {
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
return name.charAt(0).toUpperCase() + name.slice(1);
};
export const processContactableInboxes = inboxes => {
return inboxes.map(inbox => ({
...inbox.inbox,
sourceId: inbox.sourceId,
}));
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
) => {
const files = [];
attachedFiles.forEach(attachment => {
if (directUploadsEnabled) {
files.push(attachment.blobSignedId);
} else {
files.push(attachment.resource.file);
}
});
return files;
};
export const prepareNewMessagePayload = ({
targetInbox,
selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser,
attachedFiles = [],
directUploadsEnabled = false,
}) => {
const payload = {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id),
message: { content: message },
assigneeId: currentUser.id,
};
if (attachedFiles?.length) {
payload.files = prepareAttachmentPayload(
attachedFiles,
directUploadsEnabled
);
}
if (subject) {
payload.mailSubject = subject;
}
if (ccEmails) {
payload.message.cc_emails = ccEmails;
}
if (bccEmails) {
payload.message.bcc_emails = bccEmails;
}
return payload;
};
export const prepareWhatsAppMessagePayload = ({
targetInbox,
selectedContact,
message,
templateParams,
currentUser,
}) => {
return {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: selectedContact.id,
message: { content: message, template_params: templateParams },
assigneeId: currentUser.id,
};
};
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls
export const searchContacts = async ({ keys, query }) => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
};
export const createNewContact = async input => {
const payload = {
name: input.startsWith('+')
? input.slice(1) // Remove the '+' prefix if it exists
: getCapitalizedNameFromEmail(input),
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
};
const {
data: {
payload: { contact: newContact },
},
} = await ContactAPI.create(payload);
return camelcaseKeys(newContact, { deep: true });
};
export const fetchContactableInboxes = async contactId => {
const {
data: { payload: inboxes = [] },
} = await ContactAPI.getContactableInboxes(contactId);
const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
return processContactableInboxes(convertInboxesToCamelKeys);
};
@@ -0,0 +1,558 @@
import { describe, it, expect, vi } from 'vitest';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import ContactAPI from 'dashboard/api/contacts';
import * as helpers from '../composeConversationHelper';
vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
};
it('generates label for email inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('John Doe (john@example.com)');
});
it('generates label for twilio inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for whatsapp inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for other inbox types', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe');
});
});
describe('buildContactableInboxesList', () => {
it('returns empty array if no contact inboxes', () => {
expect(helpers.buildContactableInboxesList(null)).toEqual([]);
expect(helpers.buildContactableInboxesList(undefined)).toEqual([]);
});
it('builds list of contactable inboxes with correct format', () => {
const inboxes = [
{
id: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
phoneNumber: null,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-ri-mail-line',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
});
});
});
describe('getCapitalizedNameFromEmail', () => {
it('extracts and capitalizes name from email', () => {
expect(helpers.getCapitalizedNameFromEmail('john.doe@example.com')).toBe(
'John.doe'
);
expect(helpers.getCapitalizedNameFromEmail('jane@example.com')).toBe(
'Jane'
);
});
});
describe('processContactableInboxes', () => {
it('processes inboxes with correct structure', () => {
const inboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
const result = helpers.processContactableInboxes(inboxes);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
expect(helpers.prepareAttachmentPayload(files, true)).toEqual([
'signed1',
]);
});
it('prepares regular files', () => {
const files = [{ resource: { file: 'file1' } }];
expect(helpers.prepareAttachmentPayload(files, false)).toEqual(['file1']);
});
});
describe('prepareNewMessagePayload', () => {
const baseParams = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: '2' },
message: 'Hello',
currentUser: { id: 3 },
};
it('prepares basic message payload', () => {
const result = helpers.prepareNewMessagePayload(baseParams);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: { content: 'Hello' },
assigneeId: 3,
});
});
it('includes optional fields when provided', () => {
const result = helpers.prepareNewMessagePayload({
...baseParams,
subject: 'Test',
ccEmails: 'cc@test.com',
bccEmails: 'bcc@test.com',
attachedFiles: [{ blobSignedId: 'file1' }],
directUploadsEnabled: true,
});
expect(result).toMatchObject({
mailSubject: 'Test',
message: {
content: 'Hello',
cc_emails: 'cc@test.com',
bcc_emails: 'bcc@test.com',
},
files: ['file1'],
});
});
});
describe('prepareWhatsAppMessagePayload', () => {
it('prepares whatsapp message payload', () => {
const params = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: 2 },
message: 'Hello',
templateParams: { param1: 'value1' },
currentUser: { id: 3 },
};
const result = helpers.prepareWhatsAppMessagePayload(params);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: {
content: 'Hello',
template_params: { param1: 'value1' },
},
assigneeId: 3,
});
});
});
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('searches contacts and returns only contacts with email or phone number', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
{
id: 2,
name: 'Jane Doe',
email: null,
phone_number: null,
created_at: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phone_number: null,
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phoneNumber: null,
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('transforms nested objects to camelCase', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [
{
inbox_id: 1,
source_id: 'source1',
created_at: '2023-01-01',
},
],
custom_attributes: {
custom_field_name: 'value',
},
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('test');
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [
{
inboxId: 1,
sourceId: 'source1',
createdAt: '2023-01-01',
},
],
customAttributes: {
customFieldName: 'value',
},
},
]);
});
});
describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('john@example.com');
expect(result).toEqual(mockContact);
expect(ContactAPI.create).toHaveBeenCalledWith({
name: 'John',
email: 'john@example.com',
});
});
it('creates new contact with phone number', async () => {
const mockContact = {
id: 1,
name: '919999999999',
phone_number: '+919999999999',
};
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('+919999999999');
expect(result).toEqual({
id: 1,
name: '919999999999',
phoneNumber: '+919999999999',
});
expect(ContactAPI.create).toHaveBeenCalledWith({
name: '919999999999',
phone_number: '+919999999999',
});
});
});
describe('fetchContactableInboxes', () => {
it('fetches and processes contactable inboxes', async () => {
const mockInboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: mockInboxes },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
expect(ContactAPI.getContactableInboxes).toHaveBeenCalledWith(1);
});
it('returns empty array when no inboxes found', async () => {
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result).toEqual([]);
});
});
});
});
describe('compareInboxes', () => {
it('should sort inboxes by channel priority', () => {
const inboxes = [
{ channelType: 'Channel::Api', name: 'API Inbox' },
{ channelType: 'Channel::Email', name: 'Email Inbox' },
{ channelType: 'Channel::WebWidget', name: 'Widget' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Email');
expect(sorted[1].channelType).toBe('Channel::Whatsapp');
expect(sorted[2].channelType).toBe('Channel::WebWidget');
expect(sorted[3].channelType).toBe('Channel::Api');
});
it('should sort SMS channels correctly', () => {
const inboxes = [
{ channelType: 'Channel::TwilioSms', name: 'Twilio' },
{ channelType: 'Channel::Sms', name: 'Regular SMS' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Sms');
expect(sorted[1].channelType).toBe('Channel::TwilioSms');
});
it('should sort by name when channel types are same', () => {
const inboxes = [
{ channelType: 'Channel::Email', name: 'Support' },
{ channelType: 'Channel::Email', name: 'Marketing' },
{ channelType: 'Channel::Email', name: 'Billing' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(inbox => inbox.name)).toEqual([
'Billing',
'Marketing',
'Support',
]);
});
it('should put channels without priority at the end', () => {
const inboxes = [
{ channelType: 'Channel::Unknown', name: 'Unknown' },
{ channelType: 'Channel::Email', name: 'Email' },
{ channelType: 'Channel::LineChannel', name: 'Line' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(i => i.channelType)).toEqual([
'Channel::Email',
'Channel::Whatsapp',
'Channel::LineChannel',
'Channel::Unknown',
]);
});
it('should handle empty array', () => {
const inboxes = [];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted).toEqual([]);
});
});

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