Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub 6c17211d29 Merge branch 'develop' into fix/show-error-on-clipboard-errors 2024-08-27 13:49:08 +05:30
bc6420019f feat: Rewrite automations/methodsMixin to a composable (#9956)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-27 12:30:08 +05:30
Fayaz AhmedandGitHub f82ec3b885 chore: Repalce message formatter mixin with useMessageFormatter [CW-3470] (#9986)
# Pull Request Template

## Description

Replaced the old messageFormatterMixin with a useMessageFormatter
composable
2024-08-27 08:06:51 +05:30
Sivin VargheseandGitHub 32c25047c4 feat: Rewrite reportMixin to a composable (#10029)
# Pull Request Template

## Description

The PR will replace the usage of `reportMixin` with the help of
`useReportMetrics()` composable.

Fixes
https://linear.app/chatwoot/issue/CW-3450/rewrite-reportmixin-mixin-to-a-composable

**Files updated**
1. dashboard/routes/dashboard/settings/reports/Index.vue
2. dashboard/routes/dashboard/settings/reports/BotReports.vue
3. dashboard/routes/dashboard/settings/reports/ReportContainer.vue
4.
dashboard/routes/dashboard/settings/reports/components/WootReports.vue
5.
dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue

## Type of change

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

## How Has This Been Tested?

Test the all the reports view.


## 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
2024-08-27 08:00:05 +05:30
7f8d718da3 feat: Rewrite command bar mixin to a composable (#10015)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-26 15:55:59 +05:30
3489783cb8 feat: add domain blocklist feature (#10016)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-08-26 13:05:36 +05:30
Muhsin KelothandGitHub 53d68868c6 chore: Hide linear linked issues error toast messages (#10020)
We are fetching linked Linear issues when opening a conversation if Linear integration is enabled. There may be some cases where the API call fails. We don't need to show an error message every time a user opens the conversation, as it's not critical. However, when someone clicks on the Linear icon, we can inform them that the integration is disabled. This PR will fix the issue.
2024-08-23 17:19:06 +05:30
b61ad6e41a feat: Add APIs to manage custom roles in Chatwoot (#9995)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-08-23 17:18:28 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
41c5e7d3f1 chore(deps): bump rexml from 3.3.4 to 3.3.6 (#10013)
Bumps [rexml](https://github.com/ruby/rexml) from 3.3.4 to 3.3.6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby/rexml/releases">rexml's
releases</a>.</em></p>
<blockquote>
<h2>REXML 3.3.6 - 2024-08-22</h2>
<h3>Improvements</h3>
<ul>
<li>
<p>Removed duplicated entity expansions for performance.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/194">GH-194</a></li>
<li>Patch by Viktor Ivarsson.</li>
</ul>
</li>
<li>
<p>Improved namespace conflicted attribute check performance. It was
too slow for deep elements.</p>
<ul>
<li>Reported by l33thaxor.</li>
</ul>
</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>
<p>Fixed a bug that default entity expansions are counted for
security check. Default entity expansions should not be counted
because they don't have a security risk.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/198">GH-198</a></li>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/199">GH-199</a></li>
<li>Patch Viktor Ivarsson</li>
</ul>
</li>
<li>
<p>Fixed a parser bug that parameter entity references in internal
subsets are expanded. It's not allowed in the XML specification.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/191">GH-191</a></li>
<li>Patch by NAITOH Jun.</li>
</ul>
</li>
<li>
<p>Fixed a stream parser bug that user-defined entity references in
text aren't expanded.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/200">GH-200</a></li>
<li>Patch by NAITOH Jun.</li>
</ul>
</li>
</ul>
<h3>Thanks</h3>
<ul>
<li>
<p>Viktor Ivarsson</p>
</li>
<li>
<p>NAITOH Jun</p>
</li>
<li>
<p>l33thaxor</p>
</li>
</ul>
<h2>REXML 3.3.5 - 2024-08-12</h2>
<h3>Fixes</h3>
<ul>
<li>Fixed a bug that
<code>REXML::Security.entity_expansion_text_limit</code>
check has wrong text size calculation in SAX and pull parsers.
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/193">GH-193</a></li>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/195">GH-195</a></li>
<li>Reported by Viktor Ivarsson.</li>
<li>Patch by NAITOH Jun.</li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ruby/rexml/blob/master/NEWS.md">rexml's
changelog</a>.</em></p>
<blockquote>
<h2>3.3.6 - 2024-08-22 {#version-3-3-6}</h2>
<h3>Improvements</h3>
<ul>
<li>
<p>Removed duplicated entity expansions for performance.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/194">GH-194</a></li>
<li>Patch by Viktor Ivarsson.</li>
</ul>
</li>
<li>
<p>Improved namespace conflicted attribute check performance. It was
too slow for deep elements.</p>
<ul>
<li>Reported by l33thaxor.</li>
</ul>
</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>
<p>Fixed a bug that default entity expansions are counted for
security check. Default entity expansions should not be counted
because they don't have a security risk.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/198">GH-198</a></li>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/199">GH-199</a></li>
<li>Patch Viktor Ivarsson</li>
</ul>
</li>
<li>
<p>Fixed a parser bug that parameter entity references in internal
subsets are expanded. It's not allowed in the XML specification.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/191">GH-191</a></li>
<li>Patch by NAITOH Jun.</li>
</ul>
</li>
<li>
<p>Fixed a stream parser bug that user-defined entity references in
text aren't expanded.</p>
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/200">GH-200</a></li>
<li>Patch by NAITOH Jun.</li>
</ul>
</li>
</ul>
<h3>Thanks</h3>
<ul>
<li>
<p>Viktor Ivarsson</p>
</li>
<li>
<p>NAITOH Jun</p>
</li>
<li>
<p>l33thaxor</p>
</li>
</ul>
<h2>3.3.5 - 2024-08-12 {#version-3-3-5}</h2>
<h3>Fixes</h3>
<ul>
<li>Fixed a bug that
<code>REXML::Security.entity_expansion_text_limit</code>
check has wrong text size calculation in SAX and pull parsers.
<ul>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/193">GH-193</a></li>
<li><a
href="https://redirect.github.com/ruby/rexml/issues/195">GH-195</a></li>
<li>Reported by Viktor Ivarsson.</li>
<li>Patch by NAITOH Jun.</li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby/rexml/commit/95871f399eda642a022b03550479b7994895c742"><code>95871f3</code></a>
Add 3.3.6 entry</li>
<li><a
href="https://github.com/ruby/rexml/commit/7cb5eaeb221c322b9912f724183294d8ce96bae3"><code>7cb5eae</code></a>
parser tree: improve namespace conflicted attribute check
performance</li>
<li><a
href="https://github.com/ruby/rexml/commit/6109e0183cecf4f8b587d76209716cb1bbcd6bd5"><code>6109e01</code></a>
Fix a bug that Stream parser doesn't expand the user-defined entity
reference...</li>
<li><a
href="https://github.com/ruby/rexml/commit/cb158582f18cebb3bf7b3f21f230e2fb17d435aa"><code>cb15858</code></a>
parser: keep the current namespaces instead of stack of Set</li>
<li><a
href="https://github.com/ruby/rexml/commit/2b47b161db19c38c5e45e36c2008c045543e976e"><code>2b47b16</code></a>
parser: move duplicated end tag check to BaseParser</li>
<li><a
href="https://github.com/ruby/rexml/commit/35e1681a179c28d5b6ec97d4ab1c110e5ac00303"><code>35e1681</code></a>
test tree-parser: move common method to base class</li>
<li><a
href="https://github.com/ruby/rexml/commit/6e00a14daf2f901df535eafe96cc94d43a957ffe"><code>6e00a14</code></a>
test: fix indent</li>
<li><a
href="https://github.com/ruby/rexml/commit/df3a0cc83013f3cde7b7c2044e3ce00bcad321cb"><code>df3a0cc</code></a>
test: fix indent</li>
<li><a
href="https://github.com/ruby/rexml/commit/fdbffe744b38811be8b1cf6a9eec3eea4d71c412"><code>fdbffe7</code></a>
Use loop instead of recursive call for Element#namespace</li>
<li><a
href="https://github.com/ruby/rexml/commit/6422fa34494fd4145d7bc68fbbe9525d42becf62"><code>6422fa3</code></a>
Use loop instead of recursive call for Element#root</li>
<li>Additional commits viewable in <a
href="https://github.com/ruby/rexml/compare/v3.3.4...v3.3.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rexml&package-manager=bundler&previous-version=3.3.4&new-version=3.3.6)](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-08-23 09:27:17 +05:30
abc511d00f fix: inconsistent OpenAI cache interface (#10009)
Signed-off-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-08-22 23:04:14 +05:30
Shivam MishraandGitHub a48f98de9d revert: "chore: Replace messageMixing with useMessage composable [CW-3475]" (#10011)
Reverts chatwoot/chatwoot#9942

This was causing the widget email input to break
2024-08-22 19:41:11 +05:30
7c2353c7d9 chore: Repalce Hook Mixin with useHook composable [CW-3454] (#9994)
# Pull Request Template

## Description

Replace Hook mixin with useHook composable

Fixes # (issue)

## Type of change

Please delete options that are not relevant.

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

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-22 16:48:02 +05:30
Sivin VargheseandGitHub c80de24ac1 fix: TypeError Unhandled Promise Rejection: null is not an object (evaluating 'e[Symbol.iterator]') (#10007)
# Pull Request Template

## Description

**Cause of Issue**

The problem was that the `clearSelectedState` action was being
dispatched late in the component lifecycle. By the time it was called,
child components like `ConversationBox` and `MessagesView` had already
been mounted and were trying to access data from a previous conversation
that no longer existed. This resulted in throwing error `TypeError
Unhandled Promise Rejection: null is not an object (evaluating
'e[Symbol.iterator]')` when users navigated from other screens to the
conversation view screen.

**Solution**

I added the `clearSelectedState` dispatch to the `created()` lifecycle
hook for cases where there's no `conversationId` from route props. This
ensures that the state is cleared before any child components are
mounted.


Fixes
https://chatwoot-p3.sentry.io/issues/5707937964/?project=4507182691975168

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

**Steps to reproduce**
1. Navigate to chat list screen and open the console.
2. Open any chat, wait to load the messages and stay in conversation
view screen.
3. Then navigate to contact or any other view from primary sidebar. 
4. Then back to chat list view. 
5. Now you can see this error in console.

**Before**

https://www.loom.com/share/193aaf1d1926479982a192dfb06a8764?sid=3f9ee000-d6a0-47cc-a49f-0050d2c64bbf

**After**

https://www.loom.com/share/3d88cfd5e7744958bc5856dd601ee6c4?sid=0e07a5d0-e461-4a1a-914b-e49f669422f5

## 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
2024-08-22 16:41:42 +05:30
dadd572f9d refactor: useKeyboardEvents composable (#9959)
This PR has the following changes

1. Fix tab styles issue caused by adding an additional wrapper for
getting an element ref on `ChatTypeTabs.vue`
2. Refactor `useKeyboardEvents` composable to not require an element
ref. It will use a local abort controller to abort any listener

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-22 16:40:55 +05:30
776579ba5b feat: enable disposable email check for account creation (#9989)
This PR disallows usage of disposable emails when creating an account

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-08-22 16:40:27 +05:30
4aa4e2549f feat: Update the design for automation (#10002)
This is the continuation of the design update for the settings screens.
In this PR, the automation page is updated with the latest design.

- Moved the row to a new component
- Migrated both components to composition API.
- Order by ID (Earlier this was order by updated_at which was
confusing).

| Light | Dark |
| --  | -- |
| <img width="1438" alt="Screenshot 2024-08-21 at 9 46 48 PM"
src="https://github.com/user-attachments/assets/89d96745-6556-48a1-82fa-a115325c24c0">
| <img width="1398" alt="Screenshot 2024-08-21 at 9 46 57 PM"
src="https://github.com/user-attachments/assets/5f1935ec-6d0e-4f82-b895-f47244764474">
|

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-22 16:22:54 +05:30
Muhsin KelothandGitHub eb6de74269 fix: OpenAPI label suggestion response payload (#10008) 2024-08-22 15:32:33 +05:30
d19a9c38d7 feat: Rewrite aiMixin to a composable (#9955)
This PR will replace the usage of aiMixin with the useAI composable.

Fixes https://linear.app/chatwoot/issue/CW-3443/rewrite-aimixin-mixin-to-a-composable

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-22 13:58:51 +05:30
Sivin VargheseandGitHub c63a6ed8ec feat: Rewrite agentMixin to a helper (#9940)
# Pull Request Template

## Description

This PR will replace the usage of `agentMixin`with the utility helpers
functions.

**Files updated**

1. dashboard/components/widgets/conversation/contextMenu/Index.vue
2. dashboard/components/widgets/conversation/ConversationHeader.vue
**(Not used)**
3. dashboard/routes/dashboard/commands/commandbar.vue
4. dashboard/routes/dashboard/conversation/ConversationAction.vue
5. dashboard/routes/dashboard/conversation/ConversationParticipant.vue

Fixes
https://linear.app/chatwoot/issue/CW-3442/rewrite-agentmixin-mixin-to-a-composable

## Type of change

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

## How Has This Been Tested?

**Test cases**

1. See agent list sorting based on availability, if agents are on the
same status, then sorted by name.
2. Test actions like assigning/unassigning agent from conversation
sidebar, CMD bar, Context menu.
3. Test actions like adding/removing participants from conversation
sidebar.
4. See agent list is generated properly, none value.


## 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
2024-08-22 13:02:11 +05:30
Muhsin KelothandGitHub 429d281501 fix: Handle OpenAI API errors (#9560) 2024-08-22 10:24:13 +05:30
Muhsin KelothandGitHub 6571baf211 fix: Delete attachments when removing them from Instagram in real-time (#9996) 2024-08-21 20:10:31 +05:30
44227de97e feat: Update the design for macros design page (#9999)
This is the continuation of the design update for settings page. This PR updates the design for the macros page.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-21 18:27:53 +05:30
PranavandGitHub 77b718c22c fix: Remove user.permissions, resolve it from accounts (#9990)
Remove the `user.permissions` field and resolve the permissions directly
from the accounts array in the user. This change ensures that the cache
or previous values from the last active account don't affect the
permissions.

In this PR: 
- Remove user.permissions usage, replace it with getUserPermissions
method.
- Remove json.permissions from user.json.jbuilder
2024-08-21 11:36:26 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
04b67eb431 chore(deps): bump fugit from 1.9.0 to 1.11.1 (#9981)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-21 08:53:26 +05:30
Chatwoot BotandGitHub a92d7fd7d5 chore: Update translations (#9976) 2024-08-20 20:13:09 +05:30
dependabot[bot]andGitHub e9e515c436 chore(deps): bump elliptic from 6.5.4 to 6.5.7 (#9975) 2024-08-20 19:53:13 +05:30
Vishnu NarayananandGitHub bb947ca50d chore: Remove closing comment on issues/PRs (#9964) 2024-08-20 14:27:15 +05:30
Sojan JoseandGitHub 8473e72a7e chore: [Snyk] Fix for 1 vulnerabilities (#9971)
- update gem to fix vulnerabilities
2024-08-19 17:41:53 -07:00
Sojan 292435e0e7 Merge branch 'release/3.12.0' into develop 2024-08-19 15:54:48 -07:00
Sojan d3dacccb1d Bump version to 3.12.0 2024-08-19 15:53:25 -07:00
Sojan 0190b6c36d Merge branch 'master' into develop 2024-08-19 15:52:43 -07:00
855b9c8f25 chore: [Snyk] Fix for 1 vulnerabilities (#9966)
Fixes for vulnerable gems

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-08-17 00:34:15 -07:00
Chatwoot BotandGitHub bb78639696 chore: Update translations (#9946) 2024-08-16 14:06:32 +05:30
f38101527f feat: Redesign onboarding screen (#9939)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-08-16 13:01:22 +05:30
Shivam MishraandGitHub 4310a547ca fix: Chat list tab styles (#9960) 2024-08-14 18:33:14 +05:30
Muhsin KelothandGitHub 06a362318c fix: Instagram audio rendering issues (#9957)
We are using `audio` component for rendering audio files in dashboard.

```
<audio v-else-if="isAudio" controls>
   <source :src="`${dataUrl}?t=${Date.now()}`" />
</audio>
```
We have added the timestamp for every audio URL for cache busting. For
Instagram, we are getting a signature URL. When we add any value and
access the URL, it results in an "Invalid signature. If I remove the
timestamp, the audio is rendering properly. This PR will change the
logic to construct the URL properly instead of direct string
manipulation.
2024-08-14 13:24:00 +05:30
Sivin VargheseandGitHub dd8abe975c feat: Rewrite configMixin to a composable (#9921)
# Pull Request Template

## Description

This PR will replace the usage of `configMixin` with the `useConfig`
composable.

**Files updated**
1. dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue
2. dashboard/components/widgets/conversation/MessagesView.vue
3. dashboard/routes/dashboard/settings/inbox/Settings.vue **(Not used)**
4. dashboard/routes/dashboard/settings/inbox/FinishSetup.vue **(Not
used)**
5.
dashboard/routes/dashboard/settings/inbox/settingsPage/CollaboratorsPage.vue
6.
dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue
**(Not used)**
7. dashboard/routes/dashboard/settings/profile/AudioNotifications.vue
**(Not used)**
8. dashboard/routes/dashboard/settings/sla/Index.vue **(Not used)**
9. dashboard/routes/dashboard/settings/account/Index.vue
10. survey/views/Response.vue **(Not used)**

Fixes
https://linear.app/chatwoot/issue/CW-3464/rewrite-configmixin-mixin-to-a-composable

## Type of change

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

## How Has This Been Tested?

Test in the component related pages


## 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
2024-08-14 12:56:30 +05:30
Sivin VargheseandGitHub 207933ed70 fix: TypeError cannot read properties of undefined (reading '$el') (#9951)
# Pull Request Template

## Description

This PR will fix an error `TypeError cannot read properties of undefined
(reading '$el')` with the keyboard shortcut `Alt+KeyA` for triggering
the file picker.

**Issue**
I couldn't able reproduce this issue. It might be because `$children`
was unavailable at the time of access.

**Solution**
Previously, it relied on `$children`. Now, it uses the more reliable
`querySelector` method to find the target element.

Fixes
https://chatwoot-p3.sentry.io/issues/5708410274/?alert_rule_id=15157525&alert_timestamp=1723552508790&alert_type=email&environment=production&notification_uuid=be5966b2-f17d-4273-8709-98e3322f1f6f&project=4507182691975168&referrer=alert_email

## Type of change

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

## How Has This Been Tested?

**Test cases**
1. Open a conversation
2. And click `Alt+KeyA` to trigger the file picker.
3. See if there is any issue or errors.


## 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
2024-08-14 11:22:51 +05:30
b998f04826 feat: Update the design for user management page (#9948)
This PR is the part of the settings page design update series. This PR updates the design for the user management page.


Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-13 19:31:31 +05:30
Sivin VargheseandGitHub 7d6466022a fix: TypeError: t.messages.at is not a function (#9950) 2024-08-13 19:16:15 +05:30
Sivin VargheseandGitHub 7ff939efd5 fix: TypeError cannot read properties of undefined (reading 'id') (#9947) 2024-08-13 16:28:41 +05:30
Sivin VargheseandGitHub b33d59d804 feat: Rewrite conversations mixin to a helper (#9931) 2024-08-13 15:15:04 +05:30
Fayaz AhmedandGitHub c26490e9c1 chore: Replace messageMixing with useMessage composable [CW-3475] (#9942)
# Pull Request Template

## Description

Replaces the messageMixin with the new useMessage composable

Fixes
https://linear.app/chatwoot/issue/CW-3475/rewrite-messagemixin-mixin-to-a-composable

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
2024-08-13 09:21:54 +05:30
Sivin VargheseandGitHub 66db9a0cc1 feat: Rewrite accountMixin to a composable (#9914) 2024-08-12 18:53:30 +05:30
Sivin VargheseandGitHub b1da3dc7cf feat: Replace attributeMixin within the component (#9919)
# Pull Request Template

## Description

This PR will replace the usage of `attributeMixin` within the component
itself. And moved the component from option API to composition API and
updated the styles and related component

Fixes
https://linear.app/chatwoot/issue/CW-3444/rewrite-attributemixin-mixin-to-a-composable

## Type of change

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

## How Has This Been Tested?

**Test cases**
1. Add custom attributes for both conversation and contact from the
settings
2. See all attributes are showing based on the conversation and contact
in both conversation and contact sidebar.
3. Try all CRUD operations like EDIT, DELETE. 
4. Check whether styles are properly showing or not (Background color
based on odd/even)

## 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
2024-08-12 18:26:07 +05:30
Sivin VargheseandGitHub 4c6572c2c9 feat: Rewrite conversation/labelMixin to a composable (#9936)
# Pull Request Template

## Description

This PR will replace the usage of `conversation/labelMixin` with a
composable

Fixes
https://linear.app/chatwoot/issue/CW-3439/rewrite-conversationlabelmixin-mixin-to-a-composable

## Type of change

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

## How Has This Been Tested?

**Test cases**

1. Add/remove labels from conversation sidebar
2. See labels are showing up dynamically
3. Check add/remove labels working fine with CMD bar
4. Check card labels in conversation card and SLA reports table.


## 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
2024-08-12 17:41:12 +05:30
Sivin VargheseandGitHub 452096f4b2 feat: Replace rtlMixin to a composable (#9924)
This PR will replace the usage of `rtlMixin` to the `useUISettings` composable, and moved the method to component itself.
2024-08-12 15:50:21 +05:30
Shivam MishraandGitHub 96d60674aa chore(deps): Update browserlistdb (#9933) 2024-08-12 15:47:54 +05:30
Pranav 0b0e266455 Bump version to v3.11.1 2024-08-12 15:15:49 +05:30
Shivam MishraandPranav 6e12ba04ab fix: last_activity_at is nil when conv is created (#9934)
The payload does not include last_activity_at when the conversation is created. Because of this the frontend is not able to sort the conversations when appending this. Another problem is that the last_activity_at is not always present, it is added only when a message is created, and it updates it. So this can be nil when the conversation is created, so we fallback to created_at only at the presentation layer
2024-08-12 15:15:49 +05:30
Pranav ffc0183809 Merge branch 'hotfix/3.11.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-08-12 15:11:19 +05:30
Pranav dcefd58240 Bump version to v3.11.1 2024-08-12 15:10:42 +05:30
Shivam MishraandGitHub 6196a6d99a fix: last_activity_at is nil when conv is created (#9934)
The payload does not include last_activity_at when the conversation is created. Because of this the frontend is not able to sort the conversations when appending this. Another problem is that the last_activity_at is not always present, it is added only when a message is created, and it updates it. So this can be nil when the conversation is created, so we fallback to created_at only at the presentation layer
2024-08-12 15:08:06 +05:30
PranavandGitHub 4a63d1d896 feat: Update the design for label management page (#9932)
This PR is part of the settings design update series. It updates the design for the label management page. I've made a few changes to the SettingsLayout page to reduce boilerplate code.
2024-08-12 09:29:39 +05:30
Sivin VargheseandGitHub d5f34bf9d0 feat: Replace conversation/teamMixin within the components (#9923) 2024-08-11 10:09:08 +05:30
Sivin VargheseandGitHub 3558878ae2 feat: Replace the use of macroMixin with a composable (#9912)
# Pull Request Template

## Description

This PR will replace usage of `macroMixin` with the `useMacros`
composable. And updated components from option API to composition API.

**Files updated**
1. dashboard/routes/dashboard/settings/macros/MacroNode.vue
2. dashboard/routes/dashboard/settings/macros/MacroEditor.vue

Fixes
https://linear.app/chatwoot/issue/CW-3449/rewrite-macrosmixin-mixin-to-a-composable

## Type of change

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

## How Has This Been Tested?

**Test cases**
1. Check whether we can create a new macro.
2. Check whether validations and error animation are working or not.
3. Ability to drag the macro files
4. Check whether the edit pages and functionality is working or not.


## 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
2024-08-09 18:40:06 +05:30
Sivin VargheseandGitHub d1d1398d80 feat: Rewrite customAttributeMixin to validation helper (#9916)
# Pull Request Template

## Description

This PR will replace the use of `customAttributeMixin` with
`shared/helpers/Validators` helper.

Fixes
https://linear.app/chatwoot/issue/CW-3446/rewrite-customattributemixin-mixin-to-a-composable

**Files updated**
1. widget/components/PreChat/Form.vue
2. dashboard/components/CustomAttribute.vue
3. dashboard/routes/dashboard/settings/attributes/EditAttribute.vue

## Type of change

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

## How Has This Been Tested?

Test the custom validation is working or not with the custom attributes.


## 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
2024-08-09 18:37:26 +05:30
Muhsin KelothandGitHub 40bd79f1ca feat: Add issue status in linear issue search item (#9598) 2024-08-08 13:44:08 +05:30
Sivin VargheseandGitHub 74bbbd25b9 feat: Replace the use of keyboardEventListener mixin to a composable (Part -3) (#9897) 2024-08-08 12:40:56 +05:30
PranavandGitHub ae938b2154 fix: Disable the password section is the admin has disabled user profile update (#9910)
This PR updates the profile settings page to completely disable the password section, including the heading, if the admin has disabled user profile updates. Previously, the section heading was shown with empty content, which caused confusion.
2024-08-07 09:51:37 -07:00
80a90d9d8c feat: Update the design for canned responses (#9903)
This is the continuation of the design update series. Canned responses listing page is rewritten with the design change.
---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2024-08-07 09:43:47 -07:00
Sivin VargheseandGitHub 646cfb97e7 fix: Snooze conversation not working in Inbox view (#9875) 2024-08-07 18:57:29 +05:30
Sivin VargheseandGitHub 89acbd8d09 feat: Replace the use of keyboardEventListener mixin to a composable (Part -2) (#9892) 2024-08-07 15:43:11 +05:30
Sivin VargheseandGitHub b03a839809 fix: TypeError: Cannot read properties of null (reading 'user_id') (#9884) 2024-08-07 14:42:01 +05:30
Sivin VargheseandGitHub 56e93d152d feat: Replace the use of mentionSelectionKeyboard mixin to a composable (#9904) 2024-08-07 14:14:41 +05:30
c344f2b9cf fix: Handle the case where message has no attachments (#9902)
Fix the broken message sending due to the errors in attachment update PR
https://github.com/chatwoot/chatwoot/pull/9784

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-07 09:57:12 +05:30
aea68f1ecf feat: Update the design of the audit logs page (#9901)
This is continuation of the design update, updates the design for audit logs listing page.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-06 20:58:04 -07:00
e393bcf125 fix: Update the logic to handle attachments in a conversation (#9784)
When the chat is viewed, a function `fetchAllAttachments` is run to get all attachments for a particular conversation. This function, before updating the record creates the `attachments` property on the `chat` object in the store.
If in any case this function fails, the `attachments` property is not created, and when the code reaches the `dashboard/store/modules/conversations/index.js` the error occurs

This PR fixes it by ensuring that `SET_ALL_ATTACHMENTS` is always run. And it handles the default case as well


---

Sentry Issue:
[CHATWOOT-FRONTEND-APP-5Y](https://chatwoot-p3.sentry.io/issues/5459056982/)

```
TypeError: Cannot read properties of undefined (reading 'some')
  at forEach(./app/javascript/dashboard/store/modules/conversations/index.js:160:31)
  at Array.forEach(<anonymous>)
  at mutations(./app/javascript/dashboard/store/modules/conversations/index.js:159:27)
  at handler(./node_modules/vuex/dist/vuex.js:771:7)
  at forEach(./node_modules/vuex/dist/vuex.js:470:9)
  at Array.forEach(<anonymous>)
  at fn(./node_modules/vuex/dist/vuex.js:469:13)
  at Store.prototype._withCommit(./node_modules/vuex/dist/vuex.js:574:5)
  at Store.prototype.commit(./node_modules/vuex/dist/vuex.js:468:10)
  at this.commit(./node_modules/vuex/dist/vuex.js:420:21)
  at call(./app/javascript/dashboard/store/modules/conversations/actions.js:273:7)
  at tryCatch(./node_modules/videojs-record/dist/videojs.record.js:2868:27)
  at _invoke(./node_modules/videojs-record/dist/videojs.record.js:3088:32)
  at prototype[method](./node_modules/videojs-record/dist/videojs.record.js:2921:31)
  at as(/packs/js/application-cf716bca3c984faeb095.js:4:76)
  at as(/packs/js/application-cf716bca3c984faeb095.js:4:76)
  at nrWrapper(/app/accounts/81898/conversations/95:6:17817)
```

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-08-06 18:13:41 -07:00
e330c2f6c0 feat: Update the design for teams (#9899)
This PR updates the design for the team listing page. This PR is part of the design revamp project for all the settings pages.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-08-06 17:26:23 -07:00
Sojan JoseandGitHub cb4ad28a13 chore: Auto resolution job for captain (#9898)
- Add a conversation auto-resolution job for the captain integration
2024-08-06 16:15:11 -07:00
rotsenandGitHub 91b713f6f5 fix: Use appropriate logos for integrations in the dark mode (#9881) 2024-08-06 09:40:41 -07:00
Sivin VargheseandGitHub 59b9c55967 fix: Keydown handler in useKeyboardEvent composable not registering correctly (#9896)
… correctly

# Pull Request Template

## Description

This PR fixes an issue where the key down handler in the
`useKeyboardEvent` composable was not registering correctly.

## 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
- [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
2024-08-06 19:34:36 +05:30
PranavandGitHub 736f16b170 fix: Fix issues with new conversation form (#9891)
`requiredIf('isAnEmailInbox')` was not evaluated properly. This PR fixes
it by moving it to a method and using the `this.[variable]` pattern.
2024-08-06 16:22:38 +05:30
Sivin VargheseandGitHub 28d4432152 feat: Dynamically show Shift key in shortcut modal for QWERTZ layout (#9888)
# Pull Request Template

## Description

This PR includes UI changes to dynamically add the `Shift` key to the
key set `Alt+KeyP` and `Alt+KeyL` in the keyboard shortcut modal for the
`QWERTZ` layout.

**Context**
Previously, the `Alt+L` shortcut for toggling the reply editor
conflicted with the `@` symbol on the QWERTZ layout in macOS. The new
`useDetectLayout` composable checks the active keyboard layout. If
`QWERTZ` is detected, the shortcuts are modified to `Shift+Alt+KeyP` and
`Shift+Alt+KeyL`.

[PR with the functionality
changes](https://github.com/chatwoot/chatwoot/pull/9831#event-13764407813)

Fixes
https://linear.app/chatwoot/issue/PR-1095/typing-a-in-private-note-switches-to-reply-tab-with-german-keyboard

## Type of change

- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/35b741c5afc64bc58bd4e7dc5dad012d?sid=f66ca0bf-b6a7-40fc-8972-ff0cd0196a16

## 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
2024-08-06 16:17:43 +05:30
Sivin VargheseandGitHub e0b67bb552 feat: Rewrite keyboardEventListener mixin to a composable (#9831) 2024-08-05 18:59:47 +05:30
Shivam MishraandGitHub 0ba086a7ef Merge branch 'develop' into fix/show-error-on-clipboard-errors 2024-07-22 21:43:10 +05:30
Sojan 8ea412bc85 Merge branch 'release/3.11.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-07-16 19:23:03 -07:00
Shivam MishraandGitHub a2b2567371 Merge branch 'develop' into fix/show-error-on-clipboard-errors 2024-07-01 13:52:18 +05:30
Shivam Mishra 11155b755d chore: added deprecation notice 2024-07-01 13:51:44 +05:30
Sojan 862ef37e7f Merge branch 'release/3.10.2'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-06-26 17:08:38 -07:00
Sojan 9a1d5519ec Merge branch 'release/3.10.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-06-21 15:07:01 -07:00
Sojan e17f0ea753 Merge branch 'release/3.10.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-06-17 23:59:25 -07:00
Fayaz AhmedandGitHub 7ce0b4fad5 Merge branch 'develop' into fix/show-error-on-clipboard-errors 2024-06-14 15:50:15 +05:30
Fayaz Ahmed cfd6f25426 Fallback with old methods in case if copy fails with clipboard.writeText 2024-06-11 07:18:56 +05:30
Fayaz Ahmed fe8e58a80b fix: Show toast on clipboard errors 2024-06-11 07:12:42 +05:30
Sojan f7580f864c Merge branch 'release/3.9.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-05-15 22:32:12 -07:00
Sojan 85aeaf2aee Merge branch 'release/3.8.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-04-16 17:09:07 -07:00
Sojan e93af2681e Merge branch 'release/3.7.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-03-18 17:27:13 +05:30
Sojan cb2b75ca17 Merge branch 'release/3.6.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-02-19 15:59:20 +05:30
Sojan 608592db0c Merge branch 'release/3.5.2'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-19 15:15:27 +04:00
Sojan ab1f803354 Merge branch 'release/3.5.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-17 13:48:22 +04:00
Sojan b81c722ac8 Merge branch 'release/3.5.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-16 11:15:11 +04:00
Sojan b40ad399f6 Merge branch 'release/3.4.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-12-18 21:22:55 -08:00
Sojan dcd18072b4 Merge branch 'release/3.3.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-18 10:24:25 -08:00
Sojan bda2d1d9a4 Merge branch 'release/3.3.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-17 20:37:25 -08:00
Sojan 83231b9678 Merge branch 'release/3.2.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-10-17 17:55:34 -07:00
1211 changed files with 15451 additions and 9343 deletions
-8
View File
@@ -25,13 +25,5 @@ jobs:
with:
issue-inactive-days: '30'
issue-lock-reason: 'resolved'
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
pr-inactive-days: '30'
pr-lock-reason: 'resolved'
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
+4 -4
View File
@@ -111,12 +111,12 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.18.2', require: false
gem 'sentry-rails', '>= 5.19.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.18.2', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.3.0'
gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
@@ -228,7 +228,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.1.3'
gem 'rspec-rails', '>= 6.1.4'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
+19 -19
View File
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -230,7 +230,7 @@ GEM
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.13.0)
et-orbi (1.2.7)
et-orbi (1.2.11)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@@ -268,8 +268,8 @@ GEM
rake
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.9.0)
et-orbi (~> 1, >= 1.2.7)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
gapic-common (0.18.0)
faraday (>= 1.9, < 3.a)
@@ -467,7 +467,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.24.1)
minitest (5.25.1)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -480,7 +480,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.12)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
@@ -634,7 +634,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.4)
rexml (3.3.6)
strscan
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
@@ -644,7 +644,7 @@ GEM
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.3)
rspec-rails (6.1.4)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
@@ -710,19 +710,19 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.18.2)
sentry-rails (5.19.0)
railties (>= 5.0)
sentry-ruby (~> 5.18.2)
sentry-ruby (5.18.2)
sentry-ruby (~> 5.19.0)
sentry-ruby (5.19.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.18.2)
sentry-ruby (~> 5.18.2)
sentry-sidekiq (5.19.0)
sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.0)
sidekiq (7.3.1)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
logger
@@ -796,7 +796,7 @@ GEM
uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (4.0.6)
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
@@ -930,7 +930,7 @@ DEPENDENCIES
responders (>= 3.1.1)
rest-client
reverse_markdown
rspec-rails (>= 6.1.3)
rspec-rails (>= 6.1.4)
rspec_junit_formatter
rubocop
rubocop-performance
@@ -939,11 +939,11 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.18.2)
sentry-rails (>= 5.19.0)
sentry-ruby
sentry-sidekiq (>= 5.18.2)
sentry-sidekiq (>= 5.19.0)
shoulda-matchers
sidekiq (>= 7.3.0)
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
+21 -2
View File
@@ -32,11 +32,13 @@ class AccountBuilder
end
def validate_email
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email)
if address.valid? # && !address.disposable?
if address.valid? && !address.disposable?
true
else
raise InvalidEmail.new(valid: address.valid?)
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
end
end
@@ -79,4 +81,21 @@ class AccountBuilder
@user.confirm if @confirmed
@user.save!
end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
domains.split("\n").map(&:strip) if domains.present?
[]
end
end
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def update
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
end
def destroy
@@ -67,8 +67,16 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id])
end
def account_user_attributes
[:role, :availability, :auto_offline]
end
def allowed_agent_params
[:name, :email, :name, :role, :availability, :auto_offline]
end
def agent_params
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
params.require(:agent).permit(allowed_agent_params)
end
def new_agent_params
@@ -101,3 +109,5 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end
end
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
@@ -11,7 +11,17 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end
def process_event
render json: { message: @hook.process_event(params[:event]) }
response = @hook.process_event(params[:event])
# for cases like an invalid event, or when conversation does not have enough messages
# for a label suggestion, the response is nil
if response.nil?
render json: { message: nil }
elsif response[:error]
render json: { error: response[:error] }, status: :unprocessable_entity
else
render json: { message: response[:message] }
end
end
def destroy
+3 -7
View File
@@ -10,7 +10,6 @@ import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import {
@@ -32,9 +31,6 @@ export default {
UpgradeBanner,
PendingEmailVerificationBanner,
},
mixins: [rtlMixin],
data() {
return {
showAddAccountModal: false,
@@ -46,6 +42,7 @@ export default {
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
@@ -102,7 +99,6 @@ export default {
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
@@ -124,8 +120,8 @@ export default {
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
@@ -1,15 +1,15 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
@@ -41,7 +41,7 @@ export default {
IntersectionObserver,
VirtualList,
},
mixins: [conversationMixin, keyboardEventListenerMixins, filterMixin],
mixins: [filterMixin],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -89,8 +89,63 @@ export default {
setup() {
const { uiSettings } = useUISettings();
const conversationListRef = ref(null);
const getKeyboardListenerParams = () => {
const allConversations = conversationListRef.value.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = conversationListRef.value.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
};
const handlePreviousConversation = () => {
const { allConversations, activeConversationIndex } =
getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
};
const handleNextConversation = () => {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleNextConversation(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
conversationListRef,
};
},
data() {
@@ -113,7 +168,7 @@ export default {
isContextMenuOpen: false,
appliedFilter: [],
infiniteLoaderOptions: {
root: this.$refs.conversationList,
root: this.$refs.conversationListRef,
rootMargin: '100px 0px 100px 0px',
},
@@ -490,58 +545,6 @@ export default {
}))
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.conversationList.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = this.$refs.conversationList.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
},
handlePreviousConversation() {
const { allConversations, activeConversationIndex } =
this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
},
handleNextConversation() {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
},
getKeyboardEvents() {
return {
'Alt+KeyJ': {
action: () => this.handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => this.handleNextConversation(),
allowOnFocusedInput: true,
},
};
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
@@ -902,7 +905,6 @@ export default {
v-if="!hasAppliedFiltersOrActiveFolders"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
class="tab--chat-type"
@chatTabChange="updateAssigneeTab"
/>
@@ -927,7 +929,7 @@ export default {
@assignTeam="onAssignTeamsForBulk"
/>
<div
ref="conversationList"
ref="conversationListRef"
class="flex-1 conversations-list"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
@@ -990,14 +992,4 @@ export default {
.conversations-list {
@apply overflow-hidden hover:overflow-y-auto;
}
.tab--chat-type {
@apply py-0 px-4;
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -5,7 +5,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core';
const DATE_FORMAT = 'yyyy-MM-dd';
@@ -15,7 +15,6 @@ export default {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
@@ -128,8 +127,7 @@ export default {
required,
regexValidation: value => {
return !(
this.attributeRegex &&
!this.getRegexp(this.attributeRegex).test(value)
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
);
},
},
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components/ui/Banner.vue';
import accountMixin from 'dashboard/mixins/account';
const EMPTY_SUBSCRIPTION_INFO = {
status: null,
@@ -11,10 +11,13 @@ const EMPTY_SUBSCRIPTION_INFO = {
export default {
components: { Banner },
mixins: [accountMixin],
setup() {
const { isAdmin } = useAdmin();
const { accountId } = useAccount();
return {
accountId,
isAdmin,
};
},
@@ -1,12 +1,10 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import { useAlert } from 'dashboard/composables';
export default {
components: { Banner },
mixins: [accountMixin],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -1,12 +1,17 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
mixins: [accountMixin],
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
@@ -1,145 +1,139 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
const openDropdown = () => toggleDropdown(true);
const currentChat = computed(() => getters.getSelectedChat.value);
const isOpen = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.OPEN
);
const isPending = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.PENDING
);
const isResolved = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.RESOLVED
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const buttonClass = computed(() => {
if (isPending.value) return 'primary';
if (isOpen.value) return 'success';
if (isResolved.value) return 'warning';
return '';
});
const showAdditionalActions = computed(
() => !isPending.value && !isSnoozed.value
);
const showOpenButton = computed(() => {
return isPending.value || isSnoozed.value;
});
const getConversationParams = () => {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
};
const openSnoozeModal = () => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
};
const toggleStatus = (status, snoozedUntil) => {
closeDropdown();
isLoading.value = true;
store
.dispatch('toggleStatus', {
conversationId: currentChat.value.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
};
const onCmdOpenConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.OPEN);
};
const onCmdResolveConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
};
const keyboardEvents = {
'Alt+KeyM': {
action: () => arrowDownButtonRef.value?.$el.click(),
allowOnFocusedInput: true,
},
mixins: [keyboardEventListenerMixins],
data() {
return {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
isOpen() {
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
},
isPending() {
return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING;
},
isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() {
if (this.isPending) return 'primary';
if (this.isOpen) return 'success';
if (this.isResolved) return 'warning';
return '';
},
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
'Alt+KeyE': {
action: async () => {
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
},
},
mounted() {
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyM': {
action: () => this.$refs.arrowDownButton?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': this.resolveOrToast,
'$mod+Alt+KeyE': async event => {
const { all, activeIndex, lastIndex } = this.getConversationParams();
await this.resolveOrToast();
'$mod+Alt+KeyE': {
action: async event => {
const { all, activeIndex, lastIndex } = getConversationParams();
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
};
},
getConversationParams() {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
},
async resolveOrToast() {
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
onCmdResolveConversation() {
this.toggleStatus(this.STATUS_TYPE.RESOLVED);
},
showOpenButton() {
return this.isResolved || this.isSnoozed;
},
closeDropdown() {
this.showActionsDropdown = false;
},
openDropdown() {
this.showActionsDropdown = true;
},
toggleStatus(status, snoozedUntil) {
this.closeDropdown();
this.isLoading = true;
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
openSnoozeModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
event.preventDefault();
},
},
};
useKeyboardEvents(keyboardEvents);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
@@ -165,7 +159,7 @@ export default {
:is-loading="isLoading"
@click="onCmdOpenConversation"
>
{{ $t('CONVERSATION.HEADER.REOPEN_ACTION') }}
{{ t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button>
<woot-button
v-else-if="showOpenButton"
@@ -175,11 +169,11 @@ export default {
:is-loading="isLoading"
@click="onCmdOpenConversation"
>
{{ $t('CONVERSATION.HEADER.OPEN_ACTION') }}
{{ t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button>
<woot-button
v-if="showAdditionalActions"
ref="arrowDownButton"
ref="arrowDownButtonRef"
:color-scheme="buttonClass"
:disabled="isLoading"
icon="chevron-down"
@@ -190,7 +184,7 @@ export default {
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
class="dropdown-pane dropdown-pane--open left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem]"
>
<WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending">
@@ -201,7 +195,7 @@ export default {
icon="snooze"
@click="() => openSnoozeModal()"
>
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
{{ t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem v-if="!isPending">
@@ -210,9 +204,9 @@ export default {
color-scheme="secondary"
size="small"
icon="book-clock"
@click="() => toggleStatus(STATUS_TYPE.PENDING)"
@click="() => toggleStatus(wootConstants.STATUS_TYPE.PENDING)"
>
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
{{ t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button>
</WootDropdownItem>
</WootDropdownMenu>
@@ -222,8 +216,6 @@ export default {
<style lang="scss" scoped>
.dropdown-pane {
@apply left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem];
.dropdown-menu__item {
@apply mb-0;
}
@@ -1,19 +1,22 @@
<script>
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useRoute, useRouter } from 'dashboard/composables/route';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import router, { routesWithPermissions } from '../../routes';
import { hasPermissions } from '../../helper/permissionsHelper';
import { routesWithPermissions } from '../../routes';
import {
getUserPermissions,
hasPermissions,
} from '../../helper/permissionsHelper';
export default {
components: {
PrimarySidebar,
SecondarySidebar,
},
mixins: [keyboardEventListenerMixins],
props: {
showSecondarySidebar: {
type: Boolean,
@@ -24,6 +27,50 @@ export default {
default: '',
},
},
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const toggleKeyShortcutModal = () => {
emit('openKeyShortcutModal');
};
const closeKeyShortcutModal = () => {
emit('closeKeyShortcutModal');
};
const isCurrentRouteSameAsNavigation = routeName => {
return route.name === routeName;
};
const navigateToRoute = routeName => {
if (!isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
};
const keyboardEvents = {
'$mod+Slash': {
action: toggleKeyShortcutModal,
},
'$mod+Escape': {
action: closeKeyShortcutModal,
},
'Alt+KeyC': {
action: () => navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => navigateToRoute('agent_list'),
},
};
useKeyboardEvents(keyboardEvents);
return {
toggleKeyShortcutModal,
};
},
data() {
return {
showOptionsMenu: false,
@@ -66,7 +113,10 @@ export default {
return getSidebarItems(this.accountId);
},
primaryMenuItems() {
const userPermissions = this.currentUser.permissions;
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem => {
const isAvailableForTheUser = hasPermissions(
@@ -131,38 +181,6 @@ export default {
this.$store.dispatch('customViews/get', this.activeCustomView);
}
},
toggleKeyShortcutModal() {
this.$emit('openKeyShortcutModal');
},
closeKeyShortcutModal() {
this.$emit('closeKeyShortcutModal');
},
getKeyboardEvents() {
return {
'$mod+Slash': this.toggleKeyShortcutModal,
'$mod+Escape': this.closeKeyShortcutModal,
'Alt+KeyC': {
action: () => this.navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => this.navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => this.navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => this.navigateToRoute('agent_list'),
},
};
},
navigateToRoute(routeName) {
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
},
isCurrentRouteSameAsNavigation(routeName) {
return this.$route.name === routeName;
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
@@ -173,11 +173,10 @@ const settings = accountId => ({
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
toState: frontendURL(`accounts/${accountId}/settings/audit-logs/list`),
toStateName: 'auditlogs_list',
isEnterpriseOnly: true,
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
beta: true,
},
{
icon: 'document-list-clock',
@@ -4,7 +4,10 @@ import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { hasPermissions } from '../../../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../../../helper/permissionsHelper';
import { routesWithPermissions } from '../../../routes';
export default {
@@ -59,7 +62,10 @@ export default {
accessibleMenuItems() {
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
menuItem => {
const { permissions: userPermissions = [] } = this.currentUser;
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
return hasPermissions(
routesWithPermissions[menuItem.toStateName],
userPermissions
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import configMixin from 'shared/mixins/configMixin';
import { useConfig } from 'dashboard/composables/useConfig';
import {
getInboxClassByType,
getInboxWarningIconClass,
@@ -16,7 +16,6 @@ import Policy from '../../policy.vue';
export default {
components: { SecondaryChildNavItem, Policy },
mixins: [configMixin],
props: {
menuItem: {
type: Object,
@@ -25,8 +24,10 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const { isEnterprise } = useConfig();
return {
isAdmin,
isEnterprise,
};
},
computed: {
+14 -5
View File
@@ -1,7 +1,11 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
import { hasPermissions } from '../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../helper/permissionsHelper';
const props = defineProps({
permissions: {
type: Array,
@@ -10,12 +14,17 @@ const props = defineProps({
});
const getters = useStoreGetters();
const user = getters.getCurrentUser.value;
const hasPermission = computed(() =>
hasPermissions(props.permissions, user.permissions)
);
const user = computed(() => getters.getCurrentUser.value);
const accountId = computed(() => getters.getCurrentAccountId.value);
const userPermissions = computed(() => {
return getUserPermissions(user.value, accountId.value);
});
const hasPermission = computed(() => {
return hasPermissions(props.permissions, userPermissions.value);
});
</script>
<!-- eslint-disable vue/no-root-v-if -->
<template>
<div v-if="hasPermission">
<slot />
@@ -101,6 +101,8 @@ const shouldShowEmptyState = computed(() => {
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
:icon="item.icon"
:icon-color="item.iconColor"
@click="$emit('click', item)"
/>
</slot>
@@ -8,6 +8,14 @@ defineProps({
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
iconColor: {
type: String,
default: '',
},
});
</script>
@@ -20,6 +28,12 @@ defineProps({
@focus="$emit('focus')"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
v-if="icon"
:icon="icon"
size="18"
:style="{ color: iconColor }"
/>
<span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
@@ -1,12 +1,13 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
export default {
@@ -15,22 +16,46 @@ export default {
AICTAModal,
AIAssistanceCTAButton,
},
mixins: [aiMixin, keyboardEventListenerMixins],
setup() {
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const initialMessage = ref('');
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
aiOption: '',
initialMessage: '',
}),
computed: {
...mapGetters({
@@ -56,22 +81,10 @@ export default {
mounted() {
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage;
this.initializeMessage(this.draftMessage);
},
methods: {
getKeyboardEvents() {
return {
'$mod+KeyZ': {
action: () => {
if (this.initialMessage) {
this.$emit('replaceText', this.initialMessage);
this.initialMessage = '';
}
},
},
};
},
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption,
@@ -85,7 +98,7 @@ export default {
is_open_ai_cta_modal_dismissed: true,
});
}
this.initialMessage = this.draftMessage;
this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
@@ -107,7 +120,7 @@ export default {
</script>
<template>
<div v-if="!isFetchingAppIntegrations">
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@@ -1,20 +1,23 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
export default {
components: {
AILoader,
},
mixins: [aiMixin, messageFormatterMixin],
props: {
aiOption: {
type: String,
required: true,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
},
data() {
return {
generatedContent: '',
@@ -3,16 +3,16 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import aiMixin from 'dashboard/mixins/aiMixin';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
mixins: [aiMixin],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$ };
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
@@ -1,49 +1,52 @@
<script>
<script setup>
import { computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import wootConstants from 'dashboard/constants/globals';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [keyboardEventListenerMixins],
props: {
items: {
type: Array,
default: () => [],
},
activeTab: {
type: String,
default: wootConstants.ASSIGNEE_TYPE.ME,
},
const props = defineProps({
items: {
type: Array,
default: () => [],
},
computed: {
activeTabIndex() {
return this.items.findIndex(item => item.key === this.activeTab);
},
activeTab: {
type: String,
default: wootConstants.ASSIGNEE_TYPE.ME,
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyN': {
action: () => {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
},
},
};
},
onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) {
this.$emit('chatTabChange', this.items[selectedTabIndex].key);
});
const emit = defineEmits(['chatTabChange']);
const activeTabIndex = computed(() => {
return props.items.findIndex(item => item.key === props.activeTab);
});
const onTabChange = selectedTabIndex => {
if (props.items[selectedTabIndex].key !== props.activeTab) {
emit('chatTabChange', props.items[selectedTabIndex].key);
}
};
const keyboardEvents = {
'Alt+KeyN': {
action: () => {
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
onTabChange(0);
} else {
onTabChange(activeTabIndex.value + 1);
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<woot-tabs :index="activeTabIndex" @change="onTabChange">
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
v-for="item in items"
:key="item.key"
@@ -52,3 +55,13 @@ export default {
/>
</woot-tabs>
</template>
<style scoped lang="scss">
.tab--chat-type {
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -1,83 +1,65 @@
<script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
<script setup>
import { ref, computed } from 'vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
export default {
components: {
AddLabel,
LabelDropdown,
const props = defineProps({
allLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
},
});
mixins: [keyboardEventListenerMixins],
const emit = defineEmits(['add', 'remove']);
props: {
allLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
const { isAdmin } = useAdmin();
const showSearchDropdownLabel = ref(false);
const selectedLabels = computed(() => {
return props.savedLabels.map(label => label.title);
});
const addItem = label => {
emit('add', label);
};
const removeItem = label => {
emit('remove', label);
};
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
toggleLabels();
e.preventDefault();
},
},
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showSearchDropdownLabel: false,
};
},
computed: {
selectedLabels() {
return this.savedLabels.map(label => label.title);
},
},
methods: {
addItem(label) {
this.$emit('add', label);
},
removeItem(label) {
this.$emit('remove', label);
},
toggleLabels() {
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
},
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
getKeyboardEvents() {
return {
KeyL: {
action: e => {
this.toggleLabels();
e.preventDefault();
},
},
Escape: {
action: () => this.closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
},
Escape: {
action: () => closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div v-on-clickaway="closeDropdownLabel" class="label-wrap">
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in savedLabels"
@@ -89,10 +71,10 @@ export default {
variant="smooth"
@click="removeItem"
/>
<div class="dropdown-wrap">
<div class="absolute w-full top-7">
<div
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
class="dropdown-pane"
class="!box-border !w-full dropdown-pane"
>
<LabelDropdown
v-if="showSearchDropdownLabel"
@@ -106,28 +88,3 @@ export default {
</div>
</div>
</template>
<style lang="scss" scoped>
.title-icon {
margin-right: var(--space-smaller);
}
.label-wrap {
position: relative;
line-height: var(--space-medium);
.dropdown-wrap {
display: flex;
position: absolute;
margin-right: var(--space-medium);
top: var(--space-medium);
width: 100%;
left: -1px;
.dropdown-pane {
width: 100%;
box-sizing: border-box;
}
}
}
</style>
@@ -1,8 +1,8 @@
<script>
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
@@ -18,7 +18,7 @@ import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [keyboardEventListenerMixins, inboxMixin],
mixins: [inboxMixin],
props: {
mode: {
type: String,
@@ -116,6 +116,24 @@ export default {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const keyboardEvents = {
'Alt+KeyA': {
action: () => {
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
@@ -125,6 +143,7 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
@@ -207,21 +226,14 @@ export default {
);
return isFeatEnabled && this.portalSlug;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
},
mounted() {
ActiveStorage.start();
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyA': {
action: () => {
this.$refs.upload.$children[1].$el.click();
},
allowOnFocusedInput: true,
},
};
},
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
@@ -249,7 +261,6 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
@@ -330,6 +341,7 @@ export default {
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@@ -337,7 +349,7 @@ export default {
/>
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
v-show="$refs.uploadRef && $refs.uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
@@ -1,18 +1,13 @@
<script>
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'ReplyTopPanel',
mixins: [keyboardEventListenerMixins],
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
setReplyMode: {
type: Function,
default: () => {},
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -26,6 +21,33 @@ export default {
default: false,
},
},
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
};
const handleReplyClick = () => {
setReplyMode(REPLY_EDITOR_MODES.REPLY);
};
const handleNoteClick = () => {
setReplyMode(REPLY_EDITOR_MODES.NOTE);
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => handleReplyClick(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
handleReplyClick,
handleNoteClick,
};
},
computed: {
replyButtonClass() {
return {
@@ -46,26 +68,6 @@ export default {
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
},
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyP': {
action: () => this.handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => this.handleReplyClick(),
allowOnFocusedInput: true,
},
};
},
handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
},
handleNoteClick() {
this.setReplyMode(REPLY_EDITOR_MODES.NOTE);
},
},
};
</script>
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import conversationMixin from '../../../mixins/conversations';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
@@ -24,7 +24,7 @@ export default {
PriorityMark,
SLACardLabel,
},
mixins: [inboxMixin, conversationMixin],
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
@@ -118,7 +118,7 @@ export default {
},
lastMessageInChat() {
return this.lastMessage(this.chat);
return getLastMessage(this.chat);
},
inbox() {
@@ -316,7 +316,11 @@ export default {
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
<CardLabels
:conversation-id="chat.id"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -1,8 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import agentMixin from '../../../mixins/agentMixin.js';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import BackButton from '../BackButton.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
@@ -23,7 +22,7 @@ export default {
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
mixins: [inboxMixin],
props: {
chat: {
type: Object,
@@ -42,6 +41,14 @@ export default {
default: false,
},
},
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -117,16 +124,6 @@ export default {
);
},
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyO': {
action: () => this.$emit('contactPanelToggle'),
},
};
},
},
};
</script>
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import accountMixin from 'dashboard/mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
import OnboardingView from '../OnboardingView.vue';
import EmptyStateMessage from './EmptyStateMessage.vue';
@@ -10,7 +10,6 @@ export default {
OnboardingView,
EmptyStateMessage,
},
mixins: [accountMixin],
props: {
isOnExpandedLayout: {
type: Boolean,
@@ -19,8 +18,12 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
computed: {
@@ -44,7 +47,7 @@ export default {
return this.$t('CONVERSATION.404');
},
newInboxURL() {
return this.addAccountScoping('settings/inboxes/new');
return this.accountScopedUrl('settings/inboxes/new');
},
emptyClassName() {
if (
@@ -1,5 +1,5 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import BubbleActions from './bubble/Actions.vue';
import BubbleContact from './bubble/Contact.vue';
import BubbleFile from './bubble/File.vue';
@@ -39,7 +39,6 @@ export default {
InstagramStoryReply,
Spinner,
},
mixins: [messageFormatterMixin],
props: {
data: {
type: Object,
@@ -74,6 +73,12 @@ export default {
default: () => ({}),
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
showContextMenu: false,
@@ -1,11 +1,10 @@
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
name: 'MessagePreview',
mixins: [messageFormatterMixin],
props: {
message: {
type: Object,
@@ -20,6 +19,12 @@ export default {
default: '',
},
},
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
@@ -1,4 +1,10 @@
<script>
import { ref } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
// components
import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
@@ -9,18 +15,17 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
// mixins
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -35,13 +40,7 @@ export default {
Banner,
ConversationLabelSuggestion,
},
mixins: [
conversationMixin,
inboxMixin,
keyboardEventListenerMixins,
configMixin,
aiMixin,
],
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -52,7 +51,44 @@ export default {
default: false,
},
},
setup() {
const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
};
const showPopOutReplyBox = () => {
isPopOutReplyBox.value = !isPopOutReplyBox.value;
};
const keyboardEvents = {
Escape: {
action: closePopOutReplyBox,
},
};
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
return {
isEnterprise,
isPopOutReplyBox,
closePopOutReplyBox,
showPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
};
},
data() {
return {
isLoadingPrevious: true,
@@ -60,7 +96,6 @@ export default {
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
isPopoutReplyBox: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
@@ -116,14 +151,14 @@ export default {
}
return messages;
},
getReadMessages() {
return this.readMessages(
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
getUnReadMessages() {
return this.unReadMessages(
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
@@ -303,19 +338,6 @@ export default {
});
this.makeMessagesRead();
},
showPopoutReplyBox() {
this.isPopoutReplyBox = !this.isPopoutReplyBox;
},
closePopoutReplyBox() {
this.isPopoutReplyBox = false;
},
getKeyboardEvents() {
return {
Escape: {
action: () => this.closePopoutReplyBox(),
},
};
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
@@ -459,7 +481,7 @@ export default {
</li>
</transition>
<Message
v-for="message in getReadMessages"
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
@@ -484,7 +506,7 @@ export default {
</span>
</li>
<Message
v-for="message in getUnReadMessages"
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
@@ -506,7 +528,7 @@ export default {
</ul>
<div
class="conversation-footer"
:class="{ 'modal-mask': isPopoutReplyBox }"
:class="{ 'modal-mask': isPopOutReplyBox }"
>
<div
v-if="isAnyoneTyping"
@@ -525,8 +547,8 @@ export default {
</div>
<ReplyBox
:conversation-id="currentChat.id"
:popout-reply-box.sync="isPopoutReplyBox"
@click="showPopoutReplyBox"
:popout-reply-box.sync="isPopOutReplyBox"
@click="showPopOutReplyBox"
/>
</div>
</div>
@@ -7,7 +7,7 @@ import {
CMD_MUTE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
CMD_UNMUTE_CONVERSATION,
} from '../../../routes/dashboard/commands/commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
export default {
components: {
@@ -0,0 +1,54 @@
<script setup>
defineProps({
imageSrc: {
type: String,
default: '',
},
imageAlt: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
to: {
type: String,
default: '',
},
linkText: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="h-full w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-white/10 rounded-lg p-4 flex flex-col"
>
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
<div class="mt-auto">
<p
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
>
{{ title }}
</p>
<p class="text-slate-600 dark:text-slate-400 text-sm">
{{ description }}
</p>
<router-link
v-if="to"
:to="{ name: to }"
class="no-underline text-woot-500 text-sm font-medium"
>
<span>{{ linkText }}</span>
<span>{{ `` }}</span>
</router-link>
</div>
</div>
</template>
@@ -1,159 +1,80 @@
<script>
import { mapGetters } from 'vuex';
import accountMixin from '../../../mixins/account';
<script setup>
import OnboardingFeatureCard from './OnboardingFeatureCard.vue';
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters } from 'dashboard/composables/store';
export default {
mixins: [accountMixin],
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
newInboxURL() {
return this.addAccountScoping('settings/inboxes/new');
},
newAgentURL() {
return this.addAccountScoping('settings/agents/list');
},
newLabelsURL() {
return this.addAccountScoping('settings/labels/list');
},
},
};
const getters = useStoreGetters();
const { t } = useI18n();
const globalConfig = computed(() => getters['globalConfig/get'].value);
const currentUser = computed(() => getters.getCurrentUser.value);
const greetingMessage = computed(() => {
const hours = new Date().getHours();
let translationKey;
if (hours < 12) {
translationKey = 'ONBOARDING.GREETING_MORNING';
} else if (hours < 18) {
translationKey = 'ONBOARDING.GREETING_AFTERNOON';
} else {
translationKey = 'ONBOARDING.GREETING_EVENING';
}
return t(translationKey, {
name: currentUser.value.name,
installationName: globalConfig.value.installationName,
});
});
</script>
<template>
<div class="onboarding-wrap">
<div class="onboarding">
<div class="scroll-wrap">
<div class="features-item">
<h1 class="text-2xl text-slate-900 dark:text-slate-50">
<span>{{
$t('ONBOARDING.TITLE', {
installationName: globalConfig.installationName,
})
}}</span>
</h1>
<p class="intro-body">
{{
$t('ONBOARDING.DESCRIPTION', {
installationName: globalConfig.installationName,
})
}}
</p>
<p v-if="globalConfig.installationName === 'Chatwoot'">
<a
href="https://www.chatwoot.com/changelog"
target="_blank"
rel="noopener nofollow noreferrer"
class="onboarding--link"
>
{{ $t('ONBOARDING.READ_LATEST_UPDATES') }}
</a>
<span>🎉</span>
</p>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">💬</span>
<span class="conversation--title">{{
$t('ONBOARDING.ALL_CONVERSATION.TITLE')
}}</span>
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION') }}
</p>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">👥</span>
{{ $t('ONBOARDING.TEAM_MEMBERS.TITLE') }}
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION') }}
</p>
<router-link :to="newAgentURL" class="onboarding--link">
{{ $t('ONBOARDING.TEAM_MEMBERS.NEW_LINK') }}
</router-link>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">📥</span>{{ $t('ONBOARDING.INBOXES.TITLE') }}
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.INBOXES.DESCRIPTION') }}
</p>
<router-link :to="newInboxURL" class="onboarding--link">
{{ $t('ONBOARDING.INBOXES.NEW_LINK') }}
</router-link>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">🔖</span>{{ $t('ONBOARDING.LABELS.TITLE') }}
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.LABELS.DESCRIPTION') }}
</p>
<router-link :to="newLabelsURL" class="onboarding--link">
{{ $t('ONBOARDING.LABELS.NEW_LINK') }}
</router-link>
</div>
</div>
<div
class="min-h-screen max-w-4xl mx-auto grid grid-cols-1 xl:grid-cols-2 grid-rows-[auto_1fr_1fr] gap-4 p-8 w-full font-inter overflow-auto"
>
<div class="col-span-full self-start">
<p
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
>
{{ greetingMessage }}
</p>
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-base">
{{
$t('ONBOARDING.DESCRIPTION', {
installationName: globalConfig.installationName,
})
}}
</p>
</div>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/omnichannel-inbox.png"
image-alt="Omnichannel"
to="settings_inbox_new"
:title="$t('ONBOARDING.ALL_CONVERSATION.TITLE')"
:description="$t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION')"
:link-text="$t('ONBOARDING.ALL_CONVERSATION.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/teams.png"
image-alt="Teams"
to="settings_teams_new"
:title="$t('ONBOARDING.TEAM_MEMBERS.TITLE')"
:description="$t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION')"
:link-text="$t('ONBOARDING.TEAM_MEMBERS.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/canned-responses.png"
image-alt="Canned responses"
to="canned_list"
:title="$t('ONBOARDING.CANNED_RESPONSES.TITLE')"
:description="$t('ONBOARDING.CANNED_RESPONSES.DESCRIPTION')"
:link-text="$t('ONBOARDING.CANNED_RESPONSES.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/labels.png"
image-alt="Labels"
to="labels_list"
:title="$t('ONBOARDING.LABELS.TITLE')"
:description="$t('ONBOARDING.LABELS.DESCRIPTION')"
:link-text="$t('ONBOARDING.LABELS.NEW_LINK')"
/>
</div>
</template>
<style lang="scss" scoped>
.onboarding-wrap {
display: flex;
font-size: var(--font-size-small);
justify-content: center;
overflow: auto;
text-align: left;
}
.onboarding {
height: 100vh;
overflow: auto;
}
.scroll-wrap {
padding: var(--space-larger) 8.5rem;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
.features-item {
margin-bottom: var(--space-large);
}
.conversation--title {
margin-left: var(--space-minus-smaller);
}
.page-title {
font-size: var(--font-size-big);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-one);
}
.block-title {
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-smaller);
margin-left: var(--space-minus-large);
}
.intro-body {
margin-bottom: var(--space-small);
line-height: 1.5;
}
.onboarding--link {
color: var(--w-500);
font-weight: var(--font-weight-medium);
text-decoration: underline;
}
.emoji {
width: var(--space-large);
display: inline-block;
}
</style>
@@ -17,7 +17,6 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -31,7 +30,6 @@ import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -62,13 +60,7 @@ export default {
MessageSignatureMissingAlert,
ArticleSearchPopover,
},
mixins: [
inboxMixin,
messageFormatterMixin,
rtlMixin,
fileUploadMixin,
keyboardEventListenerMixins,
],
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
popoutReplyBox: {
type: Boolean,
@@ -121,6 +113,7 @@ export default {
},
computed: {
...mapGetters({
isRTL: 'accounts/isRTL',
currentChat: 'getSelectedChat',
messageSignature: 'getMessageSignature',
currentUser: 'getCurrentUser',
@@ -307,7 +300,7 @@ export default {
if (this.isOnExpandedLayout || this.popoutReplyBox) {
return 'emoji-dialog--expanded';
}
if (this.isRTLView) {
if (this.isRTL) {
return 'emoji-dialog--rtl';
}
return '';
@@ -1091,10 +1084,10 @@ export default {
/>
<ReplyTopPanel
:mode="replyType"
:set-reply-mode="setReplyMode"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popoutReplyBox"
@setReplyMode="setReplyMode"
@click="$emit('click')"
/>
<ArticleSearchPopover
@@ -1,124 +1,118 @@
<script>
import { mapGetters } from 'vuex';
import mentionSelectionKeyboardMixin from '../mentions/mentionSelectionKeyboardMixin';
export default {
mixins: [mentionSelectionKeyboardMixin],
props: {
searchKey: {
type: String,
default: '',
},
},
data() {
return { selectedIndex: 0 };
},
computed: {
...mapGetters({ agents: 'agents/getVerifiedAgents' }),
items() {
if (!this.searchKey) {
return this.agents;
}
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
return this.agents.filter(agent =>
agent.name
.toLocaleLowerCase()
.includes(this.searchKey.toLocaleLowerCase())
);
},
},
watch: {
items(newListOfAgents) {
if (newListOfAgents.length < this.selectedIndex + 1) {
this.selectedIndex = 0;
}
},
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
methods: {
adjustScroll() {
this.$nextTick(() => {
this.$el.scrollTop = 50 * this.selectedIndex;
});
},
onHover(index) {
this.selectedIndex = index;
},
onAgentSelect(index) {
this.selectedIndex = index;
this.onSelect();
},
onSelect() {
this.$emit('click', this.items[this.selectedIndex]);
},
},
const emit = defineEmits(['click']);
const getters = useStoreGetters();
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const tagAgentsRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
if (!props.searchKey) {
return agents.value;
}
return agents.value.filter(agent =>
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
);
});
const adjustScroll = () => {
nextTick(() => {
if (tagAgentsRef.value) {
tagAgentsRef.value.scrollTop = 50 * selectedIndex.value;
}
});
};
const onSelect = () => {
emit('click', items.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
watch(items, newListOfAgents => {
if (newListOfAgents.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
const onHover = index => {
selectedIndex.value = index;
};
const onAgentSelect = index => {
selectedIndex.value = index;
onSelect();
};
</script>
<template>
<ul
v-if="items.length"
class="vertical dropdown menu mention--box"
:class="{ 'with-bottom-border': items.length <= 4 }"
>
<li
v-for="(agent, index) in items"
:id="`mention-item-${index}`"
:key="agent.id"
:class="{ active: index === selectedIndex }"
class="last:mb-2 items-center rounded-md flex p-2"
@click="onAgentSelect(index)"
@mouseover="onHover(index)"
<div>
<ul
v-if="items.length"
ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-white text-sm dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border-t border-solid border-slate-75 dark:border-slate-800"
:class="{
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
items.length <= 4,
}"
>
<div class="mr-2">
<woot-thumbnail
:src="agent.thumbnail"
:username="agent.name"
size="32px"
/>
</div>
<div
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
<li
v-for="(agent, index) in items"
:id="`mention-item-${index}`"
:key="agent.id"
:class="{
'bg-slate-50 dark:bg-slate-800': index === selectedIndex,
'last:mb-0': items.length <= 4,
}"
class="flex items-center p-2 rounded-md last:mb-2"
@click="onAgentSelect(index)"
@mouseover="onHover(index)"
>
<h5
class="mention--user-name mb-0 text-sm text-slate-900 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ agent.name }}
</h5>
<div
class="mention--email overflow-hidden whitespace-nowrap text-ellipsis text-slate-700 dark:text-slate-300 text-xs"
>
{{ agent.email }}
<div class="mr-2">
<woot-thumbnail
:src="agent.thumbnail"
:username="agent.name"
size="32px"
/>
</div>
</div>
</li>
</ul>
<div
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
>
<h5
class="mb-0 overflow-hidden text-sm text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
:class="{
'text-slate-900 dark:text-slate-100': index === selectedIndex,
}"
>
{{ agent.name }}
</h5>
<div
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-slate-700 dark:text-slate-300"
:class="{
'text-slate-800 dark:text-slate-200': index === selectedIndex,
}"
>
{{ agent.email }}
</div>
</div>
</li>
</ul>
</div>
</template>
<style scoped lang="scss">
.mention--box {
@apply bg-white text-sm dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border-t border-solid border-slate-75 dark:border-slate-800;
&.with-bottom-border {
@apply border-b-[0.5rem] border-solid border-white dark:border-slate-600;
li {
&:last-child {
@apply mb-0;
}
}
}
li {
&.active {
@apply bg-slate-50 dark:bg-slate-800;
.mention--user-name {
@apply text-slate-900 dark:text-slate-100;
}
.mention--email {
@apply text-slate-800 dark:text-slate-200;
}
}
}
}
</style>
@@ -2,6 +2,7 @@
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
@@ -42,6 +43,9 @@ export default {
isAudio() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
},
timeStampURL() {
return timeStampAppendedURL(this.dataUrl);
},
attachmentTypeClasses() {
return {
image: this.isImage,
@@ -108,7 +112,7 @@ export default {
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
<source :src="`${dataUrl}?t=${Date.now()}`" />
<source :src="timeStampURL" />
</audio>
<GalleryView
v-if="show"
@@ -1,10 +1,30 @@
<script>
import { mapGetters } from 'vuex';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
const props = defineProps({
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['close']);
const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
@@ -15,194 +35,177 @@ const ALLOWED_FILE_TYPES = {
const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1;
export default {
components: {
Thumbnail,
},
mixins: [keyboardEventListenerMixins],
props: {
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
const zoomScale = ref(1);
const activeAttachment = ref({});
const activeFileType = ref('');
const activeImageIndex = ref(
props.allAttachments.findIndex(
attachment => attachment.message_id === props.attachment.message_id
) || 0
);
const activeImageRotation = ref(0);
const currentUser = computed(() => getters.getCurrentUser.value);
const hasMoreThanOneAttachment = computed(
() => props.allAttachments.length > 1
);
const readableTime = computed(() => {
const { created_at: createdAt } = activeAttachment.value;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
});
const isImage = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
);
const isVideo = computed(
() =>
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
);
const isAudio = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
);
const senderDetails = computed(() => {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = activeAttachment.value?.sender || props.attachment?.sender || {};
const currentUserID = currentUser.value?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
});
const fileNameFromDataUrl = computed(() => {
const { data_url: dataUrl } = activeAttachment.value;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return decodeURIComponent(fileName || '');
});
const imageRotationStyle = computed(() => ({
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
}));
const onClose = () => {
emit('close');
};
const setImageAndVideoSrc = attachment => {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
activeAttachment.value = attachment;
activeFileType.value = type;
};
const onClickChangeAttachment = (attachment, index) => {
if (!attachment) {
return;
}
activeImageIndex.value = index;
setImageAndVideoSrc(attachment);
activeImageRotation.value = 0;
zoomScale.value = 1;
};
const onClickDownload = () => {
const { file_type: type, data_url: url } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
};
const onRotate = type => {
if (!isImage.value) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(activeImageRotation.value) === 360) {
activeImageRotation.value = rotation;
} else {
activeImageRotation.value += rotation;
}
};
const onZoom = scale => {
if (!isImage.value) {
return;
}
const newZoomScale = zoomScale.value + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
zoomScale.value = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
zoomScale.value = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
zoomScale.value = newZoomScale;
};
const onClickZoomImage = () => {
onZoom(0.1);
};
const onWheelImageZoom = e => {
if (!isImage.value) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
onZoom(scale);
};
const keyboardEvents = {
Escape: {
action: () => {
onClose();
},
},
data() {
return {
zoomScale: 1,
activeAttachment: {},
activeFileType: '',
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id
) || 0,
activeImageRotation: 0,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
readableTime() {
const { created_at: createdAt } = this.activeAttachment;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return (
this.activeFileType === ALLOWED_FILE_TYPES.VIDEO ||
this.activeFileType === ALLOWED_FILE_TYPES.IG_REEL
ArrowLeft: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value - 1],
activeImageIndex.value - 1
);
},
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
senderDetails() {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = this.activeAttachment?.sender || this.attachment?.sender || {};
const currentUserID = this.currentUser?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
},
fileNameFromDataUrl() {
const { data_url: dataUrl } = this.activeAttachment;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return decodeURIComponent(fileName || '');
},
imageRotationStyle() {
return {
transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`,
cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
};
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
},
methods: {
onClose() {
this.$emit('close');
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
this.activeImageRotation = 0;
this.zoomScale = 1;
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.activeAttachment = attachment;
this.activeFileType = type;
},
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
},
ArrowLeft: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
},
},
ArrowRight: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
},
},
};
},
onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
},
onRotate(type) {
if (!this.isImage) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(this.activeImageRotation) === 360) {
this.activeImageRotation = rotation;
} else {
this.activeImageRotation += rotation;
}
},
onClickZoomImage() {
this.onZoom(0.1);
},
onZoom(scale) {
if (!this.isImage) {
return;
}
const newZoomScale = this.zoomScale + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
this.zoomScale = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
this.zoomScale = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
this.zoomScale = newZoomScale;
},
onWheelImageZoom(e) {
if (!this.isImage) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
this.onZoom(scale);
ArrowRight: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value + 1],
activeImageIndex.value + 1
);
},
},
};
useKeyboardEvents(keyboardEvents);
onMounted(() => {
setImageAndVideoSrc(props.attachment);
});
</script>
<!-- eslint-disable vue/no-mutating-props -->
@@ -1,9 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
components: {
@@ -11,7 +14,6 @@ export default {
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [agentMixin],
props: {
chatId: {
type: Number,
@@ -112,13 +114,19 @@ export default {
labels: 'labels/getLabels',
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const filteredAgents = this.sortedAgentsByAvailability(
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
this.currentUser,
this.currentAccountId
);
const filteredAgents = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
@@ -2,7 +2,9 @@
// components
import WootButton from '../../../ui/WootButton.vue';
import Avatar from '../../Avatar.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
// composables
import { useAI } from 'dashboard/composables/useAI';
// store & api
import { mapGetters } from 'vuex';
@@ -18,7 +20,6 @@ export default {
Avatar,
WootButton,
},
mixins: [aiMixin],
props: {
suggestedLabels: {
type: Array,
@@ -30,6 +31,11 @@ export default {
default: () => [],
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
return { isAIIntegrationEnabled };
},
data() {
return {
isDismissed: false,
@@ -41,7 +47,11 @@ export default {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat',
}),
conversationId() {
return this.currentChat?.id;
},
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
@@ -162,7 +172,7 @@ export default {
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
class="label-suggestion--option"
class="label-suggestion--option !px-0"
@click="pushOrAddLabel(label.title)"
>
<woot-label
@@ -6,7 +6,7 @@ import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
@@ -1,23 +1,26 @@
<script>
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
export default {
mixins: [conversationLabelMixin],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationId: {
type: Number,
conversationLabels: {
type: Array,
required: true,
},
// conversationLabels prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationLabels: {
type: String,
required: false,
default: '',
},
},
setup(props) {
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.conversationLabels.includes(title)
);
});
return {
activeLabels,
};
},
data() {
return {
@@ -80,7 +83,7 @@ export default {
<slot name="before" />
<woot-label
v-for="(label, index) in activeLabels"
:key="label.id"
:key="label ? label.id : index"
:title="label.title"
:description="label.description"
:color="label.color"
@@ -62,6 +62,8 @@ const onSearch = async value => {
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
@@ -48,11 +48,7 @@ const loadLinkedIssue = async () => {
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
);
useAlert(errorMessage);
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
}
};
@@ -1,71 +1,81 @@
<script>
import mentionSelectionKeyboardMixin from './mentionSelectionKeyboardMixin';
export default {
mixins: [mentionSelectionKeyboardMixin],
props: {
items: {
type: Array,
default: () => {},
},
type: {
type: String,
default: 'canned',
},
<script setup>
import { ref, watch, computed } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
const props = defineProps({
items: {
type: Array,
default: () => [],
},
data() {
return {
selectedIndex: 0,
};
},
watch: {
items(newItems) {
if (newItems.length < this.selectedIndex + 1) {
this.selectedIndex = 0;
}
},
selectedIndex() {
const container = this.$refs.mentionsListContainer;
const item = container.querySelector(
`#mention-item-${this.selectedIndex}`
);
if (item) {
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.offsetHeight;
if (itemTop < containerTop) {
container.scrollTop = itemTop;
} else if (itemBottom + 34 > containerBottom) {
container.scrollTop = itemBottom - container.offsetHeight + 34;
}
}
},
},
methods: {
adjustScroll() {},
onHover(index) {
this.selectedIndex = index;
},
onListItemSelection(index) {
this.selectedIndex = index;
this.onSelect();
},
onSelect() {
this.$emit('mentionSelect', this.items[this.selectedIndex]);
},
variableKey(item = {}) {
return this.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
},
type: {
type: String,
default: 'canned',
},
});
const emit = defineEmits(['mentionSelect']);
const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
const adjustScroll = () => {
const container = mentionsListContainerRef.value;
const item = container.querySelector(`#mention-item-${selectedIndex.value}`);
if (item) {
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.offsetHeight;
if (itemTop < containerTop) {
container.scrollTop = itemTop;
} else if (itemBottom + 34 > containerBottom) {
container.scrollTop = itemBottom - container.offsetHeight + 34;
}
}
};
const onSelect = () => {
emit('mentionSelect', props.items[selectedIndex.value]);
};
useKeyboardNavigableList({
items: computed(() => props.items),
onSelect,
adjustScroll,
selectedIndex,
});
watch(
() => props.items,
newItems => {
if (newItems.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
}
);
watch(selectedIndex, adjustScroll);
const onHover = index => {
selectedIndex.value = index;
};
const onListItemSelection = index => {
selectedIndex.value = index;
onSelect();
};
const variableKey = (item = {}) => {
return props.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
};
</script>
<template>
<div
ref="mentionsListContainer"
ref="mentionsListContainerRef"
class="bg-white dark:bg-slate-800 rounded-md overflow-auto absolute w-full z-20 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border border-solid border-slate-100 dark:border-slate-700 mention--box"
>
<ul class="vertical dropdown menu">
<ul class="mb-0 vertical dropdown menu">
<woot-dropdown-item
v-for="(item, index) in items"
:id="`mention-item-${index}`"
@@ -1,62 +0,0 @@
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [keyboardEventListenerMixins],
methods: {
moveSelectionUp() {
if (!this.selectedIndex) {
this.selectedIndex = this.items.length - 1;
} else {
this.selectedIndex -= 1;
}
this.adjustScroll();
},
moveSelectionDown() {
if (this.selectedIndex === this.items.length - 1) {
this.selectedIndex = 0;
} else {
this.selectedIndex += 1;
}
this.adjustScroll();
},
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
this.moveSelectionUp();
e.preventDefault();
},
allowOnFocusedInput: true,
},
'Control+KeyP': {
action: e => {
this.moveSelectionUp();
e.preventDefault();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
'Control+KeyN': {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
this.onSelect();
e.preventDefault();
},
allowOnFocusedInput: true,
},
};
},
},
};
@@ -1,69 +0,0 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
const localVue = createLocalVue();
const buildComponent = ({ data = {}, methods = {} }) => ({
render() {},
data() {
return { ...data, selectedIndex: 0, items: [1, 2, 3] };
},
methods: { ...methods, onSelect: vi.fn(), adjustScroll: vi.fn() },
mixins: [keyboardEventListenerMixins],
});
describe('mentionSelectionKeyboardMixin', () => {
let wrapper;
beforeEach(() => {
const Component = buildComponent({});
wrapper = shallowMount(Component, { localVue });
});
it('ArrowUp and Control+KeyP update selectedIndex correctly', () => {
const preventDefault = vi.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.ArrowUp) {
keyboardEvents.ArrowUp.action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(2);
expect(preventDefault).toHaveBeenCalled();
}
wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyP']) {
keyboardEvents['Control+KeyP'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(0);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
});
it('ArrowDown and Control+KeyN update selectedIndex correctly', () => {
const preventDefault = vi.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.ArrowDown) {
keyboardEvents.ArrowDown.action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(1);
expect(preventDefault).toHaveBeenCalled();
}
wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyN']) {
keyboardEvents['Control+KeyN'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(2);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
});
it('Enter key triggers onSelect method', () => {
const preventDefault = vi.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.Enter) {
keyboardEvents.Enter.action({ preventDefault });
expect(wrapper.vm.onSelect).toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalled();
}
});
});
@@ -1,28 +1,43 @@
<script>
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { SHORTCUT_KEYS } from './constants';
import {
LAYOUT_QWERTZ,
keysToModifyInQWERTZ,
} from 'shared/helpers/KeyboardHelpers';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
export default {
components: {
Hotkey,
},
props: {
show: {
type: Boolean,
default: false,
},
},
data() {
return {
shortcutKeys: SHORTCUT_KEYS,
};
},
methods: {
title(item) {
return this.$t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`);
},
defineProps({
show: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const shortcutKeys = SHORTCUT_KEYS;
const currentLayout = ref(null);
const title = item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`);
// Added this function to check if the keySet needs a shift key
// This is used to display the shift key in the modal
// If the current layout is QWERTZ and the keySet contains a key that needs a shift key
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
const needsShiftKey = keySet => {
return (
currentLayout.value === LAYOUT_QWERTZ &&
keySet.some(key => keysToModifyInQWERTZ.has(key))
);
};
onMounted(async () => {
currentLayout.value = await useDetectKeyboardLayout();
});
</script>
<template>
@@ -94,6 +109,12 @@ export default {
{{ title(shortcutKey) }}
</h5>
<div class="flex items-center gap-2 mb-1 ml-2">
<Hotkey
v-if="needsShiftKey(shortcutKey.keySet)"
custom-class="min-h-[28px] min-w-[36px] key"
>
{{ 'Shift' }}
</Hotkey>
<Hotkey
:class="{ 'min-w-[60px]': shortcutKey.firstKey !== 'Up' }"
custom-class="min-h-[28px] normal-case key"
@@ -4,71 +4,83 @@ export const SHORTCUT_KEYS = [
label: 'NAVIGATE_DROPDOWN',
firstKey: 'Up',
secondKey: 'Down',
keySet: ['ArrowUp', 'ArrowDown'],
},
{
id: 2,
label: 'RESOLVE_CONVERSATION',
firstKey: 'Alt / ⌥',
secondKey: 'E',
keySet: ['Alt+KeyE'],
},
{
id: 3,
label: 'GO_TO_CONVERSATION_DASHBOARD',
firstKey: 'Alt / ⌥',
secondKey: 'C',
keySet: ['Alt+KeyC'],
},
{
id: 4,
label: 'ADD_ATTACHMENT',
firstKey: 'Alt / ⌥',
secondKey: 'A',
keySet: ['Alt+KeyA'],
},
{
id: 5,
label: 'GO_TO_CONTACTS_DASHBOARD',
firstKey: 'Alt / ⌥',
secondKey: 'V',
keySet: ['Alt+KeyV'],
},
{
id: 6,
label: 'TOGGLE_SIDEBAR',
firstKey: 'Alt / ⌥',
secondKey: 'O',
keySet: ['Alt+KeyO'],
},
{
id: 7,
label: 'GO_TO_REPORTS_SIDEBAR',
firstKey: 'Alt / ⌥',
secondKey: 'R',
keySet: ['Alt+KeyR'],
},
{
id: 8,
label: 'MOVE_TO_NEXT_TAB',
firstKey: 'Alt / ⌥',
secondKey: 'N',
keySet: ['Alt+KeyN'],
},
{
id: 9,
label: 'GO_TO_SETTINGS',
firstKey: 'Alt / ⌥',
secondKey: 'S',
keySet: ['Alt+KeyS'],
},
{
id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE',
firstKey: 'Alt / ⌥',
secondKey: 'P',
keySet: ['Alt+KeyP'],
},
{
id: 13,
id: 12,
label: 'SWITCH_TO_REPLY',
firstKey: 'Alt / ⌥',
secondKey: 'L',
keySet: ['Alt+KeyL'],
},
{
id: 14,
id: 13,
label: 'TOGGLE_SNOOZE_DROPDOWN',
firstKey: 'Alt / ⌥',
secondKey: 'M',
keySet: ['Alt+KeyM'],
},
];
@@ -0,0 +1,197 @@
export const mockAssignableAgents = [
{
id: 1,
account_id: 1,
availability_status: 'online',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'administrator',
thumbnail: '',
},
];
export const mockCurrentChat = {
meta: {
sender: {
additional_attributes: {},
availability_status: 'offline',
email: null,
id: 212,
name: 'Chatwoot',
phone_number: null,
identifier: null,
thumbnail: '',
custom_attributes: {},
last_activity_at: 1723553344,
created_at: 1722588710,
},
channel: 'Channel::WebWidget',
assignee: {
id: 1,
account_id: 1,
availability_status: 'online',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'administrator',
thumbnail: '',
},
hmac_verified: false,
},
id: 138,
messages: [
{
id: 3348,
content: 'Hello, how can I assist you today?',
account_id: 1,
inbox_id: 1,
conversation_id: 138,
message_type: 1,
created_at: 1724398739,
updated_at: '2024-08-23T07:38:59.763Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Hello, how can I assist you today?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1724398739,
contact_inbox: {
source_id: '5e57317d-053b-4a72-8292-a25b9f29c401',
},
},
sender: {
id: 1,
name: 'John Doe',
available_name: 'John Doe',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
],
account_id: 1,
uuid: '69dd6922-2f0c-4317-8796-bbeb3679cead',
additional_attributes: {
browser: {
device_name: 'Unknown',
browser_name: 'Chrome',
platform_name: 'macOS',
browser_version: '127.0.0.0',
platform_version: '10.15.7',
},
referer: 'http://chatwoot.com/widget_tests?dark_mode=auto',
initiated_at: {
timestamp: 'Fri Aug 02 2024 15:21:18 GMT+0530 (India Standard Time)',
},
browser_language: 'en',
},
agent_last_seen_at: 1724400730,
assignee_last_seen_at: 1724400686,
can_reply: true,
contact_last_seen_at: 1723553351,
custom_attributes: {},
inbox_id: 1,
labels: ['billing'],
muted: false,
snoozed_until: null,
status: 'open',
created_at: 1722592278,
timestamp: 1724398739,
first_reply_created_at: 1722592316,
unread_count: 0,
last_non_activity_message: {},
last_activity_at: 1724398739,
priority: null,
waiting_since: 0,
sla_policy_id: 10,
applied_sla: {
id: 143,
sla_id: 10,
sla_status: 'missed',
created_at: 1722592279,
updated_at: 1722874214,
sla_description: '',
sla_name: 'Hacker SLA',
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 240,
sla_only_during_business_hours: false,
sla_resolution_time_threshold: 259200,
},
sla_events: [
{
id: 270,
event_type: 'nrt',
meta: {
message_id: 2743,
},
updated_at: 1722592819,
created_at: 1722592819,
},
{
id: 275,
event_type: 'rt',
meta: {},
updated_at: 1722852322,
created_at: 1722852322,
},
],
allMessagesLoaded: false,
dataFetched: true,
};
export const mockTeamsList = [
{
id: 5,
name: 'design',
description: 'design team',
allow_auto_assign: true,
account_id: 1,
is_member: false,
},
];
export const mockActiveLabels = [
{
id: 16,
title: 'billing',
description: '',
color: '#D8EA19',
show_on_sidebar: true,
},
];
export const mockInactiveLabels = [
{
id: 2,
title: 'Feature Request',
description: '',
color: '#D8EA19',
show_on_sidebar: true,
},
];
export const MOCK_FEATURE_FLAGS = {
CRM: 'crm',
AGENT_MANAGEMENT: 'agent_management',
TEAM_MANAGEMENT: 'team_management',
INBOX_MANAGEMENT: 'inbox_management',
REPORTS: 'reports',
LABELS: 'labels',
CANNED_RESPONSES: 'canned_responses',
INTEGRATIONS: 'integrations',
};
@@ -0,0 +1,83 @@
import { useAppearanceHotKeys } from '../useAppearanceHotKeys';
import { useI18n } from 'dashboard/composables/useI18n';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
vi.mock('dashboard/composables/useI18n');
vi.mock('shared/helpers/localStorage');
vi.mock('dashboard/helper/themeHelper.js');
describe('useAppearanceHotKeys', () => {
beforeEach(() => {
useI18n.mockReturnValue({
t: vi.fn(key => key),
});
window.matchMedia = vi.fn().mockReturnValue({ matches: false });
});
it('should return goToAppearanceHotKeys computed property', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
expect(goToAppearanceHotKeys.value).toBeDefined();
});
it('should have the correct number of appearance options', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
expect(goToAppearanceHotKeys.value.length).toBe(4); // 1 parent + 3 theme options
});
it('should have the correct parent option', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const parentOption = goToAppearanceHotKeys.value.find(
option => option.id === 'appearance_settings'
);
expect(parentOption).toBeDefined();
expect(parentOption.children.length).toBe(3);
});
it('should have the correct theme options', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const themeOptions = goToAppearanceHotKeys.value.filter(
option => option.parent === 'appearance_settings'
);
expect(themeOptions.length).toBe(3);
expect(themeOptions.map(option => option.id)).toEqual([
'light',
'dark',
'auto',
]);
});
it('should call setAppearance when a theme option is selected', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const lightThemeOption = goToAppearanceHotKeys.value.find(
option => option.id === 'light'
);
lightThemeOption.handler();
expect(LocalStorage.set).toHaveBeenCalledWith(
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
'light'
);
expect(setColorTheme).toHaveBeenCalledWith(false);
});
it('should handle system dark mode preference', () => {
window.matchMedia = vi.fn().mockReturnValue({ matches: true });
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const autoThemeOption = goToAppearanceHotKeys.value.find(
option => option.id === 'auto'
);
autoThemeOption.handler();
expect(LocalStorage.set).toHaveBeenCalledWith(
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
'auto'
);
expect(setColorTheme).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,102 @@
import { useBulkActionsHotKeys } from '../useBulkActionsHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('shared/helpers/mitt');
describe('useBulkActionsHotKeys', () => {
let store;
beforeEach(() => {
store = {
getters: {
'bulkActions/getSelectedConversationIds': [],
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
emitter.emit = vi.fn();
});
it('should return bulk actions when conversations are selected', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
expect(bulkActionsHotKeys.value.length).toBeGreaterThan(0);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
});
it('should include snooze options in bulk actions', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const snoozeAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_snooze_conversation'
);
expect(snoozeAction).toBeDefined();
expect(snoozeAction.children).toEqual(
Object.values(wootConstants.SNOOZE_OPTIONS)
);
});
it('should create handlers for reopen and resolve actions', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const reopenAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_reopen_conversation'
);
const resolveAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_resolve_conversation'
);
expect(reopenAction.handler).toBeDefined();
expect(resolveAction.handler).toBeDefined();
reopenAction.handler();
expect(emitter.emit).toHaveBeenCalledWith(
'CMD_BULK_ACTION_REOPEN_CONVERSATION'
);
resolveAction.handler();
expect(emitter.emit).toHaveBeenCalledWith(
'CMD_BULK_ACTION_RESOLVE_CONVERSATION'
);
});
it('should return an empty array when no conversations are selected', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
expect(bulkActionsHotKeys.value).toEqual([]);
});
});
@@ -0,0 +1,204 @@
import { useConversationHotKeys } from '../useConversationHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
mockAssignableAgents,
mockCurrentChat,
mockTeamsList,
mockActiveLabels,
mockInactiveLabels,
} from './fixtures';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useAI');
vi.mock('dashboard/composables/useAgentsList');
describe('useConversationHotKeys', () => {
let store;
beforeEach(() => {
store = {
dispatch: vi.fn(),
getters: {
getSelectedChat: mockCurrentChat,
'draftMessages/getReplyEditorMode': REPLY_EDITOR_MODES.REPLY,
getContextMenuChatId: null,
'teams/getTeams': mockTeamsList,
'draftMessages/get': vi.fn(),
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRoute.mockReturnValue({ name: 'inbox_conversation' });
useConversationLabels.mockReturnValue({
activeLabels: { value: mockActiveLabels },
inactiveLabels: { value: mockInactiveLabels },
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
useAgentsList.mockReturnValue({
agentsList: { value: [] },
assignableAgents: { value: mockAssignableAgents },
});
});
it('should return the correct computed properties', () => {
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value).toBeDefined();
});
it('should generate conversation hot keys', () => {
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
});
it('should include AI assist actions when AI integration is enabled', () => {
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
);
expect(aiAssistAction).toBeDefined();
});
it('should not include AI assist actions when AI integration is disabled', () => {
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
);
expect(aiAssistAction).toBeUndefined();
});
it('should dispatch actions when handlers are called', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignAgentAction = conversationHotKeys.value.find(
action => action.id === 'assign_an_agent'
);
expect(assignAgentAction).toBeDefined();
if (assignAgentAction && assignAgentAction.children) {
const childAction = conversationHotKeys.value.find(
action => action.id === assignAgentAction.children[0]
);
if (childAction && childAction.handler) {
childAction.handler({ agentInfo: { id: 2 } });
expect(store.dispatch).toHaveBeenCalledWith('assignAgent', {
conversationId: 1,
agentId: 2,
});
}
}
});
it('should return snooze actions when in snooze context', () => {
store.getters.getContextMenuChatId = 1;
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useRoute.mockReturnValue({ name: 'inbox_conversation' });
const { conversationHotKeys } = useConversationHotKeys();
const snoozeAction = conversationHotKeys.value.find(action =>
action.id.includes('snooze_conversation')
);
expect(snoozeAction).toBeDefined();
});
it('should return the correct label actions when there are active labels', () => {
const { conversationHotKeys } = useConversationHotKeys();
const addLabelAction = conversationHotKeys.value.find(
action => action.id === 'add_a_label_to_the_conversation'
);
const removeLabelAction = conversationHotKeys.value.find(
action => action.id === 'remove_a_label_to_the_conversation'
);
expect(addLabelAction).toBeDefined();
expect(removeLabelAction).toBeDefined();
});
it('should return only add label actions when there are no active labels', () => {
useConversationLabels.mockReturnValue({
activeLabels: { value: [] },
inactiveLabels: { value: [{ title: 'inactive_label' }] },
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
const { conversationHotKeys } = useConversationHotKeys();
const addLabelAction = conversationHotKeys.value.find(
action => action.id === 'add_a_label_to_the_conversation'
);
const removeLabelAction = conversationHotKeys.value.find(
action => action.id === 'remove_a_label_to_the_conversation'
);
expect(addLabelAction).toBeDefined();
expect(removeLabelAction).toBeUndefined();
});
it('should return the correct team assignment actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignTeamAction = conversationHotKeys.value.find(
action => action.id === 'assign_a_team'
);
expect(assignTeamAction).toBeDefined();
expect(assignTeamAction.children.length).toBe(mockTeamsList.length);
});
it('should return the correct priority assignment actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignPriorityAction = conversationHotKeys.value.find(
action => action.id === 'assign_priority'
);
expect(assignPriorityAction).toBeDefined();
expect(assignPriorityAction.children.length).toBe(4);
});
it('should return the correct conversation additional actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const muteAction = conversationHotKeys.value.find(
action => action.id === 'mute_conversation'
);
const sendTranscriptAction = conversationHotKeys.value.find(
action => action.id === 'send_transcript'
);
expect(muteAction).toBeDefined();
expect(sendTranscriptAction).toBeDefined();
});
it('should return unmute action when conversation is muted', () => {
store.getters.getSelectedChat = { ...mockCurrentChat, muted: true };
const { conversationHotKeys } = useConversationHotKeys();
const unmuteAction = conversationHotKeys.value.find(
action => action.id === 'unmute_conversation'
);
expect(unmuteAction).toBeDefined();
});
it('should not return conversation hot keys when not in conversation or inbox route', () => {
useRoute.mockReturnValue({ name: 'some_other_route' });
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value.length).toBe(0);
});
});
@@ -0,0 +1,188 @@
import { useGoToCommandHotKeys } from '../useGoToCommandHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRouter } from 'dashboard/composables/route';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { MOCK_FEATURE_FLAGS } from './fixtures';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/composables/useAdmin');
vi.mock('dashboard/helper/URLHelper');
const mockRoutes = [
{ path: 'accounts/:accountId/dashboard', name: 'dashboard' },
{
path: 'accounts/:accountId/contacts',
name: 'contacts',
featureFlag: MOCK_FEATURE_FLAGS.CRM,
},
{
path: 'accounts/:accountId/settings/agents/list',
name: 'agent_settings',
featureFlag: MOCK_FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
path: 'accounts/:accountId/settings/teams/list',
name: 'team_settings',
featureFlag: MOCK_FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
path: 'accounts/:accountId/settings/inboxes/list',
name: 'inbox_settings',
featureFlag: MOCK_FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{ path: 'accounts/:accountId/profile/settings', name: 'profile_settings' },
{ path: 'accounts/:accountId/notifications', name: 'notifications' },
{
path: 'accounts/:accountId/reports/overview',
name: 'reports_overview',
featureFlag: MOCK_FEATURE_FLAGS.REPORTS,
},
{
path: 'accounts/:accountId/settings/labels/list',
name: 'label_settings',
featureFlag: MOCK_FEATURE_FLAGS.LABELS,
},
{
path: 'accounts/:accountId/settings/canned-response/list',
name: 'canned_responses',
featureFlag: MOCK_FEATURE_FLAGS.CANNED_RESPONSES,
},
{
path: 'accounts/:accountId/settings/applications',
name: 'applications',
featureFlag: MOCK_FEATURE_FLAGS.INTEGRATIONS,
},
];
describe('useGoToCommandHotKeys', () => {
let store;
beforeEach(() => {
store = {
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn().mockReturnValue(true),
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRouter.mockReturnValue({ push: vi.fn() });
useAdmin.mockReturnValue({ isAdmin: { value: true } });
frontendURL.mockImplementation(url => url);
});
it('should return goToCommandHotKeys computed property', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
expect(goToCommandHotKeys.value).toBeDefined();
expect(goToCommandHotKeys.value.length).toBeGreaterThan(0);
});
it('should filter commands based on feature flags', () => {
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(
(accountId, flag) => flag !== MOCK_FEATURE_FLAGS.CRM
);
const { goToCommandHotKeys } = useGoToCommandHotKeys();
mockRoutes.forEach(route => {
const command = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes(route.name)
);
if (route.featureFlag === MOCK_FEATURE_FLAGS.CRM) {
expect(command).toBeUndefined();
} else if (!route.featureFlag) {
expect(command).toBeDefined();
}
});
});
it('should filter commands for non-admin users', () => {
useAdmin.mockReturnValue({ isAdmin: { value: false } });
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const adminOnlyCommands = goToCommandHotKeys.value.filter(
cmd =>
cmd.id.includes('agent_settings') ||
cmd.id.includes('team_settings') ||
cmd.id.includes('inbox_settings')
);
expect(adminOnlyCommands.length).toBe(0);
});
it('should include commands for both admin and agent roles when user is admin', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const adminCommand = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes('agent_settings')
);
const agentCommand = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes('profile_settings')
);
expect(adminCommand).toBeDefined();
expect(agentCommand).toBeDefined();
});
it('should translate section and title for each command', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
expect(useI18n().t).toHaveBeenCalledWith(
expect.stringContaining('COMMAND_BAR.SECTIONS.')
);
expect(useI18n().t).toHaveBeenCalledWith(
expect.stringContaining('COMMAND_BAR.COMMANDS.')
);
expect(command.section).toBeDefined();
expect(command.title).toBeDefined();
});
});
it('should call router.push with correct URL when handler is called', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
command.handler();
expect(useRouter().push).toHaveBeenCalledWith(expect.any(String));
});
});
it('should use current account ID in the path', () => {
store.getters.getCurrentAccountId = 42;
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
command.handler();
expect(useRouter().push).toHaveBeenCalledWith(
expect.stringContaining('42')
);
});
});
it('should include icon for each command', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
expect(command.icon).toBeDefined();
});
});
it('should return commands for all enabled features', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const enabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
);
expect(enabledFeatureCommands.length).toBeGreaterThan(0);
});
it('should not return commands for disabled features', () => {
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(() => false);
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const disabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
);
expect(disabledFeatureCommands.length).toBe(0);
});
});
@@ -0,0 +1,37 @@
import { useInboxHotKeys } from '../useInboxHotKeys';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/helper/routeHelpers');
vi.mock('shared/helpers/mitt');
describe('useInboxHotKeys', () => {
beforeEach(() => {
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRoute.mockReturnValue({ name: 'inbox_dashboard' });
isAInboxViewRoute.mockReturnValue(true);
});
it('should return inbox hot keys when on an inbox view route', () => {
const { inboxHotKeys } = useInboxHotKeys();
expect(inboxHotKeys.value.length).toBeGreaterThan(0);
expect(inboxHotKeys.value[0].id).toBe('snooze_notification');
});
it('should return an empty array when not on an inbox view route', () => {
isAInboxViewRoute.mockReturnValue(false);
const { inboxHotKeys } = useInboxHotKeys();
expect(inboxHotKeys.value).toEqual([]);
});
it('should have the correct structure for snooze actions', () => {
const { inboxHotKeys } = useInboxHotKeys();
const snoozeNotificationAction = inboxHotKeys.value.find(
action => action.id === 'snooze_notification'
);
expect(snoozeNotificationAction).toBeDefined();
});
});
@@ -0,0 +1,70 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import {
ICON_APPEARANCE,
ICON_LIGHT_MODE,
ICON_DARK_MODE,
ICON_SYSTEM_MODE,
} from 'dashboard/helper/commandbar/icons';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
const getThemeOptions = t => [
{
key: 'light',
label: t('COMMAND_BAR.COMMANDS.LIGHT_MODE'),
icon: ICON_LIGHT_MODE,
},
{
key: 'dark',
label: t('COMMAND_BAR.COMMANDS.DARK_MODE'),
icon: ICON_DARK_MODE,
},
{
key: 'auto',
label: t('COMMAND_BAR.COMMANDS.SYSTEM_MODE'),
icon: ICON_SYSTEM_MODE,
},
];
const setAppearance = theme => {
LocalStorage.set(LOCAL_STORAGE_KEYS.COLOR_SCHEME, theme);
const isOSOnDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
setColorTheme(isOSOnDarkMode);
};
export function useAppearanceHotKeys() {
const { t } = useI18n();
const themeOptions = computed(() => getThemeOptions(t));
const goToAppearanceHotKeys = computed(() => {
const options = themeOptions.value.map(theme => ({
id: theme.key,
title: theme.label,
parent: 'appearance_settings',
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: theme.icon,
handler: () => {
setAppearance(theme.key);
},
}));
return [
{
id: 'appearance_settings',
title: t('COMMAND_BAR.COMMANDS.CHANGE_APPEARANCE'),
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: ICON_APPEARANCE,
children: options.map(option => option.id),
},
...options,
];
});
return {
goToAppearanceHotKeys,
};
}
@@ -0,0 +1,89 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useMapGetter } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import {
ICON_SNOOZE_CONVERSATION,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/icons';
import { emitter } from 'shared/helpers/mitt';
import { createSnoozeHandlers } from 'dashboard/helper/commandbar/actions';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const createEmitHandler = event => () => emitter.emit(event);
const SNOOZE_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
'bulk_action_snooze_conversation',
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
),
];
const RESOLVED_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION,
handler: createEmitHandler(CMD_BULK_ACTION_REOPEN_CONVERSATION),
},
];
const OPEN_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION,
handler: createEmitHandler(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
},
];
export function useBulkActionsHotKeys() {
const { t } = useI18n();
const selectedConversations = useMapGetter(
'bulkActions/getSelectedConversationIds'
);
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
}));
};
const bulkActionsHotKeys = computed(() => {
let actions = [];
if (selectedConversations.value.length > 0) {
actions = [
...SNOOZE_CONVERSATION_BULK_ACTIONS,
...RESOLVED_CONVERSATION_BULK_ACTIONS,
...OPEN_CONVERSATION_BULK_ACTIONS,
];
}
return prepareActions(actions);
});
return {
bulkActionsHotKeys,
};
}
@@ -0,0 +1,408 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'dashboard/composables/route';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import wootConstants from 'dashboard/constants/globals';
import {
ICON_ADD_LABEL,
ICON_ASSIGN_AGENT,
ICON_ASSIGN_PRIORITY,
ICON_ASSIGN_TEAM,
ICON_REMOVE_LABEL,
ICON_PRIORITY_URGENT,
ICON_PRIORITY_HIGH,
ICON_PRIORITY_LOW,
ICON_PRIORITY_MEDIUM,
ICON_PRIORITY_NONE,
ICON_AI_ASSIST,
ICON_AI_SUMMARY,
ICON_AI_SHORTEN,
ICON_AI_EXPAND,
ICON_AI_GRAMMAR,
} from 'dashboard/helper/commandbar/icons';
import {
OPEN_CONVERSATION_ACTIONS,
SNOOZE_CONVERSATION_ACTIONS,
RESOLVED_CONVERSATION_ACTIONS,
SEND_TRANSCRIPT_ACTION,
UNMUTE_ACTION,
MUTE_ACTION,
} from 'dashboard/helper/commandbar/actions';
import {
isAConversationRoute,
isAInboxViewRoute,
} from 'dashboard/helper/routeHelpers';
const prepareActions = (actions, t) => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
}));
};
const createPriorityOptions = (t, currentPriority) => {
return [
{
label: t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
icon: ICON_PRIORITY_NONE,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
icon: ICON_PRIORITY_URGENT,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
icon: ICON_PRIORITY_HIGH,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
icon: ICON_PRIORITY_MEDIUM,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
icon: ICON_PRIORITY_LOW,
},
].filter(item => item.key !== currentPriority);
};
const createNonDraftMessageAIAssistActions = (t, replyMode) => {
if (replyMode === REPLY_EDITOR_MODES.REPLY) {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPLY_SUGGESTION'),
key: 'reply_suggestion',
icon: ICON_AI_ASSIST,
},
];
}
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: ICON_AI_SUMMARY,
},
];
};
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.FIX_SPELLING_GRAMMAR'),
key: 'fix_spelling_grammar',
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
icon: ICON_AI_ASSIST,
},
];
};
export function useConversationHotKeys() {
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { agentsList } = useAgentsList();
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const contextMenuChatId = useMapGetter('getContextMenuChatId');
const teams = useMapGetter('teams/getTeams');
const getDraftMessage = useMapGetter('draftMessages/get');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = computed(() => getDraftMessage.value(draftKey.value));
const hasAnAssignedTeam = computed(() => !!currentChat.value?.meta?.team);
const teamsList = computed(() => {
if (hasAnAssignedTeam.value) {
return [{ id: 0, name: t('TEAMS_SETTINGS.LIST.NONE') }, ...teams.value];
}
return teams.value;
});
const onChangeAssignee = action => {
store.dispatch('assignAgent', {
conversationId: currentChat.value.id,
agentId: action.agentInfo.id,
});
};
const onChangePriority = action => {
store.dispatch('assignPriority', {
conversationId: currentChat.value.id,
priority: action.priority.key,
});
};
const onChangeTeam = action => {
store.dispatch('assignTeam', {
conversationId: currentChat.value.id,
teamId: action.teamInfo.id,
});
};
const statusActions = computed(() => {
const isOpen = currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
const isSnoozed =
currentChat.value?.status === wootConstants.STATUS_TYPE.SNOOZED;
const isResolved =
currentChat.value?.status === wootConstants.STATUS_TYPE.RESOLVED;
let actions = [];
if (isOpen) {
actions = [...OPEN_CONVERSATION_ACTIONS, ...SNOOZE_CONVERSATION_ACTIONS];
} else if (isResolved || isSnoozed) {
actions = RESOLVED_CONVERSATION_ACTIONS;
}
return prepareActions(actions, t);
});
const priorityOptions = computed(() =>
createPriorityOptions(t, currentChat.value?.priority)
);
const assignAgentActions = computed(() => {
const agentOptions = agentsList.value.map(agent => ({
id: `agent-${agent.id}`,
title: agent.name,
parent: 'assign_an_agent',
section: t('COMMAND_BAR.SECTIONS.CHANGE_ASSIGNEE'),
agentInfo: agent,
icon: ICON_ASSIGN_AGENT,
handler: onChangeAssignee,
}));
return [
{
id: 'assign_an_agent',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_AN_AGENT'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_AGENT,
children: agentOptions.map(option => option.id),
},
...agentOptions,
];
});
const assignPriorityActions = computed(() => {
const options = priorityOptions.value.map(priority => ({
id: `priority-${priority.key}`,
title: priority.label,
parent: 'assign_priority',
section: t('COMMAND_BAR.SECTIONS.CHANGE_PRIORITY'),
priority: priority,
icon: priority.icon,
handler: onChangePriority,
}));
return [
{
id: 'assign_priority',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_PRIORITY'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_PRIORITY,
children: options.map(option => option.id),
},
...options,
];
});
const assignTeamActions = computed(() => {
const teamOptions = teamsList.value.map(team => ({
id: `team-${team.id}`,
title: team.name,
parent: 'assign_a_team',
section: t('COMMAND_BAR.SECTIONS.CHANGE_TEAM'),
teamInfo: team,
icon: ICON_ASSIGN_TEAM,
handler: onChangeTeam,
}));
return [
{
id: 'assign_a_team',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_A_TEAM'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_TEAM,
children: teamOptions.map(option => option.id),
},
...teamOptions,
];
});
const addLabelActions = computed(() => {
const availableLabels = inactiveLabels.value.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'add_a_label_to_the_conversation',
section: t('COMMAND_BAR.SECTIONS.ADD_LABEL'),
icon: ICON_ADD_LABEL,
handler: action => addLabelToConversation({ title: action.id }),
}));
return [
...availableLabels,
{
id: 'add_a_label_to_the_conversation',
title: t('COMMAND_BAR.COMMANDS.ADD_LABELS_TO_CONVERSATION'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ADD_LABEL,
children: inactiveLabels.value.map(label => label.title),
},
];
});
const removeLabelActions = computed(() => {
const activeLabelsComputed = activeLabels.value.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'remove_a_label_to_the_conversation',
section: t('COMMAND_BAR.SECTIONS.REMOVE_LABEL'),
icon: ICON_REMOVE_LABEL,
handler: action => removeLabelFromConversation(action.id),
}));
return [
...activeLabelsComputed,
{
id: 'remove_a_label_to_the_conversation',
title: t('COMMAND_BAR.COMMANDS.REMOVE_LABEL_FROM_CONVERSATION'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_REMOVE_LABEL,
children: activeLabels.value.map(label => label.title),
},
];
});
const labelActions = computed(() => {
if (activeLabels.value.length) {
return [...addLabelActions.value, ...removeLabelActions.value];
}
return addLabelActions.value;
});
const conversationAdditionalActions = computed(() => {
return prepareActions(
[
currentChat.value.muted ? UNMUTE_ACTION : MUTE_ACTION,
SEND_TRANSCRIPT_ACTION,
],
t
);
});
const AIAssistActions = computed(() => {
const aiOptions = draftMessage.value
? createDraftMessageAIAssistActions(t)
: createNonDraftMessageAIAssistActions(t, replyMode.value);
const options = aiOptions.map(item => ({
id: `ai-assist-${item.key}`,
title: item.label,
parent: 'ai_assist',
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item,
icon: item.icon,
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
}));
return [
{
id: 'ai_assist',
title: t('COMMAND_BAR.COMMANDS.AI_ASSIST'),
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
icon: ICON_AI_ASSIST,
children: options.map(option => option.id),
},
...options,
];
});
const isConversationOrInboxRoute = computed(() => {
return isAConversationRoute(route.name) || isAInboxViewRoute(route.name);
});
const shouldShowSnoozeOption = computed(() => {
return (
isAConversationRoute(route.name, true, false) && contextMenuChatId.value
);
});
const getDefaultConversationHotKeys = computed(() => {
const defaultConversationHotKeys = [
...statusActions.value,
...conversationAdditionalActions.value,
...assignAgentActions.value,
...assignTeamActions.value,
...labelActions.value,
...assignPriorityActions.value,
];
if (isAIIntegrationEnabled.value) {
return [...defaultConversationHotKeys, ...AIAssistActions.value];
}
return defaultConversationHotKeys;
});
const conversationHotKeys = computed(() => {
if (shouldShowSnoozeOption.value) {
return prepareActions(SNOOZE_CONVERSATION_ACTIONS, t);
}
if (isConversationOrInboxRoute.value) {
return getDefaultConversationHotKeys.value;
}
return [];
});
return {
conversationHotKeys,
};
}
@@ -1,3 +1,8 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'dashboard/composables/route';
import { useAdmin } from 'dashboard/composables/useAdmin';
import {
ICON_ACCOUNT_SETTINGS,
ICON_AGENT_REPORTS,
@@ -14,11 +19,9 @@ import {
ICON_TEAM_REPORTS,
ICON_USER_PROFILE,
ICON_CONVERSATION_REPORTS,
} from './CommandBarIcons';
import { frontendURL } from '../../../helper/URLHelper';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { FEATURE_FLAGS } from '../../../featureFlags';
} from 'dashboard/helper/commandbar/icons';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const GO_TO_COMMANDS = [
{
@@ -172,45 +175,45 @@ const GO_TO_COMMANDS = [
},
];
export default {
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
goToCommandHotKeys() {
let commands = GO_TO_COMMANDS.filter(cmd => {
if (cmd.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
cmd.featureFlag
);
}
return true;
});
export function useGoToCommandHotKeys() {
const { t } = useI18n();
const router = useRouter();
const { isAdmin } = useAdmin();
if (!this.isAdmin) {
commands = commands.filter(command => command.role.includes('agent'));
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledOnAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const openRoute = url => {
router.push(frontendURL(url));
};
const goToCommandHotKeys = computed(() => {
let commands = GO_TO_COMMANDS.filter(cmd => {
if (cmd.featureFlag) {
return isFeatureEnabledOnAccount.value(
currentAccountId.value,
cmd.featureFlag
);
}
return true;
});
return commands.map(command => ({
id: command.id,
section: this.$t(command.section),
title: this.$t(command.title),
icon: command.icon,
handler: () => this.openRoute(command.path(this.accountId)),
}));
},
},
methods: {
openRoute(url) {
this.$router.push(frontendURL(url));
},
},
};
if (!isAdmin.value) {
commands = commands.filter(command => command.role.includes('agent'));
}
return commands.map(command => ({
id: command.id,
section: t(command.section),
title: t(command.title),
icon: command.icon,
handler: () => openRoute(command.path(currentAccountId.value)),
}));
});
return {
goToCommandHotKeys,
};
}
@@ -1,13 +1,19 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
import { ICON_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/icons';
import { emitter } from 'shared/helpers/mitt';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const createSnoozeHandler = option => () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, option);
const INBOX_SNOOZE_EVENTS = [
{
id: 'snooze_notification',
@@ -21,8 +27,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
handler: createSnoozeHandler(SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
@@ -30,8 +35,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
@@ -39,8 +43,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
@@ -48,35 +51,38 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
title: 'COMMAND_BAR.COMMANDS.UNTIL_CUSTOM_TIME',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export default {
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
export function useInboxHotKeys() {
const { t } = useI18n();
const route = useRoute();
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: action.section ? t(action.section) : undefined,
}));
};
const inboxHotKeys = computed(() => {
if (isAInboxViewRoute(route.name)) {
return prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
});
return {
inboxHotKeys,
};
}
@@ -0,0 +1,63 @@
import { allAgentsData } from 'dashboard/helper/specs/fixtures/agentFixtures';
export { allAgentsData };
export const formattedAgentsData = [
{
account_id: 0,
confirmed: true,
email: 'None',
id: 0,
name: 'None',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
@@ -0,0 +1,37 @@
export const summary = {
avg_first_response_time: '198.6666666666667',
avg_resolution_time: '208.3333333333333',
conversations_count: 5000,
incoming_messages_count: 5,
outgoing_messages_count: 3,
previous: {
avg_first_response_time: '89.0',
avg_resolution_time: '145.0',
conversations_count: 4,
incoming_messages_count: 5,
outgoing_messages_count: 4,
resolutions_count: 0,
},
resolutions_count: 3,
};
export const botSummary = {
bot_resolutions_count: 10,
bot_handoffs_count: 20,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 5,
},
};
export const report = {
data: [
{ value: '0.00', timestamp: 1647541800, count: 0 },
{ value: '0.00', timestamp: 1647628200, count: 0 },
{ value: '0.00', timestamp: 1647714600, count: 0 },
{ value: '0.00', timestamp: 1647801000, count: 0 },
{ value: '0.01', timestamp: 1647887400, count: 4 },
{ value: '0.00', timestamp: 1647973800, count: 0 },
{ value: '0.00', timestamp: 1648060200, count: 0 },
],
};
@@ -0,0 +1,119 @@
import { useAI } from '../useAI';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from '../useI18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('../useI18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useAI', () => {
const mockStore = {
dispatch: vi.fn(),
};
const mockGetters = {
'integrations/getUIFlags': { value: { isFetching: false } },
'draftMessages/get': { value: () => 'Draft message' },
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useStoreGetters.mockReturnValue(mockGetters);
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useTrack.mockReturnValue(vi.fn());
useI18n.mockReturnValue({ t: vi.fn() });
useAlert.mockReturnValue(vi.fn());
});
it('initializes computed properties correctly', async () => {
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
useAI();
expect(uiFlags.value).toEqual({ isFetching: false });
expect(appIntegrations.value).toEqual([]);
expect(currentChat.value).toEqual({ id: '123' });
expect(replyMode.value).toBe('reply');
expect(draftMessage.value).toBe('Draft message');
});
it('fetches integrations if required', async () => {
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations if already loaded', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [{ id: 'openai' }],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('records analytics correctly', async () => {
const mockTrack = vi.fn();
useTrack.mockReturnValue(mockTrack);
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [
{ id: 'openai', hooks: [{ id: 'hook1' }] },
],
getSelectedChat: { id: '123' },
};
return { value: mockValues[getter] };
});
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
expect(result).toEqual(['label1', 'label2']);
});
});
@@ -0,0 +1,37 @@
import { ref } from 'vue';
import { describe, it, expect, vi } from 'vitest';
import { useAccount } from '../useAccount';
import { useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useAccount', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
getCurrentAccountId: ref(123),
});
});
it('returns accountId as a computed property', () => {
const { accountId } = useAccount();
expect(accountId.value).toBe(123);
});
it('generates account-scoped URLs correctly', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('settings/inbox/new');
expect(result).toBe('/app/accounts/123/settings/inbox/new');
});
it('handles URLs with leading slash', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('users');
expect(result).toBe('/app/accounts/123/users');
});
it('handles empty URL', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('');
expect(result).toBe('/app/accounts/123/');
});
});
@@ -0,0 +1,91 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/agentHelper');
const mockUseMapGetter = (overrides = {}) => {
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
};
const mergedGetters = { ...defaultGetters, ...overrides };
useMapGetter.mockImplementation(getter => mergedGetters[getter]);
};
describe('useAgentsList', () => {
beforeEach(() => {
vi.clearAllMocks();
agentHelper.getAgentsByUpdatedPresence.mockImplementation(agents => agents);
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
formattedAgentsData.slice(1)
);
agentHelper.getCombinedAgents.mockImplementation(
(agents, includeNone, isAgentSelected) => {
if (includeNone && isAgentSelected) {
return [agentHelper.createNoneAgent, ...agents];
}
return agents;
}
);
mockUseMapGetter();
});
it('returns agentsList and assignableAgents', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(agentsList.value).toEqual([
agentHelper.createNoneAgent,
...formattedAgentsData.slice(1),
]);
});
it('includes None agent when includeNoneAgent is true', () => {
const { agentsList } = useAgentsList(true);
expect(agentsList.value[0]).toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length);
});
it('excludes None agent when includeNoneAgent is false', () => {
const { agentsList } = useAgentsList(false);
expect(agentsList.value[0]).not.toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length - 1);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
});
it('handles missing inbox_id', () => {
mockUseMapGetter({
getSelectedChat: ref({ meta: { assignee: true } }),
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
});
});
@@ -0,0 +1,295 @@
import { useAutomation } from '../useAutomation';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from '../useI18n';
import * as automationHelper from 'dashboard/helper/automationHelper';
import {
customAttributes,
agents,
teams,
labels,
statusFilterOptions,
campaigns,
contacts,
inboxes,
languages,
countries,
slaPolicies,
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('../useI18n');
vi.mock('dashboard/helper/automationHelper');
describe('useAutomation', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
'attributes/getAttributes': { value: customAttributes },
'attributes/getAttributesByModel': {
value: model => {
return model === 'conversation_attribute'
? [{ id: 1, name: 'Conversation Attribute' }]
: [{ id: 2, name: 'Contact Attribute' }];
},
},
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
'labels/getLabels': labels,
'teams/getTeams': teams,
'sla/getSLA': slaPolicies,
};
return { value: getterMap[getter] };
});
useI18n.mockReturnValue({ t: key => key });
useAlert.mockReturnValue(vi.fn());
// Mock getConditionOptions for different types
automationHelper.getConditionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'status':
return statusFilterOptions;
case 'team_id':
return teams;
case 'assignee_id':
return agents;
case 'contact':
return contacts;
case 'inbox_id':
return inboxes;
case 'campaigns':
return campaigns;
case 'browser_language':
return languages;
case 'country_code':
return countries;
case 'message_type':
return MESSAGE_CONDITION_VALUES;
default:
return [];
}
});
// Mock getActionOptions for different types
automationHelper.getActionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'add_label':
return labels;
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
case 'send_email_to_team':
return teams;
case 'send_message':
return [];
case 'add_sla':
return slaPolicies;
default:
return [];
}
});
});
it('initializes computed properties correctly', () => {
const {
agents: computedAgents,
campaigns: computedCampaigns,
contacts: computedContacts,
inboxes: computedInboxes,
labels: computedLabels,
teams: computedTeams,
slaPolicies: computedSlaPolicies,
} = useAutomation();
expect(computedAgents.value).toEqual(agents);
expect(computedCampaigns.value).toEqual(campaigns);
expect(computedContacts.value).toEqual(contacts);
expect(computedInboxes.value).toEqual(inboxes);
expect(computedLabels.value).toEqual(labels);
expect(computedTeams.value).toEqual(teams);
expect(computedSlaPolicies.value).toEqual(slaPolicies);
});
it('appends new condition and action correctly', () => {
const { appendNewCondition, appendNewAction } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
appendNewCondition(mockAutomation);
appendNewAction(mockAutomation);
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
});
it('removes filter and action correctly', () => {
const { removeFilter, removeAction } = useAutomation();
const mockAutomation = {
conditions: [{ id: 1 }, { id: 2 }],
actions: [{ id: 1 }, { id: 2 }],
};
removeFilter(mockAutomation, 0);
removeAction(mockAutomation, 0);
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
expect(mockAutomation.conditions[0].id).toBe(2);
expect(mockAutomation.actions[0].id).toBe(2);
});
it('resets filter and action correctly', () => {
const { resetFilter, resetAction } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: 'open',
},
],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
const mockAutomationTypes = {
message_created: {
conditions: [
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
],
},
};
resetFilter(
mockAutomation,
mockAutomationTypes,
0,
mockAutomation.conditions[0]
);
resetAction(mockAutomation, 0);
expect(mockAutomation.conditions[0].filter_operator).toBe('not_equal_to');
expect(mockAutomation.conditions[0].values).toBe('');
expect(mockAutomation.actions[0].action_params).toEqual([]);
});
it('formats automation correctly', () => {
const { formatAutomation } = useAutomation();
const mockAutomation = {
conditions: [{ attribute_key: 'status', values: ['open'] }],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
const mockAutomationTypes = {};
const mockAutomationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
automationHelper.getConditionOptions.mockReturnValue([
{ id: 'open', name: 'open' },
]);
automationHelper.getActionOptions.mockReturnValue([
{ id: 1, name: 'Agent 1' },
]);
const result = formatAutomation(
mockAutomation,
customAttributes,
mockAutomationTypes,
mockAutomationActionTypes
);
expect(result.conditions[0].values).toEqual([{ id: 'open', name: 'open' }]);
expect(result.actions[0].action_params).toEqual([
{ id: 1, name: 'Agent 1' },
]);
});
it('manifests custom attributes correctly', () => {
const { manifestCustomAttributes } = useAutomation();
const mockAutomationTypes = {
message_created: { conditions: [] },
conversation_created: { conditions: [] },
conversation_updated: { conditions: [] },
conversation_opened: { conditions: [] },
};
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
manifestCustomAttributes(mockAutomationTypes);
expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes(
2
);
expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1);
Object.values(mockAutomationTypes).forEach(type => {
expect(type.conditions).toHaveLength(0);
});
});
it('gets condition dropdown values correctly', () => {
const { getConditionDropdownValues } = useAutomation();
expect(getConditionDropdownValues('status')).toEqual(statusFilterOptions);
expect(getConditionDropdownValues('team_id')).toEqual(teams);
expect(getConditionDropdownValues('assignee_id')).toEqual(agents);
expect(getConditionDropdownValues('contact')).toEqual(contacts);
expect(getConditionDropdownValues('inbox_id')).toEqual(inboxes);
expect(getConditionDropdownValues('campaigns')).toEqual(campaigns);
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
expect(getConditionDropdownValues('country_code')).toEqual(countries);
expect(getConditionDropdownValues('message_type')).toEqual(
MESSAGE_CONDITION_VALUES
);
});
it('gets action dropdown values correctly', () => {
const { getActionDropdownValues } = useAutomation();
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
});
it('handles event change correctly', () => {
const { onEventChange } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
onEventChange(mockAutomation);
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
});
});
@@ -0,0 +1,51 @@
import { useConfig } from '../useConfig';
describe('useConfig', () => {
const originalChatwootConfig = window.chatwootConfig;
beforeEach(() => {
window.chatwootConfig = {
hostURL: 'https://example.com',
vapidPublicKey: 'vapid-key',
enabledLanguages: ['en', 'fr'],
isEnterprise: 'true',
enterprisePlanName: 'enterprise',
};
});
afterEach(() => {
window.chatwootConfig = originalChatwootConfig;
});
it('returns the correct configuration values', () => {
const config = useConfig();
expect(config.hostURL).toBe('https://example.com');
expect(config.vapidPublicKey).toBe('vapid-key');
expect(config.enabledLanguages).toEqual(['en', 'fr']);
expect(config.isEnterprise).toBe(true);
expect(config.enterprisePlanName).toBe('enterprise');
});
it('handles missing configuration values', () => {
window.chatwootConfig = {};
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
it('handles undefined window.chatwootConfig', () => {
window.chatwootConfig = undefined;
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { useConversationLabels } from '../useConversationLabels';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useConversationLabels', () => {
let store;
let getters;
beforeEach(() => {
store = {
getters: {
'conversationLabels/getConversationLabels': vi.fn(),
},
dispatch: vi.fn(),
};
getters = {
getSelectedChat: { value: { id: 1 } },
'labels/getLabels': {
value: [
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
{ id: 3, title: 'Label 3' },
],
},
};
useStore.mockReturnValue(store);
useStoreGetters.mockReturnValue(getters);
});
it('should return the correct computed properties', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
useConversationLabels();
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
expect(activeLabels.value).toEqual([
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
]);
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
});
it('should update labels correctly', async () => {
const { onUpdateLabels } = useConversationLabels();
await onUpdateLabels(['Label 1', 'Label 3']);
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 3'],
});
});
it('should add a label to the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
]);
const { addLabelToConversation } = useConversationLabels();
addLabelToConversation({ title: 'Label 2' });
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 2'],
});
});
it('should remove a label from the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { removeLabelFromConversation } = useConversationLabels();
removeLabelFromConversation('Label 2');
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1'],
});
});
});
@@ -0,0 +1,101 @@
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import {
LAYOUT_QWERTY,
LAYOUT_QWERTZ,
LAYOUT_AZERTY,
} from 'shared/helpers/KeyboardHelpers';
describe('useDetectKeyboardLayout', () => {
beforeEach(() => {
window.cw_keyboard_layout = null;
});
it('returns cached layout if available', async () => {
window.cw_keyboard_layout = LAYOUT_QWERTY;
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTZ layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'z'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTZ);
});
it('should detect AZERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'a'],
['KeyW', 'z'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_AZERTY);
});
it('should use legacy method if navigator.keyboard is not available', async () => {
navigator.keyboard = undefined;
const layout = await useDetectKeyboardLayout();
expect([LAYOUT_QWERTY, LAYOUT_QWERTZ, LAYOUT_AZERTY]).toContain(layout);
});
it('should cache the detected layout', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
const layoutAgain = await useDetectKeyboardLayout();
expect(layoutAgain).toBe(LAYOUT_QWERTY);
expect(navigator.keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,56 @@
import { useIntegrationHook } from '../useIntegrationHook';
import { useMapGetter } from 'dashboard/composables/store';
import { nextTick } from 'vue';
vi.mock('dashboard/composables/store');
describe('useIntegrationHook', () => {
let integrationGetter;
beforeEach(() => {
integrationGetter = vi.fn();
useMapGetter.mockReturnValue({ value: integrationGetter });
});
it('should return the correct computed properties', async () => {
const mockIntegration = {
id: 1,
hook_type: 'inbox',
hooks: ['hook1', 'hook2'],
allow_multiple_hooks: true,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(1);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('multiple');
expect(hook.isIntegrationMultiple.value).toBe(true);
expect(hook.isIntegrationSingle.value).toBe(false);
expect(hook.isHookTypeInbox.value).toBe(true);
expect(hook.hasConnectedHooks.value).toBe(true);
});
it('should handle single integration type correctly', async () => {
const mockIntegration = {
id: 2,
hook_type: 'channel',
hooks: [],
allow_multiple_hooks: false,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(2);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('single');
expect(hook.isIntegrationMultiple.value).toBe(false);
expect(hook.isIntegrationSingle.value).toBe(true);
expect(hook.isHookTypeInbox.value).toBe(false);
expect(hook.hasConnectedHooks.value).toBe(false);
});
});
@@ -0,0 +1,26 @@
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
describe('useKeyboardEvents', () => {
it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined();
});
it('should return a function', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('should set up listeners on mount and remove them on unmount', async () => {
const events = {
'ALT+KeyL': () => {},
};
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
import { useKeyboardNavigableList } from '../useKeyboardNavigableList';
import { useKeyboardEvents } from '../useKeyboardEvents';
// Mock the useKeyboardEvents function
vi.mock('../useKeyboardEvents', () => ({
useKeyboardEvents: vi.fn(),
}));
describe('useKeyboardNavigableList', () => {
let items;
let onSelect;
let adjustScroll;
let selectedIndex;
const createMockEvent = () => ({ preventDefault: vi.fn() });
beforeEach(() => {
items = ref(['item1', 'item2', 'item3']);
onSelect = vi.fn();
adjustScroll = vi.fn();
selectedIndex = ref(0);
vi.clearAllMocks();
});
it('should return moveSelectionUp and moveSelectionDown functions', () => {
const result = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(result).toHaveProperty('moveSelectionUp');
expect(result).toHaveProperty('moveSelectionDown');
expect(typeof result.moveSelectionUp).toBe('function');
expect(typeof result.moveSelectionDown).toBe('function');
});
it('should move selection up correctly', () => {
const { moveSelectionUp } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
expect(selectedIndex.value).toBe(2);
moveSelectionUp();
expect(selectedIndex.value).toBe(1);
moveSelectionUp();
expect(selectedIndex.value).toBe(0);
moveSelectionUp();
expect(selectedIndex.value).toBe(2);
});
it('should move selection down correctly', () => {
const { moveSelectionDown } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionDown();
expect(selectedIndex.value).toBe(1);
moveSelectionDown();
expect(selectedIndex.value).toBe(2);
moveSelectionDown();
expect(selectedIndex.value).toBe(0);
moveSelectionDown();
expect(selectedIndex.value).toBe(1);
});
it('should call adjustScroll after moving selection', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
expect(adjustScroll).toHaveBeenCalledTimes(1);
moveSelectionDown();
expect(adjustScroll).toHaveBeenCalledTimes(2);
});
it('should include Enter key handler when onSelect is provided', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEvents).toHaveProperty('Enter');
expect(keyboardEvents.Enter.allowOnFocusedInput).toBe(true);
});
it('should not include Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
items,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEvents).not.toHaveProperty('Enter');
});
it('should not trigger onSelect when items are empty', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
items: ref([]),
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
moveSelectionDown();
expect(onSelect).not.toHaveBeenCalled();
});
it('should call useKeyboardEvents with correct parameters', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
});
// Keyboard event handlers
it('should handle ArrowUp key', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.ArrowUp.action(mockEvent);
expect(selectedIndex.value).toBe(2);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Control+KeyP', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents['Control+KeyP'].action(mockEvent);
expect(selectedIndex.value).toBe(2);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle ArrowDown key', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.ArrowDown.action(mockEvent);
expect(selectedIndex.value).toBe(1);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Control+KeyN', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents['Control+KeyN'].action(mockEvent);
expect(selectedIndex.value).toBe(1);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Enter key when onSelect is provided', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.Enter.action(mockEvent);
expect(onSelect).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should not have Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
items,
adjustScroll,
selectedIndex,
});
const keyboardEventsWithoutSelect = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEventsWithoutSelect).not.toHaveProperty('Enter');
});
it('should set allowOnFocusedInput to true for all key handlers', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const keyHandlers = [
'ArrowUp',
'Control+KeyP',
'ArrowDown',
'Control+KeyN',
'Enter',
];
keyHandlers.forEach(key => {
if (keyboardEvents[key]) {
expect(keyboardEvents[key].allowOnFocusedInput).toBe(true);
}
});
});
});
@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from 'vitest';
import { useMacros } from '../useMacros';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
describe('useMacros', () => {
const mockLabels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];
const mockTeams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
const mockAgents = [
{
id: 1,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'agent',
thumbnail: 'https://example.com/image.png',
},
{
id: 9,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'clark@kent.com',
available_name: 'Clark Kent',
name: 'Clark Kent',
role: 'agent',
thumbnail: '',
},
];
beforeEach(() => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
});
});
it('initializes computed properties correctly', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
const { getMacroDropdownValues } = useMacros();
const expectedLabels = mockLabels.map(i => ({
id: i.title,
name: i.title,
}));
expect(getMacroDropdownValues('add_label')).toEqual(expectedLabels);
expect(getMacroDropdownValues('remove_label')).toEqual(expectedLabels);
});
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
});
it('returns an empty array for unknown types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('unknown_type')).toEqual([]);
});
it('handles empty data correctly', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
]);
});
});
@@ -0,0 +1,61 @@
import { ref } from 'vue';
import { useReportMetrics } from '../useReportMetrics';
import { useMapGetter } from 'dashboard/composables/store';
import { summary, botSummary } from './fixtures/reportFixtures';
vi.mock('dashboard/composables/store');
vi.mock('@chatwoot/utils', () => ({
formatTime: vi.fn(time => `formatted_${time}`),
}));
describe('useReportMetrics', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockReturnValue(ref(summary));
});
it('calculates trend correctly', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('conversations_count')).toBe(124900);
expect(calculateTrend('incoming_messages_count')).toBe(0);
expect(calculateTrend('avg_first_response_time')).toBe(123);
});
it('returns 0 for trend when previous value is not available', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('non_existent_key')).toBe(0);
});
it('identifies average metric types correctly', () => {
const { isAverageMetricType } = useReportMetrics();
expect(isAverageMetricType('avg_first_response_time')).toBe(true);
expect(isAverageMetricType('avg_resolution_time')).toBe(true);
expect(isAverageMetricType('reply_time')).toBe(true);
expect(isAverageMetricType('conversations_count')).toBe(false);
});
it('displays metrics correctly for account', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('conversations_count')).toBe('5,000');
expect(displayMetric('incoming_messages_count')).toBe('5');
});
it('displays the metric for bot', () => {
const customKey = 'getBotSummary';
useMapGetter.mockReturnValue(ref(botSummary));
const { displayMetric } = useReportMetrics(customKey);
expect(displayMetric('bot_resolutions_count')).toBe('10');
expect(displayMetric('bot_handoffs_count')).toBe('20');
});
it('handles non-existent metrics', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('non_existent_key')).toBe('0');
});
});
@@ -17,7 +17,7 @@ export const useStoreGetters = () => {
);
};
export const mapGetter = key => {
export const useMapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};
@@ -0,0 +1,204 @@
import { computed, onMounted } from 'vue';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from './useI18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index);
};
/**
* A composable function for AI-related operations in the dashboard.
* @returns {Object} An object containing AI-related methods and computed properties.
*/
export function useAI() {
const store = useStore();
const getters = useStoreGetters();
const track = useTrack();
const { t } = useI18n();
/**
* Computed property for UI flags.
* @type {import('vue').ComputedRef<Object>}
*/
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
/**
* Computed property for the AI integration.
* @type {import('vue').ComputedRef<Object|undefined>}
*/
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
/**
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
/**
* Computed property to check if label suggestion feature is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return settings.label_suggestion;
}
return false;
});
/**
* Computed property to check if app integrations are being fetched.
* @type {import('vue').ComputedRef<boolean>}
*/
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
/**
* Computed property for the hook ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* Computed property for the draft key.
* @type {import('vue').ComputedRef<string>}
*/
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
/**
* Computed property for the draft message.
* @type {import('vue').ComputedRef<string>}
*/
const draftMessage = computed(() =>
getters['draftMessages/get'].value(draftKey.value)
);
/**
* Fetches integrations if they haven't been loaded yet.
* @returns {Promise<void>}
*/
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
track(event, {
type,
...payload,
});
}
};
/**
* Fetches label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const fetchLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
*/
const processEvent = async (type = 'rephrase') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
draftMessage,
uiFlags,
appIntegrations,
currentChat,
replyMode,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
isFetchingAppIntegrations,
fetchIntegrationsIfRequired,
recordAnalytics,
fetchLabelSuggestions,
processEvent,
};
}
@@ -0,0 +1,30 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for account-related operations.
* @returns {Object} An object containing account-related properties and methods.
*/
export function useAccount() {
const getters = useStoreGetters();
/**
* Computed property for the current account ID.
* @type {import('vue').ComputedRef<number>}
*/
const accountId = computed(() => getters.getCurrentAccountId.value);
/**
* Generates an account-scoped URL.
* @param {string} url - The URL to be scoped to the account.
* @returns {string} The account-scoped URL.
*/
const accountScopedUrl = url => {
return `/app/accounts/${accountId.value}/${url}`;
};
return {
accountId,
accountScopedUrl,
};
}
@@ -0,0 +1,57 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
getCombinedAgents,
} from 'dashboard/helper/agentHelper';
/**
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
/**
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
currentAccountId.value
);
const filteredAgentsByAvailability = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return getCombinedAgents(
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected.value
);
});
return {
agentsList,
assignableAgents,
};
}
@@ -0,0 +1,349 @@
import { computed } from 'vue';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from './useI18n';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import {
generateCustomAttributeTypes,
getActionOptions,
getConditionOptions,
getCustomAttributeInputType,
getDefaultConditions,
getDefaultActions,
filterCustomAttributes,
getStandardAttributeInputType,
isCustomAttribute,
generateCustomAttributes,
} from 'dashboard/helper/automationHelper';
/**
* Composable for handling automation-related functionality.
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export function useAutomation() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabels');
const teams = useMapGetter('teams/getTeams');
const slaPolicies = useMapGetter('sla/getSLA');
const booleanFilterOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const statusFilterOptions = computed(() => {
const statusFilters = t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
return [
...Object.keys(statusFilters).map(status => ({
id: status,
name: statusFilters[status].TEXT,
})),
{ id: 'all', name: t('CHAT_LIST.FILTER_ALL') },
];
});
/**
* Handles the event change for an automation.
* @param {Object} automation - The automation object to update.
*/
const onEventChange = automation => {
automation.conditions = getDefaultConditions(automation.event_name);
automation.actions = getDefaultActions();
};
/**
* Gets the condition dropdown values for a given type.
* @param {string} type - The type of condition.
* @returns {Array} An array of condition dropdown values.
*/
const getConditionDropdownValues = type => {
return getConditionOptions({
agents: agents.value,
booleanFilterOptions: booleanFilterOptions.value,
campaigns: campaigns.value,
contacts: contacts.value,
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
statusFilterOptions: statusFilterOptions.value,
teams: teams.value,
languages,
countries,
type,
});
};
/**
* Appends a new condition to the automation.
* @param {Object} automation - The automation object to update.
*/
const appendNewCondition = automation => {
automation.conditions.push(...getDefaultConditions(automation.event_name));
};
/**
* Appends a new action to the automation.
* @param {Object} automation - The automation object to update.
*/
const appendNewAction = automation => {
automation.actions.push(...getDefaultActions());
};
/**
* Removes a filter from the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the filter to remove.
*/
const removeFilter = (automation, index) => {
if (automation.conditions.length <= 1) {
useAlert(t('AUTOMATION.CONDITION.DELETE_MESSAGE'));
} else {
automation.conditions.splice(index, 1);
}
};
/**
* Removes an action from the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the action to remove.
*/
const removeAction = (automation, index) => {
if (automation.actions.length <= 1) {
useAlert(t('AUTOMATION.ACTION.DELETE_MESSAGE'));
} else {
automation.actions.splice(index, 1);
}
};
/**
* Resets a filter in the automation.
* @param {Object} automation - The automation object to update.
* @param {Object} automationTypes - The automation types object.
* @param {number} index - The index of the filter to reset.
* @param {Object} currentCondition - The current condition object.
*/
const resetFilter = (
automation,
automationTypes,
index,
currentCondition
) => {
automation.conditions[index].filter_operator = automationTypes[
automation.event_name
].conditions.find(
condition => condition.key === currentCondition.attribute_key
).filterOperators[0].value;
automation.conditions[index].values = '';
};
/**
* Resets an action in the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the action to reset.
*/
const resetAction = (automation, index) => {
automation.actions[index].action_params = [];
};
/**
* This function sets the conditions for automation.
* It help to format the conditions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing conditions to manifest.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @returns {Array} An array of manifested conditions.
*/
const manifestConditions = (
automation,
allCustomAttributes,
automationTypes
) => {
const customAttributes = filterCustomAttributes(allCustomAttributes);
return automation.conditions.map(condition => {
const customAttr = isCustomAttribute(
customAttributes,
condition.attribute_key
);
let inputType = 'plain_text';
if (customAttr) {
inputType = getCustomAttributeInputType(customAttr.type);
} else {
inputType = getStandardAttributeInputType(
automationTypes,
automation.event_name,
condition.attribute_key
);
}
if (inputType === 'plain_text' || inputType === 'date') {
return { ...condition, values: condition.values[0] };
}
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
),
};
});
};
/**
* Gets the action dropdown values for a given type.
* @param {string} type - The type of action.
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
return getActionOptions({
agents: agents.value,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
languages,
type,
});
};
/**
* Generates an array of actions for the automation.
* @param {Object} action - The action object.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array|Object} Generated actions array or object based on input type.
*/
const generateActionsArray = (action, automationActionTypes) => {
const params = action.action_params;
const inputType = automationActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
return [...getActionDropdownValues(action.action_name)].filter(item =>
[...params].includes(item.id)
);
}
if (inputType === 'team_message') {
return {
team_ids: [...getActionDropdownValues(action.action_name)].filter(
item => [...params[0].team_ids].includes(item.id)
),
message: params[0].message,
};
}
return [...params];
};
/**
* This function sets the actions for automation.
* It help to format the actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing actions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array} An array of manifested actions.
*/
const manifestActions = (automation, automationActionTypes) => {
return automation.actions.map(action => ({
...action,
action_params: action.action_params.length
? generateActionsArray(action, automationActionTypes)
: [],
}));
};
/**
* Formats the automation object for use when we edit the automation.
* It help to format the conditions and actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object to format.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Object} A new object with formatted automation data, including automation conditions and actions.
*/
const formatAutomation = (
automation,
allCustomAttributes,
automationTypes,
automationActionTypes
) => {
return {
...automation,
conditions: manifestConditions(
automation,
allCustomAttributes,
automationTypes
),
actions: manifestActions(automation, automationActionTypes),
};
};
/**
* This function formats the custom attributes for automation types.
* It retrieves custom attributes for conversations and contacts,
* generates custom attribute types, and adds them to the relevant automation types.
* @param {Object} automationTypes - The automation types object to update with custom attributes.
*/
const manifestCustomAttributes = automationTypes => {
const conversationCustomAttributesRaw = getters[
'attributes/getAttributesByModel'
].value('conversation_attribute');
const contactCustomAttributesRaw =
getters['attributes/getAttributesByModel'].value('contact_attribute');
const conversationCustomAttributeTypes = generateCustomAttributeTypes(
conversationCustomAttributesRaw,
'conversation_attribute'
);
const contactCustomAttributeTypes = generateCustomAttributeTypes(
contactCustomAttributesRaw,
'contact_attribute'
);
const manifestedCustomAttributes = generateCustomAttributes(
conversationCustomAttributeTypes,
contactCustomAttributeTypes,
t('AUTOMATION.CONDITION.CONVERSATION_CUSTOM_ATTR_LABEL'),
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
);
automationTypes.message_created.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_created.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_updated.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_opened.conditions.push(
...manifestedCustomAttributes
);
};
return {
agents,
campaigns,
contacts,
inboxes,
labels,
teams,
slaPolicies,
booleanFilterOptions,
statusFilterOptions,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
formatAutomation,
getActionDropdownValues,
manifestCustomAttributes,
};
}
@@ -0,0 +1,46 @@
/**
* A function that provides access to various configuration values.
* @returns {Object} An object containing configuration values.
*/
export function useConfig() {
const config = window.chatwootConfig || {};
/**
* The host URL of the Chatwoot instance.
* @type {string|undefined}
*/
const hostURL = config.hostURL;
/**
* The VAPID public key for web push notifications.
* @type {string|undefined}
*/
const vapidPublicKey = config.vapidPublicKey;
/**
* An array of enabled languages in the Chatwoot instance.
* @type {string[]|undefined}
*/
const enabledLanguages = config.enabledLanguages;
/**
* Indicates whether the current instance is an enterprise version.
* @type {boolean}
*/
const isEnterprise = config.isEnterprise === 'true';
/**
* The name of the enterprise plan, if applicable.
* Returns "community" or "enterprise"
* @type {string|undefined}
*/
const enterprisePlanName = config.enterprisePlanName;
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
};
}
@@ -0,0 +1,101 @@
import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for managing conversation labels
* @returns {Object} An object containing methods and computed properties for conversation labels
*/
export function useConversationLabels() {
const store = useStore();
const getters = useStoreGetters();
/**
* The currently selected chat
* @type {import('vue').ComputedRef<Object>}
*/
const currentChat = computed(() => getters.getSelectedChat.value);
/**
* The ID of the current conversation
* @type {import('vue').ComputedRef<number|null>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* All labels available for the account
* @type {import('vue').ComputedRef<Array>}
*/
const accountLabels = computed(() => getters['labels/getLabels'].value);
/**
* Labels currently saved to the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const savedLabels = computed(() => {
return store.getters['conversationLabels/getConversationLabels'](
conversationId.value
);
});
/**
* Labels currently active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const activeLabels = computed(() =>
accountLabels.value.filter(({ title }) => savedLabels.value.includes(title))
);
/**
* Labels available but not active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const inactiveLabels = computed(() =>
accountLabels.value.filter(
({ title }) => !savedLabels.value.includes(title)
)
);
/**
* Updates the labels for the current conversation
* @param {string[]} selectedLabels - Array of label titles to be set for the conversation
* @returns {Promise<void>}
*/
const onUpdateLabels = async selectedLabels => {
await store.dispatch('conversationLabels/update', {
conversationId: conversationId.value,
labels: selectedLabels,
});
};
/**
* Adds a label to the current conversation
* @param {Object} value - The label object to be added
* @param {string} value.title - The title of the label to be added
*/
const addLabelToConversation = value => {
const result = activeLabels.value.map(item => item.title);
result.push(value.title);
onUpdateLabels(result);
};
/**
* Removes a label from the current conversation
* @param {string} value - The title of the label to be removed
*/
const removeLabelFromConversation = value => {
const result = activeLabels.value
.map(label => label.title)
.filter(label => label !== value);
onUpdateLabels(result);
};
return {
accountLabels,
savedLabels,
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
onUpdateLabels,
};
}
@@ -0,0 +1,73 @@
import {
LAYOUT_QWERTY,
LAYOUT_QWERTZ,
LAYOUT_AZERTY,
} from 'shared/helpers/KeyboardHelpers';
/**
* Detects the keyboard layout using a legacy method by creating a hidden input and dispatching a key event.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
async function detectLegacy() {
const input = document.createElement('input');
input.style.position = 'fixed';
input.style.top = '-100px';
document.body.appendChild(input);
input.focus();
return new Promise(resolve => {
const keyboardEvent = new KeyboardEvent('keypress', {
key: 'y',
keyCode: 89,
which: 89,
bubbles: true,
cancelable: true,
});
const handler = e => {
document.body.removeChild(input);
document.removeEventListener('keypress', handler);
if (e.key === 'z') {
resolve(LAYOUT_QWERTY);
} else if (e.key === 'y') {
resolve(LAYOUT_QWERTZ);
} else {
resolve(LAYOUT_AZERTY);
}
};
document.addEventListener('keypress', handler);
input.dispatchEvent(keyboardEvent);
});
}
/**
* Detects the keyboard layout using the modern navigator.keyboard API.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
async function detect() {
const map = await navigator.keyboard.getLayoutMap();
const q = map.get('KeyQ');
const w = map.get('KeyW');
const e = map.get('KeyE');
const r = map.get('KeyR');
const t = map.get('KeyT');
const y = map.get('KeyY');
return [q, w, e, r, t, y].join('').toUpperCase();
}
/**
* Uses either the modern or legacy method to detect the keyboard layout, caching the result.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
export async function useDetectKeyboardLayout() {
const cachedLayout = window.cw_keyboard_layout;
if (cachedLayout) {
return cachedLayout;
}
const layout = navigator.keyboard ? await detect() : await detectLegacy();
window.cw_keyboard_layout = layout;
return layout;
}
@@ -0,0 +1,68 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
/**
* Composable for managing integration hooks
* @param {string|number} integrationId - The ID of the integration
* @returns {Object} An object containing computed properties for the integration
*/
export const useIntegrationHook = integrationId => {
const integrationGetter = useMapGetter('integrations/getIntegration');
/**
* The integration object
* @type {import('vue').ComputedRef<Object>}
*/
const integration = computed(() => {
return integrationGetter.value(integrationId);
});
/**
* Whether the integration hook type is 'inbox'
* @type {import('vue').ComputedRef<boolean>}
*/
const isHookTypeInbox = computed(() => {
return integration.value.hook_type === 'inbox';
});
/**
* Whether the integration has any connected hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const hasConnectedHooks = computed(() => {
return !!integration.value.hooks.length;
});
/**
* The type of integration: 'multiple' or 'single'
* @type {import('vue').ComputedRef<string>}
*/
const integrationType = computed(() => {
return integration.value.allow_multiple_hooks ? 'multiple' : 'single';
});
/**
* Whether the integration allows multiple hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationMultiple = computed(() => {
return integrationType.value === 'multiple';
});
/**
* Whether the integration allows only a single hook
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationSingle = computed(() => {
return integrationType.value === 'single';
});
return {
integration,
integrationType,
isIntegrationMultiple,
isIntegrationSingle,
isHookTypeInbox,
hasConnectedHooks,
};
};
@@ -0,0 +1,90 @@
import {
isActiveElementTypeable,
isEscape,
keysToModifyInQWERTZ,
LAYOUT_QWERTZ,
} from 'shared/helpers/KeyboardHelpers';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { createKeybindingsHandler } from 'tinykeys';
import { onUnmounted, onMounted } from 'vue';
/**
* Determines if the keyboard event should be ignored based on the element type and handler settings.
* @param {Event} e - The event object.
* @param {Object|Function} handler - The handler configuration or function.
* @returns {boolean} - True if the event should be ignored, false otherwise.
*/
const shouldIgnoreEvent = (e, handler) => {
const isTypeable = isActiveElementTypeable(e);
const allowOnFocusedInput =
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
}
return !allowOnFocusedInput;
}
return false;
};
/**
* Wraps the event handler to include custom logic before executing the handler.
* @param {Function} handler - The original event handler.
* @returns {Function} - The wrapped handler.
*/
const keydownWrapper = handler => {
return e => {
if (shouldIgnoreEvent(e, handler)) return;
// extract the action to perform from the handler
const actionToPerform =
typeof handler === 'function' ? handler : handler.action;
actionToPerform(e);
};
};
/**
* Wraps all provided keyboard events in handlers that respect the current keyboard layout.
* @param {Object} events - The object containing event names and their handlers.
* @returns {Object} - The object with event names possibly modified based on the keyboard layout and wrapped handlers.
*/
async function wrapEventsInKeybindingsHandler(events) {
const wrappedEvents = {};
const currentLayout = await useDetectKeyboardLayout();
Object.keys(events).forEach(originalEventName => {
const modifiedEventName =
currentLayout === LAYOUT_QWERTZ &&
keysToModifyInQWERTZ.has(originalEventName)
? `Shift+${originalEventName}`
: originalEventName;
wrappedEvents[modifiedEventName] = keydownWrapper(
events[originalEventName]
);
});
return wrappedEvents;
}
/**
* Vue composable to handle keyboard events with support for different keyboard layouts.
* @param {Object} keyboardEvents - The keyboard events to handle.
*/
export async function useKeyboardEvents(keyboardEvents) {
let abortController = new AbortController();
onMounted(async () => {
if (!keyboardEvents) return;
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
const keydownHandler = createKeybindingsHandler(wrappedEvents);
document.addEventListener('keydown', keydownHandler, {
signal: abortController.signal,
});
});
onUnmounted(() => {
abortController.abort();
});
}
@@ -0,0 +1,117 @@
/**
* This composable provides keyboard navigation functionality for list-like UI components
* such as dropdowns, autocomplete suggestions, or any list of selectable items.
*
* TODO - Things that can be improved in the future
* - The scrolling should be handled by the component instead of the consumer of this composable
* it can be done if we know the item height.
* - The focus should be trapped within the list.
* - onSelect should be callback instead of a function that is passed
*/
import { useKeyboardEvents } from './useKeyboardEvents';
/**
* Wrap the action in a function that calls the action and prevents the default event behavior.
* @param {Function} action - The action to be called.
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
*/
const createAction = action => ({
action: e => {
action();
e.preventDefault();
},
allowOnFocusedInput: true,
});
/**
* Creates keyboard event handlers for navigation.
* @param {Function} moveSelectionUp - Function to move selection up.
* @param {Function} moveSelectionDown - Function to move selection down.
* @param {Function} [onSelect] - Optional function to handle selection.
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
* @returns {Object.<string, {action: Function, allowOnFocusedInput: boolean}>}
*/
const createKeyboardEvents = (
moveSelectionUp,
moveSelectionDown,
onSelect,
items
) => {
const events = {
ArrowUp: createAction(moveSelectionUp),
'Control+KeyP': createAction(moveSelectionUp),
ArrowDown: createAction(moveSelectionDown),
'Control+KeyN': createAction(moveSelectionDown),
};
// Adds an event handler for the Enter key if the onSelect function is provided.
if (typeof onSelect === 'function') {
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
}
return events;
};
/**
* Updates the selection index based on the current index, total number of items, and direction of movement.
*
* @param {number} currentIndex - The current index of the selected item.
* @param {number} itemsLength - The total number of items in the list.
* @param {string} direction - The direction of movement, either 'up' or 'down'.
* @returns {number} The new index after moving in the specified direction.
*/
const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
// If the selected index is the first item, move to the last item
// If the selected index is the last item, move to the first item
if (direction === 'up') {
return currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
}
return currentIndex === itemsLength - 1 ? 0 : currentIndex + 1;
};
/**
* A composable for handling keyboard navigation in mention selection scenarios.
*
* @param {Object} options - The options for the composable.
* @param {import('vue').Ref<HTMLElement>} options.elementRef - A ref to the DOM element that will receive keyboard events.
* @param {import('vue').Ref<Array>} options.items - A ref to the array of selectable items.
* @param {Function} [options.onSelect] - An optional function to be called when an item is selected.
* @param {Function} options.adjustScroll - A function to adjust the scroll position after selection changes.
* @param {import('vue').Ref<number>} options.selectedIndex - A ref to the currently selected index.
* @returns {{
* moveSelectionUp: Function,
* moveSelectionDown: Function
* }} An object containing functions to move the selection up and down.
*/
export function useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
}) {
const moveSelection = direction => {
selectedIndex.value = updateSelectionIndex(
selectedIndex.value,
items.value.length,
direction
);
adjustScroll();
};
const moveSelectionUp = () => moveSelection('up');
const moveSelectionDown = () => moveSelection('down');
const keyboardEvents = createKeyboardEvents(
moveSelectionUp,
moveSelectionDown,
onSelect,
items
);
useKeyboardEvents(keyboardEvents);
return {
moveSelectionUp,
moveSelectionDown,
};
}
@@ -0,0 +1,44 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useMacros = () => {
const getters = useStoreGetters();
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
* @returns {Array} An array of dropdown values
*/
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({
id: i.title,
name: i.title,
}));
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
default:
return [];
}
};
return {
getMacroDropdownValues,
};
};
@@ -0,0 +1,57 @@
import { useMapGetter } from 'dashboard/composables/store';
import { formatTime } from '@chatwoot/utils';
/**
* A composable function for report metrics calculations and display.
*
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
* @returns {Object} An object containing utility functions for report metrics.
*/
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
const accountSummary = useMapGetter(accountSummaryKey);
/**
* Calculates the trend percentage for a given metric.
*
* @param {string} key - The key of the metric to calculate trend for.
* @returns {number} The calculated trend percentage, rounded to the nearest integer.
*/
const calculateTrend = key => {
if (!accountSummary.value.previous[key]) return 0;
const diff = accountSummary.value[key] - accountSummary.value.previous[key];
return Math.round((diff / accountSummary.value.previous[key]) * 100);
};
/**
* Checks if a given metric key represents an average metric type.
*
* @param {string} key - The key of the metric to check.
* @returns {boolean} True if the metric is an average type, false otherwise.
*/
const isAverageMetricType = key => {
return [
'avg_first_response_time',
'avg_resolution_time',
'reply_time',
].includes(key);
};
/**
* Formats and displays a metric value based on its type.
*
* @param {string} key - The key of the metric to display.
* @returns {string} The formatted metric value as a string.
*/
const displayMetric = key => {
if (isAverageMetricType(key)) {
return formatTime(accountSummary.value[key]);
}
return Number(accountSummary.value[key] || '').toLocaleString();
};
return {
calculateTrend,
isAverageMetricType,
displayMetric,
};
}
@@ -0,0 +1,70 @@
export const DEFAULT_MESSAGE_CREATED_CONDITION = [
{
attribute_key: 'message_type',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
export const DEFAULT_OTHER_CONDITION = [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
export const DEFAULT_ACTIONS = [
{
action_name: 'assign_agent',
action_params: [],
},
];
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
},
{
id: 'low',
name: 'Low',
},
{
id: 'medium',
name: 'Medium',
},
{
id: 'high',
name: 'High',
},
{
id: 'urgent',
name: 'Urgent',
},
];
@@ -108,3 +108,12 @@ export const hasValidAvatarUrl = avatarUrl => {
return false;
}
};
export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
return url.toString();
};
@@ -0,0 +1,83 @@
/**
* Default agent object representing 'None'
* @type {Object}
*/
export const createNoneAgent = {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
/**
* Filters and sorts agents by availability status
* @param {Array} agents - List of agents
* @param {string} availability - Availability status to filter by
* @returns {Array} Filtered and sorted list of agents
*/
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
};
/**
* Sorts agents by availability status: online, busy, then offline
* @param {Array} agents - List of agents
* @returns {Array} Sorted list of agents
*/
export const getSortedAgentsByAvailability = agents => {
const onlineAgents = getAgentsByAvailability(agents, 'online');
const busyAgents = getAgentsByAvailability(agents, 'busy');
const offlineAgents = getAgentsByAvailability(agents, 'offline');
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
return filteredAgents;
};
/**
* Updates the availability status of the current user based on the current account
* @param {Array} agents - List of agents
* @param {Object} currentUser - Current user object
* @param {number} currentAccountId - ID of the current account
* @returns {Array} Updated list of agents with dynamic presence
*/
// Here we are updating the availability status of the current user dynamically
// based on the current account availability status
export const getAgentsByUpdatedPresence = (
agents,
currentUser,
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id
? {
...item,
availability_status: currentUser.accounts.find(
account => account.id === currentAccountId
).availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
};
/**
* Combines the filtered agents with the 'None' agent option if applicable.
*
* @param {Array} filteredAgentsByAvailability - The list of agents sorted by availability.
* @param {boolean} includeNoneAgent - Whether to include the 'None' agent option.
* @param {boolean} isAgentSelected - Whether an agent is currently selected.
* @returns {Array} The combined list of agents, potentially including the 'None' agent.
*/
export const getCombinedAgents = (
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected
) => {
return [
...(includeNoneAgent && isAgentSelected ? [createNoneAgent] : []),
...filteredAgentsByAvailability,
];
};
@@ -30,6 +30,7 @@ const translationKeys = {
'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`,
'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`,
'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`,
'accountuser:update:deleted': `AUDIT_LOGS.ACCOUNT_USER.EDIT.DELETED`,
'inboxmember:create': `AUDIT_LOGS.INBOX_MEMBER.ADD`,
'inboxmember:destroy': `AUDIT_LOGS.INBOX_MEMBER.REMOVE`,
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
@@ -89,9 +90,9 @@ function handleAccountUserCreate(auditLogItem, translationPayload, agentList) {
}
function handleAccountUserUpdate(auditLogItem, translationPayload, agentList) {
if (auditLogItem.user_id !== auditLogItem.auditable.user_id) {
if (auditLogItem.user_id !== auditLogItem.auditable?.user_id) {
translationPayload.user = getAgentName(
auditLogItem.auditable.user_id,
auditLogItem.auditable?.user_id,
agentList
);
}
@@ -187,16 +188,25 @@ export function generateTranslationPayload(auditLogItem, agentList) {
return translationPayload;
}
function getAccountUserUpdateSuffix(auditLogItem) {
if (auditLogItem.auditable === null) {
// If the user is deleted, we don't need to check if the user is the same as the auditLogItem.user_id
// Else we can use the deleted translation key
// It doesn't need the agent name
return ':deleted';
}
return auditLogItem.user_id === auditLogItem.auditable.user_id
? ':self'
: ':other';
}
export const generateLogActionKey = auditLogItem => {
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
let logActionKey = `${auditableType}:${action}`;
if (auditableType === 'accountuser' && action === 'update') {
logActionKey +=
auditLogItem.user_id === auditLogItem.auditable.user_id
? ':self'
: ':other';
logActionKey += getAccountUserUpdateSuffix(auditLogItem);
}
return translationKeys[logActionKey] || '';
@@ -3,41 +3,16 @@ import {
OPERATOR_TYPES_3,
OPERATOR_TYPES_4,
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
DEFAULT_MESSAGE_CREATED_CONDITION,
DEFAULT_CONVERSATION_OPENED_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
import filterQueryGenerator from './filterQueryGenerator';
import actionQueryGenerator from './actionQueryGenerator';
const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
},
{
id: 'low',
name: 'Low',
},
{
id: 'medium',
name: 'Medium',
},
{
id: 'high',
name: 'High',
},
{
id: 'urgent',
name: 'Urgent',
},
];
export const getCustomAttributeInputType = key => {
const customAttributeMap = {
@@ -198,45 +173,16 @@ export const getFileName = (action, files = []) => {
export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return [
{
attribute_key: 'message_type',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
return DEFAULT_MESSAGE_CREATED_CONDITION;
}
if (eventName === 'conversation_opened') {
return [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
return DEFAULT_CONVERSATION_OPENED_CONDITION;
}
return [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
return DEFAULT_OTHER_CONDITION;
};
export const getDefaultActions = () => {
return [
{
action_name: 'assign_agent',
action_params: [],
},
];
return DEFAULT_ACTIONS;
};
export const filterCustomAttributes = customAttributes => {
@@ -297,3 +243,100 @@ export const generateCustomAttributes = (
}
return customAttributes;
};
/**
* Get attributes for a given key from automation types.
* @param {Object} automationTypes - Object containing automation types.
* @param {string} key - The key to get attributes for.
* @returns {Array} Array of condition objects for the given key.
*/
export const getAttributes = (automationTypes, key) => {
return automationTypes[key].conditions;
};
/**
* Get the automation type for a given key.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the automation type for.
* @returns {Object} The automation type object.
*/
export const getAutomationType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find(
condition => condition.key === key
);
};
/**
* Get the input type for a given key.
* @param {Array} allCustomAttributes - Array of all custom attributes.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the input type for.
* @returns {string} The input type.
*/
export const getInputType = (
allCustomAttributes,
automationTypes,
automation,
key
) => {
const customAttribute = isACustomAttribute(allCustomAttributes, key);
if (customAttribute) {
return getCustomAttributeInputType(customAttribute.attribute_display_type);
}
const type = getAutomationType(automationTypes, automation, key);
return type.inputType;
};
/**
* Get operators for a given key.
* @param {Array} allCustomAttributes - Array of all custom attributes.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} mode - The mode ('edit' or other).
* @param {string} key - The key to get operators for.
* @returns {Array} Array of operators.
*/
export const getOperators = (
allCustomAttributes,
automationTypes,
automation,
mode,
key
) => {
if (mode === 'edit') {
const customAttribute = isACustomAttribute(allCustomAttributes, key);
if (customAttribute) {
return getOperatorTypes(customAttribute.attribute_display_type);
}
}
const type = getAutomationType(automationTypes, automation, key);
return type.filterOperators;
};
/**
* Get the custom attribute type for a given key.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the custom attribute type for.
* @returns {string} The custom attribute type.
*/
export const getCustomAttributeType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find(
i => i.key === key
).customAttributeType;
};
/**
* Determine if an action input should be shown.
* @param {Array} automationActionTypes - Array of automation action type objects.
* @param {string} action - The action to check.
* @returns {boolean} True if the action input should be shown, false otherwise.
*/
export const showActionInput = (automationActionTypes, action) => {
if (action === 'send_email_to_team' || action === 'send_message')
return false;
const type = automationActionTypes.find(i => i.key === action).inputType;
return !!type;
};
@@ -8,7 +8,7 @@ import {
CMD_SEND_TRANSCRIPT,
CMD_SNOOZE_CONVERSATION,
CMD_UNMUTE_CONVERSATION,
} from './commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
import {
ICON_MUTE_CONVERSATION,
@@ -17,7 +17,7 @@ import {
ICON_SEND_TRANSCRIPT,
ICON_SNOOZE_CONVERSATION,
ICON_UNMUTE_CONVERSATION,
} from './CommandBarIcons';
} from 'dashboard/helper/commandbar/icons';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -46,6 +46,7 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
@@ -0,0 +1,94 @@
/**
* Determines the last non-activity message between store and API messages.
* @param {Object} messageInStore - The last non-activity message from the store.
* @param {Object} messageFromAPI - The last non-activity message from the API.
* @returns {Object} The latest non-activity message.
*/
const getLastNonActivityMessage = (messageInStore, messageFromAPI) => {
// If both API value and store value for last non activity message
// are available, then return the latest one.
if (messageInStore && messageFromAPI) {
return messageInStore.created_at >= messageFromAPI.created_at
? messageInStore
: messageFromAPI;
}
// Otherwise, return whichever is available
return messageInStore || messageFromAPI;
};
/**
* Filters out duplicate source messages from an array of messages.
* @param {Array} messages - The array of messages to filter.
* @returns {Array} An array of messages without duplicates.
*/
export const filterDuplicateSourceMessages = (messages = []) => {
const messagesWithoutDuplicates = [];
// We cannot use Map or any short hand method as it returns the last message with the duplicate ID
// We should return the message with smaller id when there is a duplicate
messages.forEach(m1 => {
if (m1.source_id) {
const index = messagesWithoutDuplicates.findIndex(
m2 => m1.source_id === m2.source_id
);
if (index < 0) {
messagesWithoutDuplicates.push(m1);
}
} else {
messagesWithoutDuplicates.push(m1);
}
});
return messagesWithoutDuplicates;
};
/**
* Retrieves the last message from a conversation, prioritizing non-activity messages.
* @param {Object} m - The conversation object containing messages.
* @returns {Object} The last message of the conversation.
*/
export const getLastMessage = m => {
const lastMessageIncludingActivity = m.messages[m.messages.length - 1];
const nonActivityMessages = m.messages.filter(
message => message.message_type !== 2
);
const lastNonActivityMessageInStore =
nonActivityMessages[nonActivityMessages.length - 1];
const lastNonActivityMessageFromAPI = m.last_non_activity_message;
// If API value and store value for last non activity message
// is empty, then return the last activity message
if (!lastNonActivityMessageInStore && !lastNonActivityMessageFromAPI) {
return lastMessageIncludingActivity;
}
return getLastNonActivityMessage(
lastNonActivityMessageInStore,
lastNonActivityMessageFromAPI
);
};
/**
* Filters messages that have been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of read messages.
*/
export const getReadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 <= agentLastSeenAt * 1000
);
};
/**
* Filters messages that have not been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of unread messages.
*/
export const getUnreadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 > agentLastSeenAt * 1000
);
};
@@ -1,18 +1,21 @@
const FEATURE_HELP_URLS = {
agent_bots: 'https://chwt.app/hc/agent-bots',
agents: 'https://chwt.app/hc/agents',
audit_logs: 'https://chwt.app/hc/audit-logs',
campaigns: 'https://chwt.app/hc/campaigns',
canned_responses: 'https://chwt.app/hc/canned',
channel_email: 'https://chwt.app/hc/email',
channel_facebook: 'https://chwt.app/hc/fb',
help_center: 'https://chwt.app/hc/help-center',
agent_bots: 'https://chwt.app/hc/agent-bots',
team_management: 'https://chwt.app/hc/teams',
labels: 'https://chwt.app/hc/labels',
custom_attributes: 'https://chwt.app/hc/custom-attributes',
canned_responses: 'https://chwt.app/hc/canned',
integrations: 'https://chwt.app/hc/integrations',
campaigns: 'https://chwt.app/hc/campaigns',
reports: 'https://chwt.app/hc/reports',
message_reply_to: 'https://chwt.app/hc/reply-to',
sla: 'https://chwt.app/hc/sla',
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
help_center: 'https://chwt.app/hc/help-center',
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
message_reply_to: 'https://chwt.app/hc/reply-to',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
};
export function getHelpUrlForFeature(featureName) {
@@ -7,6 +7,15 @@ export const hasPermissions = (
);
};
export const getCurrentAccount = ({ accounts } = {}, accountId = null) => {
return accounts.find(account => Number(account.id) === Number(accountId));
};
export const getUserPermissions = (user, accountId) => {
const currentAccount = getCurrentAccount(user, accountId) || {};
return currentAccount.permissions || [];
};
const isPermissionsPresentInRoute = route =>
route.meta && route.meta.permissions;

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