Compare commits

..
Author SHA1 Message Date
Vishnu NarayananandGitHub e05e947392 Merge branch 'develop' into chore/newrelic_tuning 2024-11-20 20:46:39 +05:30
Sivin VargheseandGitHub b0d6089bb6 feat: Updates on new components (#10444) 2024-11-20 20:21:35 +05:30
Vishnu Narayanan c04b3b4297 feat: disable newrelic monitoring in staging env by default 2024-11-20 20:03:31 +05:30
76a4140224 fix: Feature flags are not be stored on account creation (#10387)
when creating an account via the platform API the feature flags do not get stored

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-11-20 21:39:17 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
93ebfccac2 chore(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#10447)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from
7.0.3 to 7.0.6.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md">cross-spawn's
changelog</a>.</em></p>
<blockquote>
<h3><a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.5...v7.0.6">7.0.6</a>
(2024-11-18)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>update cross-spawn version to 7.0.5 in package-lock.json (<a
href="https://github.com/moxystudio/node-cross-spawn/commit/f700743918d901eff92960e15a8dd68f87bd4176">f700743</a>)</li>
</ul>
<h3><a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.4...v7.0.5">7.0.5</a>
(2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>fix escaping bug introduced by backtracking (<a
href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f">640d391</a>)</li>
</ul>
<h3><a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.4">7.0.4</a>
(2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>disable regexp backtracking (<a
href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>)
(<a
href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff">5ff3a07</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/77cd97f3ca7b62c904a63a698fc4a79bf41977d0"><code>77cd97f</code></a>
chore(release): 7.0.6</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/6717de49ff1e5de49622488dcb9c33fb25370c85"><code>6717de4</code></a>
chore: upgrade standard-version</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/f700743918d901eff92960e15a8dd68f87bd4176"><code>f700743</code></a>
fix: update cross-spawn version to 7.0.5 in package-lock.json</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/9a7e3b2165917367f74b8365faad9873b30d7263"><code>9a7e3b2</code></a>
chore: fix build status badge</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/085268352dcbcad8064c64c5efb25268b4023184"><code>0852683</code></a>
chore(release): 7.0.5</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f"><code>640d391</code></a>
fix: fix escaping bug introduced by backtracking</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/bff0c87c8b627c4e6d04ec2449e733048bebb464"><code>bff0c87</code></a>
chore: remove codecov</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/a7c6abc6fee79641d45b452fe6217deaa1bd0973"><code>a7c6abc</code></a>
chore: replace travis with github workflows</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/9b9246e0969e86656d7ccd527716bc3c18842a19"><code>9b9246e</code></a>
chore(release): 7.0.4</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff"><code>5ff3a07</code></a>
fix: disable regexp backtracking (<a
href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-19 18:45:24 -08:00
759615d041 fix: Update the dropdown bg to match the design system (#10438)
This PR updates the background used in dropdown to match our design system. Previous PR failed to add this correctly.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-19 14:29:45 -08:00
Sivin VargheseandGitHub a7e3d443c9 feat: Add the new select menu component (#10445) 2024-11-19 14:25:45 -08:00
Sojan JoseandGitHub 7a3303e841 fix: Undefined method `encode' for nil for avatar from url job (#10450)
Invalid urls supplied to the job was causing sentry issues. The issue primarily occurs when the download file.original_filename comes out as empty

fixes: https://github.com/chatwoot/chatwoot/issues/10449
2024-11-19 14:24:01 -08:00
Sojan 9cc6b1a4ba Merge branch 'release/3.15.0' into develop 2024-11-19 18:02:13 +08:00
Sojan 7d9800d2f8 Bump version to 3.15.0 2024-11-19 18:01:33 +08:00
Sivin VargheseandGitHub b680fa43ec fix: Country code missing when typing in phone input. (#10439) 2024-11-19 14:38:00 +05:30
Shivam MishraandGitHub c2e2954dfa fix: Remove the warnings generated from the Sidebar component (#10437)
The following warning occurred since the `ChannelLeaf` component was passed extra props. This PR fixes it by passing only the required props
2024-11-18 19:09:09 -08:00
aaa328be87 feat: Add dropdown component (#10358)
This PR adds dropdown primitives to help compose custom dropdowns across the app. The following the sample usage

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-18 17:29:27 -08:00
Vishnu NarayananandGitHub 54afed9fb4 feat: add judoscaler gem for heroku autoscaling (#10419)
- add judoscaler gem to allow judoscale use in heroku environments
- This will allow auto scaling for both web and worker dynos across both
standard-1x/2x and performance dynos
- This will scaling in response to queue time rather than response
time(heroku default)
- This also allows you to scale multiple dynos in and out at once,
rather than scaling them one at a time, as is the default.

Ref
----
1. https://judoscale.com/
2. https://devcenter.heroku.com/articles/judoscale
2024-11-18 12:36:27 +05:30
casparandGitHub 8773929c0e fix: Fix line sticker URL to prevent certain images from failing to d… (#10416)
This commit fixes the issue with Line stickers URLs to prevent certain
images from failing to display. The problem was due to the use of
incorrect URLs. The original URLs pointed to the `iphone` variant, which
failed to load properly in some cases. The fix updates the URLs to use
the `android` variant, ensuring all images are displayed correctly.

### Example:  
- Original (failing URL):  

`https://stickershop.line-scdn.net/stickershop/v1/sticker/17/iphone/sticker.png`
- Fixed (working URL):  

`https://stickershop.line-scdn.net/stickershop/v1/sticker/17/android/sticker.png`


## How Has This Been Tested?

1. Verified the updated URLs by loading multiple Line sticker images to
ensure they display correctly.
2. Tested in both local and production-like environments to confirm the
fix resolves the issue.
3. Reviewed logs to ensure no additional errors are generated related to
Line sticker URLs.
2024-11-15 17:27:58 +04:00
933ae8aa49 fix: Email attachments created with empty filename (#10420)
- We observed in prod for certain emails active storage blob objects
were getting created with empty file name. The conversations further
causes conversation and filter pages to break. This change will fix the
mentioned issue.

fixes:
https://linear.app/chatwoot/issue/CW-3331/missing-file-name-for-some-of-the-uploads-for-emails

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-15 09:07:24 +04:00
Sivin VargheseandGitHub 4cfa7e4c97 feat: Remove incoming messages metrics from the agent reports (#10415) 2024-11-14 17:55:24 +05:30
Shivam MishraandGitHub ac729cf0cf feat: update debounce duration for article editor (#10410)
Quick fix for CW-3721 until we work on a better solution
2024-11-13 12:29:50 +05:30
PranavandGitHub 7dc0eba48a fix: Remove duplicate / character on the proxy route (#10404)
The proxy method adds an extra slash even if the route param has the
character. It’s challenging to check the expected format on each route.
The simplest solution is to check if the route param begins with a
slash, and if not, append one.

NB: The existing tests are sufficient to cover this case. There’s no
need for an additional test to specifically test this.
2024-11-12 22:38:29 +04:00
Vishnu Narayanan 879da72b10 chore: drop request/response headers from traces 2024-11-12 17:28:13 +05:30
Vishnu Narayanan 4436d1a9de chore: set sampling to 10% for distributed tracing 2024-11-12 17:15:15 +05:30
Vishnu Narayanan d6b1533866 chore: tune newrelic apm to reduce data ingestion 2024-11-12 17:11:57 +05:30
Shivam MishraandGitHub 97d7b9d754 feat(ee): Setup @chatwoot/captain NPM library (#10389)
--- 
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-11 17:39:09 -08:00
Vishnu NarayananandGitHub 7a45144526 chore: Set rack-timeout to log at ERROR level (#10400)
40 % of Chatwoot's current log volume is from state transition logs generated by `rack-timeout`, which are logged at the `INFO` level. This PR reduce the noise in logs and set RACK::TIMEOUT to log at `error` level
2024-11-11 12:35:05 -08:00
Sivin VargheseandGitHub db327378fa feat(v4): Add new conversation card component (#10392) 2024-11-07 20:30:56 -08:00
100 changed files with 3480 additions and 1386 deletions
+3 -3
View File
@@ -175,15 +175,15 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'opensearch-ruby'
gem 'searchkick'
### Gems required only in specific deployment environments ###
##############################################################
group :production do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
# for heroku autoscaling
gem 'judoscale-rails', require: false
gem 'judoscale-sidekiq', require: false
end
group :development do
+9 -8
View File
@@ -397,6 +397,13 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
judoscale-ruby (1.8.2)
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.8.1)
base64
kaminari (1.2.2)
@@ -532,9 +539,6 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -706,9 +710,6 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.4.0)
activemodel (>= 6.1)
hashie
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
@@ -897,6 +898,8 @@ DEPENDENCIES
jbuilder
json_refs
json_schemer
judoscale-rails
judoscale-sidekiq
jwt
kaminari
koala
@@ -916,7 +919,6 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opensearch-ruby
pg
pg_search
pgvector
@@ -942,7 +944,6 @@ DEPENDENCIES
rubocop-rspec
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
@@ -1,22 +1,53 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook
before_action :hook
def sso_url
params_string =
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
sso_url = "#{installation_config.value}/sso?#{params_string}"
render json: { sso_url: sso_url }, status: :ok
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
"api/accounts/#{hook.settings['account_id']}#{request_route}"
end
def request_url
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def with_leading_hash_on_route(request_route)
return '' if request_route.blank?
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def permitted_params
params.permit(:method, :route, body: {})
end
end
@@ -4,6 +4,7 @@ class Platform::Api::V1::AccountsController < PlatformController
def create
@resource = Account.create!(account_params)
update_resource_features
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
+2 -2
View File
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
}
@@ -31,7 +31,13 @@ hr {
ul,
ol,
dl {
@apply mb-2 list-disc list-outside leading-[1.65];
@apply list-disc list-outside leading-[1.65];
}
ul:not(.reset-base),
ol:not(.reset-base),
dl:not(.reset-base) {
@apply mb-0;
}
// Form elements
@@ -96,7 +96,7 @@ button {
}
// @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content {
.icon + .button__content {
@apply w-auto;
}
@@ -82,60 +82,56 @@ const inboxIcon = computed(() => {
</script>
<template>
<CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row">
<template #header>
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between gap-3 w-fit">
<span
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
>
{{ title }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
<div
v-dompurify-html="formatMessage(message)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
<LiveChatCampaignDetails
v-if="isLiveChatType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
:scheduled-at="scheduledAt"
/>
</div>
<CardLayout layout="row">
<div class="flex flex-col items-start justify-between flex-1 min-w-0 gap-2">
<div class="flex justify-between gap-3 w-fit">
<span
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
>
{{ title }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
</template>
<template #footer>
<div class="flex items-center justify-end w-20 gap-2">
<Button
<div
v-dompurify-html="formatMessage(message)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
<LiveChatCampaignDetails
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-sliders-vertical"
@click="emit('edit')"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<Button
variant="faded"
color="ruby"
size="sm"
icon="i-lucide-trash"
@click="emit('delete')"
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
:scheduled-at="scheduledAt"
/>
</div>
</template>
</div>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-sliders-vertical"
@click="emit('edit')"
/>
<Button
variant="faded"
color="ruby"
size="sm"
icon="i-lucide-trash"
@click="emit('delete')"
/>
</div>
</CardLayout>
</template>
@@ -1,7 +1,8 @@
<script setup>
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
@@ -33,10 +34,11 @@ const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Thumbnail
:author="sender || { name: senderName }"
<Avatar
:name="senderName"
:src="senderThumbnailSrc"
:size="16"
rounded-full
/>
<span class="text-sm font-medium text-n-slate-12">
{{ senderName }}
@@ -27,7 +27,6 @@ defineProps({
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
@@ -27,7 +27,6 @@ defineProps({
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
@@ -27,7 +27,6 @@ const handleDelete = campaign => emit('delete', campaign);
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
@@ -63,14 +63,12 @@ defineExpose({ dialogRef });
overflow-y-auto
@confirm="handleSubmit"
>
<template #form>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</template>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</Dialog>
</template>
@@ -1,11 +1,13 @@
<script setup>
const props = defineProps({
defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
@@ -13,11 +15,18 @@ const handleClick = () => {
<template>
<div
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
@click="handleClick"
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<slot name="header" />
<slot name="footer" />
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
@click="handleClick"
>
<slot />
</div>
<slot name="after" />
</div>
</template>
@@ -0,0 +1,95 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
conversationLabels: {
type: Array,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const WIDTH_CONFIG = Object.freeze({
DEFAULT_WIDTH: 80,
CHAR_WIDTH: {
SHORT: 8, // For labels <= 5 chars
LONG: 6, // For labels > 5 chars
},
BASE_WIDTH: 12, // dot + gap
THRESHOLD: 5, // character length threshold
});
const containerRef = ref(null);
const maxLabels = ref(1);
const activeLabels = computed(() => {
const labelSet = new Set(props.conversationLabels);
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
});
const calculateLabelWidth = ({ title = '' }) => {
const charWidth =
title.length > WIDTH_CONFIG.THRESHOLD
? WIDTH_CONFIG.CHAR_WIDTH.LONG
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
};
const getAverageWidth = labels => {
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
const totalWidth = labels.reduce(
(sum, label) => sum + calculateLabelWidth(label),
0
);
return totalWidth / labels.length;
};
const visibleLabels = computed(() =>
activeLabels.value?.slice(0, maxLabels.value)
);
const updateVisibleLabels = () => {
if (!containerRef.value) return;
const containerWidth = containerRef.value.offsetWidth;
const avgWidth = getAverageWidth(activeLabels.value);
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
};
</script>
<template>
<div
ref="containerRef"
v-resize="updateVisibleLabels"
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
>
<template v-for="(label, index) in visibleLabels" :key="label.id">
<div
class="flex items-center gap-1.5 min-w-0"
:class="[
index !== visibleLabels.length - 1
? 'flex-shrink-0 text-ellipsis'
: 'flex-shrink',
]"
>
<div
:style="{ backgroundColor: label.color }"
class="size-1.5 rounded-full flex-shrink-0"
/>
<span
class="text-sm text-n-slate-10 whitespace-nowrap"
:class="{ truncate: index === visibleLabels.length - 1 }"
>
{{ label.title }}
</span>
</div>
</template>
</div>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
</script>
<template>
<div class="flex items-end w-full gap-2 pb-1">
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
{{ lastNonActivityMessageContent }}
</p>
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
<Avatar
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const { t } = useI18n();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
</script>
<template>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
{{ lastNonActivityMessageContent }}
</p>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
<div
class="grid items-center gap-2.5 h-7"
:class="
hasSlaThreshold
? 'grid-cols-[auto_auto_1fr_20px]'
: 'grid-cols-[1fr_20px]'
"
>
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
<div class="overflow-hidden">
<CardLabels
:conversation-labels="conversation.labels"
:account-labels="accountLabels"
/>
</div>
<Avatar
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
</div>
</div>
</template>
@@ -0,0 +1,207 @@
<script setup>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({
priority: {
type: String,
default: '',
},
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
</template>
@@ -0,0 +1,477 @@
<script setup>
import { computed } from 'vue';
import ConversationCard from './ConversationCard.vue';
// Base conversation object
const conversationWithoutMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phone_number: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
last_activity_at: 1712127410,
created_at: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 38,
messages: [
{
id: 3597,
content: 'Sivin set the priority to low',
accountId: 2,
inboxId: 7,
conversationId: 38,
messageType: 2,
createdAt: 1730885168,
updatedAt: '2024-11-06T09:26:08.565Z',
private: false,
status: 'sent',
source_id: null,
contentType: 'text',
contentAttributes: {},
senderType: null,
senderId: null,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent: 'Sivin set the priority to low',
sentiment: {},
conversation: {
assigneeId: 1,
unreadCount: 0,
lastActivityAt: 1730885168,
contactInbox: {
sourceId: 'candice@chatwoot.com',
},
},
},
],
accountId: 2,
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
additionalAttributes: {
mail_subject: 'Test email',
},
agentLastSeenAt: 0,
assigneeLastSeenAt: 0,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [],
status: 'open',
createdAt: 1730836533,
timestamp: 1730885168,
firstReplyCreatedAt: 1730836533,
unreadCount: 0,
lastNonActivityMessage: {
id: 3591,
content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
account_id: 2,
inbox_id: 7,
conversation_id: 38,
message_type: 1,
created_at: 1730836533,
updated_at: '2024-11-05T19:55:37.158Z',
private: false,
status: 'sent',
source_id:
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: ['test@gmail.com'],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885168,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885168,
priority: 'low',
waitingSince: 0,
slaPolicyId: null,
slaEvents: [],
};
const conversationWithMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 37,
messages: [
{
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
accountId: 2,
inboxId: 7,
conversationId: 37,
messageType: 1,
createdAt: 1730885428,
updatedAt: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
sourceId:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
contentType: 'text',
contentAttributes: {
ccEmails: [],
bccEmails: [],
toEmails: [],
},
sender_type: 'User',
senderId: 1,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
availableName: 'Sivin',
avatarUrl: '',
type: 'user',
availabilityStatus: 'online',
thumbnail: '',
},
},
],
accountId: 2,
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
additionalAttributes: {
mail_subject: 'we',
},
agentLastSeenAt: 1730885428,
assigneeLastSeenAt: 1730885428,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [
'billing',
'delivery',
'lead',
'premium-customer',
'software',
'ops-handover',
],
muted: false,
snoozedUntil: null,
status: 'open',
createdAt: 1722487645,
timestamp: 1730885428,
firstReplyCreatedAt: 1722487645,
unreadCount: 0,
lastNonActivityMessage: {
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
account_id: 2,
inbox_id: 7,
conversation_id: 37,
message_type: 1,
created_at: 1730885428,
updated_at: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
source_id:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 2,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'willy@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885428,
priority: 'urgent',
waitingSince: 1730885428,
slaPolicyId: 3,
appliedSla: {
id: 4,
sla_id: 3,
sla_status: 'active_with_misses',
created_at: 1712127410,
updated_at: 1712127545,
sla_description:
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
sla_name: 'Premium SLA',
sla_first_response_time_threshold: 120,
sla_next_response_time_threshold: 180,
sla_only_during_business_hours: false,
sla_resolution_time_threshold: 360,
},
slaEvents: [
{
id: 8,
event_type: 'frt',
meta: {},
updated_at: 1712127545,
created_at: 1712127545,
},
{
id: 9,
event_type: 'rt',
meta: {},
updated_at: 1712127790,
created_at: 1712127790,
},
],
};
const contactForConversationWithoutMeta = computed(() => ({
availabilityStatus: null,
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
customAttributes: {},
last_activity_at: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const contactForConversationWithMeta = computed(() => ({
availabilityStatus: null,
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
customAttributes: {},
lastActivityAt: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const webWidgetInbox = computed(() => ({
phone_number: '+918585858585',
channel_type: 'Channel::WebWidget',
}));
const accountLabels = computed(() => [
{
id: 1,
title: 'billing',
description: 'Label is used for tagging billing related conversations',
color: '#28AD21',
show_on_sidebar: true,
},
{
id: 3,
title: 'delivery',
description: null,
color: '#A2FDD5',
show_on_sidebar: true,
},
{
id: 6,
title: 'lead',
description: null,
color: '#F161C8',
show_on_sidebar: true,
},
{
id: 4,
title: 'ops-handover',
description: null,
color: '#A53326',
show_on_sidebar: true,
},
{
id: 5,
title: 'premium-customer',
description: null,
color: '#6FD4EF',
show_on_sidebar: true,
},
{
id: 2,
title: 'software',
description: null,
color: '#8F6EF2',
show_on_sidebar: true,
},
]);
</script>
<template>
<Story
title="Components/ConversationCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Conversation without meta">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="conversationWithoutMeta"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
priority: 'medium',
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation without meta (Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="{
...conversationWithoutMeta,
unreadCount: 2,
priority: 'high',
}"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
unreadCount: 2,
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardMessagePreview from './CardMessagePreview.vue';
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
contact: {
type: Object,
required: true,
},
stateInbox: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const currentContact = computed(() => props.contact);
const currentContactName = computed(() => currentContact.value?.name);
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
const currentContactStatus = computed(
() => currentContact.value?.availabilityStatus
);
const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
});
const lastActivityAt = computed(() => {
const timestamp = props.conversation?.timestamp;
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
});
const showMessagePreviewWithoutMeta = computed(() => {
const { slaPolicyId, labels = [] } = props.conversation;
return !slaPolicyId && labels.length === 0;
});
</script>
<template>
<div
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out rounded-xl"
>
<Avatar
:name="currentContactName"
:src="currentContactThumbnail"
:size="24"
:status="currentContactStatus"
rounded-full
/>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between h-6 gap-2">
<h4 class="text-base font-medium truncate text-n-slate-12">
{{ currentContactName }}
</h4>
<div class="flex items-center gap-2">
<CardPriorityIcon :priority="conversation.priority || null" />
<div
v-tooltip.top-start="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-3"
/>
</div>
<span class="text-sm text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
</div>
<CardMessagePreview
v-if="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-else
:conversation="conversation"
:account-labels="accountLabels"
/>
</div>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
// TODO: Remove this once we update the helper from utils
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
const convertObjectCamelCaseToSnakeCase = object => {
return Object.keys(object).reduce((acc, key) => {
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
return acc;
}, {});
};
const appliedSLA = computed(() => props.conversation?.appliedSla);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
chat: props.conversation,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.conversation, updateSlaStatus);
</script>
<template>
<div class="flex items-center min-w-fit gap-0.5 h-6">
<div class="flex items-center justify-center size-4">
<svg
width="10"
height="13"
viewBox="0 0 10 13"
fill="none"
:class="isSlaMissed ? 'fill-n-ruby-10' : 'fill-n-slate-9'"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.55091 12.412C7.44524 12.412 9.37939 10.4571 9.37939 7.51446C9.37939 2.63072 5.21405 0.599854 2.36808 0.599854C1.81546 0.599854 1.45626 0.800176 1.45626 1.1801C1.45626 1.32516 1.52534 1.48404 1.64277 1.62219C2.27828 2.38204 2.92069 3.27314 2.93451 4.36455C2.93451 4.5925 2.9276 4.78592 2.76181 5.08295L3.05194 5.03459C2.81017 4.21949 2.18848 3.63234 1.5806 3.63234C1.32501 3.63234 1.15232 3.81884 1.15232 4.09514C1.15232 4.23331 1.19377 4.56488 1.19377 4.79974C1.19377 5.95332 0.26123 6.69935 0.26123 8.67495C0.26123 10.92 1.97434 12.412 4.55091 12.412ZM4.68906 10.8923C3.65982 10.8923 2.96905 10.2637 2.96905 9.33119C2.96905 8.3572 3.66672 8.01181 3.75652 7.38322C3.76344 7.32796 3.79107 7.31414 3.83251 7.34867C4.08809 7.57663 4.24697 7.85293 4.37822 8.1776C4.67525 7.77696 4.81341 6.9204 4.73051 6.0293C4.72361 5.97404 4.75814 5.94642 4.80649 5.96713C6.02916 6.53357 6.65085 7.74241 6.65085 8.82693C6.65085 9.92527 6.00844 10.8923 4.68906 10.8923Z"
/>
</svg>
</div>
<span
class="text-sm truncate"
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
</span>
</div>
</template>
@@ -13,7 +13,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: {
@@ -101,7 +101,7 @@ const categoryName = computed(() => {
});
const authorName = computed(() => {
return props.author?.name || props.author?.availableName || '-';
return props.author?.name || props.author?.availableName || '';
});
const authorThumbnailSrc = computed(() => {
@@ -124,75 +124,72 @@ const handleClick = id => {
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-1">
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ title }}
{{ statusText }}
</span>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ statusText }}
</span>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleArticleAction($event)"
/>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleArticleAction($event)"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Thumbnail
:author="author"
:name="authorName"
:src="authorThumbnailSrc"
/>
<span class="text-sm text-n-slate-11">
{{ authorName }}
</span>
</div>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
:size="16"
rounded-full
/>
<span class="text-sm truncate text-n-slate-11">
{{ authorName || '-' }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
{{
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
count: views,
})
}}
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
{{
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
count: views,
})
}}
</span>
</div>
</div>
</template>
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
</CardLayout>
</template>
@@ -79,59 +79,55 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<template #header>
<div class="flex gap-2">
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
<div class="flex w-full gap-2">
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
@click="handleClick(slug)"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
variant="ghost"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="categoryMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
@action="handleAction"
/>
</div>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
variant="ghost"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="categoryMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
@action="handleAction"
/>
</div>
</div>
</template>
<template #footer>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
>
{{ description }}
</span>
</template>
</div>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
>
{{ description }}
</span>
</CardLayout>
</template>
@@ -53,66 +53,64 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{ locale }} ({{ localeCode }})
</span>
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ locale }} ({{ localeCode }})
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
articleCount
)
}}
</span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
categoryCount
)
}}
</span>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
articleCount
)
}}
</span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
categoryCount
)
}}
</span>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
</div>
</template>
</div>
</CardLayout>
</template>
@@ -35,7 +35,7 @@ const emit = defineEmits([
const { t } = useI18n();
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
const articleTitle = computed({
get: () => props.article.title,
@@ -6,7 +6,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
@@ -50,7 +50,7 @@ const author = computed(() => {
});
const authorName = computed(
() => author.value?.name || author.value?.available_name || '-'
() => author.value?.name || author.value?.available_name || ''
);
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
@@ -186,17 +186,14 @@ onMounted(() => {
text-variant="info"
@click="openAgentsList = !openAgentsList"
>
<Thumbnail
:author="author"
<Avatar
:name="authorName"
:size="20"
:src="authorThumbnailSrc"
:size="20"
rounded-full
/>
<span
v-if="author"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{ author.available_name }}
<span class="text-sm text-n-slate-12 hover:text-n-slate-11">
{{ authorName || '-' }}
</span>
</Button>
<DropdownMenu
@@ -97,16 +97,14 @@ defineExpose({ dialogRef });
:disable-confirm-button="isUpdatingCategory || isInvalidForm"
@confirm="onUpdateCategory"
>
<template #form>
<CategoryForm
ref="categoryFormRef"
mode="edit"
:selected-category="selectedCategory"
:active-locale-code="activeLocaleCode"
:portal-name="route.params.portalSlug"
:active-locale-name="activeLocaleName"
:show-action-buttons="false"
/>
</template>
<CategoryForm
ref="categoryFormRef"
mode="edit"
:selected-category="selectedCategory"
:active-locale-code="activeLocaleCode"
:portal-name="route.params.portalSlug"
:active-locale-name="activeLocaleName"
:show-action-buttons="false"
/>
</Dialog>
</template>
@@ -85,17 +85,15 @@ defineExpose({ dialogRef });
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@confirm="onCreate"
>
<template #form>
<div class="flex flex-col gap-6">
<ComboBox
v-model="selectedLocale"
:options="locales"
:placeholder="
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
"
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
/>
</div>
</template>
<div class="flex flex-col gap-6">
<ComboBox
v-model="selectedLocale"
:options="locales"
:placeholder="
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
"
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
/>
</div>
</Dialog>
</template>
@@ -55,20 +55,18 @@ defineExpose({ dialogRef });
"
@confirm="handleDialogConfirm"
>
<template #form>
<Input
v-model="formState.customDomain"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
)
"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
/>
</template>
<Input
v-model="formState.customDomain"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
)
"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
/>
</Dialog>
</template>
@@ -59,21 +59,20 @@ defineExpose({ dialogRef });
}}
</p>
</template>
<template #form>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
</div>
</template>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
</div>
</Dialog>
</template>
@@ -12,7 +12,7 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
@@ -187,10 +187,12 @@ const handleAvatarDelete = () => {
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
</label>
<EditableAvatar
label="Avatar"
<Avatar
:src="state.logoUrl"
:name="state.name"
:size="72"
allow-upload
icon-name="i-lucide-building-2"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
@@ -120,29 +120,27 @@ defineExpose({ dialogRef });
:is-loading="isCreatingPortal"
@confirm="handleDialogConfirm"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
v-model="state.name"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
:message-type="nameError ? 'error' : 'info'"
:message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
"
/>
<Input
id="portal-slug"
v-model="state.slug"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
/>
</div>
</template>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
v-model="state.name"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
:message-type="nameError ? 'error' : 'info'"
:message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
"
/>
<Input
id="portal-slug"
v-model="state.slug"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
/>
</div>
</Dialog>
</template>
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const emit = defineEmits(['close', 'createPortal']);
@@ -148,14 +148,13 @@ const redirectToPortalHomePage = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<Thumbnail
<Avatar
v-if="portal"
:author="portal"
:name="portal.name"
:size="20"
:src="getPortalThumbnailSrc(portal)"
:show-author-name="false"
:size="20"
icon-name="i-lucide-building-2"
rounded-full
/>
</Button>
</div>
@@ -5,37 +5,30 @@ import Avatar from './Avatar.vue';
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<div class="flex p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar
name=""
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
<Avatar name="Amaya" src="" />
<Avatar name="" src="" />
</div>
</Variant>
<Variant title="Default with upload">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
allow-upload
/>
</div>
</Variant>
<Variant title="Invalid or empty SRC">
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
<Avatar name="Bruce Wayne" allow-upload />
</div>
</Variant>
<Variant title="Rounded Full">
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Variant title="Different Shapes">
<div class="gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
name=""
allow-upload
rounded-full
:size="48"
/>
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
name=""
allow-upload
:size="48"
/>
</div>
</Variant>
@@ -43,24 +36,78 @@ import Avatar from './Avatar.vue';
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
name=""
allow-upload
/>
<Avatar
:size="72"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
name=""
allow-upload
/>
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
name=""
:size="96"
class="bg-woot-300 dark:bg-woot-900"
allow-upload
/>
</div>
</Variant>
<Variant title="With Status">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
status="online"
name="Felix Online"
/>
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
status="busy"
name="Jade Busy"
/>
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
status="offline"
name="Emery Offline"
/>
</div>
</Variant>
<Variant title="With Custom Icon">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar name="Custom Icon" icon-name="i-lucide-user" :size="48" />
<Avatar
name="Custom Industry"
icon-name="i-lucide-building-2"
:size="48"
/>
</div>
</Variant>
<Variant title="Upload States">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<!-- Empty state with upload -->
<Avatar name="Upload New" allow-upload :size="48" />
<!-- With image and upload -->
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Upload"
name="Replace Image"
allow-upload
:size="48"
/>
</div>
</Variant>
<Variant title="Name Initials">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar name="Catherine" :size="48" />
<Avatar name="John Doe" :size="48" />
<Avatar name="Rose Doe John" :size="48" />
</div>
</Variant>
</Story>
</template>
@@ -1,6 +1,9 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { removeEmoji } from 'shared/helpers/emoji';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
@@ -27,34 +30,141 @@ const props = defineProps({
status: {
type: String,
default: null,
validator: value => {
if (!value) return true;
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
},
validator: value =>
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
},
iconName: {
type: String,
default: null,
},
});
const emit = defineEmits(['upload']);
const emit = defineEmits(['upload', 'delete']);
const { t } = useI18n();
const isImageValid = ref(true);
const fileInput = ref(null);
function invalidateCurrentImage() {
isImageValid.value = false;
}
const AVATAR_COLORS = {
dark: [
['#4B143D', '#FF8DCC'],
['#3F220D', '#FFA366'],
['#2A2A2A', '#ADB1B8'],
['#023B37', '#0BD8B6'],
['#27264D', '#A19EFF'],
['#1D2E62', '#9EB1FF'],
],
light: [
['#FBDCEF', '#C2298A'],
['#FFE0BB', '#99543A'],
['#E8E8E8', '#60646C'],
['#CCF3EA', '#008573'],
['#EBEBFE', '#4747C2'],
['#E1E9FF', '#3A5BC7'],
],
default: { bg: '#E8E8E8', text: '#60646C' },
};
const STATUS_CLASSES = {
online: 'bg-n-teal-10',
busy: 'bg-n-amber-10',
offline: 'bg-n-slate-10',
};
const showDefaultAvatar = computed(() => !props.src && !props.name);
const initials = computed(() => {
const splitNames = props.name.split(' ');
if (splitNames.length > 1) {
const firstName = splitNames[0];
const lastName = splitNames[splitNames.length - 1];
return firstName[0] + lastName[0];
}
const firstName = splitNames[0];
return firstName[0];
if (!props.name) return '';
const words = removeEmoji(props.name).split(/\s+/);
return words.length === 1
? words[0].charAt(0).toUpperCase()
: words
.slice(0, 2)
.map(word => word.charAt(0))
.join('')
.toUpperCase();
});
const getColorsByNameLength = computed(() => {
if (!props.name) return AVATAR_COLORS.default;
const index = props.name.length % AVATAR_COLORS.light.length;
return {
bg: AVATAR_COLORS.light[index][0],
darkBg: AVATAR_COLORS.dark[index][0],
text: AVATAR_COLORS.light[index][1],
darkText: AVATAR_COLORS.dark[index][1],
};
});
const containerStyles = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
const avatarStyles = computed(() => ({
...containerStyles.value,
backgroundColor:
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
? getColorsByNameLength.value.bg
: undefined,
color:
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
? getColorsByNameLength.value.text
: undefined,
'--dark-bg': getColorsByNameLength.value.darkBg,
'--dark-text': getColorsByNameLength.value.darkText,
}));
const badgeStyles = computed(() => {
const badgeSize = Math.max(props.size * 0.35, 8); // 35% of avatar size, minimum 8px
return {
width: `${badgeSize}px`,
height: `${badgeSize}px`,
top: `${props.size - badgeSize / 1.1}px`,
left: `${props.size - badgeSize / 1.1}px`,
};
});
const iconStyles = computed(() => ({
fontSize: `${props.size / 1.6}px`,
}));
const initialsStyles = computed(() => ({
fontSize: `${props.size / 2}px`,
}));
const invalidateCurrentImage = () => {
isImageValid.value = false;
};
const handleUploadAvatar = () => {
fileInput.value.click();
};
const handleImageUpload = event => {
const [file] = event.target.files;
if (file) {
emit('upload', {
file,
url: file ? URL.createObjectURL(file) : null,
});
}
};
const handleDeleteAvatar = () => {
if (fileInput.value) {
fileInput.value.value = null;
}
emit('delete');
};
const handleDismiss = event => {
event.stopPropagation();
handleDeleteAvatar();
};
watch(
() => props.src,
() => {
@@ -64,57 +174,87 @@ watch(
</script>
<template>
<span
class="relative inline"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<slot name="badge" :size>
<span class="relative inline-flex group/avatar" :style="containerStyles">
<!-- Status Badge -->
<slot name="badge" :size="size">
<div
class="rounded-full w-2.5 h-2.5 absolute z-20"
:style="{
top: `${size - 10}px`,
left: `${size - 10}px`,
}"
:class="{
'bg-n-teal-10': status === 'online',
'bg-n-amber-10': status === 'busy',
'bg-n-slate-10': status === 'offline',
}"
v-if="status"
class="absolute z-20 border rounded-full border-n-slate-3"
:style="badgeStyles"
:class="STATUS_CLASSES[status]"
/>
</slot>
<!-- Delete Avatar Button -->
<div
v-if="src && allowUpload"
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
</div>
<!-- Avatar Container -->
<span
role="img"
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
:class="{
'rounded-full': roundedFull,
'rounded-xl': !roundedFull,
}"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
:class="[
roundedFull ? 'rounded-full' : 'rounded-xl',
{
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
!showDefaultAvatar && (!src || !isImageValid),
'bg-n-slate-3 dark:bg-n-slate-4': showDefaultAvatar,
},
]"
:style="avatarStyles"
>
<!-- Avatar Content -->
<img
v-if="src && isImageValid"
:src="src"
:alt="name"
@error="invalidateCurrentImage"
/>
<span v-else>
{{ initials }}
</span>
<template v-else>
<!-- Custom Icon -->
<Icon v-if="iconName" :icon="iconName" :style="iconStyles" />
<!-- Initials -->
<span
v-else-if="!showDefaultAvatar"
:style="initialsStyles"
class="select-none"
>
{{ initials }}
</span>
<!-- Fallback Icon if no name or image -->
<Icon
v-else
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
icon="i-lucide-user"
:style="iconStyles"
/>
</template>
<!-- Upload Overlay and Input -->
<div
v-if="allowUpload"
role="button"
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="emit('upload')"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
class="text-white dark:text-white size-4"
class="text-white"
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</span>
@@ -1,36 +0,0 @@
<script setup>
import EditableAvatar from './EditableAvatar.vue';
</script>
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
/>
<EditableAvatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
/>
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,105 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 72,
},
name: {
type: String,
default: '',
},
});
const emit = defineEmits(['upload', 'delete']);
const avatarSize = computed(() => `${props.size}px`);
const iconSize = computed(() => `${props.size / 2}px`);
const fileInput = ref(null);
const imgError = ref(false);
const shouldShowImage = computed(() => props.src && !imgError.value);
const handleUploadAvatar = () => {
fileInput.value.click();
};
const handleImageUpload = event => {
const [file] = event.target.files;
if (file) {
emit('upload', {
file,
url: file ? URL.createObjectURL(file) : null,
});
}
};
const handleDeleteAvatar = () => {
if (fileInput.value) {
fileInput.value.value = null;
}
emit('delete');
};
const handleDismiss = event => {
event.stopPropagation();
handleDeleteAvatar();
};
</script>
<template>
<div
class="relative flex flex-col items-center gap-2 select-none rounded-xl outline outline-1 outline-n-container group/avatar"
:style="{ width: avatarSize, height: avatarSize }"
>
<img
v-if="shouldShowImage"
:src="src"
:alt="name || 'avatar'"
class="object-cover w-full h-full shadow-sm rounded-xl"
@error="imgError = true"
/>
<div
v-else
class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2"
>
<Icon
icon="i-lucide-building-2"
class="text-n-brand/50"
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
/>
</div>
<div
v-if="src"
class="absolute z-20 outline outline-1 outline-n-container flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
</div>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</div>
</template>
@@ -6,7 +6,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
label: {
type: String,
type: [String, Number],
default: '',
},
variant: {
@@ -47,9 +47,9 @@ const STYLE_CONFIG = {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
link: 'text-n-brand hover:underline outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
@@ -161,7 +161,7 @@ const linkButtonClasses = computed(() => {
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
<slot v-if="label || $slots.default" name="default">
<span class="min-w-0 truncate">{{ label }}</span>
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
</slot>
</button>
</template>
@@ -43,7 +43,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'search']);
const { t } = useI18n();
@@ -118,13 +118,13 @@ watch(
<ComboBoxDropdown
ref="dropdownRef"
v-model:search-value="search"
:open="open"
:options="filteredOptions"
:search-value="search"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
:selected-values="selectedValue"
@update:search-value="search = $event"
@search="emit('search', $event)"
@select="selectOption"
/>
@@ -11,10 +11,6 @@ const props = defineProps({
type: Array,
required: true,
},
searchValue: {
type: String,
required: true,
},
searchPlaceholder: {
type: String,
default: '',
@@ -33,10 +29,15 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:searchValue', 'select']);
const emit = defineEmits(['update:searchValue', 'select', 'search']);
const { t } = useI18n();
const searchValue = defineModel('searchValue', {
type: String,
default: '',
});
const searchInput = ref(null);
const isSelected = option => {
@@ -46,6 +47,11 @@ const isSelected = option => {
return option.value === props.selectedValues;
};
const onInputSearch = event => {
searchValue.value = event.target.value;
emit('search', event.target.value);
};
defineExpose({
focus: () => searchInput.value?.focus(),
});
@@ -64,7 +70,7 @@ defineExpose({
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
@input="emit('update:searchValue', $event.target.value)"
@input="onInputSearch"
/>
</div>
<ul
@@ -7,6 +7,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
const alertDialog = ref(null);
const editDialog = ref(null);
const confirmDialog = ref(null);
const confirmDialogWithCustomFooter = ref(null);
const openAlertDialog = () => {
alertDialog.value.open();
@@ -17,6 +18,9 @@ const openEditDialog = () => {
const openConfirmDialog = () => {
confirmDialog.value.open();
};
const openConfirmDialogWithCustomFooter = () => {
confirmDialogWithCustomFooter.value.open();
};
// eslint-disable-next-line no-unused-vars
const onConfirm = dialog => {};
@@ -44,24 +48,22 @@ const onConfirm = dialog => {};
confirm-button-label="Save"
@confirm="onConfirm()"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
type="text"
placeholder="User Guide | Chatwoot"
label="Name"
message="This will be the name of your public facing portal"
/>
<Input
id="portal-slug"
type="text"
placeholder="user-guide"
label="Slug"
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
/>
</div>
</template>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
type="text"
placeholder="User Guide | Chatwoot"
label="Name"
message="This will be the name of your public facing portal"
/>
<Input
id="portal-slug"
type="text"
placeholder="user-guide"
label="Slug"
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
/>
</div>
</Dialog>
</Variant>
@@ -77,5 +79,21 @@ const onConfirm = dialog => {};
@confirm="onConfirm()"
/>
</Variant>
<Variant title="With custom footer">
<Button
label="Open Confirm Dialog with custom footer"
@click="openConfirmDialogWithCustomFooter"
/>
<Dialog
ref="confirmDialogWithCustomFooter"
title="Confirm Action"
description="Are you sure you want to perform this action?"
>
<template #footer>
<Button label="Custom Button" @click="onConfirm()" />
</template>
</Dialog>
</Variant>
</Story>
</template>
@@ -1,12 +1,12 @@
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
const props = defineProps({
type: {
type: String,
default: 'edit',
@@ -14,7 +14,7 @@ defineProps({
},
title: {
type: String,
required: true,
default: '',
},
description: {
type: String,
@@ -48,6 +48,11 @@ defineProps({
type: Boolean,
default: false,
},
width: {
type: String,
default: 'lg',
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
},
});
const emit = defineEmits(['confirm', 'close']);
@@ -59,6 +64,19 @@ const isRTL = useMapGetter('accounts/isRTL');
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const maxWidthClass = computed(() => {
const classesMap = {
'3xl': 'max-w-3xl',
'2xl': 'max-w-2xl',
xl: 'max-w-xl',
lg: 'max-w-lg',
md: 'max-w-md',
sm: 'max-w-sm',
};
return classesMap[props.width] ?? 'max-w-md';
});
const open = () => {
dialogRef.value?.showModal();
};
@@ -77,8 +95,11 @@ defineExpose({ open, close });
<Teleport to="body">
<dialog
ref="dialogRef"
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[
maxWidthClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close"
>
@@ -88,7 +109,7 @@ defineExpose({ open, close });
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@click.stop
>
<div class="flex flex-col gap-2">
<div v-if="title || description" class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ title }}
</h3>
@@ -98,28 +119,29 @@ defineExpose({ open, close });
</p>
</slot>
</div>
<slot name="form">
<!-- Form content will be injected here -->
<slot />
<!-- Dialog content will be injected here -->
<slot name="footer">
<div class="flex items-center justify-between w-full gap-3">
<Button
v-if="showCancelButton"
variant="faded"
color="slate"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
@click="close"
/>
<Button
v-if="showConfirmButton"
:color="type === 'edit' ? 'blue' : 'ruby'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isLoading"
:disabled="disableConfirmButton || isLoading"
@click="confirm"
/>
</div>
</slot>
<div class="flex items-center justify-between w-full gap-3">
<Button
v-if="showCancelButton"
variant="faded"
color="slate"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
@click="close"
/>
<Button
v-if="showConfirmButton"
:color="type === 'edit' ? 'blue' : 'ruby'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isLoading"
:disabled="disableConfirmButton || isLoading"
@click="confirm"
/>
</div>
</div>
</OnClickOutside>
</dialog>
@@ -51,5 +51,19 @@ const handleAction = () => {
/>
</div>
</Variant>
<Variant title="With search">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<DropdownMenu
:menu-items="[
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
{ label: 'Danger', action: 'delete', icon: 'delete' },
]"
show-search
@action="handleAction"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,10 +1,11 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
const props = defineProps({
menuItems: {
type: Array,
required: true,
@@ -16,21 +17,61 @@ defineProps({
type: Number,
default: 20,
},
showSearch: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['action']);
const handleAction = (action, value) => {
emit('action', { action, value });
const { t } = useI18n();
const searchInput = ref(null);
const searchQuery = ref('');
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return props.menuItems;
return props.menuItems.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
};
onMounted(() => {
if (searchInput.value && props.showSearch) {
searchInput.value.focus();
}
});
</script>
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
>
<div v-if="showSearch" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<button
v-for="item in menuItems"
v-for="item in filteredMenuItems"
:key="item.action"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
@@ -39,20 +80,29 @@ const handleAction = (action, value) => {
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item.action, item.value)"
@click="handleAction(item)"
@keydown.enter="handleAction(item)"
>
<Thumbnail
v-if="item.thumbnail"
:author="item.thumbnail"
:name="item.thumbnail.name"
:size="thumbnailSize"
:src="item.thumbnail.src"
/>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
}}</span>
</button>
<div
v-if="filteredMenuItems.length === 0"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ t('DROPDOWN_MENU.EMPTY_STATE') }}
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from './base/DropdownContainer.vue';
import DropdownBody from './base/DropdownBody.vue';
import DropdownSection from './base/DropdownSection.vue';
import DropdownItem from './base/DropdownItem.vue';
import DropdownSeparator from './base/DropdownSeparator.vue';
import WootSwitch from 'components/ui/Switch.vue';
const currentUserAutoOffline = ref(false);
const menuItems = ref([
{
label: 'Contact Support',
icon: 'i-lucide-life-buoy',
click: () => window.alert('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => window.alert('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => window.alert('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => window.alert('Change Appearance'),
},
{
label: 'Open SuperAdmin',
icon: 'i-lucide-castle',
link: '/super_admin',
target: '_blank',
},
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => window.alert('Log Out'),
},
]);
</script>
<template>
<Story
title="Components/DropdownPrimitives"
:layout="{ type: 'grid', width: 400, height: 800 }"
>
<Variant title="Profile Menu">
<div class="p-4 bg-white h-[500px] dark:bg-slate-900">
<DropdownContainer>
<template #trigger="{ toggle }">
<Button label="Open Menu" size="sm" @click="toggle" />
</template>
<DropdownBody class="w-80">
<DropdownSection title="Profile Options">
<DropdownItem label="Contact Support" class="justify-between">
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
<div class="flex-shrink-0">
<WootSwitch v-model="currentUserAutoOffline" />
</div>
</DropdownItem>
</DropdownSection>
<DropdownSeparator />
<DropdownItem
v-for="item in menuItems"
:key="item.label"
v-bind="item"
/>
</DropdownBody>
</DropdownContainer>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,9 @@
<template>
<div class="absolute">
<ul
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base"
>
<slot />
</ul>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { provideDropdownContext } from './provider.js';
const emit = defineEmits(['close']);
const [isOpen, toggle] = useToggle(false);
const closeMenu = () => {
if (isOpen.value) {
emit('close');
toggle(false);
}
};
provideDropdownContext({
isOpen,
toggle,
closeMenu,
});
</script>
<template>
<div class="relative z-20 space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" v-on-clickaway="closeMenu" class="absolute">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useDropdownContext } from './provider.js';
const props = defineProps({
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
link: { type: String, default: '' },
click: { type: Function, default: null },
preserveOpen: { type: Boolean, default: false },
});
defineOptions({
inheritAttrs: false,
});
const { closeMenu } = useDropdownContext();
const componentIs = computed(() => {
if (props.link) return 'router-link';
if (props.click) return 'button';
return 'div';
});
const triggerClick = () => {
if (props.click) {
props.click();
if (!props.preserveOpen) closeMenu();
}
};
</script>
<template>
<li class="n-dropdown-item">
<component
:is="componentIs"
v-bind="$attrs"
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0"
:class="{
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
}"
:href="props.link || null"
@click="triggerClick"
>
<slot>
<slot name="icon">
<Icon v-if="icon" class="size-4 text-n-slate-11" :icon="icon" />
</slot>
<slot name="label">{{ label }}</slot>
</slot>
</component>
</li>
</template>
@@ -0,0 +1,22 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
});
</script>
<template>
<div class="-mx-2 n-dropdown-section">
<div
v-if="title"
class="px-4 mb-3 mt-1 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ title }}
</div>
<ul class="gap-2 grid reset-base list-none px-2">
<slot />
</ul>
</div>
</template>
@@ -0,0 +1,3 @@
<template>
<div class="h-0 border-b border-n-strong -mx-2" />
</template>
@@ -0,0 +1,13 @@
import DropdownBody from './DropdownBody.vue';
import DropdownContainer from './DropdownContainer.vue';
import DropdownItem from './DropdownItem.vue';
import DropdownSection from './DropdownSection.vue';
import DropdownSeparator from './DropdownSeparator.vue';
export {
DropdownBody,
DropdownContainer,
DropdownItem,
DropdownSection,
DropdownSeparator,
};
@@ -0,0 +1,19 @@
import { inject, provide } from 'vue';
const DropdownControl = Symbol('DropdownControl');
export function useDropdownContext() {
const context = inject(DropdownControl, null);
if (context === null) {
throw new Error(
`Component is missing a parent <DropdownContainer /> component.`
);
}
return context;
}
export function provideDropdownContext(context) {
provide(DropdownControl, context);
}
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
@@ -42,9 +42,22 @@ const props = defineProps({
type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
const emit = defineEmits([
'update:modelValue',
'blur',
'input',
'focus',
'enter',
]);
const isFocused = ref(false);
const inputRef = ref(null);
const messageClass = computed(() => {
switch (props.messageType) {
@@ -62,7 +75,7 @@ const inputBorderClass = computed(() => {
case 'error':
return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
default:
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak focus:border-n-brand dark:focus:border-n-brand';
}
});
@@ -70,6 +83,28 @@ const handleInput = event => {
emit('update:modelValue', event.target.value);
emit('input', event);
};
const handleFocus = event => {
emit('focus', event);
isFocused.value = true;
};
const handleBlur = event => {
emit('blur', event);
isFocused.value = false;
};
const handleEnter = event => {
emit('enter', event);
};
onMounted(() => {
if (props.autofocus) {
nextTick(() => {
inputRef.value?.focus();
});
}
});
</script>
<template>
@@ -85,15 +120,25 @@ const handleInput = event => {
<slot name="prefix" />
<input
:id="id"
ref="inputRef"
:value="modelValue"
:class="[customInputClass, inputBorderClass]"
:class="[
customInputClass,
inputBorderClass,
{
error: messageType === 'error',
focus: isFocused,
},
]"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@blur="emit('blur')"
@focus="handleFocus"
@blur="handleBlur"
@keyup.enter="handleEnter"
/>
<p
v-if="message"
@@ -0,0 +1,46 @@
<script setup>
import { ref } from 'vue';
import SelectMenu from './SelectMenu.vue';
const sampleOptions = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
{ label: 'Option 4', value: 'option4' },
];
const selectedValue = ref('option1');
</script>
<template>
<Story
title="Components/SelectMenu"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Default">
<div class="flex flex-col gap-4 p-4 h-[400px]">
<SelectMenu
v-model="selectedValue"
:options="sampleOptions"
:label="sampleOptions.find(opt => opt.value === selectedValue)?.label"
/>
<div class="text-sm">Selected value: {{ selectedValue }}</div>
</div>
</Variant>
<Variant title="With Many Options">
<div class="flex flex-col gap-4 p-4 h-[400px]">
<SelectMenu
v-model="selectedValue"
:options="
Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i + 1}`,
value: `value${i + 1}`,
}))
"
label="Select from many"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,71 @@
<script setup>
import { ref, computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
options: {
type: Array,
required: true,
},
modelValue: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const labelValue = computed(() => props.label);
const toggleMenu = () => {
isOpen.value = !isOpen.value;
};
const handleSelect = value => {
emit('update:modelValue', value);
isOpen.value = false;
};
</script>
<template>
<div
v-on-clickaway="() => (isOpen = false)"
class="relative flex flex-col gap-1 w-fit"
>
<Button
icon="i-lucide-chevron-down"
size="sm"
trailing-icon
color="slate"
variant="faded"
class="!w-fit"
:class="{ 'dark:!bg-n-alpha-2 !bg-n-slate-9/20': isOpen }"
:label="labelValue"
@click="toggleMenu"
/>
<div
v-if="isOpen"
class="absolute ltr:left-full rtl:right-full select-none max-w-48 ltr:ml-1 rtl:mr-1 flex flex-col gap-1 bg-n-alpha-3 backdrop-blur-[100px] p-1 top-0 shadow-lg rounded-lg border border-n-weak"
>
<Button
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.value === modelValue ? 'i-lucide-check' : ''"
size="sm"
variant="ghost"
color="slate"
trailing-icon
class="!justify-end !px-2.5 !h-7"
:class="{ '!bg-n-alpha-2': option.value === modelValue }"
@click="handleSelect(option.value)"
/>
</div>
</div>
</template>
@@ -140,7 +140,12 @@ const menuItems = computed(() => {
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps => h(ChannelLeaf, { ...leafProps, inbox }),
component: leafProps =>
h(ChannelLeaf, {
label: leafProps.label,
active: leafProps.active,
inbox,
}),
})),
},
{
@@ -164,9 +169,25 @@ const menuItems = computed(() => {
},
{
name: 'Captain',
icon: 'i-lucide-bot',
icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'),
to: accountScopedRoute('captain'),
children: [
{
name: 'Documents',
label: 'Documents',
to: accountScopedRoute('captain', { page: 'documents' }),
},
{
name: 'Responses',
label: 'Responses',
to: accountScopedRoute('captain', { page: 'responses' }),
},
{
name: 'Playground',
label: 'Playground',
to: accountScopedRoute('captain', { page: 'playground' }),
},
],
},
{
name: 'Contacts',
@@ -1,24 +1,23 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
const emit = defineEmits(['showCreateAccountModal']);
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const [showDropdown, toggleDropdown] = useToggle(false);
const close = () => {
if (showDropdown.value) {
toggleDropdown(false);
}
};
const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`;
@@ -26,51 +25,45 @@ const onChangeAccount = newId => {
};
const emitNewAccount = () => {
close();
emit('showCreateAccountModal');
};
</script>
<template>
<div class="relative z-20">
<button
id="sidebar-account-switcher"
:data-account-id="accountId"
aria-haspopup="listbox"
aria-controls="account-options"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
:class="{ 'bg-n-alpha-1': showDropdown }"
@click="toggleDropdown()"
>
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<button
id="sidebar-account-switcher"
:data-account-id="accountId"
aria-haspopup="listbox"
aria-controls="account-options"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
{{ currentAccount.name }}
</span>
<span
aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/>
</button>
<div v-if="showDropdown" v-on-clickaway="close" class="absolute top-8 z-50">
<div
class="min-w-72 max-w-96 text-sm bg-n-solid-1 border border-n-weak rounded-xl shadow-sm py-4 px-2 flex flex-col gap-2"
>
<div
class="px-4 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
>
{{ t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</div>
<div class="px-1 gap-1 grid">
<button
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="flex w-full hover:bg-n-alpha-1 space-x-4"
@click="onChangeAccount(account.id)"
>
{{ currentAccount.name }}
</span>
<span
aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/>
</button>
</template>
<DropdownBody class="min-w-80">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
<DropdownItem
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="cursor-pointer"
@click="onChangeAccount(account.id)"
>
<template #label>
<div
:for="account.name"
class="text-left rtl:text-right flex gap-2 items-center"
@@ -98,19 +91,20 @@ const emitNewAccount = () => {
icon="i-lucide-check"
class="text-n-teal-11 size-5"
/>
</button>
</div>
<div v-if="globalConfig.createNewAccountFromDashboard" class="px-2">
<ButtonNext
variant="secondary"
class="w-full"
size="sm"
@click="emitNewAccount"
>
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</ButtonNext>
</div>
</div>
</div>
</div>
</template>
</DropdownItem>
</DropdownSection>
<DropdownItem v-if="globalConfig.createNewAccountFromDashboard">
<ButtonNext
color="slate"
variant="faded"
class="w-full"
size="sm"
@click="emitNewAccount"
>
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</ButtonNext>
</DropdownItem>
</DropdownBody>
</DropdownContainer>
</template>
@@ -101,7 +101,7 @@ const toggleTrigger = () => {
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="text-sm cursor-pointer select-none gap-1 grid"
class="grid gap-1 text-sm cursor-pointer select-none"
>
<SidebarGroupHeader
:icon
@@ -117,12 +117,14 @@ const toggleTrigger = () => {
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="list-none m-0 grid sidebar-group-children"
class="grid m-0 list-none sidebar-group-children"
>
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
v-bind="child"
:label="child.label"
:icon="child.icon"
:children="child.children"
:is-expanded="isExpanded"
:active-child="activeChild"
/>
@@ -4,11 +4,16 @@ import Auth from 'dashboard/api/auth';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'next/icon/Icon.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import {
DropdownContainer,
DropdownBody,
DropdownSeparator,
DropdownItem,
} from 'next/dropdown-menu/base';
const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({
@@ -21,14 +26,6 @@ const router = useRouter();
const globalConfig = useMapGetter('globalConfig/get');
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const [showProfileMenu, toggleProfileMenu] = useToggle(false);
const closeMenu = () => {
if (showProfileMenu.value) {
emit('close');
toggleProfileMenu(false);
}
};
const menuItems = computed(() => {
return [
@@ -37,7 +34,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
closeMenu();
window.$chatwoot.toggle();
},
},
@@ -46,7 +42,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
icon: 'i-lucide-keyboard',
click: () => {
closeMenu();
emit('openKeyShortcutModal');
},
},
@@ -55,20 +50,26 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
icon: 'i-lucide-user-pen',
click: () => {
closeMenu();
router.push({ name: 'profile_settings_index' });
},
},
{
show: true,
label: t('SIDEBAR_ITEMS.APPEARANCE'),
icon: 'i-lucide-swatch-book',
icon: 'i-lucide-palette',
click: () => {
closeMenu();
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'appearance_settings' });
},
},
{
show: true,
label: t('SIDEBAR_ITEMS.DOCS'),
icon: 'i-lucide-book',
click: () => {
window.open('https://www.chatwoot.com/hc/user-guide/en', '_blank');
},
},
{
show: currentUser.value.type === 'SuperAdmin',
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
@@ -79,7 +80,7 @@ const menuItems = computed(() => {
{
show: true,
label: t('SIDEBAR_ITEMS.LOGOUT'),
icon: 'i-lucide-log-out',
icon: 'i-lucide-power',
click: Auth.logout,
},
];
@@ -91,56 +92,40 @@ const allowedMenuItems = computed(() => {
</script>
<template>
<div class="relative z-20 w-full min-w-0">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
v-bind="$attrs"
:class="{
'bg-n-alpha-1': showProfileMenu,
}"
@click="toggleProfileMenu"
>
<Avatar
:size="32"
:name="currentUser.available_name"
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div class="min-w-0">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
{{ currentUser.available_name }}
</div>
<div class="text-n-slate-11 text-xs truncate">
{{ currentUser.email }}
</div>
</div>
</button>
<div
v-if="showProfileMenu"
v-on-clickaway="closeMenu"
class="absolute left-0 bottom-12 z-50"
>
<div
class="w-72 min-h-32 bg-n-solid-1 border border-n-weak rounded-xl shadow-sm"
<DropdownContainer
class="relative z-20 w-full min-w-0"
@close="emit('close')"
>
<template #trigger="{ toggle, isOpen }">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
<SidebarProfileMenuStatus />
<div class="border-t border-n-strong mx-2 my-0" />
<ul class="list-none m-0 grid gap-1 p-1 text-n-slate-12">
<li v-for="item in allowedMenuItems" :key="item.label" class="m-0">
<component
:is="item.link ? 'a' : 'button'"
v-bind="item.link ? { target: item.target, href: item.link } : {}"
class="text-left hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
@click="item.click"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.label }}
</component>
</li>
</ul>
</div>
</div>
</div>
<Avatar
:size="32"
:name="currentUser.available_name"
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div class="min-w-0">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
{{ currentUser.available_name }}
</div>
<div class="text-n-slate-11 text-xs truncate">
{{ currentUser.email }}
</div>
</div>
</button>
</template>
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
<DropdownItem v-if="item.show" v-bind="item" />
</template>
</DropdownBody>
</DropdownContainer>
</template>
@@ -1,11 +1,18 @@
<script setup>
import { computed } from 'vue';
import { computed, h } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
@@ -22,14 +29,22 @@ const statusList = computed(() => {
];
});
const statusColors = ['bg-n-teal-9', 'bg-n-amber-9', 'bg-n-slate-9'];
const availabilityStatuses = computed(() => {
return statusList.value.map((statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
color: statusColors[index],
icon: h('span', { class: [statusColors[index], 'size-[12px] rounded'] }),
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
}));
});
const activeStatus = computed(() => {
return availabilityStatuses.value.find(status => status.active);
});
function changeAvailabilityStatus(availability) {
try {
store.dispatch('updateAvailability', {
@@ -50,70 +65,58 @@ function updateAutoOffline(autoOffline) {
</script>
<template>
<div class="pt-2 text-n-slate-12">
<span
class="px-3 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ t('SIDEBAR.SET_AVAILABILITY_TITLE') }}
</span>
<ul class="list-none m-0 grid gap-1 p-1">
<li
v-for="status in availabilityStatuses"
:key="status.value"
class="flex items-baseline"
>
<button
class="text-left rtl:text-right hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
:class="{
'pointer-events-none bg-n-amber-10/10': status.active,
'bg-n-teal-3': status.active && status.value === 'online',
'bg-n-amber-3': status.active && status.value === 'busy',
'bg-n-slate-3': status.active && status.value === 'offline',
}"
@click="changeAvailabilityStatus(status.value)"
>
<div
class="rounded-full w-2.5 h-2.5"
:class="{
'bg-n-teal-10': status.value === 'online',
'bg-n-amber-10': status.value === 'busy',
'bg-n-slate-10': status.value === 'offline',
}"
/>
<span class="flex-grow">{{ status.label }}</span>
<Icon
v-if="status.active"
icon="i-lucide-check"
class="size-4 flex-shrink-0"
:class="{
'text-n-teal-11': status.value === 'online',
'text-n-amber-11': status.value === 'busy',
'text-n-slate-11': status.value === 'offline',
}"
/>
</button>
</li>
</ul>
</div>
<div class="border-t border-n-strong mx-2 my-0" />
<ul class="list-none m-0 grid gap-1 p-1">
<li class="px-2 py-1.5 flex items-start w-full gap-2">
<div class="h-5 flex items-center flex-shrink-0">
<Icon icon="i-lucide-info" class="size-4" />
</div>
<div class="flex-grow">
<div class="h-5 leading-none flex place-items-center text-n-slate-12">
<DropdownSection>
<div class="grid gap-0">
<DropdownItem>
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div>
<DropdownContainer>
<template #trigger="{ toggle }">
<Button
size="sm"
color="slate"
variant="faded"
class="min-w-[96px]"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggle"
>
<div class="flex gap-1 items-center flex-grow text-sm">
<div class="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" />
</div>
<span>{{ activeStatus.label }}</span>
</div>
</Button>
</template>
<DropdownBody class="min-w-32">
<DropdownItem
v-for="status in availabilityStatuses"
:key="status.value"
:label="status.label"
:icon="status.icon"
class="cursor-pointer"
@click="changeAvailabilityStatus(status.value)"
/>
</DropdownBody>
</DropdownContainer>
</DropdownItem>
<DropdownItem>
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info"
class="size-4 text-n-slate-10"
/>
</div>
<div class="text-xs leading-tight text-n-slate-10 mt-1">
{{ t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT') }}
</div>
</div>
<woot-switch
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</li>
</ul>
<woot-switch
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</DropdownItem>
</div>
</DropdownSection>
</template>
@@ -52,7 +52,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon
class="my-1"
/>
<ul class="m-0 list-none relative group">
<ul class="m-0 list-none reset-base relative group">
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div
@@ -46,6 +46,13 @@ const handleClickOutside = () => {
}
};
const handleKeydown = event => {
if (event.key === ',') {
event.preventDefault();
addTag();
}
};
watch(
() => props.modelValue,
newValue => {
@@ -81,6 +88,7 @@ watch(
:placeholder="placeholder"
custom-input-class="flex-grow"
@enter-press="addTag"
@keydown="handleKeydown"
/>
</div>
</OnClickOutside>
@@ -1,117 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { removeEmoji } from 'shared/helpers/emoji';
const props = defineProps({
author: {
type: Object,
default: null,
},
name: {
type: String,
default: '',
},
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 16,
},
showAuthorName: {
type: Boolean,
default: true,
},
iconName: {
type: String,
default: '',
},
});
const { t } = useI18n();
const hasImageLoaded = ref(false);
const imgError = ref(false);
const authorInitial = computed(() => {
if (!props.name) return '';
const name = removeEmoji(props.name);
const words = name.split(/\s+/);
if (words.length === 1) {
return name.substring(0, 2).toUpperCase();
}
return words
.slice(0, 2)
.map(word => word[0])
.join('')
.toUpperCase();
});
const fontSize = computed(() => {
return props.size / 2;
});
const iconSize = computed(() => {
return Math.round(props.size / 1.8);
});
const shouldShowImage = computed(() => {
return props.src && !imgError.value;
});
const onImgError = () => {
imgError.value = true;
};
const onImgLoad = () => {
hasImageLoaded.value = true;
};
</script>
<template>
<div
class="flex items-center justify-center rounded-full bg-n-slate-3 dark:bg-n-slate-4"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<div v-if="author" class="flex items-center justify-center">
<img
v-if="shouldShowImage"
:src="src"
:alt="name"
class="w-full h-full rounded-full"
@load="onImgLoad"
@error="onImgError"
/>
<template v-else>
<span
v-if="showAuthorName"
class="flex items-center justify-center font-medium text-n-slate-11"
:style="{ fontSize: `${fontSize}px` }"
>
{{ authorInitial }}
</span>
<div
v-else
class="flex items-center justify-center w-full h-full rounded-xl"
>
<span
v-if="iconName"
:class="`${iconName} text-n-brand/70`"
:style="{ width: `${iconSize}px`, height: `${iconSize}px` }"
/>
</div>
</template>
</div>
<div
v-else
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
class="flex items-center justify-center w-4 h-4 rounded-full bg-n-slate-3 dark:bg-n-slate-4"
>
<span class="i-lucide-user size-2.5 text-n-brand" />
</div>
</div>
</template>
@@ -22,7 +22,7 @@ const primaryMenuItems = accountId => [
key: 'captain',
label: 'CAPTAIN',
featureFlag: FEATURE_FLAGS.CAPTAIN,
toState: frontendURL(`accounts/${accountId}/captain`),
toState: frontendURL(`accounts/${accountId}/captain/documents`),
toStateName: 'captain',
},
{
@@ -247,7 +247,7 @@ export default {
<transition-group
name="menu-list"
tag="ul"
class="pt-2 mb-0 ml-0 list-none"
class="pt-2 reset-base list-none"
>
<SecondaryNavItem
v-for="menuItem in accessibleMenuItems"
@@ -242,7 +242,7 @@ export default {
</span>
</router-link>
<ul v-if="hasSubMenu" class="mb-0 ml-0 list-none">
<ul v-if="hasSubMenu" class="reset-base list-none">
<SecondaryChildNavItem
v-for="child in menuItem.children"
:key="child.id"
@@ -53,7 +53,7 @@ describe('AccountSelector', () => {
it('title and sub title exist', () => {
const headerComponent = accountSelector.findComponent(WootModalHeader);
const title = headerComponent.find('[data-test-id="modal-header-title"]');
expect(title.text()).toBe('Switch Account');
expect(title.text()).toBe('Switch account');
const content = headerComponent.find(
'[data-test-id="modal-header-content"]'
);
@@ -8,6 +8,10 @@
"EMPTY_STATE": "No results found.",
"SEARCH_PLACEHOLDER": "Search..."
},
"DROPDOWN_MENU": {
"SEARCH_PLACEHOLDER": "Search...",
"EMPTY_STATE": "No results found."
},
"DIALOG": {
"BUTTONS": {
"CANCEL": "Cancel",
@@ -177,14 +177,16 @@
},
"SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "Change",
"CHANGE_ACCOUNTS": "Switch Account",
"CONTACT_SUPPORT": "Contact Support",
"CHANGE_ACCOUNTS": "Switch account",
"SWITCH_WORKSPACE": "Switch workspace",
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile Settings",
"KEYBOARD_SHORTCUTS": "Keyboard Shortcuts",
"APPEARANCE": "Change Appearance",
"SUPER_ADMIN_CONSOLE": "Super Admin Console",
"LOGOUT": "Logout"
"PROFILE_SETTINGS": "Profile settings",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"DOCS": "Read documentation",
"LOGOUT": "Log out"
},
"APP_GLOBAL": {
"TRIAL_MESSAGE": "days trial remaining.",
@@ -279,6 +281,7 @@
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"SET_YOUR_AVAILABILITY": "Set your availability",
"SLA": "SLA",
"CUSTOM_ROLES": "Custom Roles",
"BETA": "Beta",
@@ -1,75 +1,107 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { nextTick, watch, computed } from 'vue';
import IntegrationsAPI from 'dashboard/api/integrations';
import { useStoreGetters } from 'dashboard/composables/store';
import { makeRouter, setupApp } from '@chatwoot/captain';
import integrations from '../../api/integrations';
import Spinner from 'shared/components/Spinner.vue';
const isLoading = ref(true);
const captainURL = ref('');
const hasError = ref(false);
const loadCaptainFrame = async integration => {
if (!integration || !integration.enabled) {
return;
}
try {
isLoading.value = true;
const { data } = await integrations.fetchCaptainURL();
captainURL.value = data.sso_url;
} catch (error) {
hasError.value = true;
} finally {
isLoading.value = false;
}
};
const props = defineProps({
page: {
type: String,
required: true,
},
});
const getters = useStoreGetters();
const routeMap = {
documents: '/app/accounts/[account_id]/documents/',
playground: '/app/accounts/[account_id]/playground/',
responses: '/app/accounts/[account_id]/responses/',
};
const resolvedRoute = computed(() => routeMap[props.page]);
let router = null;
watch(
() => props.page,
() => {
if (router) {
router.push({ name: resolvedRoute.value });
}
},
{ immediate: true }
);
const buildApp = () => {
router = makeRouter();
setupApp('#captain', {
router,
fetchFn: async (source, options) => {
const parsedSource = new URL(source);
let path = parsedSource.pathname;
if (path === `/api/sessions/profile`) {
path = '/sessions/profile';
} else {
path = path.replace(/^\/api\/accounts\/\d+/, '');
}
// include search params
path = `${path}${parsedSource.search}`;
const response = await IntegrationsAPI.requestCaptain({
method: options.method ?? 'GET',
route: path,
body: options.body ? JSON.parse(options.body) : null,
});
return {
json: () => {
return response.data;
},
ok: response.status >= 200 && response.status < 300,
status: response.status,
headers: response.headers,
};
},
});
router.push({ name: resolvedRoute.value });
};
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
onMounted(() => loadCaptainFrame(captainIntegration.value));
watch(captainIntegration, updatedIntegration =>
loadCaptainFrame(updatedIntegration)
watch(
() => captainIntegration.value,
(newValue, prevValue) => {
if (!prevValue && newValue) {
nextTick(() => buildApp());
}
},
{ immediate: true }
);
</script>
<template>
<div
class="flex-1 overflow-auto flex gap-8 flex-col font-inter text-slate-900 dark:text-slate-500"
>
<div class="flex-1 flex items-center justify-center">
<div v-if="!captainIntegration">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
</div>
<div
v-else-if="!captainIntegration.enabled"
class="flex-1 flex flex-col gap-2 items-center justify-center"
>
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
<router-link :to="{ name: 'settings_applications' }">
<woot-button class="clear link">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
</woot-button>
</router-link>
</div>
<div
v-else-if="isLoading"
class="flex-1 flex items-center justify-center"
>
<Spinner color-scheme="primary" />
<span>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.LOADING_CONSOLE') }}</span>
</div>
<div v-else-if="!isLoading && hasError">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.FAILED_TO_LOAD_CONSOLE') }}
</div>
<iframe
v-else-if="!isLoading && captainURL"
:src="captainURL"
class="w-full min-h-[800px] h-full"
/>
</div>
<div v-if="!captainIntegration">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
</div>
<div
v-else-if="!captainIntegration.enabled"
class="flex-1 flex flex-col gap-2 items-center justify-center"
>
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
<router-link :to="{ name: 'settings_applications' }">
<woot-button class="clear link">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
</woot-button>
</router-link>
</div>
<div v-else id="captain" class="w-full" />
</template>
<style>
@import '@chatwoot/captain/dist/style.css';
</style>
@@ -21,13 +21,14 @@ export default {
component: AppContainer,
children: [
{
path: frontendURL('accounts/:accountId/captain'),
path: frontendURL('accounts/:accountId/captain/:page'),
name: 'captain',
component: Captain,
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
},
props: true,
},
...inboxRoutes,
...conversation.routes,
@@ -6,16 +6,6 @@ import { GROUP_BY_FILTER } from '../constants';
import { generateFileName } from '../../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
const REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count',
INCOMING_MESSAGES: 'incoming_messages_count',
OUTGOING_MESSAGES: 'outgoing_messages_count',
FIRST_RESPONSE_TIME: 'avg_first_response_time',
RESOLUTION_TIME: 'avg_resolution_time',
RESOLUTION_COUNT: 'resolutions_count',
REPLY_TIME: 'reply_time',
};
const GROUP_BY_OPTIONS = {
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
WEEK: [
@@ -72,6 +62,22 @@ export default {
filterItemsList() {
return this.$store.getters[this.getterKey] || [];
},
isAgentType() {
return this.type === 'agent';
},
reportKeys() {
return {
CONVERSATIONS: 'conversations_count',
...(!this.isAgentType && {
INCOMING_MESSAGES: 'incoming_messages_count',
}),
OUTGOING_MESSAGES: 'outgoing_messages_count',
FIRST_RESPONSE_TIME: 'avg_first_response_time',
RESOLUTION_TIME: 'avg_resolution_time',
RESOLUTION_COUNT: 'resolutions_count',
REPLY_TIME: 'reply_time',
};
},
},
mounted() {
this.$store.dispatch(this.actionKey);
@@ -92,19 +98,11 @@ export default {
}
},
fetchChartData() {
[
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
'REPLY_TIME',
].forEach(async key => {
Object.keys(this.reportKeys).forEach(async key => {
try {
const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', {
metric: REPORTS_KEYS[key],
metric: this.reportKeys[key],
from,
to,
type: this.type,
@@ -220,6 +218,10 @@ export default {
@group-by-filter-change="onGroupByFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/>
<ReportContainer v-if="filterItemsList.length" :group-by="groupBy" />
<ReportContainer
v-if="filterItemsList.length"
:group-by="groupBy"
:report-keys="reportKeys"
/>
</div>
</template>
+31 -4
View File
@@ -151,8 +151,15 @@ export const actions = {
}
},
updateAvailability: async ({ commit, dispatch }, params) => {
updateAvailability: async (
{ commit, dispatch, getters: _getters },
params
) => {
const previousStatus = _getters.getCurrentUserAvailability;
try {
// optimisticly update current status
commit(types.SET_CURRENT_USER_AVAILABILITY, params.availability);
const response = await authAPI.updateAvailability(params);
const userData = response.data;
const { id } = userData;
@@ -162,16 +169,23 @@ export const actions = {
availabilityStatus: params.availability,
});
} catch (error) {
// Ignore error
// revert back to previous status if update fails
commit(types.SET_CURRENT_USER_AVAILABILITY, previousStatus);
}
},
updateAutoOffline: async ({ commit }, { accountId, autoOffline }) => {
updateAutoOffline: async (
{ commit, getters: _getters },
{ accountId, autoOffline }
) => {
const previousAutoOffline = _getters.getCurrentUserAutoOffline;
try {
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, autoOffline);
const response = await authAPI.updateAutoOffline(accountId, autoOffline);
commit(types.SET_CURRENT_USER, response.data);
} catch (error) {
// Ignore error
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, previousAutoOffline);
}
},
@@ -212,6 +226,19 @@ export const mutations = {
accounts,
};
},
[types.SET_CURRENT_USER_AUTO_OFFLINE](_state, autoOffline) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === _state.currentUser.account_id) {
return { ...account, autoOffline: autoOffline };
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.CLEAR_USER](_state) {
_state.currentUser = initialState.currentUser;
},
@@ -1,7 +1,7 @@
import axios from 'axios';
import Cookies from 'js-cookie';
import { actions } from '../../auth';
import * as types from '../../../mutation-types';
import types from '../../../mutation-types';
import * as APIHelpers from '../../../utils/api';
import '../../../../routes';
@@ -25,7 +25,7 @@ describe('#actions', () => {
await actions.validityCheck({ commit });
expect(APIHelpers.setUser).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([
[types.default.SET_CURRENT_USER, { id: 1, name: 'John' }],
[types.SET_CURRENT_USER, { id: 1, name: 'John' }],
]);
});
it('sends correct actions if API is error', async () => {
@@ -45,7 +45,7 @@ describe('#actions', () => {
});
await actions.updateProfile({ commit }, { name: 'Pranav' });
expect(commit.mock.calls).toEqual([
[types.default.SET_CURRENT_USER, { id: 1, name: 'John' }],
[types.SET_CURRENT_USER, { id: 1, name: 'John' }],
]);
});
});
@@ -61,12 +61,13 @@ describe('#actions', () => {
headers: { expiry: 581842904 },
});
await actions.updateAvailability(
{ commit, dispatch },
{ commit, dispatch, getters: { getCurrentUserAvailability: 'online' } },
{ availability: 'offline', account_id: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AVAILABILITY, 'offline'],
[
types.default.SET_CURRENT_USER,
types.SET_CURRENT_USER,
{
id: 1,
name: 'John',
@@ -81,6 +82,18 @@ describe('#actions', () => {
],
]);
});
it('sends correct actions if API is a failure', async () => {
axios.post.mockRejectedValue({ error: 'Authentication Failure' });
await actions.updateAvailability(
{ commit, dispatch, getters: { getCurrentUserAvailability: 'online' } },
{ availability: 'offline', account_id: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AVAILABILITY, 'offline'],
[types.SET_CURRENT_USER_AVAILABILITY, 'online'],
]);
});
});
describe('#updateAutoOffline', () => {
@@ -99,12 +112,13 @@ describe('#actions', () => {
headers: { expiry: 581842904 },
});
await actions.updateAutoOffline(
{ commit, dispatch },
{ commit, dispatch, getters: { getCurrentUserAutoOffline: true } },
{ autoOffline: false, accountId: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AUTO_OFFLINE, false],
[
types.default.SET_CURRENT_USER,
types.SET_CURRENT_USER,
{
id: 1,
name: 'John',
@@ -113,6 +127,17 @@ describe('#actions', () => {
],
]);
});
it('sends correct actions if API is failure', async () => {
axios.post.mockRejectedValue({ error: 'Authentication Failure' });
await actions.updateAutoOffline(
{ commit, dispatch, getters: { getCurrentUserAutoOffline: true } },
{ autoOffline: false, accountId: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AUTO_OFFLINE, false],
[types.SET_CURRENT_USER_AUTO_OFFLINE, true],
]);
});
});
describe('#updateUISettings', () => {
@@ -132,11 +157,11 @@ describe('#actions', () => {
);
expect(commit.mock.calls).toEqual([
[
types.default.SET_CURRENT_USER_UI_SETTINGS,
types.SET_CURRENT_USER_UI_SETTINGS,
{ uiSettings: { is_contact_sidebar_open: false } },
],
[
types.default.SET_CURRENT_USER,
types.SET_CURRENT_USER,
{
id: 1,
name: 'John',
@@ -160,8 +185,8 @@ describe('#actions', () => {
Cookies.get.mockImplementation(() => false);
actions.setUser({ commit, dispatch });
expect(commit.mock.calls).toEqual([
[types.default.CLEAR_USER],
[types.default.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }],
[types.CLEAR_USER],
[types.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }],
]);
expect(dispatch).toHaveBeenCalledTimes(0);
});
@@ -177,7 +202,7 @@ describe('#actions', () => {
{ 1: 'online' }
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CURRENT_USER_AVAILABILITY, 'online'],
[types.SET_CURRENT_USER_AVAILABILITY, 'online'],
]);
});
@@ -3,6 +3,7 @@ export default {
CLEAR_USER: 'LOGOUT',
SET_CURRENT_USER: 'SET_CURRENT_USER',
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
+2
View File
@@ -2,6 +2,7 @@ import './design-system/histoire.scss';
import { defineSetupVue3 } from '@histoire/plugin-vue';
import i18nMessages from 'dashboard/i18n';
import { createI18n } from 'vue-i18n';
import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
const i18n = createI18n({
@@ -13,4 +14,5 @@ const i18n = createI18n({
export const setupVue3 = defineSetupVue3(({ app }) => {
app.use(store);
app.use(i18n);
app.directive('resize', vResizeObserver);
});
@@ -114,31 +114,10 @@ function setContextValue(code) {
context.node.input(localValue.value);
}
function dynamicallySetCountryCode(value) {
const safeValue = unref(value);
// This function is used to set the country code dynamically.
// The country and dial code is used to set from the value of the phone number field in the pre-chat form.
if (!safeValue) return;
// check the number first four digit and check weather it is available in the countries array or not.
const country = countries.value.find(code =>
safeValue.startsWith(code.dial_code)
);
if (country) {
// if it is available then set the country code and dial code.
activeCountryCode.value = country.id;
activeDialCode.value = country.dial_code;
// set the phone number without dial code.
phoneNumber.value = safeValue.replace(country.dial_code, '');
}
}
function onChange(e) {
phoneNumber.value = e.target.value;
dynamicallySetCountryCode(phoneNumber);
// This function is used to set the context value when the user types in the phone number field.
setContextValue(activeDialCode);
setContextValue(activeDialCode.value);
}
function focusedOrActiveItem(className) {
@@ -157,8 +136,8 @@ function scrollToFocusedOrActiveItem(item) {
if (focusedOrActiveItemLocal.length > 0) {
const dropdown = dropdownRef.value;
const dropdownHeight = dropdown.clientHeight;
const itemTop = focusedOrActiveItem[0].offsetTop;
const itemHeight = focusedOrActiveItem[0].offsetHeight;
const itemTop = focusedOrActiveItemLocal[0]?.offsetTop;
const itemHeight = focusedOrActiveItemLocal[0]?.offsetHeight;
const scrollPosition = itemTop - dropdownHeight / 2 + itemHeight / 2;
dropdown.scrollTo({
top: scrollPosition,
+14 -1
View File
@@ -8,8 +8,21 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
avatar_url,
max_size: 15 * 1024 * 1024
)
avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
if valid_image?(avatar_file)
avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename,
content_type: avatar_file.content_type)
end
rescue Down::NotFound, Down::Error => e
Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
end
private
def valid_image?(file)
return false if file.original_filename.blank?
# TODO: check if the file is an actual image
true
end
end
@@ -1,5 +0,0 @@
class Migration::Search::MessageReindexJob < ApplicationJob
queue :searchkick
end
-4
View File
@@ -38,10 +38,6 @@
#
class Message < ApplicationRecord
searchkick batch_size: 10_000 if SearchConfig.enabled?
scope :search_import, -> { includes(:inbox, :conversation, :sender) }
include MessageFilterHelpers
include Liquidable
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
+1 -1
View File
@@ -77,7 +77,7 @@ class MailPresenter < SimpleDelegator
mail.attachments.map do |attachment|
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(attachment.body.to_s),
filename: attachment.filename,
filename: attachment.filename.presence || "attachment_#{SecureRandom.hex(4)}",
content_type: attachment.content_type
)
{ original: attachment, blob: blob }
@@ -4,7 +4,7 @@
class Line::IncomingMessageService
include ::FileTypeHelper
pattr_initialize [:inbox!, :params!]
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/iphone/sticker.png'.freeze
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
def perform
# probably test events
+5 -16
View File
@@ -34,22 +34,11 @@ class SearchService
end
def filter_messages
@messages = if SearchConfig.enabled?
Message.search(
search_query, where: {
inbox_id: accessable_inbox_ids,
account_id: current_account.id
}, order: { created_at: :desc }, limit: 20
)
else
current_account
.messages
.where(inbox_id: accessable_inbox_ids)
.where('messages.content ILIKE :search', search: "%#{search_query}%")
.where('created_at >= ?', 3.months.ago)
.reorder('created_at DESC')
.limit(10)
end
@messages = current_account.messages.where(inbox_id: accessable_inbox_ids)
.where('messages.content ILIKE :search', search: "%#{search_query}%")
.where('created_at >= ?', 3.months.ago)
.reorder('created_at DESC')
.limit(10)
end
def filter_contacts
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.14.1'
version: '3.15.0'
development:
<<: *shared
+6
View File
@@ -27,6 +27,12 @@ if ENV.fetch('SENTRY_DSN', false).present?
require 'sentry-sidekiq'
end
# heroku autoscaling
if ENV.fetch('JUDOSCALE_URL', false).present?
require 'judoscale-rails'
require 'judoscale-sidekiq'
end
module Chatwoot
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
+6
View File
@@ -0,0 +1,6 @@
require 'rack-timeout'
# Reduce noise by filtering state=ready and state=completed which are logged at INFO level
Rails.application.config.after_initialize do
Rack::Timeout::Logger.level = Logger::ERROR
end
-32
View File
@@ -1,32 +0,0 @@
class SearchConfig
class << self
def enabled?
opensearch_url.present?
end
def setup!
return unless enabled?
Searchkick.client_options = client_options
end
def client_options
{
url: opensearch_url,
transport_options: {
request: { timeout: timeout }
}
}
end
private
def opensearch_url
ENV.fetch('OPENSEARCH_URL', nil)
end
def timeout
ENV.fetch('OPENSEARCH_TIMEOUT', 10).to_i
end
end
end
+21 -2
View File
@@ -17,6 +17,14 @@ common: &default_settings
distributed_tracing:
enabled: true
sampling:
rate: 0.1 # Sample 10% of traces instead of 100%
# Source: https://docs.newrelic.com/docs/apm/agents/ruby-agent/configuration/ruby-agent-configuration/#attributes
attributes:
exclude:
- request.headers.* # Exclude request headers from traces
- response.headers.* # Exclude response headers from traces
# To disable the agent regardless of other settings, uncomment the following:
agent_enabled: <%= ENV['NEW_RELIC_LICENSE_KEY'].present? && ENV.fetch('NEW_RELIC_AGENT_ENABLED', true) %>
@@ -32,7 +40,7 @@ common: &default_settings
# If `true`, the agent captures log records emitted by this application
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED', true) == "false" ? false : true %>
# Defines the maximum number of log records to buffer in memory at a time.
max_samples_stored: 30000
max_samples_stored: 10000
metrics:
# If `true`, the agent captures metrics related to logging for this application.
enabled: true
@@ -42,6 +50,16 @@ common: &default_settings
# This should not be used when forwarding is enabled.
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_DECORATING_ENABLED', false) %>
# Transaction Tracer settings
# Source: https://docs.newrelic.com/docs/apm/agents/ruby-agent/configuration/ruby-agent-configuration/#transaction_tracer
transaction_tracer:
enabled: true # default: true
transaction_threshold: 4.0 # default 2s to 4s - only trace slower transactions
record_sql: obfuscated # Keep default - helps with security
stack_trace_threshold: 4.0 # Increase from 0.5s to 4s - reduce stack traces
explain_enabled: false # Disable SQL explain plans (default: true)
explain_threshold: 5.0 # Increase from 0.5s to 5s, only relevant if explain_enabled is true
# Environment-specific settings are in this section.
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
@@ -53,11 +71,12 @@ development:
test:
<<: *default_settings
# It doesn't make sense to report to New Relic from automated test runs.
monitor_mode: false
monitor_mode: <%= ENV.fetch('NEW_RELIC_MONITORING_ENABLED', false) %>
staging:
<<: *default_settings
app_name: <%= ENV.fetch('NEW_RELIC_APP_NAME', 'Chatwoot') %> (Staging)
monitor_mode: <%= ENV.fetch('NEW_RELIC_MONITORING_ENABLED', false) %>
production:
<<: *default_settings
+1 -1
View File
@@ -219,7 +219,7 @@ Rails.application.routes.draw do
resources :apps, only: [:index, :show]
resource :captain, controller: 'captain', only: [] do
collection do
get :sso_url
post :proxy
end
end
resources :hooks, only: [:show, :create, :update, :destroy] do
+16 -16
View File
@@ -8,21 +8,21 @@ internal_check_new_versions_job:
class: 'Internal::CheckNewVersionsJob'
queue: scheduled_jobs
# # executed At every 5th minute..
# trigger_scheduled_items_job:
# cron: '*/5 * * * *'
# class: 'TriggerScheduledItemsJob'
# queue: scheduled_jobs
# executed At every 5th minute..
trigger_scheduled_items_job:
cron: '*/5 * * * *'
class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs
# # executed At every minute..
# trigger_imap_email_inboxes_job:
# cron: '*/1 * * * *'
# class: 'Inboxes::FetchImapEmailInboxesJob'
# queue: scheduled_jobs
# executed At every minute..
trigger_imap_email_inboxes_job:
cron: '*/1 * * * *'
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
# # executed daily at 2230 UTC
# # which is our lowest traffic time
# remove_stale_contact_inboxes_job.rb:
# cron: '30 22 * * *'
# class: 'Internal::RemoveStaleContactInboxesJob'
# queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_contact_inboxes_job.rb:
cron: '30 22 * * *'
class: 'Internal::RemoveStaleContactInboxesJob'
queue: scheduled_jobs
-1
View File
@@ -23,7 +23,6 @@
- action_mailbox_routing
- low
- scheduled_jobs
- searchkick
- async_database_migration
- active_storage_analysis
- active_storage_purge
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.14.1",
"version": "3.15.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -31,6 +31,7 @@
],
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/captain": "0.0.3-alpha.4",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.25",
@@ -83,7 +84,7 @@
"video.js": "7.18.1",
"videojs-record": "4.5.0",
"videojs-wavesurfer": "3.8.0",
"vue": "^3.5.8",
"vue": "^3.5.12",
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
@@ -101,10 +102,10 @@
},
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.8.1",
"@iconify-json/ri": "^1.2.1",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.0",
"@iconify-json/lucide": "^1.2.10",
"@iconify-json/logos": "^1.2.3",
"@iconify-json/lucide": "^1.2.11",
"@iconify-json/ri": "^1.2.3",
"@size-limit/file": "^8.2.4",
"@vitest/coverage-v8": "2.0.1",
"@vue/test-utils": "^2.4.6",
+492 -141
View File
File diff suppressed because it is too large Load Diff
@@ -1,65 +1,79 @@
require 'rails_helper'
RSpec.describe 'Captain Integrations API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let!(:account) { create(:account) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:hook) do
create(:integrations_hook, account: account, app_id: 'captain', settings: {
access_token: SecureRandom.hex,
account_email: Faker::Internet.email,
assistant_id: '1',
account_id: '1'
})
end
let(:captain_api_url) { 'https://captain.example.com/' }
describe 'GET /api/v1/accounts/{account.id}/integrations/captain/sso_url' do
before do
InstallationConfig.where(name: 'CAPTAIN_API_URL').first_or_create(value: captain_api_url)
end
describe 'POST /api/v1/accounts/{account.id}/integrations/captain/proxy' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
as: :json
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'get', route: 'some_route' },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'return unauthorized if agent' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: agent.create_new_auth_token,
as: :json
context 'when valid request method and route' do
let(:route) { 'some_route' }
let(:method) { 'get' }
expect(response).to have_http_status(:unauthorized)
it 'proxies the request to Captain API' do
stub_request(:get, "#{captain_api_url}api/accounts/#{hook.settings['account_id']}/#{route}")
.with(headers: {
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json'
})
.to_return(status: 200, body: 'Success', headers: {})
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: method, route: route },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to eq('Success')
end
end
it 'returns 404 if hook is not available' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: admin.create_new_auth_token,
as: :json
context 'when HTTP method is invalid' do
it 'returns unprocessable entity' do
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'invalid', route: 'some_route', body: { some: 'data' } },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:internal_server_error)
end
end
it 'returns sso url if hook is available' do
InstallationConfig.where(name: 'CAPTAIN_APP_URL').first_or_create(value: 'https://app.chatwoot.com')
context 'when the hook is not found' do
before { hook.destroy }
hook = create(:integrations_hook, account: account, app_id: 'captain', settings: {
access_token: SecureRandom.hex,
account_email: Faker::Internet.email,
account_id: '1',
assistant_id: '1',
inbox_ids: '1'
})
it 'returns not found' do
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'get', route: 'some_route' },
headers: agent.create_new_auth_token,
as: :json
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = response.parsed_body
params_string = "token=#{URI.encode_www_form_component(hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(hook['settings']['account_id'])}"
sso_url = "https://app.chatwoot.com/sso?#{params_string}"
expect(data['sso_url']).to eq(sso_url)
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -60,11 +60,10 @@ RSpec.describe 'Platform Accounts API', type: :request do
} }, headers: { api_access_token: platform_app.access_token.token }, as: :json
json_response = response.parsed_body
created_account = Account.find(json_response['id'])
expect(created_account.enabled_features.keys).to match_array(%w[inbox_management ip_lookup help_center])
expect(json_response['name']).to include('Test Account')
expect(json_response['features']['inbox_management']).to be(true)
expect(json_response['features']['ip_lookup']).to be(true)
expect(json_response['features']['help_center']).to be(true)
expect(json_response['features']['disable_branding']).to be_nil
expect(json_response['features'].keys).to match_array(%w[inbox_management ip_lookup help_center])
end
it 'creates an account with limits settings' do
+43
View File
@@ -0,0 +1,43 @@
From: test@gmail.com
Date: Thu, 4 May 2023 10:35:52 +0530
Message-ID: <6215d536e0484_10bc6191402183@tejaswinis-MacBook-Pro.local.mail>
Subject: multiple attachments
To: test@outlook.com
Content-Type: multipart/mixed; boundary="0000000000002488f405fad721cc"
--0000000000002488f405fad721cc
Content-Type: multipart/alternative; boundary="0000000000002488f205fad721ca"
--0000000000002488f205fad721ca
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
Hi people!
We are excited to inform you that we have recently released some new
features and several updates to our platform. These features and updates
are designed to enhance your experience and make your trading journey
seamless and efficient.
> Okay noted
--0000000000002488f205fad721ca--
--0000000000002488f405fad721cc
Content-Type: image/png; name=""
Content-Disposition: attachment; filename=""
Content-Transfer-Encoding: base64
Content-ID: <f_lh8nwk8l3>
X-Attachment-Id: f_lh8nwk8l3
--0000000000002488f405fad721cc
Content-Type: image/png; name=""
Content-Disposition: attachment; filename=""
Content-Transfer-Encoding: base64
Content-ID: <f_lh8nwk8l2>
X-Attachment-Id: f_lh8nwk8l2
--0000000000002488f405fad721cc--
@@ -17,4 +17,20 @@ RSpec.describe Avatar::AvatarFromUrlJob do
described_class.perform_now(avatarable, avatar_url)
expect(avatarable.avatar).to be_attached
end
# ref: https://github.com/chatwoot/chatwoot/issues/10449
it 'will not throw error if the avatar url is not valid and the file does not have a filename' do
# Create a temporary file with no filename and content type application/xml
temp_file = Tempfile.new(['invalid', '.xml'])
temp_file.write('<invalid>content</invalid>')
temp_file.rewind
expect(Down).to receive(:download).with(avatar_url, max_size: 15 * 1024 * 1024)
.and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml'))
expect { described_class.perform_now(avatarable, avatar_url) }.not_to raise_error
temp_file.close
temp_file.unlink # deletes the temp file
end
end
+16
View File
@@ -43,6 +43,22 @@ RSpec.describe Imap::ImapMailbox do
end
end
context 'when the email has attachments with no filename' do
let(:inbound_mail) { create_inbound_email_from_fixture('attachments_without_filename.eml') }
it 'creates a conversation and a message with properly named attachments' do
expect do
class_instance.process(inbound_mail.mail, channel)
end.to change(Conversation, :count).by(1)
last_message = conversation.messages.last
expect(last_message.attachments.count).to be 2
filenames = last_message.attachments.map(&:file).map { |file| file.blob.filename.to_s }
expect(filenames.all? { |filename| filename.present? && filename.start_with?('attachment_') }).to be true
end
end
context 'when the email has 15 or more attachments' do
let(:inbound_mail) { create_inbound_email_from_fixture('multiple_attachments.eml') }
@@ -178,7 +178,7 @@ describe Line::IncomingMessageService do
described_class.new(inbox: line_channel.inbox, params: sticker_params).perform
expect(line_channel.inbox.conversations).not_to eq(0)
expect(Contact.all.first.name).to eq('LINE Test')
expect(line_channel.inbox.messages.first.content).to eq('![sticker-52002738](https://stickershop.line-scdn.net/stickershop/v1/sticker/52002738/iphone/sticker.png)')
expect(line_channel.inbox.messages.first.content).to eq('![sticker-52002738](https://stickershop.line-scdn.net/stickershop/v1/sticker/52002738/android/sticker.png)')
end
end
+7
View File
@@ -117,6 +117,13 @@ const tailwindConfig = {
width: 2,
height: 12,
},
captain: {
body: `<path d="M150.485 213.282C150.485 200.856 160.559 190.782 172.985 190.782C185.411 190.782 195.485 200.856 195.485 213.282V265.282C195.485 277.709 185.411 287.782 172.985 287.782C160.559 287.782 150.485 277.709 150.485 265.282V213.282Z" fill="currentColor"/>
<path d="M222.485 213.282C222.485 200.856 232.559 190.782 244.985 190.782C257.411 190.782 267.485 200.856 267.485 213.282V265.282C267.485 277.709 257.411 287.782 244.985 287.782C232.559 287.782 222.485 277.709 222.485 265.282V213.282Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M412.222 109.961C317.808 96.6217 240.845 96.0953 144.309 109.902C119.908 113.392 103.762 115.751 91.4521 119.354C80.0374 122.694 73.5457 126.678 68.1762 132.687C57.0576 145.13 55.592 159.204 54.0765 208.287C52.587 256.526 55.5372 299.759 61.1249 348.403C64.1025 374.324 66.1515 391.817 69.4229 405.117C72.526 417.732 76.2792 424.515 81.4954 429.708C86.7533 434.942 93.4917 438.633 105.859 441.629C118.94 444.797 136.104 446.713 161.613 449.5C244.114 458.514 305.869 458.469 388.677 449.548C414.495 446.767 431.939 444.849 445.216 441.702C457.83 438.712 464.612 435.047 469.797 429.962C474.873 424.985 478.752 418.118 482.116 404.874C485.626 391.056 488.014 372.772 491.47 345.913C497.636 297.99 502.076 255.903 502.248 209.798C502.433 160.503 501.426 146.477 490.181 133.468C484.75 127.185 478.148 123.053 466.473 119.612C453.865 115.897 437.283 113.502 412.222 109.961ZM138.414 68.5711C238.977 54.1882 319.888 54.7514 418.047 68.6199L419.483 68.8227C442.724 72.1054 462.359 74.8786 478.244 79.5601C495.387 84.6124 509.724 92.2821 521.706 106.145C544.308 132.295 544.161 163.321 543.965 204.542C543.956 206.327 543.948 208.131 543.941 209.954C543.758 258.703 539.048 302.844 532.821 351.247L532.656 352.528C529.407 377.787 526.729 398.602 522.522 415.166C518.098 432.584 511.485 447.517 498.968 459.792C486.56 471.959 471.897 478.282 454.819 482.33C438.691 486.153 418.624 488.314 394.436 490.919L393.136 491.059C307.385 500.297 242.618 500.349 157.091 491.004L155.772 490.86C131.921 488.255 112.062 486.086 96.056 482.209C79.0408 478.087 64.4759 471.637 52.1005 459.316C39.6835 446.955 33.1618 432.265 28.94 415.102C24.9582 398.915 22.6435 378.759 19.8561 354.488L19.7052 353.174C13.9746 303.287 10.8315 257.908 12.4035 206.997C12.4606 205.15 12.5151 203.323 12.5691 201.516C13.7911 160.603 14.7077 129.914 37.1055 104.847C48.989 91.5477 63.035 84.1731 79.7563 79.2794C95.2643 74.7408 114.386 72.0068 137.018 68.7707C137.482 68.7044 137.948 68.6379 138.414 68.5711Z" fill="currentColor"/>`,
width: 556,
height: 556,
},
},
},
...getIconCollections(['lucide', 'logos', 'ri']),