Compare commits

...
Author SHA1 Message Date
Shivam MishraandGitHub b9e32791a4 Merge branch 'develop' into feat/update-editor 2024-06-03 13:04:37 +05:30
a7945384e0 style: no need for assignment
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-06-03 12:50:58 +05:30
f956530b36 chore: upgrade packages (#9555)
This PR upgrades the packages to the latest patch versions, no-breaking
changes. Can be merged once CI clears

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-06-02 19:09:06 +05:30
Pavel KuzminandGitHub 803471e42f fix: browser info (#9569)
# Pull Request Template

## Description

fixed getting browser data

In Vue, without computed properties, the data doesn't update, and
because of this, it doesn't display information about the browser.

## Type of change

Please delete options that are not relevant.

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] 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
2024-06-02 19:06:10 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b0114dee42 chore(deps): bump pug from 3.0.2 to 3.0.3 (#9564)
Bumps [pug](https://github.com/pugjs/pug) from 3.0.2 to 3.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pugjs/pug/releases">pug's
releases</a>.</em></p>
<blockquote>
<h2>pug-code-gen@3.0.3</h2>
<h2>Bug Fixes</h2>
<ul>
<li>Validate <code>templateName</code> and <code>globals</code> are
valid JavaScript identifiers to prevent possible remote code execution
if un-trusted user input is passed to the compilation options (<a
href="https://redirect.github.com/pugjs/pug/issues/3438">#3438</a>)</li>
</ul>
<h2>pug@3.0.3</h2>
<h2>Bug Fixes</h2>
<ul>
<li>
<p>Update pug-code-gen with the following fix: (<a
href="https://redirect.github.com/pugjs/pug/issues/3438">#3438</a>)</p>
<p>Validate <code>templateName</code> and <code>globals</code> are valid
JavaScript identifiers to prevent possible remote code execution if
un-trusted user input is passed to the compilation options</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pugjs/pug/commit/32acfe8f197dc44c54e8af32c7d7b19aa9d350fb"><code>32acfe8</code></a>
fix: ensure template names are valid identifiers (<a
href="https://redirect.github.com/pugjs/pug/issues/3438">#3438</a>)</li>
<li><a
href="https://github.com/pugjs/pug/commit/4767cafea0af3d3f935553df0f9a8a6e76d470c2"><code>4767caf</code></a>
refactor: convert pug-error to TypeScript (<a
href="https://redirect.github.com/pugjs/pug/issues/3355">#3355</a>)</li>
<li><a
href="https://github.com/pugjs/pug/commit/a724446abb128f2a09acb143ce436021be8f6e6a"><code>a724446</code></a>
chore: update character-parser (<a
href="https://redirect.github.com/pugjs/pug/issues/3354">#3354</a>)</li>
<li><a
href="https://github.com/pugjs/pug/commit/6cca8f76befe7285eead0416ba4e2dbba825ab65"><code>6cca8f7</code></a>
docs: fix GitHub format in README (<a
href="https://redirect.github.com/pugjs/pug/issues/3335">#3335</a>)</li>
<li>See full diff in <a
href="https://github.com/pugjs/pug/compare/pug@3.0.2...pug@3.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pug&package-manager=npm_and_yarn&previous-version=3.0.2&new-version=3.0.3)](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-05-31 10:46:16 -07:00
Shivam MishraandGitHub ae35b47ae3 fix: Add the translation for Sign in with Microsoft button (#9567)
The sign in button for Microsoft inbox was not translated, this PR fixes it
2024-05-31 09:01:50 -07:00
Fayaz AhmedandGitHub b474929f5e chore: Replace eventBus with mitt.js [CW-3275] (#9539)
# Replace the deprecated `eventBus` with mitt.js

## Description

Since eventBus and it's respective methods are deprecated and removed
from all future releases of vue, this was blocking us from migrating.
This PR replaces eventBus with
[mitt](https://github.com/developit/mitt). I have created a wrapper
mitt.js to simulate the same old event names so it's backwards
compatible, without making a lot of changes.


Fixes # (issue)

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

1. Made sure all the places we're listening to bus events are working as
expected.
2. Respective specsf or the events from mitt.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2024-05-31 15:50:36 +05:30
Shivam MishraandGitHub 20c4b70e8d Merge branch 'develop' into feat/update-editor 2024-05-31 12:26:40 +05:30
Sivin VargheseandGitHub e3eca47c31 feat: Split reconnect logic PR (store) (#9520)
# Pull Request Template

## Description

This PR includes store filter parts split from this [Reconnect
PR](https://github.com/chatwoot/chatwoot/pull/9453)
2024-05-30 12:29:55 +05:30
Shivam MishraandGitHub 6c682a6869 feat: add reauthorization banner for Facebook (#9553) 2024-05-30 09:41:04 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b53ce3fa79 chore(deps-dev): bump rack-contrib from 2.4.0 to 2.5.0 (#9559)
Bumps rack-contrib from 2.4.0 to 2.5.0.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-29 13:15:05 -07:00
Sivin VargheseandGitHub 41e27e95b4 fix: TypeError cannot read properties of undefined (reading 'name') (#9562) 2024-05-29 22:04:30 +05:30
Sivin VargheseandGitHub b31be37dc2 fix: TypeError undefined is not an object (evaluating 'this.features.inbound_emails') (#9563) 2024-05-29 21:31:37 +05:30
Muhsin KelothandGitHub a55fffab3a chore: Linear integration fixes (#9538) 2024-05-29 11:28:13 +05:30
Vishnu NarayananandGitHub 59b912f22c doc: update .env.example (#9556)
- add more info on SECRET_KEY_BASE
- add a link to env variable section in selfhosted docs

Fixes https://github.com/chatwoot/chatwoot/issues/9517
2024-05-28 13:34:47 +05:30
Sivin VargheseandGitHub eb6a343810 fix: Enable edit option for custom attributes if value is present (#9543)
# Pull Request Template

## Description

This PR will enable a back edit option for custom attributes if there is
the value present, which is removed by this
[PR](https://github.com/chatwoot/chatwoot/pull/9070/files#diff-fe9b82bbef1da31faa513332ed6015c0ed23765a6368832137a5408547da8938R172)

Fixes
https://linear.app/chatwoot/issue/CW-3345/edit-attribute-does-not-work-anymore

## Type of change

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

## How Has This Been Tested?

**Screenrecordings**

**Before**



https://github.com/chatwoot/chatwoot/assets/64252451/ab89e087-d919-404d-90a9-17982b425dc3



**After**


https://github.com/chatwoot/chatwoot/assets/64252451/98d8b0b2-2f8f-46ec-ace5-b3e2a721623e



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-05-28 12:05:34 +05:30
Fernando VerdugoandGitHub 4a3376e912 fix(typo): Update bulkActions.json (#9540) 2024-05-24 08:51:36 -07:00
Sivin VargheseandGitHub 7c5e67bf28 feat: Adds backend support for rendering tables in articles (#9526) 2024-05-24 08:44:01 +05:30
eafd3ae44d feat: new re-authorization flow for Microsoft (#9510)
This PR adds a cleaner re-authorization flow to Microsoft. This PR has
the following changes

1. Use `reauthorization_required` value for Microsoft Channel
2. Refactor `InboxReconnectionRequired` to reuse the `banner` component
3. Refactor `microsoft/Reauthorize.vue` to reuse
`InboxReconnectionRequired` component
4. Update `reauthorizable.rb` to update cache keys if the model has an
inbox
5. Update `microsoft/callbacks_controller.rb` to handle the
reauthorization case with a redirect to the inbox settings page if the
inbox already exists at the time of authorization.

## How Has This Been Tested?

- [x] Local Instance
- [ ] Staging Instance
- [x] Unit tests

## Pending Tasks

- [ ] ~Success Toast~ will do this in a follow-up PR with the screen

## Demo

The following video shows the whole process of creation and
re-authorization of the Microsoft channel


https://www.loom.com/share/e5cd9bd4439c4741b0dcfe66d67f88b3?sid=100f3642-43e4-46b3-8123-88a5dd9d8509

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-05-23 16:03:40 +05:30
616e3a8092 feat: allow setting dashboard scripts from super_admin (#9514)
This PR allows setting scripts for `vueapp.html.erb` via super admin
config. This PR has the following changes

1. Allow `DASHBOARD_SCRIPTS` in internal config
2. Remove existing scripts from `vueapp.html.erb`
3. Add scripts from `GlobalConfig` to `vueapp.html.erb`

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-05-23 14:30:41 +05:30
Muhsin KelothandGitHub f83af33b87 feat: Add title while linking the linear issue (#9529) 2024-05-23 13:32:41 +05:30
Sivin VargheseandGitHub 0c4c561313 feat: Adds the ability to add line breaks in helpcenter articles (#9519) 2024-05-23 12:34:42 +05:30
Shivam MishraandGitHub 9f625715ab fix: Cannot read properties of undefined (reading 'toLowerCase') (#9511)
Tried to replicate the issue, but Sentry didn't have enough information.
`toggleMessageSignature` is a user triggered action in
`ReplyBottomPanel.vue`, the value for `channelType` is provided from
`inboxMixin`. The error will occur if either `inbox` is an empty object
`{}` or `channel_type` in `inbox` object is undefined.

I couldn't find any instance where this could be the case. The PR has a
stop gap solution that ensures that no action is triggered
2024-05-23 12:08:04 +05:30
35508feaae feat: Linear front end (#9491)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2024-05-23 11:58:24 +05:30
Sivin VargheseandGitHub be97c68721 fix: TypeError cannot read properties of undefined (reading 'status') (#9505) 2024-05-23 11:22:14 +05:30
Sivin VargheseandGitHub 4b93738462 fix: Space key in input closing dropdown (#9525) 2024-05-23 10:40:44 +05:30
PranavandGitHub 87d92f73d4 feat: Improve Report API performance (#9476)
- Re-write the methods for clarity
- Remove the dependency on the ReportHelper class.
- Remove n+1 queries in the average metric time series data.
2024-05-22 17:34:24 -07:00
Muhsin KelothandGitHub 023b3ad507 feat: Add APIs for linear integration (#9346) 2024-05-22 13:37:58 +05:30
Sivin VargheseandGitHub 0d13c11c44 fix: Right click Snooze is not working (#9498) 2024-05-22 13:03:49 +05:30
Sojan JoseandGitHub db13049e6f fix: [Snyk] Security upgrade administrate-field-active_storage from 1.0.2 to 1.0.3 (#9496)
- Security upgrade administrate-field-active_storage from 1.0.2 to 1.0.3
2024-05-21 13:36:11 -07:00
PranavandGitHub 7a1a686133 fix: Fix typo in the font for portals (#9515)
The fonts on the portal were not loaded properly before due to a typo in
the stylesheet. The font that is shown on the public portal is ui-sans
right now. This PR fixes it.
2024-05-21 13:35:35 -07:00
Sivin VargheseandGitHub 00dca9466a feat: Move the SLA filter dropdown to UI folder (#9502)
# Pull Request Template

## Description

I moved the newly created SLA filter dropdown to the UI folder to use in
adding the Linear issue modal.
2024-05-20 16:23:42 +05:30
Shivam Mishra c4e636fce0 test: unit tests for useSpecialContent 2024-05-09 18:43:58 +05:30
Shivam Mishra 8ac095dea5 chore: add comments to composable 2024-05-09 18:42:46 +05:30
Shivam Mishra ecb28383c1 refactor: move useSpecialContent to a composable 2024-05-09 18:28:40 +05:30
Shivam Mishra 6129cb5f80 feat: activate methods 2024-05-09 18:08:57 +05:30
Shivam Mishra 24a65fc483 refactor: editor.vue, make it simpler 2024-05-09 17:50:52 +05:30
Shivam Mishra 55b6e5fbf6 refactor: move create state to editorHelper 2024-05-09 17:20:14 +05:30
175 changed files with 4902 additions and 942 deletions
+5 -6
View File
@@ -1,4 +1,9 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
@@ -184,12 +189,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# SENTRY_DSN=
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
# GOOGLE_TAG_MANAGER
# GOOGLE_TAG = GTM-XXXXXXX
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
+1 -1
View File
@@ -77,7 +77,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.2'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
+57 -57
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
actioncable (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
actionmailbox (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.1)
actionpack (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activesupport (= 7.0.8.1)
actionmailer (7.0.8.3)
actionpack (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activesupport (= 7.0.8.3)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.1)
actionview (= 7.0.8.1)
activesupport (= 7.0.8.1)
actionpack (7.0.8.3)
actionview (= 7.0.8.3)
activesupport (= 7.0.8.3)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.1)
actionpack (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
actiontext (7.0.8.3)
actionpack (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.1)
activesupport (= 7.0.8.1)
actionview (7.0.8.3)
activesupport (= 7.0.8.3)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.1)
activesupport (= 7.0.8.1)
activejob (7.0.8.3)
activesupport (= 7.0.8.3)
globalid (>= 0.3.6)
activemodel (7.0.8.1)
activesupport (= 7.0.8.1)
activerecord (7.0.8.1)
activemodel (= 7.0.8.1)
activesupport (= 7.0.8.1)
activemodel (7.0.8.3)
activesupport (= 7.0.8.3)
activerecord (7.0.8.3)
activemodel (= 7.0.8.3)
activesupport (= 7.0.8.3)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activesupport (= 7.0.8.1)
activestorage (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activesupport (= 7.0.8.3)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.1)
activesupport (7.0.8.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.2)
administrate-field-active_storage (1.0.3)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
@@ -461,7 +461,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.6)
minitest (5.22.3)
minitest (5.23.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -555,7 +555,7 @@ GEM
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.4.0)
rack-contrib (2.5.0)
rack (< 4)
rack-cors (2.0.0)
rack (>= 2.0.0)
@@ -569,20 +569,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.1)
actioncable (= 7.0.8.1)
actionmailbox (= 7.0.8.1)
actionmailer (= 7.0.8.1)
actionpack (= 7.0.8.1)
actiontext (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activemodel (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
rails (7.0.8.3)
actioncable (= 7.0.8.3)
actionmailbox (= 7.0.8.3)
actionmailer (= 7.0.8.3)
actionpack (= 7.0.8.3)
actiontext (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activemodel (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
bundler (>= 1.15.0)
railties (= 7.0.8.1)
railties (= 7.0.8.3)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -590,9 +590,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
railties (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -839,7 +839,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.2)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
@@ -0,0 +1,30 @@
class V2::Reports::Conversations::BaseReportBuilder
pattr_initialize :account, :params
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
end
@@ -0,0 +1,30 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
end
def bot_summary
{
bot_resolutions_count: count('bot_resolutions_count'),
bot_handoffs_count: count('bot_handoffs_count')
}
end
private
def count(metric)
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def builder_params(metric)
params.merge({ metric: metric })
end
end
@@ -0,0 +1,21 @@
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
def timeseries
perform_action(:timeseries)
end
def aggregate_value
perform_action(:aggregate_value)
end
private
def perform_action(method_name)
return builder.new(account, params).public_send(method_name) if builder.present?
log_invalid_metric
end
def builder
builder_class(params[:metric])
end
end
@@ -0,0 +1,48 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,46 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def group_by
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
end
@@ -0,0 +1,71 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
created_at: range
).distinct
end
def grouped_count
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -0,0 +1,93 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
def teams
teams = linear_processor_service.teams
if teams[:error]
render json: { error: teams[:error] }, status: :unprocessable_entity
else
render json: teams[:data], status: :ok
end
end
def team_entities
team_id = permitted_params[:team_id]
team_entities = linear_processor_service.team_entities(team_id)
if team_entities[:error]
render json: { error: team_entities[:error] }, status: :unprocessable_entity
else
render json: team_entities[:data], status: :ok
end
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def linked_issues
issues = linear_processor_service.linked_issues(conversation_link)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
def search_issue
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
term = params[:q]
issues = linear_processor_service.search_issue(term)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
private
def conversation_link
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
end
def fetch_conversation
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
end
def linear_processor_service
Integrations::Linear::ProcessorService.new(account: Current.account)
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
end
@@ -5,19 +5,17 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def index
builder = V2::ReportBuilder.new(Current.account, report_params)
data = builder.build
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
data = builder.timeseries
render json: data
end
def summary
render json: summary_metrics
render json: build_summary(:summary)
end
def bot_summary
summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary
render json: summary
render json: build_summary(:bot_summary)
end
def agents
@@ -126,10 +124,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
}
end
def summary_metrics
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary
def build_summary(method)
builder = V2::Reports::Conversations::MetricBuilder
current_summary = builder.new(Current.account, current_summary_params).send(method)
previous_summary = builder.new(Current.account, previous_summary_params).send(method)
current_summary.merge(previous: previous_summary)
end
def conversation_metrics
+5
View File
@@ -3,6 +3,7 @@ class DashboardController < ActionController::Base
before_action :set_application_pack
before_action :set_global_config
before_action :set_dashboard_scripts
around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index]
@@ -35,6 +36,10 @@ class DashboardController < ActionController::Base
).merge(app_config)
end
def set_dashboard_scripts
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end
@@ -7,9 +7,14 @@ class Microsoft::CallbacksController < ApplicationController
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox = find_or_create_inbox
inbox, already_exists = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'].downcase)
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
if already_exists
redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -40,9 +45,17 @@ class Microsoft::CallbacksController < ApplicationController
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
channel_email.inbox
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
end
# Fallback name, for when name field is missing from users_data
+19
View File
@@ -0,0 +1,19 @@
module TimezoneHelper
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
#
# https://github.com/rails/rails/pull/22243
# https://github.com/rails/rails/issues/21501
# https://github.com/rails/rails/issues/7297
def timezone_name_from_offset(offset)
return 'UTC' if offset.blank?
offset_in_seconds = offset.to_f * 3600
matching_zone = ActiveSupport::TimeZone.all.find do |zone|
zone.now.utc_offset == offset_in_seconds
end
return matching_zone.name if matching_zone
end
end
@@ -15,6 +15,7 @@ class ConversationApi extends ApiClient {
teamId,
conversationType,
sortBy,
updatedWithin,
}) {
return axios.get(this.url, {
params: {
@@ -26,6 +27,7 @@ class ConversationApi extends ApiClient {
labels,
conversation_type: conversationType,
sort_by: sortBy,
updated_within: updatedWithin,
},
});
}
@@ -0,0 +1,47 @@
/* global axios */
import ApiClient from '../ApiClient';
class LinearAPI extends ApiClient {
constructor() {
super('integrations/linear', { accountScoped: true });
}
getTeams() {
return axios.get(`${this.url}/teams`);
}
getTeamEntities(teamId) {
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
}
createIssue(data) {
return axios.post(`${this.url}/create_issue`, data);
}
link_issue(conversationId, issueId, title) {
return axios.post(`${this.url}/link_issue`, {
issue_id: issueId,
conversation_id: conversationId,
title: title,
});
}
getLinkedIssue(conversationId) {
return axios.get(
`${this.url}/linked_issues?conversation_id=${conversationId}`
);
}
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
});
}
searchIssues(query) {
return axios.get(`${this.url}/search_issue?q=${query}`);
}
}
export default new LinearAPI();
@@ -46,6 +46,7 @@ describe('#ConversationAPI', () => {
page: 1,
labels: [],
teamId: 1,
updatedWithin: 20,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
params: {
@@ -55,6 +56,7 @@ describe('#ConversationAPI', () => {
assignee_type: 'me',
page: 1,
labels: [],
updated_within: 20,
},
});
});
@@ -0,0 +1,202 @@
import LinearAPIClient from '../../integrations/linear';
import ApiClient from '../../ApiClient';
describe('#linearAPI', () => {
it('creates correct instance', () => {
expect(LinearAPIClient).toBeInstanceOf(ApiClient);
expect(LinearAPIClient).toHaveProperty('getTeams');
expect(LinearAPIClient).toHaveProperty('getTeamEntities');
expect(LinearAPIClient).toHaveProperty('createIssue');
expect(LinearAPIClient).toHaveProperty('link_issue');
expect(LinearAPIClient).toHaveProperty('getLinkedIssue');
expect(LinearAPIClient).toHaveProperty('unlinkIssue');
expect(LinearAPIClient).toHaveProperty('searchIssues');
});
describe('getTeams', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeams();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/teams'
);
});
});
describe('getTeamEntities', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeamEntities(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/team_entities?team_id=1'
);
});
});
describe('createIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.link_issue(1, 2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 2,
conversation_id: 1,
}
);
});
});
describe('getLinkedIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getLinkedIssue(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/linked_issues?conversation_id=1'
);
});
});
describe('unlinkIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
}
);
});
});
describe('searchIssues', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.searchIssues('query');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/search_issue?q=query'
);
});
});
});
@@ -7,79 +7,17 @@
]"
>
<slot />
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="onClickOpenAddFoldersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="resetAndFetchData"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
<chat-list-header
:page-title="pageTitle"
:has-applied-filters="hasAppliedFilters"
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@reset-filters="resetAndFetchData"
@basic-filter-change="onBasicFilterChange"
/>
<add-custom-views
v-if="showAddFoldersModal"
@@ -173,6 +111,15 @@
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
@@ -180,8 +127,8 @@
import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
@@ -205,10 +152,15 @@ import {
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getUnixTime } from 'date-fns';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
ChatListHeader,
AddCustomViews,
ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components
@@ -216,9 +168,9 @@ export default {
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
CustomSnoozeModal,
},
mixins: [
timeMixin,
@@ -295,6 +247,7 @@ export default {
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
showCustomSnoozeModal: false,
itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component.
@@ -315,6 +268,7 @@ export default {
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
chatListFilters: 'getChatListFilters',
unAssignedChatsList: 'getUnAssignedChats',
chatListLoading: 'getChatListLoadingStatus',
currentUserID: 'getCurrentUserID',
@@ -329,23 +283,17 @@ export default {
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
selectedConversations: 'bulkActions/getSelectedConversationIds',
contextMenuChatId: 'getContextMenuChatId',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
},
hasActiveFolders() {
return this.activeFolder && this.foldersId !== 0;
return Boolean(this.activeFolder && this.foldersId !== 0);
},
hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders;
},
savedFoldersValue() {
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
}
return {};
},
showEndOfListMessage() {
return (
this.conversationList.length &&
@@ -421,7 +369,6 @@ export default {
labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
conversationListPagination() {
@@ -534,7 +481,13 @@ export default {
this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType);
},
activeFolder() {
activeFolder(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch(
'customViews/setActiveConversationFolder',
newVal || null
);
}
this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId);
},
@@ -544,8 +497,14 @@ export default {
showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal);
},
conversationFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('updateChatListFilters', newVal);
}
},
},
mounted() {
this.$store.dispatch('setChatListFilters', this.conversationFilters);
this.setFiltersFromUISettings();
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
@@ -555,9 +514,14 @@ export default {
this.$store.dispatch('campaigns/get');
}
bus.$on('fetch_conversation_stats', () => {
this.$emitter.on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
this.$emitter.on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
this.$emitter.off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
methods: {
updateVirtualListProps(key, value) {
@@ -736,8 +700,9 @@ export default {
this.fetchConversations();
},
fetchConversations() {
this.$store.dispatch('updateChatListFilters', this.conversationFilters);
this.$store
.dispatch('fetchAllConversations', this.conversationFilters)
.dispatch('fetchAllConversations')
.then(this.emitConversationLoaded);
},
loadMoreConversations() {
@@ -777,7 +742,7 @@ export default {
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions();
bus.$emit('clearSearchInput');
this.$emitter.emit('clearSearchInput');
this.activeAssigneeTab = selectedTab;
if (!this.currentPage) {
this.fetchConversations();
@@ -1028,12 +993,49 @@ export default {
allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item).status === status;
return this.$store.getters.getConversationById(item)?.status === status;
});
},
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
toggleStatus(status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat?.id || this.contextMenuChatId,
status,
snoozedUntil,
})
.then(() => {
this.$store.dispatch('setContextMenuChatId', null);
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
});
},
hideCustomSnoozeModal() {
// if we select custom snooze and then the custom snooze modal is open
// Then if the custom snooze modal is closed and set the context menu chat id to null
this.$store.dispatch('setContextMenuChatId', null);
this.showCustomSnoozeModal = false;
},
},
};
</script>
@@ -0,0 +1,115 @@
<script setup>
import { computed } from 'vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
});
const emits = defineEmits([
'add-folders',
'delete-folders',
'reset-filters',
'basic-filter-change',
'filters-modal',
]);
const onBasicFilterChange = (value, type) => {
emits('basic-filter-change', value, type);
};
const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
</script>
<template>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emits('add-folders')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="emits('reset-filters')"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emits('filters-modal')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="emits('delete-folders')"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emits('filters-modal')"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
</template>
+3 -1
View File
@@ -25,8 +25,10 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
script: {
type: String,
@@ -59,7 +61,7 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.script);
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
};
@@ -27,7 +27,7 @@
/>
</span>
<woot-button
v-if="showCopyAndDeleteButton"
v-if="showActions && value"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
@@ -90,7 +90,7 @@
</p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button
v-if="showCopyAndDeleteButton"
v-if="showActions && value"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
@@ -100,7 +100,7 @@
@click="onCopy"
/>
<woot-button
v-if="showEditButton"
v-if="showActions"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
@@ -174,12 +174,6 @@ export default {
};
},
computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() {
if (this.isAttributeTypeDate) {
return this.value
@@ -276,10 +270,10 @@ export default {
},
mounted() {
this.editedValue = this.formattedValue;
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
destroyed() {
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
@@ -18,8 +18,10 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
value: {
type: String,
@@ -35,7 +37,7 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
toggleMasked() {
this.masked = !this.masked;
@@ -99,7 +99,9 @@ export default {
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
this.onClose();
if (this.closeOnBackdropClick) {
this.onClose();
}
}
},
},
@@ -55,7 +55,7 @@ export default {
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
this.$emitter.on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
// TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
@@ -21,7 +21,7 @@ export default {
},
methods: {
onMenuItemClick() {
bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU);
this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
},
},
};
@@ -15,11 +15,13 @@
<script>
import WootSnackbar from './Snackbar.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
WootSnackbar,
},
mixins: [alertMixin],
props: {
duration: {
type: Number,
@@ -34,10 +36,10 @@ export default {
},
mounted() {
bus.$on('newToastMessage', this.onNewToastMessage);
this.$emitter.on('newToastMessage', this.onNewToastMessage);
},
beforeDestroy() {
bus.$off('newToastMessage', this.onNewToastMessage);
this.$emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage(message, action) {
@@ -1,5 +1,5 @@
<template>
<div class="resolve-actions relative flex items-center justify-end">
<div class="relative flex items-center justify-end resolve-actions">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -73,25 +73,13 @@
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -99,14 +87,12 @@ import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SNOOZE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
@@ -115,7 +101,6 @@ export default {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
showCustomSnoozeModal: false,
};
},
computed: {
@@ -143,14 +128,12 @@ export default {
},
},
mounted() {
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
getKeyboardEvents() {
@@ -201,28 +184,6 @@ export default {
// error
}
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
@@ -65,11 +65,11 @@
</div>
<span
v-if="warningIcon"
class="inline-flex rounded-sm mr-1 bg-slate-100"
class="inline-flex mr-1 bg-red-50 dark:bg-red-900 p-0.5 rounded-sm"
>
<fluent-icon
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
class="text-xxs"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="text-xxs text-red-500 dark:text-red-300"
:icon="warningIcon"
size="12"
/>
@@ -22,7 +22,7 @@ import {
setYear,
isAfter,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue';
@@ -185,7 +185,7 @@ const updateManualInput = (newDate, calendarType) => {
};
const handleManualInputError = message => {
bus.$emit('newToastMessage', message);
useAlert(message);
};
const resetDatePicker = () => {
@@ -201,7 +201,7 @@ const resetDatePicker = () => {
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
bus.$emit('newToastMessage', 'Please select a valid time range');
useAlert('Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
@@ -1,9 +1,11 @@
<script setup>
import { ref, computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { picoSearch } from '@scmmishra/pico-search';
import FilterListItemButton from './FilterListItemButton.vue';
import FilterDropdownSearch from './FilterDropdownSearch.vue';
import FilterDropdownEmptyState from './FilterDropdownEmptyState.vue';
import ListItemButton from './DropdownListItemButton.vue';
import DropdownSearch from './DropdownSearch.vue';
import DropdownEmptyState from './DropdownEmptyState.vue';
import DropdownLoadingState from './DropdownLoadingState.vue';
const props = defineProps({
listItems: {
@@ -19,20 +21,31 @@ const props = defineProps({
default: '',
},
activeFilterId: {
type: Number,
type: [String, Number],
default: null,
},
showClearFilter: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
loadingPlaceholder: {
type: String,
default: '',
},
});
const emits = defineEmits(['on-search']);
const searchTerm = ref('');
const onSearch = value => {
const onSearch = debounce(value => {
searchTerm.value = value;
};
emits('on-search', value);
}, 300);
const filteredListItems = computed(() => {
if (!searchTerm.value) return props.listItems;
@@ -47,6 +60,16 @@ const isFilterActive = id => {
if (!props.activeFilterId) return false;
return id === props.activeFilterId;
};
const shouldShowLoadingState = computed(() => {
return (
props.isLoading && isDropdownListEmpty.value && props.loadingPlaceholder
);
});
const shouldShowEmptyState = computed(() => {
return !props.isLoading && isDropdownListEmpty.value;
});
</script>
<template>
<div
@@ -54,8 +77,8 @@ const isFilterActive = id => {
@click.stop
>
<slot name="search">
<filter-dropdown-search
v-if="enableSearch && listItems.length"
<dropdown-search
v-if="enableSearch"
:input-value="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@@ -64,11 +87,15 @@ const isFilterActive = id => {
/>
</slot>
<slot name="listItem">
<filter-dropdown-empty-state
v-if="isDropdownListEmpty"
<dropdown-loading-state
v-if="shouldShowLoadingState"
:message="loadingPlaceholder"
/>
<dropdown-empty-state
v-else-if="shouldShowEmptyState"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/>
<filter-list-item-button
<list-item-button
v-for="item in filteredListItems"
:key="item.id"
:is-active="isFilterActive(item.id)"
@@ -0,0 +1,15 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>
@@ -18,11 +18,11 @@ defineProps({
<div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
>
<div class="flex items-center w-full gap-2">
<div class="flex items-center w-full gap-2" @keyup.space.prevent>
<fluent-icon
icon="search"
size="18"
class="text-slate-400 dark:text-slate-400"
size="16"
class="text-slate-400 dark:text-slate-400 flex-shrink-0"
/>
<input
type="text"
@@ -81,7 +81,7 @@ export default {
},
mounted() {
bus.$on(CMD_AI_ASSIST, this.onAIAssist);
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage;
},
@@ -3,17 +3,17 @@
<tag-agents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="insertMentionNode"
@click="content => insertSpecialContent('mention', content)"
/>
<canned-response
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
@click="content => insertSpecialContent('cannedResponse', content)"
/>
<variable-list
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="insertVariable"
@click="content => insertSpecialContent('variable', content)"
/>
<input
ref="imageUpload"
@@ -47,11 +47,9 @@
<script>
import {
messageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import {
@@ -70,6 +68,7 @@ import {
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
createState,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -82,10 +81,7 @@ import {
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { createTypingIndicator } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -94,27 +90,7 @@ import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
import useSpecialContent from 'dashboard/composables/editor/useSpecialContent';
export default {
name: 'WootMessageEditor',
@@ -139,6 +115,11 @@ export default {
channelType: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
},
setup() {
const { getContentNode } = useSpecialContent();
return { getContentNode };
},
data() {
return {
typingIndicator: createTypingIndicator(
@@ -352,10 +333,16 @@ export default {
// Components using this
// 1. SearchPopover.vue
bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
this.$emitter.on(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
},
beforeDestroy() {
bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
this.$emitter.off(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
},
methods: {
reloadState(content = this.value) {
@@ -446,22 +433,12 @@ export default {
this.emitOnChange();
},
handleDOMEvents: {
keyup: () => {
this.onKeyup();
},
keydown: (view, event) => {
this.onKeydown(event);
},
focus: () => {
this.onFocus();
},
click: () => {
this.isEditorMouseFocusedOnAnImage();
},
blur: () => {
this.onBlur();
},
paste: (view, event) => {
keyup: this.onKeyup,
keydown: (_view, event) => this.onKeydown(event),
focus: this.onFocus,
click: this.isEditorMouseFocusedOnAnImage,
blur: this.onBlur,
paste: (_view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
@@ -545,57 +522,39 @@ export default {
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
insertMentionNode(mentionItem) {
/**
* Inserts special content (mention, canned response, or variable) into the editor.
*
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable'.
* @param {Object|string} content - The content to insert, depending on the type.
* - For 'mention' type: An object with 'id' and 'name' properties representing the user mention.
* - For 'canned_response' type: A string representing the canned response.
* - For 'variable' type: A string representing the variable name.
*/
insertSpecialContent(type, content) {
if (!this.editorView) {
return null;
}
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
return;
}
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
let { node, from, to } = this.getContentNode(
this.editorView,
type,
content,
this.range,
this.variables
);
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
if (!node) return;
this.insertNodeIntoEditor(node, from, to);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
return false;
const event_map = {
mention: CONVERSATION_EVENTS.USED_MENTIONS,
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
};
this.$track(event_map[type]);
},
openFileBrowser() {
this.$refs.imageUpload.click();
@@ -103,6 +103,7 @@
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@@ -71,6 +71,10 @@
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -89,6 +93,8 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
export default {
components: {
@@ -97,6 +103,7 @@ export default {
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: {
@@ -121,6 +128,9 @@ export default {
...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
@@ -178,6 +188,17 @@ export default {
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
methods: {
@@ -473,11 +473,11 @@ export default {
},
mounted() {
this.hasMediaLoadError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.$emitter.on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
beforeDestroy() {
bus.$off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.$emitter.off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
clearTimeout(this.higlightTimeout);
},
methods: {
@@ -531,7 +531,7 @@ export default {
const { conversation_id: conversationId, id: replyTo } = this.data;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
},
setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
@@ -324,12 +324,12 @@ export default {
},
created() {
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
this.$emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
this.$emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
bus.$on(BUS_EVENTS.MESSAGE_SENT, () => {
this.$emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
@@ -396,7 +396,7 @@ export default {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
this.$emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
@@ -514,7 +514,7 @@ export default {
} else {
this.hasUserScrolled = true;
}
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.$emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
@@ -61,14 +61,14 @@ export default {
...mapGetters({ currentChat: 'getSelectedChat' }),
},
mounted() {
bus.$on(CMD_MUTE_CONVERSATION, this.mute);
bus.$on(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
this.$emitter.on(CMD_MUTE_CONVERSATION, this.mute);
this.$emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
this.$emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
destroyed() {
bus.$off(CMD_MUTE_CONVERSATION, this.mute);
bus.$off(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
this.$emitter.off(CMD_MUTE_CONVERSATION, this.mute);
this.$emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
this.$emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
methods: {
mute() {
@@ -596,12 +596,15 @@ export default {
);
this.fetchAndSetReplyTo();
bus.$on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
this.$emitter.on(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
// A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop
// TODO need to find a better solution
bus.$on(
this.$emitter.on(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
@@ -609,10 +612,13 @@ export default {
destroyed() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
bus.$off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
this.$emitter.off(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
},
beforeDestroy() {
bus.$off(
this.$emitter.off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
@@ -625,7 +631,7 @@ export default {
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
bus.$emit(
this.$emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
@@ -867,8 +873,8 @@ export default {
'createPendingMessageAndSend',
messagePayload
);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
bus.$emit(BUS_EVENTS.MESSAGE_SENT);
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
this.$emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private);
} catch (error) {
@@ -1194,7 +1200,7 @@ export default {
resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
},
onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file
@@ -47,7 +47,9 @@ export default {
},
methods: {
scrollToMessage() {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId: this.message.id });
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.message.id,
});
},
},
};
@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
</script>
<template>
<div
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
{{ $t('SLA.EVENTS.TITLE') }}
@@ -16,7 +16,7 @@
/>
</template>
<menu-item
v-if="show(snoozeOption.key)"
v-if="showSnooze"
:option="snoozeOption"
variant="icon"
@click="snoozeConversation()"
@@ -86,6 +86,10 @@ export default {
},
mixins: [agentMixin],
props: {
chatId: {
type: Number,
default: null,
},
status: {
type: String,
default: '',
@@ -205,6 +209,10 @@ export default {
...this.filteredAgentOnAvailability,
];
},
showSnooze() {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -213,7 +221,8 @@ export default {
toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil);
},
snoozeConversation() {
async snoozeConversation() {
await this.$store.dispatch('setContextMenuChatId', this.chatId);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
@@ -167,17 +167,29 @@ export default {
};
},
mounted() {
bus.$on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$on(
this.$emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
this.$emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
destroyed() {
bus.$off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$off(
this.$emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
this.$emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
@@ -0,0 +1,257 @@
<template>
<div @submit.prevent="onSubmit">
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '8px 12px' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<label :class="{ error: v$.teamId.$error }">
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
<select
v-model="formState.teamId"
:style="inputStyles"
@change="onChangeTeam"
>
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.teamId.$error" class="message">
{{ teamError }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
<select v-model="formState.assigneeId" :style="inputStyles">
<option v-for="item in assignees" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
<select v-model="formState.labelId" :style="inputStyles">
<option v-for="item in labels" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
<select v-model="formState.priority" :style="inputStyles">
<option v-for="item in priorities" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
<select v-model="formState.projectId" :style="inputStyles">
<option v-for="item in projects" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
<select v-model="formState.stateId" :style="inputStyles">
<option v-for="item in statuses" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChangeTeam = event => {
formState.teamId = event.target.value;
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
getTeamEntities();
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>
@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<create-issue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<link-issue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const emits = defineEmits(['close']);
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emits('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
@@ -0,0 +1,123 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlink-issue']);
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
const assigneeDetails = props.issue.assignee;
if (!assigneeDetails) return null;
const { name, avatarUrl } = assigneeDetails;
return {
name,
thumbnail: avatarUrl,
};
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlink-issue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<issue-header
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-ash-900">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-ash-800 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-ash-900">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-ash-800">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<template>
<div class="flex flex-row justify-between">
<div
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div>
<div class="flex items-center gap-0.5">
<woot-button
variant="clear"
color-scheme="secondary"
class="h-[24px]"
:is-loading="isUnlinking"
@click="unlinkIssue"
>
<fluent-icon
v-if="!isUnlinking"
icon="unlink"
size="12"
type="outline"
icon-lib="lucide"
/>
</woot-button>
<woot-button
variant="clear"
class="h-[24px]"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon icon="arrow-up-right" size="14" />
</woot-button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const isUnlinking = inject('isUnlinking');
const emit = defineEmits(['unlink-issue']);
const unlinkIssue = () => {
emit('unlink-issue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
@@ -0,0 +1,143 @@
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<filter-button
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@click="onSelectIssue"
/>
</template>
</filter-button>
<div class="flex items-center justify-end w-full gap-2">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isLinking"
@click.prevent="linkIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emits = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emits('close');
};
const onSearch = async value => {
issues.value = [];
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally {
isLinking.value = false;
}
};
</script>
@@ -0,0 +1,141 @@
<template>
<div class="relative" :class="{ group: linkedIssue }">
<woot-button
v-on-clickaway="closeIssue"
v-tooltip="tooltipText"
variant="clear"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
{{ linkedIssue.issue.identifier }}
</span>
</woot-button>
<issue
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[40px] invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
:show.sync="shouldShowPopup"
:on-close="closePopup"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<create-or-link-issue
:conversation="conversation"
:account-id="currentAccountId"
@close="closePopup"
/>
</woot-modal>
</div>
</template>
<script setup>
import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
defineComponent({
name: 'Linear',
});
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
const linkedIssue = ref(null);
const shouldShow = ref(false);
const shouldShowPopup = ref(false);
const isUnlinking = ref(false);
provide('isUnlinking', isUnlinking);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(() =>
getters.getConversationById.value(props.conversationId)
);
const tooltipText = computed(() => {
return linkedIssue.value === null
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
: null;
});
const loadLinkedIssue = async () => {
linkedIssue.value = null;
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
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);
}
};
const unlinkIssue = async linkId => {
try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
} finally {
isUnlinking.value = false;
}
};
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
};
const closePopup = () => {
shouldShowPopup.value = false;
loadLinkedIssue();
};
const closeIssue = () => {
shouldShow.value = false;
};
watch(
() => props.conversationId,
() => {
loadLinkedIssue();
}
);
onMounted(() => {
loadLinkedIssue();
});
</script>
@@ -0,0 +1,10 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};
@@ -2,12 +2,21 @@ import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip';
import Button from 'dashboard/components/buttons/Button';
import i18n from 'dashboard/i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import MoreActions from '../MoreActions';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
}));
import { emitter } from 'shared/helpers/mitt';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
@@ -16,6 +25,12 @@ localVue.use(VTooltip);
localVue.component('fluent-icon', FluentIcon);
localVue.component('woot-button', Button);
localVue.prototype.$emitter = {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
describe('MoveActions', () => {
@@ -29,12 +44,6 @@ describe('MoveActions', () => {
let moreActions = null;
beforeEach(() => {
window.bus = {
$emit: jest.fn(),
$on: jest.fn(),
$off: jest.fn(),
};
state = {
authenticated: true,
currentChat,
@@ -76,7 +85,7 @@ describe('MoveActions', () => {
it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith(
expect(emitter.emit).toBeCalledWith(
'newToastMessage',
'This contact is blocked successfully. You will not be notified of any future conversations.',
undefined
@@ -102,7 +111,7 @@ describe('MoveActions', () => {
it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith(
expect(emitter.emit).toBeCalledWith(
'newToastMessage',
'This contact is unblocked successfully.',
undefined
@@ -0,0 +1,101 @@
import { replaceVariablesInMessage } from '@chatwoot/utils';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
/**
* Provides utility functions for creating special content nodes (mentions, canned responses, variables) in the editor state.
*
* @module useSpecialContent
* @returns {Object} - An object containing the `getContentNode` function.
*/
export default function useSpecialContent() {
/**
* Creates a mention node for the editor state
* @param {Object} editorView - The editor view instance
* @param {Object} content - The content object containing user id and name
* @param {number} from - The start position of the mention in the document
* @param {number} to - The end position of the mention in the document
* @returns {Object} - The created mention node and the updated from and to positions
*/
const getMentionNode = (editorView, content, from, to) => {
const node = editorView.state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
});
return { node, from, to };
};
/**
* Creates a canned response node for the editor state
* @param {Object} editorView - The editor view instance
* @param {string} content - The canned response content
* @param {number} from - The start position of the canned response in the document
* @param {number} to - The end position of the canned response in the document
* @param {Object} variables - The variables to replace in the canned response
* @returns {Object} - The created canned response node and the updated from and to positions
*/
const getCannedResponseNode = (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
);
from = node.textContent === updatedMessage ? from : from - 1;
return { node, from, to };
};
/**
* Creates a variable node for the editor state
* @param {Object} editorView - The editor view instance
* @param {string} content - The variable content
* @param {number} from - The start position of the variable in the document
* @param {number} to - The end position of the variable in the document
* @returns {Object} - The created variable node and the updated from and to positions
*/
const getVariableNode = (editorView, content, from, to) => {
const node = editorView.state.schema.text(`{{${content}}}`);
return { node, from, to };
};
/**
* Creates a content node based on the type (mention, canned response, or variable)
* @param {Object} editorView - The editor view instance
* @param {string} type - The type of content (mention, cannedResponse, or variable)
* @param {string|Object} content - The content to create the node from
* @param {Object} range - The range object containing the from and to positions
* @param {Object} variables - The variables to replace in the canned response (optional)
* @returns {Object} - The created content node and the updated from and to positions
*/
const getContentNode = (editorView, type, content, range, variables) => {
const methodMap = {
mention: getMentionNode,
cannedResponse: getCannedResponseNode,
variable: getVariableNode,
};
if (!methodMap[type]) {
return { node: null, from: range.from, to: range.to };
}
let { node, from, to } = methodMap[type](
editorView,
content,
range.from,
range.to,
variables
);
return { node, from, to };
};
return {
getContentNode,
};
}
@@ -1,4 +1,5 @@
import { getCurrentInstance } from 'vue';
import { emitter } from 'shared/helpers/mitt';
export const useTrack = () => {
const vm = getCurrentInstance();
@@ -8,5 +9,5 @@ export const useTrack = () => {
};
export function useAlert(message, action) {
bus.$emit('newToastMessage', message, action);
emitter.emit('newToastMessage', message, action);
}
@@ -0,0 +1,115 @@
import useSpecialContent from 'dashboard/composables/editor/useSpecialContent';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
jest.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: jest.fn(),
messageSchema: {},
}));
jest.mock('@chatwoot/utils', () => ({
replaceVariablesInMessage: jest.fn(),
}));
describe('useSpecialContent', () => {
let editorView;
let specialContent;
beforeEach(() => {
editorView = {
state: {
schema: {
nodes: {
mention: {
create: jest.fn(),
},
},
text: jest.fn(),
},
},
};
specialContent = useSpecialContent();
});
describe('getMentionNode', () => {
it('should create a mention node', () => {
const content = { id: 1, name: 'John Doe' };
const from = 0;
const to = 10;
specialContent.getContentNode(editorView, 'mention', content, {
from,
to,
});
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
{
userId: content.id,
userFullName: content.name,
}
);
});
});
describe('getCannedResponseNode', () => {
it('should create a canned response node', () => {
const content = 'Hello {{name}}';
const variables = { name: 'John' };
const from = 0;
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: jest.fn().mockReturnValue({ textContent: updatedMessage }),
}));
const { node } = specialContent.getContentNode(
editorView,
'cannedResponse',
content,
{ from, to },
variables
);
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
});
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
specialContent.getContentNode(editorView, 'variable', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
});
});
describe('getContentNode', () => {
it('should return null for invalid type', () => {
const content = 'invalid';
const from = 0;
const to = 10;
const { node } = specialContent.getContentNode(
editorView,
'invalid',
content,
{ from, to }
);
expect(node).toBeNull();
});
});
});
+1
View File
@@ -30,4 +30,5 @@ export const FEATURE_FLAGS = {
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
INBOUND_EMAILS: 'inbound_emails',
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
};
@@ -1,6 +1,7 @@
import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { emitter } from 'shared/helpers/mitt';
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -177,8 +178,8 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
fetchConversationStats = () => {
bus.$emit('fetch_conversation_stats');
bus.$emit('fetch_overview_reports');
emitter.emit('fetch_conversation_stats');
emitter.emit('fetch_overview_reports');
};
onContactDelete = data => {
@@ -207,7 +208,7 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
emitter.emit('fetch_overview_reports');
};
onCacheInvalidate = data => {
@@ -2,7 +2,10 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
buildEditor,
EditorState,
} from '@chatwoot/prosemirror-schema';
import * as Sentry from '@sentry/browser';
/**
@@ -281,3 +284,24 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
}
export const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
+2 -1
View File
@@ -87,7 +87,8 @@ export const getInboxClassByType = (type, phoneNumber) => {
};
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
if (type === INBOX_TYPES.FB && reauthorizationRequired) {
const allowedInboxTypes = [INBOX_TYPES.FB, INBOX_TYPES.EMAIL];
if (allowedInboxTypes.includes(type) && reauthorizationRequired) {
return 'warning';
}
return '';
@@ -1,6 +1,7 @@
/* eslint-disable no-console */
import NotificationSubscriptions from '../api/notificationSubscription';
import auth from '../api/auth';
import { emitter } from 'shared/helpers/mitt';
export const verifyServiceWorkerExistence = (callback = () => {}) => {
if (!('serviceWorker' in navigator)) {
@@ -68,7 +69,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
onSuccess();
})
.catch(() => {
window.bus.$emit(
emitter.emit(
'newToastMessage',
'This browser does not support desktop notification'
);
@@ -77,7 +78,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
window.bus.$emit(
emitter.emit(
'newToastMessage',
'This browser does not support desktop notification'
);
@@ -52,8 +52,22 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
return null;
};
export const isAConversationRoute = routeName =>
[
export const isAConversationRoute = (
routeName,
includeBase = false,
includeExtended = true
) => {
const baseRoutes = [
'home',
'conversation_mentions',
'conversation_unattended',
'inbox_dashboard',
'label_conversations',
'team_conversations',
'folder_conversations',
'conversation_participating',
];
const extendedRoutes = [
'inbox_conversation',
'conversation_through_mentions',
'conversation_through_unattended',
@@ -62,7 +76,15 @@ export const isAConversationRoute = routeName =>
'conversations_through_team',
'conversations_through_folders',
'conversation_through_participating',
].includes(routeName);
];
const routes = [
...(includeBase ? baseRoutes : []),
...(includeExtended ? extendedRoutes : []),
];
return routes.includes(routeName);
};
export const getConversationDashboardRoute = routeName => {
switch (routeName) {
@@ -1,5 +1,6 @@
import AnalyticsHelper from './AnalyticsHelper';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { emitter } from 'shared/helpers/mitt';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
@@ -8,7 +9,7 @@ export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
emitter.on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user);
});
};
@@ -34,12 +35,12 @@ const initializeAudioAlerts = user => {
};
export const initializeChatwootEvents = () => {
window.bus.$on(CHATWOOT_RESET, () => {
emitter.on(CHATWOOT_RESET, () => {
if (window.$chatwoot) {
window.$chatwoot.reset();
}
});
window.bus.$on(CHATWOOT_SET_USER, ({ user }) => {
emitter.on(CHATWOOT_SET_USER, ({ user }) => {
if (window.$chatwoot) {
window.$chatwoot.setUser(user.email, {
avatar_url: user.avatar_url,
@@ -106,6 +106,51 @@ describe('isAConversationRoute', () => {
expect(isAConversationRoute('conversations_through_team')).toBe(true);
expect(isAConversationRoute('dashboard')).toBe(false);
});
it('returns true if base conversation route name is provided and includeBase is true', () => {
expect(isAConversationRoute('home', true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true)).toBe(true);
expect(isAConversationRoute('conversation_unattended', true)).toBe(true);
expect(isAConversationRoute('inbox_dashboard', true)).toBe(true);
expect(isAConversationRoute('label_conversations', true)).toBe(true);
expect(isAConversationRoute('team_conversations', true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true)).toBe(true);
});
it('returns false if base conversation route name is provided and includeBase is false', () => {
expect(isAConversationRoute('home', false)).toBe(false);
expect(isAConversationRoute('conversation_mentions', false)).toBe(false);
expect(isAConversationRoute('conversation_unattended', false)).toBe(false);
expect(isAConversationRoute('inbox_dashboard', false)).toBe(false);
expect(isAConversationRoute('label_conversations', false)).toBe(false);
expect(isAConversationRoute('team_conversations', false)).toBe(false);
expect(isAConversationRoute('folder_conversations', false)).toBe(false);
expect(isAConversationRoute('conversation_participating', false)).toBe(
false
);
});
it('returns true if base conversation route name is provided and includeBase and includeExtended is true', () => {
expect(isAConversationRoute('home', true, true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true, true)).toBe(
true
);
expect(isAConversationRoute('conversation_unattended', true, true)).toBe(
true
);
expect(isAConversationRoute('inbox_dashboard', true, true)).toBe(true);
expect(isAConversationRoute('label_conversations', true, true)).toBe(true);
expect(isAConversationRoute('team_conversations', true, true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true, true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true, true)).toBe(
true
);
});
it('returns false if base conversation route name is not provided', () => {
expect(isAConversationRoute('')).toBe(false);
});
});
describe('getConversationDashboardRoute', () => {
@@ -33,7 +33,7 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_SUCCESFUL": "Teams assiged successfully.",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
}
@@ -154,7 +154,7 @@
"UNTIL_TOMORROW": "Until tomorrow",
"UNTIL_NEXT_MONTH": "Until next month",
"AN_HOUR_FROM_NOW": "Until an hour from now",
"CUSTOM": "Custom...",
"UNTIL_CUSTOM_TIME": "Custom...",
"CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light",
"DARK_MODE": "Dark",
@@ -2,7 +2,8 @@
"INBOX_MGMT": {
"HEADER": "Inboxes",
"SIDEBAR_TXT": "<p><b>Inbox</b></p> <p> When you connect a website or a facebook Page to Chatwoot, it is called an <b>Inbox</b>. You can have unlimited inboxes in your Chatwoot account. </p><p> Click on <b>Add Inbox</b> to connect a website or a Facebook Page. </p><p> In the Dashboard, you can see all the conversations from all your inboxes in a single place and respond to them under the `Conversations` tab. </p><p> You can also see conversations specific to an inbox by clicking on the inbox name on the left pane of the dashboard. </p>",
"RECONNECTION_REQUIRED": "Your inbox is disconnected, you will not receive any new messages. <a class=\"underline\" href=\"%{actionUrl}\">Click here</a> to reconnect.",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -365,6 +366,7 @@
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"SIGN_IN": "Sign in with Microsoft",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
}
@@ -737,4 +739,4 @@
"OTHER_PROVIDERS": "Other Providers"
}
}
}
}
@@ -203,6 +203,87 @@
"API_SUCCESS": "Dashboard app deleted successfully",
"API_ERROR": "We couldn't delete the app. Please try again later"
}
},
"LINEAR": {
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
"LOADING": "Fetching linear issues...",
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
"CREATE": "Create",
"LINK": {
"SEARCH": "Search issues",
"SELECT": "Select issue",
"TITLE": "Link",
"EMPTY_LIST": "No linear issues found",
"LOADING": "Loading",
"ERROR": "There was an error fetching the linear issues, please try again",
"LINK_SUCCESS": "Issue linked successfully",
"LINK_ERROR": "There was an error linking the issue, please try again",
"LINK_TITLE": "Conversation (#%{conversationId}) with %{name}"
},
"ADD_OR_LINK": {
"TITLE": "Create/link linear issue",
"DESCRIPTION": "Create Linear issues from conversations, or link existing ones for seamless tracking.",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter title",
"REQUIRED_ERROR": "Title is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter description"
},
"TEAM": {
"LABEL": "Team",
"PLACEHOLDER": "Select team",
"SEARCH": "Search team",
"REQUIRED_ERROR": "Team is required"
},
"ASSIGNEE": {
"LABEL": "Assignee",
"PLACEHOLDER": "Select assignee",
"SEARCH": "Search assignee"
},
"PRIORITY": {
"LABEL": "Priority",
"PLACEHOLDER": "Select priority",
"SEARCH": "Search priority"
},
"LABEL": {
"LABEL": "Label",
"PLACEHOLDER": "Select label",
"SEARCH": "Search label"
},
"STATUS": {
"LABEL": "Status",
"PLACEHOLDER": "Select status",
"SEARCH": "Search status"
},
"PROJECT": {
"LABEL": "Project",
"PLACEHOLDER": "Select project",
"SEARCH": "Search project"
}
},
"CREATE": "Create",
"CANCEL": "Cancel",
"CREATE_SUCCESS": "Issue created successfully",
"CREATE_ERROR": "There was an error creating the issue, please try again",
"LOADING_TEAM_ERROR": "There was an error fetching the teams, please try again",
"LOADING_TEAM_ENTITIES_ERROR": "There was an error fetching the team entities, please try again"
},
"ISSUE": {
"STATUS": "Status",
"PRIORITY": "Priority",
"ASSIGNEE": "Assignee",
"LABELS": "Labels",
"CREATED_AT": "Created at %{createdAt}"
},
"UNLINK": {
"TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
}
}
}
}
@@ -274,7 +274,7 @@
"SLA": "SLA",
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview",
"FACEBOOK_REAUTHORIZE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
"REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",
"HELP_CENTER": {
"TITLE": "Help Center",
"ALL_ARTICLES": "All Articles",
@@ -15,7 +15,7 @@ export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
];
const slugifyChannel = name =>
name.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
name?.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
export const isEditorHotKeyEnabled = (uiSettings, key) => {
const {
@@ -70,6 +70,8 @@ export default {
this.updateUISettings({ [key]: !this.isContactSidebarItemOpen(key) });
},
setSignatureFlagForInbox(channelType, value) {
if (!channelType) return;
channelType = slugifyChannel(channelType);
this.updateUISettings({
[`${channelType}_signature_enabled`]: value,
@@ -110,11 +110,11 @@ export default {
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
this.$emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
bus.$off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
this.$emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
methods: {
@@ -11,6 +11,9 @@ import {
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
} from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { createSnoozeHandlers } from './commandBarActions';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -22,79 +25,11 @@ export const SNOOZE_CONVERSATION_BULK_ACTIONS = [
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_REPLY,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_REPLY',
parent: 'bulk_action_snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_NEXT_REPLY
),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'bulk_action_snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.AN_HOUR_FROM_NOW
),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_TOMORROW
),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_NEXT_WEEK
),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_NEXT_MONTH
),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
),
},
...createSnoozeHandlers(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
'bulk_action_snooze_conversation',
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
),
];
export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
@@ -103,7 +38,7 @@ export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION,
handler: () => bus.$emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
handler: () => emitter.emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
},
];
@@ -113,7 +48,7 @@ export const OPEN_CONVERSATION_BULK_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => bus.$emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
handler: () => emitter.emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
},
];
@@ -1,4 +1,5 @@
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import {
CMD_MUTE_CONVERSATION,
@@ -26,10 +27,21 @@ export const OPEN_CONVERSATION_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => bus.$emit(CMD_RESOLVE_CONVERSATION),
handler: () => emitter.emit(CMD_RESOLVE_CONVERSATION),
},
];
export const createSnoozeHandlers = (busEventName, parentId, section) => {
return Object.values(SNOOZE_OPTIONS).map(option => ({
id: option,
title: `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
}));
};
export const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
@@ -37,61 +49,11 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_REPLY,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_REPLY',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_REPLY),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
...createSnoozeHandlers(
CMD_SNOOZE_CONVERSATION,
'snooze_conversation',
'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION'
),
];
export const RESOLVED_CONVERSATION_ACTIONS = [
@@ -100,7 +62,7 @@ export const RESOLVED_CONVERSATION_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_REOPEN_CONVERSATION,
handler: () => bus.$emit(CMD_REOPEN_CONVERSATION),
handler: () => emitter.emit(CMD_REOPEN_CONVERSATION),
},
];
@@ -109,7 +71,7 @@ export const SEND_TRANSCRIPT_ACTION = {
title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SEND_TRANSCRIPT,
handler: () => bus.$emit(CMD_SEND_TRANSCRIPT),
handler: () => emitter.emit(CMD_SEND_TRANSCRIPT),
};
export const UNMUTE_ACTION = {
@@ -117,7 +79,7 @@ export const UNMUTE_ACTION = {
title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_UNMUTE_CONVERSATION,
handler: () => bus.$emit(CMD_UNMUTE_CONVERSATION),
handler: () => emitter.emit(CMD_UNMUTE_CONVERSATION),
};
export const MUTE_ACTION = {
@@ -125,5 +87,5 @@ export const MUTE_ACTION = {
title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_MUTE_CONVERSATION,
handler: () => bus.$emit(CMD_MUTE_CONVERSATION),
handler: () => emitter.emit(CMD_MUTE_CONVERSATION),
};
@@ -6,11 +6,13 @@
hideBreadcrumbs
:placeholder="placeholder"
@selected="onSelected"
@closed="onClosed"
/>
</template>
<script>
import 'ninja-keys';
import '@chatwoot/ninja-keys';
import wootConstants from 'dashboard/constants/globals';
import conversationHotKeysMixin from './conversationHotKeys';
import bulkActionsHotKeysMixin from './bulkActionsHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
@@ -34,6 +36,14 @@ export default {
appearanceHotKeys,
goToCommandHotKeys,
],
data() {
return {
// Added selectedSnoozeType to track the selected snooze type
// So if the selected snooze type is "custom snooze" then we set selectedSnoozeType with the CMD action id
// So that we can track the selected snooze type and when we close the command bar
selectedSnoozeType: null,
};
},
computed: {
placeholder() {
return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER');
@@ -67,14 +77,35 @@ export default {
this.$refs.ninjakeys.data = this.hotKeys;
},
onSelected(item) {
const { detail: { action: { title = null, section = null } = {} } = {} } =
item;
const {
detail: {
action: { title = null, section = null, id = null } = {},
} = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.selectedSnoozeType =
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
this.selectedSnoozeType = null;
}
this.$track(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
});
this.setCommandbarData();
},
onClosed() {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
if (
this.selectedSnoozeType !==
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
) {
this.$store.dispatch('setContextMenuChatId', null);
}
},
},
};
</script>
@@ -1,5 +1,6 @@
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import { CMD_AI_ASSIST } from './commandBarBusEvents';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@@ -55,11 +56,15 @@ export default {
replyMode() {
this.setCommandbarData();
},
contextMenuChatId() {
this.setCommandbarData();
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
contextMenuChatId: 'getContextMenuChatId',
}),
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -93,6 +98,7 @@ export default {
}
return this.prepareActions(actions);
},
priorityOptions() {
return [
{
@@ -313,7 +319,7 @@ export default {
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item,
icon: item.icon,
handler: () => bus.$emit(CMD_AI_ASSIST, item.key),
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
}));
return [
{
@@ -327,25 +333,42 @@ export default {
];
},
conversationHotKeys() {
if (
isConversationOrInboxRoute() {
return (
isAConversationRoute(this.$route.name) ||
isAInboxViewRoute(this.$route.name)
) {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
...this.assignAgentActions,
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
];
if (this.isAIIntegrationEnabled) {
return [...defaultConversationHotKeys, ...this.AIAssistActions];
}
return defaultConversationHotKeys;
}
);
},
shouldShowSnoozeOption() {
return (
isAConversationRoute(this.$route.name, true, false) &&
this.contextMenuChatId
);
},
getDefaultConversationHotKeys() {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
...this.assignAgentActions,
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
];
if (this.isAIIntegrationEnabled) {
return [...defaultConversationHotKeys, ...this.AIAssistActions];
}
return defaultConversationHotKeys;
},
conversationHotKeys() {
if (this.shouldShowSnoozeOption) {
return this.prepareActions(SNOOZE_CONVERSATION_ACTIONS);
}
if (this.isConversationOrInboxRoute) {
return this.getDefaultConversationHotKeys;
}
return [];
},
},
@@ -2,6 +2,7 @@ import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
@@ -21,7 +22,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
@@ -30,7 +31,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
@@ -39,7 +40,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
@@ -48,7 +49,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
@@ -57,7 +58,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export default {
@@ -145,7 +145,6 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue';
export default {
components: {
AccordionItem,
@@ -19,12 +19,12 @@ const initiatedAt = computed(
() => props.conversationAttributes.initiated_at?.timestamp
);
const browserInfo = props.conversationAttributes.browser;
const browserInfo = computed(() => props.conversationAttributes.browser);
const browserName = computed(() => {
if (!browserInfo) return '';
if (!browserInfo.value) return '';
const { browser_name: name = '', browser_version: version = '' } =
browserInfo;
browserInfo.value;
return `${name} ${version}`;
});
@@ -33,9 +33,9 @@ const browserLanguage = computed(() =>
);
const platformName = computed(() => {
if (!browserInfo) return '';
if (!browserInfo.value) return '';
const { platform_name: name = '', platform_version: version = '' } =
browserInfo;
browserInfo.value;
return `${name} ${version}`;
});
@@ -169,7 +169,7 @@ export default {
after: messageId,
})
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
this.$store.dispatch('clearSelectedState');
@@ -271,7 +271,10 @@ export default {
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
bus.$emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, this.showConversationModal);
this.$emitter.emit(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.showConversationModal
);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
@@ -265,7 +265,7 @@ export default {
},
mounted() {
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
this.$emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
const slug = this.$route.params.portalSlug;
if (slug) this.lastActivePortalSlug = slug;
@@ -273,7 +273,7 @@ export default {
this.fetchPortalAndItsCategories();
},
beforeDestroy() {
bus.$off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
this.$emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
updated() {
const slug = this.$route.params.portalSlug;
@@ -47,6 +47,7 @@
<script>
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -103,6 +104,13 @@ export default {
return !this.uiFlags.isFetching && !this.notifications.length;
},
},
watch: {
inboxFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('notifications/updateNotificationFilters', newVal);
}
},
},
mounted() {
this.setSavedFilter();
this.fetchNotifications();
@@ -190,6 +198,10 @@ export default {
this.status = status;
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
this.$store.dispatch(
'notifications/setNotificationFilters',
this.inboxFilters
);
},
},
};
@@ -140,7 +140,7 @@ export default {
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
@@ -92,10 +92,10 @@ export default {
...mapGetters({ meta: 'notifications/getMeta' }),
},
mounted() {
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
this.$emitter.on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
this.$emitter.off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
openSnoozeNotificationModal() {
@@ -200,7 +200,7 @@ export default {
},
featureInboundEmailEnabled() {
return !!this.features.inbound_emails;
return !!this.features?.inbound_emails;
},
featureCustomReplyDomainEnabled() {
@@ -69,8 +69,10 @@
<script>
import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
onClose: {
type: Function,
@@ -117,9 +119,6 @@ export default {
},
methods: {
showAlert(message) {
bus.$emit('newToastMessage', message);
},
async addAgent() {
try {
await this.$store.dispatch('agents/create', {
@@ -83,6 +83,7 @@ import WootSubmitButton from '../../../../components/buttons/FormSubmitButton.vu
import Modal from '../../../../components/Modal.vue';
import Auth from '../../../../api/auth';
import wootConstants from 'dashboard/constants/globals';
import alertMixin from 'shared/mixins/alertMixin';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
@@ -91,6 +92,7 @@ export default {
WootSubmitButton,
Modal,
},
mixins: [alertMixin],
props: {
id: {
type: Number,
@@ -169,9 +171,6 @@ export default {
},
},
methods: {
showAlert(message) {
bus.$emit('newToastMessage', message);
},
async editAgent() {
try {
await this.$store.dispatch('agents/update', {
@@ -130,6 +130,7 @@ import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Thumbnail from '../../../../components/widgets/Thumbnail.vue';
import AddAgent from './AddAgent.vue';
import EditAgent from './EditAgent.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
@@ -137,7 +138,7 @@ export default {
EditAgent,
Thumbnail,
},
mixins: [globalConfigMixin],
mixins: [globalConfigMixin, alertMixin],
data() {
return {
loading: {},
@@ -230,19 +231,19 @@ export default {
async deleteAgent(id) {
try {
await this.$store.dispatch('agents/delete', id);
this.showAlert(this.$t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'));
this.showAlertMessage(this.$t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(this.$t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'));
this.showAlertMessage(this.$t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'));
}
},
// Show SnackBar
showAlert(message) {
showAlertMessage(message) {
// Reset loading, current selected agent
this.loading[this.currentAgent.id] = false;
this.currentAgent = {};
// Show message
this.agentAPI.message = message;
bus.$emit('newToastMessage', message);
this.showAlert(message);
},
},
};
@@ -132,12 +132,14 @@
import { mapGetters } from 'vuex';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
AddCanned,
EditCanned,
},
mixins: [alertMixin],
data() {
return {
loading: {},
@@ -187,13 +189,13 @@ export default {
});
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
},
showAlert(message) {
showAlertMessage(message) {
// Reset loading, current selected agent
this.loading[this.selectedResponse.id] = false;
this.selectedResponse = {};
// Show message
this.cannedResponseAPI.message = message;
bus.$emit('newToastMessage', message);
this.showAlert(message);
},
// Edit Function
openAddPopup() {
@@ -230,12 +232,14 @@ export default {
this.$store
.dispatch('deleteCannedResponse', id)
.then(() => {
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE'));
this.showAlertMessage(
this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE')
);
})
.catch(error => {
const errorMessage =
error?.message || this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
this.showAlert(errorMessage);
this.showAlertMessage(errorMessage);
});
},
},
@@ -51,11 +51,13 @@ import { mapGetters } from 'vuex';
import InboxMembersAPI from '../../../../api/inboxMembers';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
validations: {
selectedAgents: {
@@ -98,7 +100,7 @@ export default {
},
});
} catch (error) {
bus.$emit('newToastMessage', error.message);
this.showAlert(error.message);
}
this.isCreating = false;
},
@@ -136,12 +136,13 @@ import Settings from './Settings.vue';
import adminMixin from '../../../../mixins/isAdmin';
import accountMixin from '../../../../mixins/account';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
Settings,
},
mixins: [adminMixin, accountMixin, globalConfigMixin],
mixins: [adminMixin, accountMixin, globalConfigMixin, alertMixin],
data() {
return {
loading: {},
@@ -194,15 +195,9 @@ export default {
async deleteInbox({ id }) {
try {
await this.$store.dispatch('inboxes/delete', id);
bus.$emit(
'newToastMessage',
this.$t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE')
);
this.showAlert(this.$t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
bus.$emit(
'newToastMessage',
this.$t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE')
);
this.showAlert(this.$t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE'));
}
},
@@ -21,10 +21,8 @@
</woot-tabs>
</setting-intro-banner>
<inbox-reconnection-required
v-if="isReconnectionRequired"
class="mx-8 mt-5"
/>
<microsoft-reauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<facebook-reauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<settings-section
@@ -402,7 +400,6 @@
@click="updateInbox"
/>
</settings-section>
<facebook-reauthorize v-if="isAFacebookInbox" :inbox-id="inbox.id" />
</div>
<div v-if="selectedTabKey === 'collaborators'" class="mx-8">
@@ -440,7 +437,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import InboxReconnectionRequired from './components/InboxReconnectionRequired';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
@@ -459,7 +456,7 @@ export default {
WeeklyAvailability,
WidgetBuilder,
SenderNameExamplePreview,
InboxReconnectionRequired,
MicrosoftReauthorize,
},
mixins: [alertMixin, configMixin, inboxMixin],
data() {
@@ -621,8 +618,11 @@ export default {
return true;
return false;
},
isReconnectionRequired() {
return false;
microsoftUnauthorized() {
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
},
facebookUnauthorized() {
return this.isAFacebookInbox && this.inbox.reauthorization_required;
},
},
watch: {
@@ -18,8 +18,8 @@
/>
<woot-submit-button
icon="brand-twitter"
button-text="Sign in with Microsoft"
type="submit"
:button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:loading="isRequestingAuthorization"
/>
</form>
@@ -1,63 +1,44 @@
<template>
<div class="mx-8">
<settings-section
:title="$t('INBOX_MGMT.MICROSOFT.TITLE')"
:sub-title="$t('INBOX_MGMT.MICROSOFT.SUBTITLE')"
>
<div class="mb-6">
<form @submit.prevent="requestAuthorization">
<woot-submit-button
icon="brand-twitter"
button-text="Sign in with Microsoft"
type="submit"
:loading="isRequestingAuthorization"
/>
</form>
</div>
</settings-section>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import microsoftClient from '../../../../../../api/channel/microsoftClient';
import SettingsSection from '../../../../../../components/SettingsSection.vue';
<script setup>
import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
export default {
components: {
SettingsSection,
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
const { t } = useI18n();
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return { isRequestingAuthorization: false };
},
methods: {
async requestAuthorization() {
try {
this.isRequestingAuthorization = true;
const response = await microsoftClient.generateAuthorization({
email: this.inbox.email,
});
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE'));
} finally {
this.isRequestingAuthorization = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.smtp-details-wrap {
margin-bottom: var(--space-medium);
});
const isRequestingAuthorization = ref(false);
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await microsoftClient.generateAuthorization({
email: props.inbox.email,
});
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE'));
} finally {
isRequestingAuthorization.value = false;
}
}
</style>
</script>
<template>
<inbox-reconnection-required
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
</template>
@@ -1,19 +1,14 @@
<script setup>
defineProps({
actionUrl: {
type: String,
required: true,
},
});
import Banner from 'dashboard/components/ui/Banner.vue';
</script>
<template>
<div
class="flex items-center gap-2 px-4 py-3 text-sm text-white bg-red-500 rounded-md dark:bg-red-800/30 dark:text-red-50 min-h-10"
>
<fluent-icon icon="error-circle" class="text-white dark:text-red-50" />
<slot>
<span v-html="$t('INBOX_MGMT.RECONNECTION_REQUIRED', { actionUrl })" />
</slot>
</div>
<banner
color-scheme="alert"
class="justify-start rounded-md"
:banner-message="$t('INBOX_MGMT.RECONNECTION_REQUIRED')"
:action-button-label="$t('INBOX_MGMT.CLICK_TO_RECONNECT')"
has-action-button
@click="$emit('reauthorize')"
/>
</template>
@@ -1,38 +1,32 @@
<template>
<settings-section
:title="$t('INBOX_MGMT.FACEBOOK_REAUTHORIZE.TITLE')"
:sub-title="$t('INBOX_MGMT.FACEBOOK_REAUTHORIZE.SUBTITLE')"
>
<a class="fb--login" href="#" @click="tryFBlogin">
<img
src="~dashboard/assets/images/channels/facebook_login.png"
alt="Facebook-logo"
/>
</a>
</settings-section>
<inbox-reconnection-required class="mx-8 mt-5" @reauthorize="tryFBlogin" />
</template>
<script>
/* global FB */
import SettingsSection from '../../../../../components/SettingsSection.vue';
import InboxReconnectionRequired from '../components/InboxReconnectionRequired';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
SettingsSection,
InboxReconnectionRequired,
},
mixins: [alertMixin],
props: {
inboxId: {
type: Number,
inbox: {
type: Object,
required: true,
},
},
computed: {
inboxId() {
return this.inbox.id;
},
},
mounted() {
this.initFB();
this.loadFBsdk();
},
methods: {
initFB() {
if (window.fbSDKLoaded === undefined) {
@@ -104,10 +104,6 @@
</div>
<imap-settings :inbox="inbox" />
<smtp-settings v-if="inbox.imap_enabled" :inbox="inbox" />
<microsoft-reauthorize
v-if="inbox.microsoft_reauthorization"
:inbox="inbox"
/>
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
@@ -130,7 +126,7 @@
"
>
<div
class="whatsapp-settings--content items-center flex flex-1 justify-between mt-2"
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model.trim="whatsAppInboxAPIKey"
@@ -160,7 +156,6 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import ImapSettings from '../ImapSettings.vue';
import SmtpSettings from '../SmtpSettings.vue';
import MicrosoftReauthorize from '../channels/microsoft/Reauthorize.vue';
import { required } from 'vuelidate/lib/validators';
export default {
@@ -168,7 +163,6 @@ export default {
SettingsSection,
ImapSettings,
SmtpSettings,
MicrosoftReauthorize,
},
mixins: [inboxMixin, alertMixin],
props: {

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