Compare commits

...
42 Commits
Author SHA1 Message Date
Shivam MishraandGitHub 97671f3dc0 Merge branch 'develop' into fix/article-view 2024-10-09 15:01:16 +05:30
Shivam MishraandGitHub f18ed01eb7 feat: use of imap login as default if present (#10249)
When moving form using Gmail Legacy auth to using OAuth, we need the
email address that will be used to connect. This is because we need to
store this email address in the cache and reuse when we get the callback
to find the associated inbox.

However there are cases where the imap login might be
`support@company.com` and the email used to communicate will be
`contact@company.com` (Probably an alias) In that case, we need to send
the correct email address to Chatwoot when re-authenticating

At the moment, we used the inbox email. This PR adds a check that
defaults to to `imap_login` if that is available and imap is enabled

This PR also fixes an unrelated problem where the email inbox creation
flow was not working

---

Tested it, it is working correctly

![CleanShot 2024-10-09 at 14 23
47@2x](https://github.com/user-attachments/assets/0e2cb6c8-1224-4b45-b34a-7b19611249bc)
2024-10-09 15:01:11 +05:30
aa5fa0c758 feat: Use CJS build for SDK (#10247)
The UMD build was causing issues for a few customers, this PR reverts to
using CJS like used in Webpack 4 before the vite migration

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-10-09 14:09:52 +05:30
Shivam MishraandGitHub 42eca69763 fix: Import for vue-letter (#10246) 2024-10-08 09:40:37 -07:00
Shivam MishraandGitHub 3a78192e74 fix: Resolve accountId from the route, initialize route-sync before the app is loaded (#10245)
On production on multiple instances it may happen that the UI is
rendered in correctly, with a lot of options in the sidebar not
available. On further investigation I found out that the feature flag
checks were disabling multiple of those, and also we could see many
correlated errors that pointed towards missing information.

So, there were two problems here

1. The `vuex-router-sync` was not very reliable in some cases
2. In `App.vue` the watch on `currentAccountId` didn't always trigger.

## Fix Tested on Staging

Basically tried to reload the page ~50 times with cache enabled,
disabled, throttling, navigating different pages.


https://www.loom.com/share/1bb27294aa364ac4acfb647780d6385a?sid=87e31330-8cb7-4ded-8616-5e95e2ae3516

<details><summary>

#### What I thought was the fix

</summary>
<p>

### My chain of actions

Replacing vuex-router-sync at first worked fine, but then I saw it was
still failing in some cases, I assumed (I was half-correct tho) that the
rendering of the `App.vue` and syncing of the route to the store was not
happening in a synchronous pattern. So I decided, let's not rely on the
store when the route is directly available in the App context.

Following this, I refactored `useAccount` composable to use `useRoute`
directly, instead of the store, and then replaced the getter inside
`App.vue`. What this did was surface the issue but more consistently 🤯

I saw the watcher, added some console logs, and turns out it was not
getting triggered in all those cases. So I added an `immediate` to it.
And viola, it works!

At the moment, this is deployed to staging and seems to be working
correctly. But we still need to verify it for sure, since how this issue
was surfaced is still a mystery. All we know is that it shows up when
the widget is also loaded alongside the app (if it loads before or after
the app, it works fine)

### What about the route in the store?

Well I have used the `route` usage there with fallback to the store
state. Since Vuex exists in the app context, the route should always be
available to it. But after today I have lost all trust in JavaScript and
will worship rails until end of my life, so I added that in a
`try-catch` block, logged the error to Sentry

</p>
</details> 

## Here's the real fix

If you read the explanation I wrote earlier, I thought I fixed the
issue, but then the chat list navigation completely broke. So I removed
the custom route sync implementation and added the original package
back. Turns out the vuex-router-sync earlier was placed after the app
was initalized, however for it to work, the vue app context is not
required. And it's best to run it before the app is even bootstrapped,
so I added it back and placed it correctly.

So the following changes fixes this problem

1. Hoisting the `sync` function call to before we call `createApp` this
ensures that the stores and route hooks are in place before even the app
is created
2. Ensuring the `initializeAccount` is run immediately when watching
`currentAccountId`
4. Source `currentAccountId` for critical top of the tree components
directly from the route instead of the store
2024-10-08 09:25:51 -07:00
Shivam MishraandGitHub a8c12ffb25 chore: update interpolation syntax for i18n files (#10198)
There were two warnings showing up on new teams page

1. `errorMessage` prop was getting invalid value, this was because were
short circuting the error message using `&&`, fixed it by using ternary
operator
2. `vue-i18n` has deprecated [rails
syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax#rails-i18n-format),
fixed that warning by removing `%` before `{}` for interpolation

> Note: the `vue-i18n` deprecation needs to be handled, but we can do it
later
2024-10-07 11:12:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
97d4ce65c4 chore(deps): bump braces from 3.0.2 to 3.0.3 (#10238)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to
3.0.3.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/micromatch/braces/commit/74b2db2938fad48a2ea54a9c8bf27a37a62c350d"><code>74b2db2</code></a>
3.0.3</li>
<li><a
href="https://github.com/micromatch/braces/commit/88f1429a0f47e1dd3813de35211fc97ffda27f9e"><code>88f1429</code></a>
update eslint. lint, fix unit tests.</li>
<li><a
href="https://github.com/micromatch/braces/commit/415d660c3002d1ab7e63dbf490c9851da80596ff"><code>415d660</code></a>
Snyk js braces 6838727 (<a
href="https://redirect.github.com/micromatch/braces/issues/40">#40</a>)</li>
<li><a
href="https://github.com/micromatch/braces/commit/190510f79db1adf21d92798b0bb6fccc1f72c9d6"><code>190510f</code></a>
fix tests, skip 1 test in test/braces.expand</li>
<li><a
href="https://github.com/micromatch/braces/commit/716eb9f12d820b145a831ad678618731927e8856"><code>716eb9f</code></a>
readme bump</li>
<li><a
href="https://github.com/micromatch/braces/commit/a5851e57f45c3431a94d83fc565754bc10f5bbc3"><code>a5851e5</code></a>
Merge pull request <a
href="https://redirect.github.com/micromatch/braces/issues/37">#37</a>
from coderaiser/fix/vulnerability</li>
<li><a
href="https://github.com/micromatch/braces/commit/2092bd1fb108d2c59bd62e243b70ad98db961538"><code>2092bd1</code></a>
feature: braces: add maxSymbols (<a
href="https://github.com/micromatch/braces/issues/">https://github.com/micromatch/braces/issues/</a>...</li>
<li><a
href="https://github.com/micromatch/braces/commit/9f5b4cf47329351bcb64287223ffb6ecc9a5e6d3"><code>9f5b4cf</code></a>
fix: vulnerability (<a
href="https://security.snyk.io/vuln/SNYK-JS-BRACES-6838727">https://security.snyk.io/vuln/SNYK-JS-BRACES-6838727</a>)</li>
<li><a
href="https://github.com/micromatch/braces/commit/98414f9f1fabe021736e26836d8306d5de747e0d"><code>98414f9</code></a>
remove funding file</li>
<li><a
href="https://github.com/micromatch/braces/commit/665ab5d561c017a38ba7aafd92cc6655b91d8c14"><code>665ab5d</code></a>
update keepEscaping doc (<a
href="https://redirect.github.com/micromatch/braces/issues/27">#27</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/micromatch/braces/compare/3.0.2...3.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=braces&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-10-07 11:12:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ea787fecd8 chore(deps): bump dset from 3.1.3 to 3.1.4 (#10239)
Bumps [dset](https://github.com/lukeed/dset) from 3.1.3 to 3.1.4.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/lukeed/dset/commit/05b1ec0f8cebe3b5d5667285bc7da90f38a7215e"><code>05b1ec0</code></a>
3.1.4</li>
<li><a
href="https://github.com/lukeed/dset/commit/16d6154e085bef01e99f01330e5a421a7f098afa"><code>16d6154</code></a>
fix: prevent <strong>proto</strong> assignment via implicit string</li>
<li>See full diff in <a
href="https://github.com/lukeed/dset/compare/v3.1.3...v3.1.4">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dset&package-manager=npm_and_yarn&previous-version=3.1.3&new-version=3.1.4)](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-10-07 11:11:36 -07:00
Shivam MishraandGitHub 02894ba221 Merge branch 'develop' into fix/article-view 2024-10-07 15:36:24 +05:30
ee02923ace chore: fix circleci on vite build (#10214)
- Switch to pnpm based build
- Switch circleci from docker to machine to have more memory
- Fix frontend and backend tests

Fixes
https://linear.app/chatwoot/issue/CW-3610/fix-circle-ci-for-vite-build
---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-10-07 15:27:41 +05:30
Shivam Mishra 1c3f6f46fe feat: use article props 2024-10-05 18:42:03 +05:30
0677d8763d fix: Update the reply box to handle play, pause callbacks from WaveSurfer (#10223)
- Implemented custom @play, @pause methods to update the state of the recording. Once the recording is finished the button icon changes from stop button to play/pause button.
- Removes the console error undefined hasAudio

Fixes https://linear.app/chatwoot/issue/CW-3609/audio-recorder-issue

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-10-05 00:17:11 -07:00
Sivin VargheseandGitHub 095aaf3de6 fix: Issues with drag and drop attachments to the editor (#10235) 2024-10-05 00:16:42 -07:00
PranavandGitHub b7aea99f44 fix: Add webhook string to the zh_CN to fix missing webhook feature (#10234)
The integrations are displayed based on whether the integration object
is available on the API. Since there are chances where empty objects can
be returned, the check for the presence was done based on the
"integration.name". The name was empty (this should be investigated) in
the zh_CN locale, which led to the feature being disabled for a user.

In this PR, I just added the string which should enable the feature. In
the followups, I will add a better check for the presence of the
integration object.
2024-10-04 17:50:38 -07:00
Sivin VargheseandGitHub d0bf66fe7a feat: Update widget builder to support dark mode (#10230) 2024-10-04 15:53:31 -07:00
Shivam MishraandGitHub 490fc4eaba fix: Remove the usage of asyncComponent in emoji picker (#10232)
This PR has the following fixes

1. preview of article inside the iframe, earlier this didn't work because the iframe didn't get the URL correctly. We fix that by passing the URL is a query instead
2. Emoji picker caused a weird redirect, this was only happening when the chunk was loaded async, this PR changes it to use regular loading instead
2024-10-04 08:10:27 -07:00
Vishnu NarayananandGitHub e6e0f7de5a fix: Add missing 'one' translations for time units in agent-reports (#10228) 2024-10-04 08:08:25 -07:00
d2eda8bd08 fix: Update inconsistent behaviour of create and edit automation rules (#10197)
When Vue 3 is used with options API, any assignment to `this.<something>` is converted to a Proxy before assignment. This is fine as long as we are in the options context, problem arises when we access this in a `composable` any mutations on the object doesn't behave correctly as expected, this PR fixes that by moving the `automation` object inside the composable and using it in the options.

> Another option to fix such an issue is to make the object non-reactive, like done in places where we have `editorView`, but that wasn't viable here

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-10-04 08:06:36 -07:00
55bf05d2d4 fix: Use arrow function to bind methods to class (#10215)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2024-10-04 08:05:34 -07:00
Shivam MishraandGitHub 5b22af6af8 fix: Update input event bindings to use blur events (#10216) 2024-10-04 08:04:56 -07:00
9338bc1391 fix: emit events across the app (#10227)
This PR makes the following changes

1. Update v-model bindings for components using the old `value` prop and `input` event method
2. Remove components that were not used anywhere

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-10-04 08:03:41 -07:00
Shivam MishraandGitHub 88a16b8e96 fix: submit pre chat triggering native submit event (#10224) 2024-10-04 20:31:25 +05:30
Vishnu NarayananandGitHub 8c54d7f794 feat: support vite build for linux installations(cwctl) (#10231)
- Switch to `pnpm` from `yarn`

Changelog
----
- add support for `pnpm` `vite` build for chatwoot 4.0
- for new installations, install pnpm and vite
- for existing installations, remove `node_modules` and install `pnpm`

Note: `yarn` is not removed when upgrading existing installations. If
you want to rollback to an older version of Chatwoot(pre 4.0),

```
sudo -i -u chatwoot
cd chatwoot
git checkout <tag> # tag is the version of chatwoot you want to rollback to
rm -rf node_modules # remove deps installed via pnpm

# Update dependencies
bundle
yarn

# Recompile the assets
rake assets:precompile RAILS_ENV=production

# Migrate the database schema
RAILS_ENV=production bundle exec rake db:migrate
exit
```
2024-10-04 16:50:44 +05:30
Shivam MishraandGitHub bd88bfb0fe fix: emits for FormSelect and PhoneInput component (#10226) 2024-10-04 15:09:42 +05:30
Vishnu NarayananandGitHub 83100b8f60 feat: support vite build for docker based installations (#10225)
- Switch to `pnpm` from `yarn` for docker builds
2024-10-04 14:20:56 +05:30
PranavandGitHub 0f26351fd3 fix: Update card labels button with a new one to fix the CSS issue (#10222) 2024-10-04 00:00:35 -07:00
Sojan JoseandGitHub 6d053e5816 chore: Move line attachment creation to a single db commit (#10194)
Similar to the work we did with the legacy code of other channels like Twilio, we need to move the message creation and attachment creation to be under a single db commit for the line channel. Otherwise, the emitted webhook events for message creation will miss the attachment payload.


Ref: https://github.com/orgs/chatwoot/discussions/7546#discussioncomment-10814495
Ref: https://github.com/chatwoot/chatwoot/pull/10167
2024-10-03 22:13:41 -07:00
Shivam MishraandGitHub bbb8e57fee fix: Update the styles for the datepicker in custom snooze modal (#10207) 2024-10-03 22:06:51 -07:00
Shivam MishraandGitHub 5eac95732b fix: Update translation payload for audit logs (#10217)
Vue i18n would automatically merge arrays previously, it does not do so now. This PR fixes it by cleaning up the payload before passing it for translation
2024-10-03 22:04:26 -07:00
b3262597c1 fix: vue 3 followup fixes (#10213)
Fixes: CW-3602,  CW-3606, CW-3605, CW-3601, CW-3603, CW-3600, CW-3598

-
[CW-3602](https://linear.app/chatwoot/issue/CW-3602/chat-list-infinite-loader-fetching-only-odd-numbered-pages)
Chat list pagination broken
-
[CW-3606](https://linear.app/chatwoot/issue/CW-3606/saving-greeting-message-is-not-working-in-inbox-settings)
Greetings message not getting saved
-
[CW-3605](https://linear.app/chatwoot/issue/CW-3605/copy-and-paste-image-attachment-not-working-in-widget)
Paste not working on widget
-
[CW-3601](https://linear.app/chatwoot/issue/CW-3601/edit-category-is-not-working-properly)
Edit category not updating
-
[CW-3603](https://linear.app/chatwoot/issue/CW-3603/delete-filter-is-not-working)
Delete filter modal not toggling
-
[CW-3600](https://linear.app/chatwoot/issue/CW-3600/portal-editor-is-not-working-properly)
Portal editor events were flaky
-
[CW-3598](https://linear.app/chatwoot/issue/CW-3598/rearrange-of-pre-chat-form-fields-throws-an-error)
Prechat form re-order bug

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2024-10-03 19:59:07 +05:30
Shivam MishraandGitHub 701135df92 fix: vitest resolution in vite.config [CW-3587] (#10204)
The ruby plugin conflicted with vitest resolution, this PR fixes it. We
will need to separate out the vite config for this
2024-10-03 19:54:57 +05:30
Vishnu NarayananandGitHub fdc3e370ef fix: gh actions for vite build (#10212)
Fixes https://linear.app/chatwoot/issue/CW-3604/fix-gh-actions-for-vite-build

- [x] response bot spec
- [x] chatwoot ce spec
- [x] size limit check
2024-10-03 16:25:48 +05:30
Shivam MishraandGitHub f14edd5242 fix: initOnEvents not removed [CW-3594] (#10200)
The `initOnEvents` was used to get the notification sound file and
trigger the 30 second loop, but since the function was replaced to using
class syntax, the removeEvent listener was not working. This PR fixes it
by reverting to the old syntax but moving it inside the constructor
instead and also adding a `once: true` to ensure it is always removed
automatically
2024-10-03 15:03:44 +05:30
Shivam MishraandGitHub 578dce81a1 fix: suggestions not triggering directly (#10211) 2024-10-03 15:02:56 +05:30
Shivam MishraandGitHub c51a458c25 style: apply fixes for eslint issues [cw-3590] (#10210)
These fixes are all auto generated and can be merged directly

Fixes the following issues

1. Event used on components should be hypenated
2. Attribute orders in components
3. Use `unmounted` instead of `destroyed`
4. Add explicit `emits` declarations for components, autofixed [using
this
script](https://gist.github.com/scmmishra/6f549109b96400006bb69bbde392eddf)


We ignore the top level v-if for now, we will fix it later
2024-10-03 15:02:12 +05:30
Shivam MishraandGitHub b8d0252511 feat: remove usage of .sync and define explicitly emits (#10209)
References

- https://v3-migration.vuejs.org/breaking-changes/v-model
-
https://v3-migration.vuejs.org/breaking-changes/v-on-native-modifier-removed.html
2024-10-03 12:44:18 +05:30
Shivam MishraandGitHub edc1fe2363 fix: eslint issues on CC & BCC email head [CW-3586] (#10203)
The previous usage was flagged because of outdated eslint issue, this PR
fixes it

![CleanShot 2024-10-03 at 08 46
34@2x](https://github.com/user-attachments/assets/52a9de6b-1349-48dc-850d-40e641df69b4)

![CleanShot 2024-10-03 at 08 46
54@2x](https://github.com/user-attachments/assets/7b0972dd-0cbe-4d59-b077-7f4946d53766)
2024-10-03 11:59:30 +05:30
Shivam MishraandGitHub 0491ffc94e fix: signup form broken in production (#10206)
`vue-i18n` has a new [linked message
format](https://vue-i18n.intlify.dev/guide/essentials/syntax#linked-messages),
this however conflicts with raw usage an email address like
`hey@chatwoot.com`. The way to solve this as prescribed by then is to
use [literal
interpolation](https://vue-i18n.intlify.dev/guide/essentials/syntax#literal-interpolation),
like `hey{'@'}chatwoot.com`.

This PR does that for the entire translation files
2024-10-03 11:23:30 +05:30
Shivam MishraandGitHub b52950ba5a fix: Update event name for reports filter (#10199) 2024-10-02 09:16:23 -07:00
PranavandGitHub a3b602290a feat: Upgrade vite to 5.4.8 to fix dependabot/186 (#10196)
Reference: https://github.com/chatwoot/chatwoot/pull/10195
2024-10-02 00:57:37 -07:00
Shivam MishraandGitHub fce4d5e26e feat: Show re-auth flag only for legacy Gmail and OAuth channels (#10189) 2024-10-02 00:51:02 -07:00
42f6621afb feat: Vite + vue 3 💚 (#10047)
Fixes https://github.com/chatwoot/chatwoot/issues/8436
Fixes https://github.com/chatwoot/chatwoot/issues/9767
Fixes https://github.com/chatwoot/chatwoot/issues/10156
Fixes https://github.com/chatwoot/chatwoot/issues/6031
Fixes https://github.com/chatwoot/chatwoot/issues/5696
Fixes https://github.com/chatwoot/chatwoot/issues/9250
Fixes https://github.com/chatwoot/chatwoot/issues/9762

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-10-02 00:36:30 -07:00
1680 changed files with 23513 additions and 38911 deletions
+113 -133
View File
@@ -1,84 +1,87 @@
# Ruby CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
#
version: 2
version: 2.1
orbs:
node: circleci/node@6.1.0
defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: cimg/ruby:3.3.3-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
- image: cimg/postgres:15.3
- image: cimg/redis:6.2.6
environment:
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
machine:
image: ubuntu-2204:2024.05.1
resource_class: large
environment:
RAILS_LOG_TO_STDOUT: false
COVERAGE: true
LOG_LEVEL: warn
parallelism: 4
jobs:
build:
<<: *defaults
steps:
- checkout
- node/install:
node-version: '20.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Configure Bundler
name: Install System Dependencies
command: |
echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
source $BASH_ENV
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
redis-server \
postgresql \
build-essential \
git \
curl \
libssl-dev \
zlib1g-dev \
libreadline-dev \
libyaml-dev \
openjdk-11-jdk \
jq \
software-properties-common \
ca-certificates \
imagemagick \
libxml2-dev \
libxslt1-dev \
file \
g++ \
gcc \
autoconf \
gnupg2 \
patch \
ruby-dev \
liblzma-dev \
libgmp-dev \
libncurses5-dev \
libffi-dev \
libgdbm6 \
libgdbm-dev \
libvips
- run:
name: Install RVM and Ruby 3.3.3
command: |
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler
- run:
name: Which bundler?
command: bundle -v
- run:
name: Swap node versions
name: Install Application Dependencies
command: |
set +e
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v20
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
# Run bundler
# Load installed gems from cache if possible, bundle install then save cache
# Multiple caches are used to increase the chance of a cache hit
- restore_cache:
keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- run: bundle install --frozen --path ~/.bundle
- save_cache:
paths:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
- restore_cache:
keys:
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
- chatwoot-yarn-
- run:
name: yarn
command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn
# Store yarn / webpacker cache
- save_cache:
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
paths:
- ~/.cache/yarn
source ~/.rvm/scripts/rvm
bundle install
# pnpm install
- run:
name: Download cc-test-reporter
@@ -86,12 +89,8 @@ jobs:
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
# verify swagger specification
# Swagger verification
- run:
name: Verify swagger API specification
command: |
@@ -104,45 +103,62 @@ jobs:
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# Database setup
- run: bundle exec rake db:create
- run: bundle exec rake db:schema:load
# we remove the FRONTED_URL from the .env before running the tests
- run:
name: Database Setup and Configure Environment Variables
command: |
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql
chmod 644 ${PWD}/.circleci/setup_chatwoot.sql
mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/
sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql
cp .env.example .env
sed -i '/^FRONTEND_URL/d' .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
# Database setup
- run:
name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare
# Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
# - run:
# name: Brakeman
# command: bundle exec brakeman
# ESLint linting
- run:
name: eslint
command: yarn run eslint
command: pnpm run eslint
# Run frontend tests
- run:
name: Run frontend tests
command: |
mkdir -p ~/tmp/test-results/frontend_specs
mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
yarn test:coverage
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
pnpm run test:coverage
# Run rails tests
- run:
name: Code Climate Test Coverage (Frontend)
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
- run:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p coverage
mkdir -p ~/build/coverage/backend
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
@@ -150,54 +166,18 @@ jobs:
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage
name: Code Climate Test Coverage (Backend)
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage
- persist_to_workspace:
root: coverage
root: ~/build
paths:
- codeclimate.*.json
# collect reports
- store_test_results:
path: ~/tmp/test-results
- store_artifacts:
path: ~/tmp/test-artifacts
- store_artifacts:
path: log
upload-coverage:
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:3.0.2-node-browsers
environment:
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
steps:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
- run:
name: Upload coverage results to Code Climate
command: |
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
workflows:
version: 2
commit:
jobs:
- build
- upload-coverage:
requires:
- build
- coverage
+11
View File
@@ -0,0 +1,11 @@
CREATE USER chatwoot CREATEDB;
ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD';
ALTER ROLE chatwoot SUPERUSER;
UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1';
DROP DATABASE template1;
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE';
UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1';
\c template1;
VACUUM FREEZE;
+2 -1
View File
@@ -27,7 +27,8 @@ checks:
threshold: 50
exclude_patterns:
- 'spec/'
- '**/specs/'
- '**/specs/**/**'
- '**/spec/**/**'
- 'db/*'
- 'bin/**/*'
- 'db/**/*'
+18 -13
View File
@@ -2,16 +2,22 @@ module.exports = {
extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/recommended',
'plugin:storybook/recommended',
'plugin:cypress/recommended',
'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended',
],
overrides: [
{
files: ['**/*.spec.{j,t}s?(x)'],
env: {
'vitest-globals/env': true,
},
},
],
plugins: ['html', 'prettier'],
parserOptions: {
parser: '@babel/eslint-parser',
ecmaVersion: 2020,
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['html', 'prettier', 'babel'],
rules: {
'prettier/prettier': ['error'],
camelcase: 'off',
@@ -210,13 +216,6 @@ module.exports = {
'import/extensions': ['off'],
'no-console': 'error',
},
settings: {
'import/resolver': {
webpack: {
config: 'config/webpack/resolve.js',
},
},
},
env: {
browser: true,
node: true,
@@ -224,5 +223,11 @@ module.exports = {
globals: {
bus: true,
vi: true,
// beforeEach: true,
// afterEach: true,
// test: true,
// describe: true,
// it: true,
// expect: true,
},
};
+43
View File
@@ -0,0 +1,43 @@
name: Frontend Lint & Test
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile
- name: Run eslint
run: pnpm run eslint
- name: Run frontend tests with coverage
run: |
mkdir -p coverage
pnpm run test:coverage
+38 -36
View File
@@ -21,7 +21,7 @@ jobs:
image: postgres:15.3
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_PASSWORD: ''
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
@@ -41,47 +41,49 @@ jobs:
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: yarn
run: yarn install
- name: Install pnpm dependencies
run: pnpm i
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Seed database
run: bundle exec rake db:schema:load
- name: yarn check-files
run: yarn install --check-files
- name: Run frontend tests
run: pnpm run test:coverage
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
+7 -3
View File
@@ -49,13 +49,17 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
cache: pnpm
- name: yarn
run: yarn install
- name: pnpm
run: pnpm install
- name: Create database
run: bundle exec rake db:create
+15 -5
View File
@@ -19,23 +19,33 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
cache: 'pnpm'
- name: yarn
run: yarn install
- name: pnpm
run: pnpm install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
NODE_OPTIONS: --openssl-legacy-provider
RAILS_ENV: production
- name: Size Check
run: yarn run size
run: pnpm run size
+15 -1
View File
@@ -32,6 +32,16 @@ master.key
public/uploads
public/packs*
public/assets/administrate*
public/assets/action*.js
public/assets/activestorage*.js
public/assets/trix*
public/assets/belongs_to*.js
public/assets/manifest*.js
public/assets/manifest*.js
public/assets/*.js.gz
public/assets/secretField*
public/assets/.sprockets-manifest-*.json
# VIM files
*.swp
@@ -75,4 +85,8 @@ yalc.lock
yarn-debug.log*
.yarn-integrity
/storybook-static
# Vite Ruby
/public/vite*
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
+8 -8
View File
@@ -1,11 +1,11 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# #!/bin/sh
# . "$(dirname "$0")/_/husky.sh"
# lint js and vue files
npx --no-install lint-staged
# # lint js and vue files
# npx --no-install lint-staged
# lint only staged ruby files
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# # lint only staged ruby files
# git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# stage rubocop changes to files
git diff --name-only --cached | xargs git add
# # stage rubocop changes to files
# git diff --name-only --cached | xargs git add
-56
View File
@@ -1,56 +0,0 @@
const path = require('path');
const resolve = require('../config/webpack/resolve');
// Chatwoot's webpack.config.js
process.env.NODE_ENV = 'development';
const custom = require('../config/webpack/environment');
module.exports = {
stories: [
'../stories/**/*.stories.mdx',
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
{
name: '@storybook/addon-docs',
options: {
vueDocgenOptions: {
alias: {
'@': path.resolve(__dirname, '../'),
},
},
},
},
'@storybook/addon-links',
'@storybook/addon-essentials',
{
/**
* Fix Storybook issue with PostCSS@8
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
*/
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
webpackFinal: config => {
const newConfig = {
...config,
resolve: {
...config.resolve,
modules: custom.resolvedModules.map(i => i.value),
},
};
newConfig.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
include: path.resolve(__dirname, '../app/javascript'),
});
return newConfig;
},
};
-46
View File
@@ -1,46 +0,0 @@
import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Multiselect from 'vue-multiselect';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
addDecorator(() => ({
template: '<story/>',
i18n: i18nConfig,
store,
beforeCreate: function () {
this.$root._i18n = this.$i18n;
},
}));
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
+1 -3
View File
@@ -64,7 +64,7 @@ gem 'activerecord-import'
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma'
gem 'webpacker'
gem 'vite_rails'
# metrics on heroku
gem 'barnes'
@@ -204,8 +204,6 @@ group :development do
end
group :test do
# Cypress in rails.
gem 'cypress-on-rails'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
+16 -17
View File
@@ -178,8 +178,6 @@ GEM
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
cypress-on-rails (1.16.0)
rack
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0)
@@ -222,6 +220,7 @@ GEM
railties (>= 6.1)
down (5.4.0)
addressable (~> 2.8)
dry-cli (1.1.0)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
elastic-apm (4.6.2)
@@ -496,14 +495,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.6)
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.6-arm64-darwin)
nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-darwin)
nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-linux)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -557,7 +556,7 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.0)
racc (1.8.1)
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
@@ -570,7 +569,7 @@ GEM
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack-proxy (0.7.7)
rack
rack-test (2.1.0)
rack (>= 1.3)
@@ -709,7 +708,6 @@ GEM
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.19.0)
railties (>= 5.0)
sentry-ruby (~> 5.19.0)
@@ -800,6 +798,13 @@ GEM
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2)
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
@@ -814,11 +819,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.4)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.2)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
@@ -827,7 +827,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.16)
zeitwerk (2.6.17)
PLATFORMS
arm64-darwin-20
@@ -862,7 +862,6 @@ DEPENDENCIES
climate_control
commonmarker
csv-safe
cypress-on-rails
database_cleaner
ddtrace
debug (~> 1.8)
@@ -960,10 +959,10 @@ DEPENDENCIES
tzinfo-data
uglifier
valid_email2
vite_rails
web-console (>= 4.2.1)
web-push (>= 3.0.1)
webmock
webpacker
wisper (= 2.0.0)
working_hours
+1 -1
View File
@@ -1,4 +1,4 @@
backend: bin/rails s -p 3000
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite dev
+1 -1
View File
@@ -1,3 +1,3 @@
backend: RAILS_ENV=test bin/rails s -p 5050
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
vite: bin/vite dev
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
+5 -2
View File
@@ -1,9 +1,12 @@
## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
___
# Chatwoot
# Chatwoot
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
@@ -98,7 +101,7 @@ Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
### Other deployment options
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security
+1 -1
View File
@@ -1 +1 @@
3.9.0
3.13.0
+1 -1
View File
@@ -1 +1 @@
2.8.0
3.0.0
-1
View File
@@ -2,5 +2,4 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link dashboardChart.js
//= link secretField.js
-55
View File
@@ -1,55 +0,0 @@
// eslint-disable-next-line
function prepareData(data) {
var labels = [];
var dataSet = [];
data.forEach(item => {
labels.push(item[0]);
dataSet.push(item[1]);
});
return { labels, dataSet };
}
function getChartOptions() {
var fontFamily =
'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
return {
responsive: true,
legend: { labels: { fontFamily } },
scales: {
xAxes: [
{
barPercentage: 1.26,
ticks: { fontFamily },
gridLines: { display: false },
},
],
yAxes: [
{
ticks: { fontFamily },
gridLines: { display: false },
},
],
},
};
}
// eslint-disable-next-line
function drawSuperAdminDashboard(data) {
var ctx = document.getElementById('dashboard-chart').getContext('2d');
var chartData = prepareData(data);
// eslint-disable-next-line
new Chart(ctx, {
type: 'bar',
data: {
labels: chartData.labels,
datasets: [
{
label: 'Conversations',
data: chartData.dataSet,
backgroundColor: '#1f93ff',
},
],
},
options: getChartOptions(),
});
}
@@ -86,8 +86,5 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions
$transition-ease-in: all 0.250s ease-in;
+1 -1
View File
@@ -72,7 +72,7 @@ class DashboardController < ActionController::Base
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app'
else
'application'
'dashboard'
end
end
@@ -1,6 +1,15 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
def find_channel_by_email
# find by imap_login first, and then by email
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
return imap_channel if imap_channel
Channel::Email.find_by(email: users_data['email'], account: account)
end
private
def provider_name
+5 -1
View File
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
channel_email = find_channel_by_email
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
@@ -39,6 +39,10 @@ class OauthCallbackController < ApplicationController
[channel_email.inbox, channel_exists]
end
def find_channel_by_email
Channel::Email.find_by(email: users_data['email'], account: account)
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
+43 -14
View File
@@ -1,6 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import router from '../dashboard/routes';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
@@ -9,9 +8,12 @@ import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -31,6 +33,13 @@ export default {
UpgradeBanner,
PendingEmailVerificationBanner,
},
setup() {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
return { router, store, currentAccountId: accountId };
},
data() {
return {
showAddAccountModal: false,
@@ -38,7 +47,6 @@ export default {
reconnectService: null,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
@@ -46,7 +54,6 @@ export default {
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
@@ -63,10 +70,13 @@ export default {
this.showAddAccountModal = true;
}
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
currentAccountId: {
immediate: true,
handler() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
},
},
mounted() {
@@ -74,7 +84,7 @@ export default {
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
},
beforeDestroy() {
unmounted() {
if (this.reconnectService) {
this.reconnectService.disconnect();
}
@@ -100,8 +110,9 @@ export default {
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService;
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
@@ -129,9 +140,11 @@ export default {
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner />
</template>
<transition name="fade" mode="out-in">
<router-view />
</transition>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox />
<NetworkNotification />
@@ -141,6 +154,22 @@ export default {
<style lang="scss">
@import './assets/scss/app';
.v-popper--theme-tooltip .v-popper__inner {
background: black !important;
font-size: 0.75rem;
padding: 4px 8px !important;
border-radius: 6px;
font-weight: 400;
}
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
.multiselect__input {
margin-bottom: 0px !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 76 KiB

@@ -0,0 +1,3 @@
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M800 0H600V200H400V400H200V600H0V800H200H400H600H800V600V400V200V0Z" fill="#2773E4" fill-opacity="0.42"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

@@ -0,0 +1,3 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M200 0H0V200V400V600H200V400H400V200H600V0H400H200Z" fill="#2773E4" fill-opacity="0.42"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

@@ -31,23 +31,6 @@
transform: translateX($space-medium);
}
.menu-list-enter-active,
.menu-list-leave-active {
transition: opacity 0.3s var(--ease-out-cubic),
transform 0.2s var(--ease-out-cubic);
}
.menu-list-leave-to {
opacity: 0;
position: absolute;
transform: translateX($space-small);
}
.menu-list-enter {
opacity: 0;
transform: translateX(-$space-small);
}
.slide-up-enter-active {
transition: all 0.3s var(--ease-in-cubic);
}
@@ -65,7 +48,8 @@
.menu-slide-enter-active,
.menu-slide-leave-active {
transform: translateY(0);
transition: transform 0.25s var(--ease-in-cubic),
transition:
transform 0.25s var(--ease-in-cubic),
opacity 0.15s var(--ease-in-cubic);
}
@@ -1,4 +1,4 @@
@import '~vue2-datepicker/scss/index';
@import 'vue-datepicker-next/scss/index';
.date-picker {
// To be removed one SLA reports date picker is created
@@ -1,4 +1,4 @@
@import '~dashboard/assets/scss/variables';
@import 'dashboard/assets/scss/variables';
.formulate-input {
.formulate-input-errors {
@@ -1,10 +1,11 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'shared/assets/fonts/inter';
// Inter,
html,
body {
font-family:
'PlusJakarta',
'Inter',
-apple-system,
system-ui,
BlinkMacSystemFont,
@@ -23,7 +24,7 @@ body {
}
.app-wrapper {
@apply h-full flex-grow-0 min-h-0 w-full;
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
@@ -1,5 +1,5 @@
@import '~dashboard/assets/scss/variables';
@import '~widget/assets/scss/mixins';
@import 'dashboard/assets/scss/variables';
@import 'widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
@@ -89,9 +89,6 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions
$transition-ease-in: all 0.250s ease-in;
@@ -2,7 +2,6 @@
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
@@ -34,10 +33,13 @@
@import 'plugins/multiselect';
@import 'plugins/dropdown';
@import '~shared/assets/stylesheets/ionicons';
.tooltip {
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900;
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96;
}
#app {
@apply h-full w-full;
}
.hide {
@@ -45,7 +47,6 @@
}
@layer base {
// scss-lint:disable PropertySortOrder
:root {
--color-amber-25: 254 253 251;
@@ -222,6 +222,7 @@
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0px !important;
}
.multiselect__single {
@@ -1,39 +0,0 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import 'vue-multiselect/dist/vue-multiselect.min.css';
@import '~shared/assets/stylesheets/ionicons';
@import 'mixins';
@import 'helper-classes';
@import 'typography';
@import 'layout';
@import 'animations';
@import 'widgets/buttons';
@import 'widgets/base';
@import 'plugins/multiselect';
@import 'widget/assets/scss/reset';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'widget/assets/scss/utilities';
html,
body {
font-family: 'PlusJakarta', sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
}
@@ -1,30 +0,0 @@
import { action } from '@storybook/addon-actions';
import AccordionItemComponent from './AccordionItem';
export default {
title: 'Components/Generic/Accordion',
component: AccordionItemComponent,
argTypes: {
title: {
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AccordionItem: AccordionItemComponent },
template: `
<accordion-item v-bind="$props" @click="onClick">
This is a sample content you can pass as a slot
</accordion-item>
`,
});
export const AccordionItem = Template.bind({});
AccordionItem.args = {
onClick: action('Added'),
title: 'Title of the accordion item',
};
@@ -1,32 +1,34 @@
<script>
<script setup>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { defineEmits } from 'vue';
export default {
components: {
EmojiOrIcon,
defineProps({
title: {
type: String,
required: true,
},
props: {
title: {
type: String,
required: true,
},
compact: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
emoji: {
type: String,
default: '',
},
isOpen: {
type: Boolean,
default: true,
},
compact: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
emoji: {
type: String,
default: '',
},
isOpen: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['toggle']);
const onToggle = () => {
emit('toggle');
};
</script>
@@ -34,7 +36,7 @@ export default {
<div class="-mt-px text-sm">
<button
class="flex items-center select-none w-full rounded-none bg-slate-50 dark:bg-slate-800 border border-l-0 border-r-0 border-solid m-0 border-slate-100 dark:border-slate-700/50 cursor-grab justify-between py-2 px-4 drag-handle"
@click="$emit('click')"
@click.stop="onToggle"
>
<div class="flex justify-between mb-0.5">
<EmojiOrIcon class="inline-block w-5" :icon="icon" :emoji="emoji" />
@@ -16,7 +16,6 @@ export default {
<template>
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3
File diff suppressed because it is too large Load Diff
@@ -61,7 +61,7 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<template v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
@@ -78,8 +78,8 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
icon="dismiss-circle"
@click="emit('resetFilters')"
/>
</div>
<div v-if="hasActiveFolders">
</template>
<template v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
@@ -96,7 +96,7 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
icon="delete"
@click="emit('deleteFolders')"
/>
</div>
</template>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
@@ -108,7 +108,7 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
/>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
@change-filter="onBasicFilterChange"
/>
</div>
</div>
+23 -25
View File
@@ -1,5 +1,7 @@
<script>
import 'highlight.js/styles/default.css';
import 'highlight.js/lib/common';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
@@ -28,14 +30,28 @@ export default {
return JSON.stringify({
title: this.codepenTitle,
private: true,
[lang]: this.script,
[lang]: this.scrubbedScript,
});
},
scrubbedScript() {
// remove trailing and leading extra lines and not spaces
const scrubbed = this.script.replace(/^\s*[\r\n]/gm, '');
const lines = scrubbed.split('\n');
// remove extra indentations
const minIndent = lines.reduce((min, line) => {
if (line.trim().length === 0) return min;
const indent = line.match(/^\s*/)[0].length;
return Math.min(min, indent);
}, Infinity);
return lines.map(line => line.slice(minIndent)).join('\n');
},
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.script);
await copyTextToClipboard(this.scrubbedScript);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
@@ -43,42 +59,24 @@ export default {
</script>
<template>
<div class="code--container">
<div class="code--action-area">
<div class="relative text-left">
<div class="top-1.5 absolute right-1.5 flex items-center gap-1">
<form
v-if="enableCodePen"
class="code--codeopen-form"
class="flex items-center"
action="https://codepen.io/pen/define"
method="POST"
target="_blank"
>
<input type="hidden" name="data" :value="codepenScriptValue" />
<button type="submit" class="button secondary tiny">
{{ $t('COMPONENTS.CODE.CODEPEN') }}
</button>
</form>
<button class="button secondary tiny" @click="onCopy">
<button type="button" class="button secondary tiny" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</button>
</div>
<highlightjs v-if="script" :language="lang" :code="script" />
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" />
</div>
</template>
<style lang="scss" scoped>
.code--container {
position: relative;
text-align: left;
.code--action-area {
top: var(--space-small);
position: absolute;
right: var(--space-small);
}
.code--codeopen-form {
display: inline-block;
}
}
</style>
@@ -14,6 +14,7 @@ export default {
'toggleContextMenu',
'markAsUnread',
'assignPriority',
'isConversationSelected',
],
props: {
source: {
@@ -36,10 +37,6 @@ export default {
type: [String, Number],
default: 0,
},
isConversationSelected: {
type: Function,
default: () => {},
},
showAssignee: {
type: Boolean,
default: false,
@@ -59,14 +56,14 @@ export default {
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
enable-context-menu
@selectConversation="selectConversation"
@deSelectConversation="deSelectConversation"
@assignAgent="assignAgent"
@assignTeam="assignTeam"
@assignLabel="assignLabels"
@updateConversationStatus="updateConversationStatus"
@contextMenuToggle="toggleContextMenu"
@markAsUnread="markAsUnread"
@assignPriority="assignPriority"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</template>
@@ -7,6 +7,7 @@ import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core';
import { emitter } from 'shared/helpers/mitt';
const DATE_FORMAT = 'yyyy-MM-dd';
@@ -30,6 +31,7 @@ export default {
attributeKey: { type: String, required: true },
contactId: { type: Number, default: null },
},
emits: ['update', 'delete', 'copy'],
setup() {
return { v$: useVuelidate() };
},
@@ -135,10 +137,10 @@ export default {
},
mounted() {
this.editedValue = this.formattedValue;
this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
destroyed() {
this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
unmounted() {
emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
@@ -321,7 +323,7 @@ export default {
'CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.SEARCH_INPUT_PLACEHOLDER'
)
"
@click="onUpdateListValue"
@select="onUpdateListValue"
/>
</div>
</div>
@@ -18,6 +18,7 @@ const shouldShowContent = computed(
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="shouldShowContent">
<slot />
@@ -1,10 +1,11 @@
<script>
import DatePicker from 'vue2-datepicker';
import DatePicker from 'vue-datepicker-next';
export default {
components: {
DatePicker,
},
emits: ['close', 'chooseTime'],
data() {
return {
@@ -43,15 +44,18 @@ export default {
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
<form class="modal-content" @submit.prevent="chooseTime">
<form
class="modal-content w-full pt-2 px-5 pb-6"
@submit.prevent="chooseTime"
>
<DatePicker
v-model="snoozeTime"
v-model:value="snoozeTime"
type="datetime"
inline
input-class="mx-input reset-base"
:lang="lang"
:disabled-date="disabledDate"
:disabled-time="disabledTime"
:popup-style="{ width: '100%' }"
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button variant="clear" @click.prevent="onClose">
@@ -64,9 +68,3 @@ export default {
</form>
</div>
</template>
<style lang="scss" scoped>
.modal-content {
@apply pt-2 px-5 pb-6;
}
</style>
@@ -1,32 +1,26 @@
<script>
export default {
props: {
options: {
type: Object,
default: () => ({ root: document, rootMargin: '100px 0 100px 0)' }),
},
<script setup>
import { ref, defineEmits } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';
const { options } = defineProps({
options: {
type: Object,
default: () => ({ root: document, rootMargin: '100px 0 100px 0)' }),
},
mounted() {
this.intersectionObserver = null;
this.registerInfiniteLoader();
});
const emit = defineEmits(['observed']);
const observedElement = ref('');
useIntersectionObserver(
observedElement,
([{ isIntersecting }]) => {
if (isIntersecting) {
emit('observed');
}
},
beforeDestroy() {
this.unobserveInfiniteLoadObserver();
},
methods: {
registerInfiniteLoader() {
this.intersectionObserver = new IntersectionObserver(entries => {
if (entries && entries[0].isIntersecting) {
this.$emit('observed');
}
}, this.options);
this.intersectionObserver.observe(this.$refs.observedElement);
},
unobserveInfiniteLoadObserver() {
this.intersectionObserver.unobserve(this.$refs.observedElement);
},
},
};
options
);
</script>
<template>
+63 -72
View File
@@ -1,78 +1,69 @@
<script>
export default {
props: {
closeOnBackdropClick: {
type: Boolean,
default: true,
},
show: Boolean,
showCloseButton: {
type: Boolean,
default: true,
},
onClose: {
type: Function,
required: true,
},
fullWidth: {
type: Boolean,
default: false,
},
modalType: {
type: String,
default: 'centered',
},
size: {
type: String,
default: '',
},
},
data() {
return {
mousedDownOnBackdrop: false,
};
},
computed: {
modalClassName() {
const modalClassNameMap = {
centered: '',
'right-aligned': 'right-aligned',
};
<script setup>
// [TODO] Use Teleport to move the modal to the end of the body
import { ref, computed, defineEmits, onMounted } from 'vue';
import { useEventListener } from '@vueuse/core';
return `modal-mask skip-context-menu ${
modalClassNameMap[this.modalType] || ''
}`;
},
},
mounted() {
document.addEventListener('keydown', e => {
if (this.show && e.code === 'Escape') {
this.onClose();
}
});
const { modalType, closeOnBackdropClick, onClose } = defineProps({
closeOnBackdropClick: { type: Boolean, default: true },
showCloseButton: { type: Boolean, default: true },
onClose: { type: Function, required: true },
fullWidth: { type: Boolean, default: false },
modalType: { type: String, default: 'centered' },
size: { type: String, default: '' },
});
document.body.addEventListener('mouseup', this.onMouseUp);
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.onMouseUp);
},
methods: {
handleMouseDown() {
this.mousedDownOnBackdrop = true;
},
close() {
this.onClose();
},
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
if (this.closeOnBackdropClick) {
this.onClose();
}
}
},
},
const emit = defineEmits(['close']);
const show = defineModel('show', { type: Boolean, default: false });
const modalClassName = computed(() => {
const modalClassNameMap = {
centered: '',
'right-aligned': 'right-aligned',
};
return `modal-mask skip-context-menu ${modalClassNameMap[modalType] || ''}`;
});
// [TODO] Revisit this logic to use outside click directive
const mousedDownOnBackdrop = ref(false);
const handleMouseDown = () => {
mousedDownOnBackdrop.value = true;
};
const close = () => {
show.value = false;
emit('close');
onClose();
};
const onMouseUp = () => {
if (mousedDownOnBackdrop.value) {
mousedDownOnBackdrop.value = false;
if (closeOnBackdropClick) {
close();
}
}
};
const onKeydown = e => {
if (show.value && e.code === 'Escape') {
close();
e.stopPropagation();
}
};
useEventListener(document.body, 'mouseup', onMouseUp);
useEventListener(document, 'keydown', onKeydown);
onMounted(() => {
if (onClose && typeof onClose === 'function') {
// eslint-disable-next-line no-console
console.warn(
"[DEPRECATED] The 'onClose' prop is deprecated. Please use the 'close' event instead."
);
}
});
</script>
<template>
@@ -27,14 +27,14 @@ export default {
<div class="flex flex-col items-start px-8 pt-8 pb-0">
<img v-if="headerImage" :src="headerImage" alt="No image" />
<h2
ref="modalHeaderTitle"
data-test-id="modal-header-title"
class="text-base font-semibold leading-6 text-slate-800 dark:text-slate-50"
>
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
ref="modalHeaderContent"
data-test-id="modal-header-content"
class="w-full mt-2 text-sm leading-5 break-words text-slate-600 dark:text-slate-300"
>
{{ headerContent }}
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useEmitter } from 'dashboard/composables/emitter';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -1,5 +1,6 @@
<script>
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
export default {
props: {
@@ -10,7 +11,7 @@ export default {
},
methods: {
onMenuItemClick() {
this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
},
},
};
@@ -1,5 +1,6 @@
<script>
import WootSnackbar from './Snackbar.vue';
import { emitter } from 'shared/helpers/mitt';
export default {
components: {
@@ -19,10 +20,10 @@ export default {
},
mounted() {
this.$emitter.on('newToastMessage', this.onNewToastMessage);
emitter.on('newToastMessage', this.onNewToastMessage);
},
beforeDestroy() {
this.$emitter.off('newToastMessage', this.onNewToastMessage);
unmounted() {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message, action }) {
@@ -79,6 +79,7 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
@@ -86,6 +87,6 @@ export default {
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
@primary-action="routeToBilling"
/>
</template>
@@ -28,6 +28,7 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
@@ -36,6 +37,6 @@ export default {
:action-button-label="actionButtonMessage"
action-button-icon="mail"
has-action-button
@click="resendVerificationEmail"
@primary-action="resendVerificationEmail"
/>
</template>
@@ -67,6 +67,7 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
@@ -81,6 +81,7 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
@@ -88,6 +89,6 @@ export default {
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
@primary-action="routeToBilling"
/>
</template>
@@ -27,16 +27,17 @@ export default {
default: 'primary',
},
},
methods: {
onClick(e) {
this.$emit('click', e);
},
created() {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
},
};
</script>
<template>
<button :type="type" class="button nice" :class="variant" @click="onClick">
<button :type="type" class="button nice" :class="variant">
<fluent-icon
v-if="!isLoading && icon"
class="icon"
@@ -40,11 +40,6 @@ export default {
return `button nice gap-2 ${this.buttonClass || ' '}`;
},
},
methods: {
onClick() {
this.$emit('click');
},
},
};
</script>
@@ -54,7 +49,6 @@ export default {
data-testid="submit_button"
:disabled="disabled"
:class="computedClass"
@click="onClick"
>
<fluent-icon v-if="!!iconClass" :icon="iconClass" class="icon" />
<span>{{ buttonText }}</span>
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
+16 -21
View File
@@ -1,45 +1,40 @@
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
/* eslint no-plusplus: 0 */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Bar from './widgets/chart/BarChart';
import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import Button from './ui/WootButton.vue';
import Code from './Code.vue';
import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import FeatureToggle from './widgets/FeatureToggle.vue';
import Input from './widgets/forms/Input.vue';
import PhoneInput from './widgets/forms/PhoneInput.vue';
import Label from './ui/Label';
import LoadingState from './widgets/LoadingState';
import Modal from './Modal';
import ModalHeader from './ModalHeader';
import SidemenuIcon from './SidemenuIcon';
import Spinner from 'shared/components/Spinner';
import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Label from './ui/Label.vue';
import LoadingState from './widgets/LoadingState.vue';
import ModalHeader from './ModalHeader.vue';
import Modal from './Modal.vue';
import SidemenuIcon from './SidemenuIcon.vue';
import Spinner from 'shared/components/Spinner.vue';
import SubmitButton from './buttons/FormSubmitButton.vue';
import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
Bar,
Button,
Code,
ColorPicker,
ConfirmDeleteModal,
ConfirmModal,
ContextMenu,
DeleteModal,
DropdownItem,
DropdownMenu,
FeatureToggle,
HorizontalBar,
Input,
PhoneInput,
Label,
@@ -31,26 +31,29 @@ export default {
currentAccountId: 'getCurrentAccountId',
currentUserAutoOffline: 'getCurrentUserAutoOffline',
}),
statusList() {
return [
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.ONLINE'),
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.BUSY'),
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.OFFLINE'),
];
},
availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
key => key === this.currentUserAvailability
);
return this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST')[
availabilityIndex
];
return this.statusList[availabilityIndex];
},
currentUserAvailability() {
return this.getCurrentUserAvailability;
},
availabilityStatuses() {
return this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST').map(
(statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
disabled:
this.currentUserAvailability === AVAILABILITY_STATUS_KEYS[index],
})
);
return this.statusList.map((statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
disabled:
this.currentUserAvailability === AVAILABILITY_STATUS_KEYS[index],
}));
},
},
@@ -102,7 +105,7 @@ export default {
size="small"
:color-scheme="status.disabled ? '' : 'secondary'"
:variant="status.disabled ? 'smooth' : 'clear'"
class-names="status-change--dropdown-button"
class="status-change--dropdown-button"
@click="changeAvailabilityStatus(status.value)"
>
<AvailabilityStatusBadge :status="status.value" />
@@ -129,7 +132,7 @@ export default {
<woot-switch
size="small"
class="mx-1 mt-px mb-0"
:value="currentUserAutoOffline"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</WootDropdownItem>
@@ -2,7 +2,8 @@
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useRoute, useRouter } from 'dashboard/composables/route';
import { useAccount } from 'dashboard/composables/useAccount';
import { useRoute, useRouter } from 'vue-router';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
@@ -22,14 +23,18 @@ export default {
type: Boolean,
default: true,
},
sidebarClassName: {
type: String,
default: '',
},
},
emits: [
'toggleAccountModal',
'showAddLabelPopup',
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
],
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const { accountId } = useAccount();
const toggleKeyShortcutModal = () => {
emit('openKeyShortcutModal');
@@ -69,6 +74,7 @@ export default {
return {
toggleKeyShortcutModal,
accountId,
};
},
data() {
@@ -79,7 +85,6 @@ export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
@@ -159,6 +164,17 @@ export default {
) || {};
return activePrimaryMenu;
},
hasSecondaryMenu() {
return (
this.activeSecondaryMenu.menuItems &&
this.activeSecondaryMenu.menuItems.length
);
},
hasSecondarySidebar() {
// if it is explicitly stated to show and it has secondary menu items to show
// showSecondarySidebar corresponds to the UI settings, indicating if the user has toggled it
return this.showSecondarySidebar && this.hasSecondaryMenu;
},
},
watch: {
@@ -206,13 +222,12 @@ export default {
:account-id="accountId"
:menu-items="primaryMenuItems"
:active-menu-item="activePrimaryMenu.key"
@toggleAccounts="toggleAccountModal"
@openKeyShortcutModal="toggleKeyShortcutModal"
@openNotificationPanel="openNotificationPanel"
@toggle-accounts="toggleAccountModal"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/>
<SecondarySidebar
v-if="showSecondarySidebar"
:class="sidebarClassName"
v-if="hasSecondarySidebar"
:account-id="accountId"
:inboxes="inboxes"
:labels="labels"
@@ -221,8 +236,8 @@ export default {
:menu-config="activeSecondaryMenu"
:current-user="currentUser"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@addLabel="showAddLabelPopup"
@toggleAccounts="toggleAccountModal"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/>
</aside>
</template>
@@ -2,6 +2,7 @@
import { mapGetters } from 'vuex';
export default {
emits: ['toggleAccounts'],
data() {
return { showSwitchButton: false };
},
@@ -25,6 +26,7 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="showShowCurrentAccountContext"
@@ -7,7 +7,7 @@ export default {
default: true,
},
},
emits: ['closeAccountModal', 'showCreateAccountModal'],
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@@ -1,7 +1,7 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
export default {
@@ -15,6 +15,7 @@ export default {
default: true,
},
},
emits: ['closeAccountCreateModal'],
setup() {
return { v$: useVuelidate() };
},
@@ -23,11 +24,13 @@ export default {
accountName: '',
};
},
validations: {
accountName: {
required,
minLength: minLength(1),
},
validations() {
return {
accountName: {
required,
minLength: minLength(1),
},
};
},
computed: {
...mapGetters({
@@ -76,7 +79,7 @@ export default {
<label :class="{ error: v$.accountName.$error }">
{{ $t('CREATE_ACCOUNT.FORM.NAME.LABEL') }}
<input
v-model.trim="accountName"
v-model="accountName"
type="text"
:placeholder="$t('CREATE_ACCOUNT.FORM.NAME.PLACEHOLDER')"
@input="v$.accountName.$touch"
@@ -6,6 +6,7 @@ export default {
components: {
Thumbnail,
},
emits: ['toggleMenu'],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -2,6 +2,8 @@
import { mapGetters } from 'vuex';
export default {
emits: ['openNotificationPanel'],
computed: {
...mapGetters({
notificationMetadata: 'notifications/getMeta',
@@ -17,6 +17,12 @@ export default {
default: false,
},
},
emits: [
'close',
'openKeyShortcutModal',
'toggleAccounts',
'showSupportChatWindow',
],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -7,6 +7,7 @@ import NotificationBell from './NotificationBell.vue';
import wootConstants from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
export default {
components: {
@@ -42,6 +43,7 @@ export default {
default: '',
},
},
emits: ['toggleAccounts', 'openNotificationPanel', 'openKeyShortcutModal'],
data() {
return {
helpDocsURL: wootConstants.DOCS_URL,
@@ -60,7 +62,7 @@ export default {
window.$chatwoot.toggle();
},
openNotificationPanel() {
this.$track(ACCOUNT_EVENTS.OPENED_NOTIFICATIONS);
useTrack(ACCOUNT_EVENTS.OPENED_NOTIFICATIONS);
this.$emit('openNotificationPanel');
},
},
@@ -95,13 +97,13 @@ export default {
open-in-new-page
:to="helpDocsURL"
/>
<NotificationBell @openNotificationPanel="openNotificationPanel" />
<AgentDetails @toggleMenu="toggleOptions" />
<NotificationBell @open-notification-panel="openNotificationPanel" />
<AgentDetails @toggle-menu="toggleOptions" />
<OptionsMenu
:show="showOptionsMenu"
@toggleAccounts="toggleAccountModal"
@showSupportChatWindow="toggleSupportChatWindow"
@openKeyShortcutModal="$emit('openKeyShortcutModal')"
@toggle-accounts="toggleAccountModal"
@show-support-chat-window="toggleSupportChatWindow"
@open-key-shortcut-modal="$emit('openKeyShortcutModal')"
@close="toggleOptions"
/>
</div>
@@ -49,13 +49,11 @@ export default {
default: false,
},
},
emits: ['addLabel', 'toggleAccounts'],
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasSecondaryMenu() {
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
},
contactCustomViews() {
return this.customViews.filter(view => view.filter_type === 'contact');
},
@@ -243,10 +241,9 @@ export default {
<template>
<div
v-if="hasSecondaryMenu"
class="flex flex-col w-48 h-full px-2 pb-8 overflow-auto text-sm bg-white border-r dark:bg-slate-900 dark:border-slate-800/50 rtl:border-r-0 rtl:border-l border-slate-50"
>
<AccountContext @toggleAccounts="toggleAccountModal" />
<AccountContext @toggle-accounts="toggleAccountModal" />
<transition-group
name="menu-list"
tag="ul"
@@ -261,7 +258,7 @@ export default {
v-for="menuItem in additionalSecondaryMenuItems[menuConfig.parentNav]"
:key="menuItem.key"
:menu-item="menuItem"
@addLabel="showAddLabelPopup"
@add-label="showAddLabelPopup"
/>
</transition-group>
</div>
@@ -58,7 +58,7 @@ export default {
active-class="active"
>
<li
class="font-medium h-7 my-1 hover:bg-slate-25 hover:text-bg-50 flex items-center px-2 rounded-md dark:hover:bg-slate-800"
class="h-7 my-1 hover:bg-slate-25 hover:text-bg-50 flex items-center px-2 rounded-md dark:hover:bg-slate-800"
:class="{
'bg-woot-25 dark:bg-slate-800': isActive,
'text-ellipsis overflow-hidden whitespace-nowrap max-w-full':
@@ -105,7 +105,7 @@ export default {
</span>
<span
v-if="showChildCount"
class="bg-slate-50 dark:bg-slate-700 rounded-full min-w-[18px] justify-center items-center flex text-xxs font-medium mx-1 py-0 px-1"
class="bg-slate-50 dark:bg-slate-700 rounded-full min-w-[18px] justify-center items-center flex text-xxs mx-1 py-0 px-1"
:class="
isCountZero
? `text-slate-300 dark:text-slate-500`
@@ -22,6 +22,7 @@ export default {
default: () => ({}),
},
},
emits: ['addLabel', 'open'],
setup() {
const { isAdmin } = useAdmin();
const { isEnterprise } = useConfig();
@@ -102,7 +103,7 @@ export default {
},
isInboxSettings() {
return (
this.$store.state.route.name === 'settings_inbox_show' &&
this.$route.name === 'settings_inbox_show' &&
this.menuItem.toStateName === 'settings_inbox_list'
);
},
@@ -208,7 +209,7 @@ export default {
</div>
<router-link
v-else
class="flex items-center p-2 m-0 text-sm font-medium leading-4 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-800"
class="flex items-center p-2 m-0 text-sm leading-4 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-800"
:class="computedClass"
:to="menuItem && menuItem.toState"
>
@@ -220,7 +221,7 @@ export default {
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="showChildCount(menuItem.count)"
class="px-1 py-0 mx-1 font-medium rounded-md text-xxs"
class="px-1 py-0 mx-1 rounded-md text-xxs"
:class="{
'text-slate-300 dark:text-slate-600': isCountZero && !isActiveView,
'text-slate-600 dark:text-slate-50': !isCountZero && !isActiveView,
@@ -235,7 +236,7 @@ export default {
v-if="menuItem.beta"
data-view-component="true"
label="Beta"
class="inline-block px-1 mx-1 font-medium leading-4 text-green-500 border border-green-400 rounded-lg text-xxs"
class="inline-block px-1 mx-1 leading-4 text-green-500 border border-green-400 rounded-lg text-xxs"
>
{{ $t('SIDEBAR.BETA') }}
</span>
@@ -1,79 +1,62 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AccountSelector from '../AccountSelector.vue';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import i18n from 'dashboard/i18n';
import WootModal from 'dashboard/components/Modal.vue';
import WootModalHeader from 'dashboard/components/ModalHeader.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const localVue = createLocalVue();
localVue.component('woot-modal', WootModal);
localVue.component('woot-modal-header', WootModalHeader);
localVue.component('fluent-icon', FluentIcon);
localVue.use(Vuex);
localVue.use(VueI18n);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
const store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => 1,
getCurrentUser: () => ({
accounts: [
{ id: 1, name: 'Chatwoot', role: 'administrator' },
{ id: 2, name: 'GitX', role: 'agent' },
],
}),
},
},
globalConfig: {
namespaced: true,
getters: {
get: () => ({ createNewAccountFromDashboard: false }),
},
},
},
});
describe('accountSelctor', () => {
describe('AccountSelector', () => {
let accountSelector = null;
const currentUser = {
accounts: [
{
id: 1,
name: 'Chatwoot',
role: 'administrator',
},
{
id: 2,
name: 'GitX',
role: 'agent',
},
],
};
let actions = null;
let modules = null;
beforeEach(() => {
actions = {};
modules = {
auth: {
getters: {
getCurrentAccountId: () => 1,
getCurrentUser: () => currentUser,
},
},
globalConfig: {
getters: {
'globalConfig/get': () => ({ createNewAccountFromDashboard: false }),
},
},
};
let store = new Vuex.Store({ actions, modules });
accountSelector = mount(AccountSelector, {
store,
localVue,
i18n: i18nConfig,
propsData: { showAccountModal: true },
stubs: { WootButton: { template: '<button />' } },
global: {
plugins: [store],
components: {
'woot-modal': WootModal,
'woot-modal-header': WootModalHeader,
'fluent-icon': FluentIcon,
},
stubs: {
WootButton: { template: '<button />' },
// override global stub
WootModalHeader: false,
},
},
props: { showAccountModal: true },
});
});
it('title and sub title exist', () => {
const headerComponent = accountSelector.findComponent(WootModalHeader);
const title = headerComponent.findComponent({ ref: 'modalHeaderTitle' });
const title = headerComponent.find('[data-test-id="modal-header-title"]');
expect(title.text()).toBe('Switch Account');
const content = headerComponent.findComponent({
ref: 'modalHeaderContent',
});
const content = headerComponent.find(
'[data-test-id="modal-header-content"]'
);
expect(content.text()).toBe('Select an account from the following list');
});
@@ -1,28 +1,10 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AgentDetails from '../AgentDetails.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip';
import i18n from 'dashboard/i18n';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('thumbnail', Thumbnail);
localVue.component('woot-button', WootButton);
localVue.component('woot-button', WootButton);
localVue.use(VTooltip, {
defaultHtml: false,
});
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
describe('agentDetails', () => {
describe('AgentDetails', () => {
const currentUser = {
name: 'Neymar Junior',
avatar_url: '',
@@ -30,37 +12,46 @@ describe('agentDetails', () => {
};
const currentRole = 'agent';
let store = null;
let actions = null;
let modules = null;
let agentDetails = null;
beforeEach(() => {
actions = {};
const mockTooltipDirective = {
mounted: (el, binding) => {
// You can mock the behavior here if necessary
el.setAttribute('data-tooltip', binding.value || '');
},
};
modules = {
auth: {
getters: {
getCurrentUser: () => currentUser,
getCurrentRole: () => currentRole,
getCurrentUserAvailability: () => currentUser.availability_status,
beforeEach(() => {
store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentUser: () => currentUser,
getCurrentRole: () => currentRole,
getCurrentUserAvailability: () => currentUser.availability_status,
},
},
},
};
store = new Vuex.Store({
actions,
modules,
});
agentDetails = shallowMount(AgentDetails, {
store,
localVue,
i18n: i18nConfig,
global: {
plugins: [store],
components: {
Thumbnail,
WootButton,
},
directives: {
tooltip: mockTooltipDirective, // Mocking the tooltip directive
},
stubs: { WootButton: { template: '<button><slot /></button>' } },
},
});
});
it(' the agent status', () => {
expect(agentDetails.find('thumbnail-stub').vm.status).toBe('online');
it('shows the correct agent status', () => {
expect(agentDetails.findComponent(Thumbnail).vm.status).toBe('online');
});
it('agent thumbnail exists', () => {
@@ -1,20 +1,7 @@
import NotificationBell from '../NotificationBell.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import i18n from 'dashboard/i18n';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('fluent-icon', FluentIcon);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
import NotificationBell from '../NotificationBell.vue';
const $route = {
name: 'notifications_index',
@@ -33,43 +20,51 @@ describe('notificationBell', () => {
};
modules = {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => accountId,
},
},
notifications: {
namespaced: false,
getters: {
'notifications/getMeta': () => notificationMetadata,
},
},
};
store = new Vuex.Store({
store = createStore({
actions,
modules,
});
});
it('it should return unread count 19 ', () => {
it('it should return unread count 19', () => {
const wrapper = shallowMount(NotificationBell, {
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
},
});
expect(wrapper.vm.unreadCount).toBe('19');
});
it('it should return unread count 99+ ', async () => {
it('it should return unread count 99+', async () => {
notificationMetadata.unreadCount = 100;
const wrapper = shallowMount(NotificationBell, {
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
},
});
expect(wrapper.vm.unreadCount).toBe('99+');
@@ -77,11 +72,14 @@ describe('notificationBell', () => {
it('isNotificationPanelActive', async () => {
const notificationBell = shallowMount(NotificationBell, {
store,
localVue,
i18n: i18nConfig,
mocks: {
$route,
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
},
});
@@ -1,9 +1,6 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AvailabilityStatus from '../AvailabilityStatus.vue';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip';
import WootButton from 'dashboard/components/ui/WootButton.vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -11,70 +8,64 @@ import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import i18n from 'dashboard/i18n';
const localVue = createLocalVue();
localVue.use(VTooltip, {
defaultHtml: false,
});
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('woot-button', WootButton);
localVue.component('woot-dropdown-header', WootDropdownHeader);
localVue.component('woot-dropdown-menu', WootDropdownMenu);
localVue.component('woot-dropdown-divider', WootDropdownDivider);
localVue.component('woot-dropdown-item', WootDropdownItem);
localVue.component('fluent-icon', FluentIcon);
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
describe('AvailabilityStatus', () => {
const currentAvailability = 'online';
const currentAccountId = '1';
const currentUserAutoOffline = false;
let store = null;
let actions = null;
let modules = null;
let availabilityStatus = null;
beforeEach(() => {
actions = {
updateAvailability: vi.fn(() => {
return Promise.resolve();
}),
updateAvailability: vi.fn(() => Promise.resolve()),
};
modules = {
auth: {
getters: {
getCurrentUserAvailability: () => currentAvailability,
getCurrentAccountId: () => currentAccountId,
getCurrentUserAutoOffline: () => currentUserAutoOffline,
store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentUserAvailability: () => currentAvailability,
getCurrentAccountId: () => currentAccountId,
getCurrentUserAutoOffline: () => currentUserAutoOffline,
},
},
},
};
store = new Vuex.Store({ actions, modules });
availabilityStatus = mount(AvailabilityStatus, {
store,
localVue,
i18n: i18nConfig,
stubs: { WootSwitch: { template: '<button />' } },
actions,
});
});
it('dispatches an action when user changes status', async () => {
await availabilityStatus;
availabilityStatus
.findAll('.status-change--dropdown-button')
.at(2)
.trigger('click');
const wrapper = mount(AvailabilityStatus, {
global: {
plugins: [store],
components: {
WootButton,
WootDropdownItem,
WootDropdownMenu,
WootDropdownHeader,
WootDropdownDivider,
FluentIcon,
},
stubs: {
WootSwitch: { template: '<button />' },
},
},
});
expect(actions.updateAvailability).toBeCalledWith(
expect.any(Object),
{ availability: 'offline', account_id: currentAccountId },
undefined
);
// Ensure that the dropdown menu is opened
await wrapper.vm.openStatusMenu();
// Simulate the user clicking the 3rd button (offline status)
const buttons = wrapper.findAll('.status-change--dropdown-button');
expect(buttons.length).toBeGreaterThan(0); // Ensure buttons exist
await buttons[2].trigger('click');
expect(actions.updateAvailability).toHaveBeenCalledTimes(1);
expect(actions.updateAvailability.mock.calls[0][1]).toEqual({
availability: 'offline',
account_id: currentAccountId,
});
});
});
@@ -7,5 +7,8 @@ exports[`SidemenuIcon > matches snapshot 1`] = `
icon="list"
size="small"
variant="clear"
/>
>
</button>
`;
@@ -0,0 +1,24 @@
<script setup>
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
content: {
type: String,
default: '',
},
});
const isRTL = useMapGetter('accounts/isRTL');
</script>
<template>
<div
class="overflow-hidden whitespace-nowrap text-ellipsis"
:class="{ 'text-right': isRTL }"
>
<slot v-if="$slots.default || content">
<template v-if="content">{{ content }}</template>
</slot>
<span v-else class="text-slate-300 dark:text-slate-700"> --- </span>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
table: {
type: Object,
required: true,
},
});
const getFormattedPages = (start, end) => {
const formatter = new Intl.NumberFormat(navigator.language);
return Array.from({ length: end - start + 1 }, (_, i) =>
formatter.format(start + i)
);
};
const currentPage = computed(() => {
return props.table.getState().pagination.pageIndex + 1;
});
const totalPages = computed(() => {
return props.table.getPageCount();
});
const visiblePages = computed(() => {
if (totalPages.value <= 3) return getFormattedPages(1, totalPages.value);
if (currentPage.value === 1) return getFormattedPages(1, 3);
if (currentPage.value === totalPages.value) {
return getFormattedPages(totalPages.value - 2, totalPages.value);
}
return getFormattedPages(currentPage.value - 1, currentPage.value + 1);
});
const total = computed(() => {
return props.table.getRowCount();
});
const start = computed(() => {
const { pagination } = props.table.getState();
return pagination.pageIndex * pagination.pageSize + 1;
});
const end = computed(() => {
const { pagination } = props.table.getState();
return Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
total.value
);
});
</script>
<template>
<div class="flex items-center justify-between">
<div class="flex flex-1 items-center justify-between">
<div>
<p class="text-sm text-gray-700">
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
</p>
</div>
<nav class="isolate inline-flex gap-1">
<woot-button
:disabled="!table.getCanPreviousPage()"
variant="clear"
class="size-8 flex items-center border border-slate-50"
color-scheme="secondary"
@click="table.setPageIndex(0)"
>
<span class="i-lucide-chevrons-left size-3" aria-hidden="true" />
</woot-button>
<woot-button
variant="clear"
class="size-8 flex items-center border border-slate-50"
color-scheme="secondary"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
>
<span class="i-lucide-chevron-left size-3" aria-hidden="true" />
</woot-button>
<woot-button
v-for="page in visiblePages"
:key="page"
variant="clear"
class="size-8 flex items-center justify-center border text-xs leading-none text-center"
:class="page == currentPage ? 'border-woot-500' : 'border-slate-50'"
color-scheme="secondary"
@click="table.setPageIndex(page - 1)"
>
<div
class="text-center"
:class="{ 'text-woot-500': page == currentPage }"
>
{{ page }}
</div>
</woot-button>
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="size-8 flex items-center border border-slate-50"
color-scheme="secondary"
@click="table.nextPage()"
>
<span class="i-lucide-chevron-right size-3" aria-hidden="true" />
</woot-button>
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="size-8 flex items-center border border-slate-50"
color-scheme="secondary"
@click="table.setPageIndex(table.getPageCount() - 1)"
>
<span class="i-lucide-chevrons-right size-3" aria-hidden="true" />
</woot-button>
</nav>
</div>
</div>
</template>
@@ -0,0 +1,18 @@
<script setup>
defineProps({
header: {
type: Object,
required: true,
},
});
const sortIconMap = {
default: 'i-lucide-chevrons-up-down',
asc: 'i-lucide-chevron-up',
desc: 'i-lucide-chevron-down',
};
</script>
<template>
<span :class="sortIconMap[header.column.getIsSorted() || 'default']" />
</template>
@@ -0,0 +1,65 @@
<script setup>
import { FlexRender } from '@tanstack/vue-table';
import SortButton from './SortButton.vue';
defineProps({
table: {
type: Object,
required: true,
},
fixed: {
type: Boolean,
default: false,
},
});
</script>
<template>
<table :class="{ 'table-fixed': fixed }">
<thead
class="sticky top-0 z-10 border-b border-slate-50 dark:border-slate-800 bg-slate-25 dark:bg-slate-800"
>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<th
v-for="header in headerGroup.headers"
:key="header.id"
:style="{
width: `${header.getSize()}px`,
}"
class="text-left py-3 px-5 dark:bg-slate-800 text-slate-800 dark:text-slate-200 font-normal text-xs"
@click="header.column.getCanSort() && header.column.toggleSorting()"
>
<div
v-if="!header.isPlaceholder"
class="flex place-items-center gap-1"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<SortButton v-if="header.column.getCanSort()" :header="header" />
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-25 dark:divide-slate-900">
<tr
v-for="row in table.getRowModel().rows"
:key="row.id"
class="hover:bg-slate-25 dark:hover:bg-slate-800"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="py-2 px-5"
>
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</template>
@@ -18,6 +18,7 @@ export default {
default: '',
},
},
emits: ['open', 'close'],
methods: {
onClickOpenPath() {
this.$emit('open');
@@ -38,6 +38,7 @@ export default {
default: false,
},
},
emits: ['primaryAction', 'close'],
computed: {
bannerClasses() {
const classList = [this.colorScheme];
@@ -50,7 +51,7 @@ export default {
},
methods: {
onClick(e) {
this.$emit('click', e);
this.$emit('primaryAction', e);
},
onClickClose(e) {
this.$emit('close', e);
@@ -61,7 +62,7 @@ export default {
<template>
<div
class="flex items-center justify-center h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white"
class="flex items-center justify-center h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white woot-banner"
:class="bannerClasses"
>
<span class="banner-message">
@@ -1,43 +1,38 @@
<script>
export default {
props: {
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
},
data() {
return {
left: this.x,
top: this.y,
show: false,
};
},
computed: {
style() {
return {
top: this.top + 'px',
left: this.left + 'px',
};
},
},
mounted() {
this.$nextTick(() => this.$el.focus());
},
};
<script setup>
import { ref, computed, onMounted, nextTick, defineEmits } from 'vue';
const { x, y } = defineProps({
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
});
const emit = defineEmits(['close']);
const left = ref(x);
const top = ref(y);
const style = computed(() => ({
top: top.value + 'px',
left: left.value + 'px',
}));
const target = ref();
onMounted(() => {
nextTick(() => {
target.value.focus();
});
});
</script>
<template>
<div
class="fixed outline-none z-[9999] cursor-pointer"
:style="style"
tabindex="0"
@blur="$emit('close')"
>
<slot />
</div>
<Teleport to="body">
<div
ref="target"
class="fixed outline-none z-[9999] cursor-pointer"
:style="style"
tabindex="0"
@blur="emit('close')"
>
<slot />
</div>
</Teleport>
</template>
@@ -222,7 +222,7 @@ const emitDateRange = () => {
>
<CalendarDateRange
:selected-range="selectedRange"
@setRange="setDateRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
@@ -264,15 +264,15 @@ const emitDateRange = () => {
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@selectYear="openCalendar($event, calendar, YEAR)"
@select-year="openCalendar($event, calendar, YEAR)"
/>
<CalendarMonth
v-else-if="calendarViews[calendar] === MONTH"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@selectMonth="openCalendar($event, calendar)"
@setView="setViewMode"
@select-month="openCalendar($event, calendar)"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev', YEAR)"
@next="moveCalendar(calendar, 'next', YEAR)"
/>
@@ -286,9 +286,9 @@ const emitDateRange = () => {
:selected-end-date="selectedEndDate"
:selecting-end-date="selectingEndDate"
:hovered-end-date="hoveredEndDate"
@updateHoveredEndDate="hoveredEndDate = $event"
@selectDate="selectDate"
@setView="setViewMode"
@update-hovered-end-date="hoveredEndDate = $event"
@select-date="selectDate"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev')"
@next="moveCalendar(calendar, 'next')"
/>
@@ -1,5 +1,5 @@
<script setup>
const emit = defineEmits(['clear', 'clear']);
const emit = defineEmits(['clear', 'change']);
const onClickClear = () => {
emit('clear');
@@ -62,7 +62,7 @@ const selectMonth = index => {
MONTH
)
"
@setView="setViewMode"
@set-view="setViewMode"
@prev="onClickPrev"
@next="onClickNext"
/>
@@ -139,7 +139,7 @@ const dayClasses = day => ({
"
@prev="onClickPrev"
@next="onClickNext"
@setView="setViewMode"
@set-view="setViewMode"
/>
<CalendarWeekLabel />
<div
@@ -1,5 +1,5 @@
<script>
import DatePicker from 'vue2-datepicker';
import DatePicker from 'vue-datepicker-next';
export default {
components: { DatePicker },
props: {
@@ -16,6 +16,7 @@ export default {
default: () => [],
},
},
emits: ['change'],
methods: {
handleChange(value) {
this.$emit('change', value);
@@ -1,6 +1,6 @@
<script>
import addDays from 'date-fns/addDays';
import DatePicker from 'vue2-datepicker';
import DatePicker from 'vue-datepicker-next';
export default {
components: { DatePicker },
props: {
@@ -17,6 +17,7 @@ export default {
default: [],
},
},
emits: ['change'],
methods: {
handleChange(value) {
@@ -18,7 +18,6 @@ defineProps({
<template>
<button
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="$emit('click')"
>
<slot name="leftIcon">
<fluent-icon
@@ -38,15 +38,19 @@ const props = defineProps({
},
});
const emit = defineEmits(['onSearch']);
const emit = defineEmits(['onSearch', 'select', 'removeFilter']);
const searchTerm = ref('');
const onSearch = debounce(value => {
searchTerm.value = value;
const debouncedEmit = debounce(value => {
emit('onSearch', value);
}, 300);
const onSearch = value => {
searchTerm.value = value;
debouncedEmit();
};
const filteredListItems = computed(() => {
if (!searchTerm.value) return props.listItems;
return picoSearch(props.listItems, searchTerm.value, ['name']);
@@ -80,11 +84,11 @@ const shouldShowEmptyState = computed(() => {
<slot name="search">
<DropdownSearch
v-if="enableSearch"
:input-value="searchTerm"
v-model="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@input="onSearch"
@click="$emit('removeFilter')"
@update:model-value="onSearch"
@remove="$emit('removeFilter')"
/>
</slot>
<slot name="listItem">
@@ -103,7 +107,7 @@ const shouldShowEmptyState = computed(() => {
:button-text="item.name"
:icon="item.icon"
:icon-color="item.iconColor"
@click="$emit('click', item)"
@click.stop.prevent="emit('select', item)"
/>
</slot>
</div>
@@ -22,10 +22,6 @@ defineProps({
<template>
<button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
@click.stop.prevent="$emit('click')"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
@focus="$emit('focus')"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
@@ -1,9 +1,6 @@
<script setup>
import { defineEmits, defineModel } from 'vue';
defineProps({
inputValue: {
type: String,
default: '',
},
inputPlaceholder: {
type: String,
default: '',
@@ -13,6 +10,13 @@ defineProps({
default: false,
},
});
const emit = defineEmits(['remove']);
const value = defineModel({
type: String,
default: '',
});
</script>
<template>
@@ -26,21 +30,20 @@ defineProps({
class="text-slate-400 dark:text-slate-400 flex-shrink-0"
/>
<input
v-model="value"
:placeholder="inputPlaceholder"
type="text"
class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
:placeholder="inputPlaceholder"
:value="inputValue"
@input="$emit('input', $event.target.value)"
/>
</div>
<!-- Clear filter button -->
<woot-button
v-if="!inputValue && showClearFilter"
v-if="!modelValue && showClearFilter"
size="small"
variant="clear"
color-scheme="primary"
class="!px-1 !py-1.5"
@click="$emit('click')"
@click="emit('remove')"
>
{{ $t('REPORT.FILTER_ACTIONS.CLEAR_FILTER') }}
</woot-button>
@@ -44,6 +44,7 @@ export default {
default: '',
},
},
emits: ['remove'],
computed: {
textColor() {
if (this.variant === 'smooth') return '';
@@ -74,7 +75,7 @@ export default {
},
methods: {
onClick() {
this.$emit('click', this.title);
this.$emit('remove', this.title);
},
},
};
@@ -1,12 +1,14 @@
<script>
export default {
props: {
value: { type: Boolean, default: false },
modelValue: { type: Boolean, default: false },
size: { type: String, default: '' },
},
emits: ['update:modelValue', 'input'],
methods: {
onClick() {
this.$emit('input', !this.value);
this.$emit('update:modelValue', !this.modelValue);
this.$emit('input', !this.modelValue);
},
},
};
@@ -16,12 +18,12 @@ export default {
<button
type="button"
class="toggle-button p-0"
:class="{ active: value, small: size === 'small' }"
:class="{ active: modelValue, small: size === 'small' }"
role="switch"
:aria-checked="value.toString()"
:aria-checked="modelValue.toString()"
@click="onClick"
>
<span aria-hidden="true" :class="{ active: value }" />
<span aria-hidden="true" :class="{ active: modelValue }" />
</button>
</template>
@@ -1,91 +0,0 @@
export default {
name: 'WootTabs',
props: {
index: {
type: Number,
default: 0,
},
border: {
type: Boolean,
default: true,
},
},
data() {
return { hasScroll: false };
},
created() {
window.addEventListener('resize', this.computeScrollWidth);
},
beforeDestroy() {
window.removeEventListener('resize', this.computeScrollWidth);
},
mounted() {
this.computeScrollWidth();
},
methods: {
computeScrollWidth() {
const tabElement = this.$el.getElementsByClassName('tabs')[0];
this.hasScroll = tabElement.scrollWidth > tabElement.clientWidth;
},
onScrollClick(direction) {
const tabElement = this.$el.getElementsByClassName('tabs')[0];
let scrollPosition = tabElement.scrollLeft;
if (direction === 'left') {
scrollPosition -= 100;
} else {
scrollPosition += 100;
}
tabElement.scrollTo({
top: 0,
left: scrollPosition,
behavior: 'smooth',
});
},
createScrollButton(createElement, direction) {
if (!this.hasScroll) {
return false;
}
return createElement(
'button',
{
class: 'tabs--scroll-button button clear secondary button--only-icon',
on: { click: () => this.onScrollClick(direction) },
},
[
createElement('fluent-icon', {
props: { icon: `chevron-${direction}`, size: 16 },
}),
]
);
},
},
render(createElement) {
const Tabs = this.$slots.default
.filter(
node =>
node.componentOptions &&
node.componentOptions.tag === 'woot-tabs-item'
)
.map((node, index) => {
const data = node.componentOptions.propsData;
data.index = index;
return node;
});
const leftButton = this.createScrollButton(createElement, 'left');
const rightButton = this.createScrollButton(createElement, 'right');
return (
<div
class={{
'tabs--container--with-border': this.border,
'tabs--container': true,
}}
>
{leftButton}
<ul class={{ tabs: true, 'tabs--with-scroll': this.hasScroll }}>
{Tabs}
</ul>
{rightButton}
</div>
);
},
};
@@ -0,0 +1,89 @@
<script setup>
// [VITE] TODO: Test this component across different screen sizes and usages
import { ref, provide, onMounted, computed } from 'vue';
import { useEventListener } from '@vueuse/core';
const props = defineProps({
index: {
type: Number,
default: 0,
},
border: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['change']);
const hasScroll = ref(false);
// TODO: We may not this internalActiveIndex, we can use activeIndex directly
// But right I'll keep it and fix it when testing the rest of the codebase
const internalActiveIndex = ref(props.index);
// Create a proxy for activeIndex using computed
const activeIndex = computed({
get: () => internalActiveIndex.value,
set: newValue => {
internalActiveIndex.value = newValue;
emit('change', newValue);
},
});
provide('activeIndex', activeIndex);
provide('updateActiveIndex', index => {
activeIndex.value = index;
});
const computeScrollWidth = () => {
// TODO: use useElementSize from vueuse
const tabElement = document.querySelector('.tabs');
if (tabElement) {
hasScroll.value = tabElement.scrollWidth > tabElement.clientWidth;
}
};
const onScrollClick = direction => {
// TODO: use useElementSize from vueuse
const tabElement = document.querySelector('.tabs');
if (tabElement) {
let scrollPosition = tabElement.scrollLeft;
scrollPosition += direction === 'left' ? -100 : 100;
tabElement.scrollTo({
top: 0,
left: scrollPosition,
behavior: 'smooth',
});
}
};
useEventListener(window, 'resize', computeScrollWidth);
onMounted(() => {
computeScrollWidth();
});
</script>
<template>
<div
:class="{ 'tabs--container--with-border': border }"
class="tabs--container"
>
<button
v-if="hasScroll"
class="tabs--scroll-button button clear secondary button--only-icon"
@click="onScrollClick('left')"
>
<fluent-icon icon="chevron-left" :size="16" />
</button>
<ul :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
<slot />
</ul>
<button
v-if="hasScroll"
class="tabs--scroll-button button clear secondary button--only-icon"
@click="onScrollClick('right')"
>
<fluent-icon icon="chevron-right" :size="16" />
</button>
</div>
</template>

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