Merge branch 'develop' into fix/auth-methods

This commit is contained in:
Shivam Mishra
2025-06-02 18:25:01 +05:30
committed by GitHub
45 changed files with 1260 additions and 760 deletions
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px;
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] {
@apply field-disabled;
@@ -477,7 +477,7 @@ const menuItems = computed(() => {
<template>
<aside
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -25,10 +25,7 @@ defineProps({
:username="user.name"
:status="user.availability_status"
/>
<span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
</span>
</div>
@@ -99,7 +99,6 @@ export default {
<ConversationHeader
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:show-back-button="isOnExpandedLayout && !isInboxView"
/>
<woot-tabs
@@ -1,7 +1,9 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
@@ -11,176 +13,161 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
});
export default {
components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
NextButton,
},
mixins: [inboxMixin],
props: {
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
},
backButtonUrl() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
},
isHMACVerified() {
if (!this.isAWebWidgetInbox) {
return true;
}
return this.chatMetadata.hmac_verified;
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id
);
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
snoozedDisplayText() {
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${snoozedReopenTime(snoozedUntil)}`;
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
};
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
const isFeatureEnabledonAccount = computed(
() => store.getters['accounts/isFeatureEnabledonAccount']
);
const appIntegrations = computed(
() => store.getters['integrations/getAppIntegrations']
);
const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId },
name,
} = route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
});
const isHMACVerified = computed(() => {
if (!isAWebWidgetInbox.value) {
return true;
}
return chatMetadata.value.hmac_verified;
});
const currentContact = computed(() =>
store.getters['contacts/getContact'](props.chat.meta.sender.id)
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const snoozedDisplayText = computed(() => {
const { snoozed_until: snoozedUntil } = currentChat.value;
if (snoozedUntil) {
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
}
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
});
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
return store.getters['inboxes/getInbox'](inboxId);
});
const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const isLinearIntegrationEnabled = computed(() =>
appIntegrations.value.find(
integration => integration.id === 'linear' && !!integration.hooks.length
)
);
const isLinearFeatureEnabled = computed(() =>
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
);
</script>
<template>
<div
class="flex flex-col items-center justify-between px-3 py-2 border-b bg-n-background border-n-weak md:flex-row h-12"
ref="conversationHeader"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
>
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
<NextButton link slate>
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
>
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
>
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden lg:flex"
/>
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
:parent-width="width"
class="hidden lg:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
</template>
<style lang="scss" scoped>
.conversation--header--actions {
::v-deep .inbox--name {
@apply m-0;
}
}
</style>
@@ -44,7 +44,7 @@ const activeTab = computed(() => {
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
>
<div class="flex flex-1 overflow-auto">
<ContactPanel
@@ -1,10 +1,14 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, onUnmounted } from 'vue';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
CMD_MUTE_CONVERSATION,
@@ -12,97 +16,111 @@ import {
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
export default {
components: {
EmailTranscriptModal,
ResolveAction,
ButtonV4,
},
data() {
return {
showEmailActionsModal: false,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
},
mounted() {
emitter.on(CMD_MUTE_CONVERSATION, this.mute);
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
unmounted() {
emitter.off(CMD_MUTE_CONVERSATION, this.mute);
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
methods: {
mute() {
this.$store.dispatch('muteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
},
unmute() {
this.$store.dispatch('unmuteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
},
toggleEmailActionsModal() {
this.showEmailActionsModal = !this.showEmailActionsModal;
},
},
// No props needed as we're getting currentChat from the store directly
const store = useStore();
const { t } = useI18n();
const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
const [showActionsDropdown, toggleDropdown] = useToggle(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const actionMenuItems = computed(() => {
const items = [];
if (!currentChat.value.muted) {
items.push({
icon: 'i-lucide-volume-off',
label: t('CONTACT_PANEL.MUTE_CONTACT'),
action: 'mute',
value: 'mute',
});
} else {
items.push({
icon: 'i-lucide-volume-1',
label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
action: 'unmute',
value: 'unmute',
});
}
items.push({
icon: 'i-lucide-share',
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
action: 'send_transcript',
value: 'send_transcript',
});
return items;
});
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
};
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script>
<template>
<div class="relative flex items-center gap-2 actions--container">
<ButtonV4
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-off"
@click="mute"
/>
<ButtonV4
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-1"
@click="unmute"
/>
<ButtonV4
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-share"
@click="toggleEmailActionsModal"
/>
<ResolveAction
:conversation-id="currentChat.id"
:status="currentChat.status"
/>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal
v-if="showEmailActionsModal"
:show="showEmailActionsModal"
:current-chat="currentChat"
@cancel="toggleEmailActionsModal"
@cancel="toggleEmailModal"
/>
</div>
</template>
<style scoped lang="scss">
.more--button {
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
}
.dropdown-pane {
@apply -right-2 top-12;
}
.icon {
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
}
</style>
@@ -1,132 +1,115 @@
<script>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
export default {
components: {
SLAPopoverCard,
},
props: {
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
slaPolicyId() {
return this.chat?.sla_policy_id;
},
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
},
showSlaPopoverCard() {
return (
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
},
},
watch: {
chat() {
this.updateSlaStatus();
},
},
mounted() {
this.updateSlaStatus();
this.createTimer();
},
unmounted() {
if (this.timer) {
clearTimeout(this.timer);
}
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.updateSlaStatus();
this.createTimer();
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
);
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
});
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const groupClass = computed(() => {
return props.showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong';
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit"
:class="
showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong'
"
class="relative flex items-center cursor-pointer min-w-fit group"
:class="groupClass"
>
<div
v-on-clickaway="closeSlaPopover"
class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
class="flex items-center w-full truncate px-1.5"
:class="showExtendedInfo ? '' : 'gap-1'"
>
<div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
"
>
<div class="flex items-center gap-1" :class="slaPopoverClass">
<fluent-icon
size="14"
size="12"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
@@ -134,7 +117,7 @@ export default {
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo"
v-if="showExtendedInfo && parentWidth > 650"
class="text-xs font-medium"
:class="slaTextStyles"
>
@@ -151,7 +134,7 @@ export default {
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="right-0 top-7"
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
/>
</div>
</template>
@@ -16,6 +16,10 @@ const props = defineProps({
type: [Number, String],
required: true,
},
parentWidth: {
type: Number,
default: 10000,
},
});
defineOptions({
@@ -73,6 +77,14 @@ const unlinkIssue = async linkId => {
}
};
const shouldShowIssueIdentifier = computed(() => {
if (!linkedIssue.value) {
return false;
}
return props.parentWidth > 600;
});
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
@@ -119,7 +131,10 @@ onMounted(() => {
class="text-[#5E6AD2] flex-shrink-0"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11">
<span
v-if="shouldShowIssueIdentifier"
class="text-xs font-medium text-n-slate-11"
>
{{ linkedIssue.issue.identifier }}
</span>
</Button>
@@ -127,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[36px] invisible group-hover:visible"
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
@@ -1,110 +0,0 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import MoreActions from '../MoreActions.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
}));
const mockDirective = {
mounted: () => {},
};
import { emitter } from 'shared/helpers/mitt';
describe('MoveActions', () => {
let currentChat = { id: 8, muted: false };
let store = null;
let muteConversation = null;
let unmuteConversation = null;
beforeEach(() => {
muteConversation = vi.fn(() => Promise.resolve());
unmuteConversation = vi.fn(() => Promise.resolve());
store = createStore({
state: {
authenticated: true,
currentChat,
},
getters: {
getSelectedChat: () => currentChat,
},
modules: {
conversations: {
namespaced: false,
actions: { muteConversation, unmuteConversation },
},
},
});
});
const createWrapper = () =>
mount(MoreActions, {
global: {
plugins: [store],
components: {
'fluent-icon': FluentIcon,
},
directives: {
'on-clickaway': mockDirective,
},
},
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(muteConversation).toHaveBeenCalledTimes(1);
expect(muteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message:
'This contact is blocked successfully. You will not be notified of any future conversations.',
action: null,
});
});
});
describe('unmuting discussion', () => {
beforeEach(() => {
currentChat.muted = true;
});
it('triggers "unmuteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(unmuteConversation).toHaveBeenCalledTimes(1);
expect(unmuteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message: 'This contact is unblocked successfully.',
action: null,
});
});
});
});
@@ -43,7 +43,7 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions).toHaveLength(6);
expect(fontSizeOptions).toHaveLength(5);
expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label');
@@ -59,12 +59,6 @@ describe('useFontSize', () => {
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
});
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
value: '22px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
});
});
it('returns currentFontSize from UI settings', () => {
@@ -84,9 +78,6 @@ describe('useFontSize', () => {
applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px');
applyFontSize('22px');
expect(document.documentElement.style.fontSize).toBe('22px');
applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px');
});
@@ -145,8 +136,6 @@ describe('useFontSize', () => {
'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
'Extra Large',
};
return translations[key] || key;
});
@@ -160,9 +149,6 @@ describe('useFontSize', () => {
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default'
);
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
'Extra Large'
);
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
@@ -19,7 +19,6 @@ const FONT_SIZE_OPTIONS = {
DEFAULT: '16px',
LARGE: '18px',
LARGER: '20px',
EXTRA_LARGE: '22px',
};
/**
@@ -70,6 +70,7 @@
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open",
"MORE_ACTIONS": "More actions",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
@@ -245,7 +245,12 @@ export default {
<button
v-for="hotKey in hotKeys"
:key="hotKey.key"
class="px-0 reset-base w-full sm:flex-1"
class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline"
:class="
isEditorHotKeyEnabled(hotKey.key)
? 'outline-n-brand/30'
: 'outline-n-weak'
"
>
<HotKeyCard
:key="hotKey.title"
@@ -1,4 +1,6 @@
<script setup>
import { computed } from 'vue';
const { row } = defineProps({
row: {
type: Object,
@@ -6,10 +8,10 @@ const { row } = defineProps({
},
});
const routerParams = {
const routerParams = computed(() => ({
name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId },
};
}));
</script>
<template>
@@ -47,14 +47,17 @@ const tableData = computed(() => {
}));
});
const defaulSpanRender = cellProps =>
h(
const defaultSpanRender = cellProps => {
const value = cellProps.getValue() || '---';
return h(
'span',
{
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
title: value,
},
cellProps.getValue() ? cellProps.getValue() : '---'
value
);
};
const columnHelper = createColumnHelper();
@@ -65,7 +68,10 @@ const columns = [
cell: cellProps => {
const { contact } = cellProps.row.original;
if (contact) {
return h(UserAvatarWithName, { user: contact });
return h(UserAvatarWithName, {
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
@@ -76,7 +82,10 @@ const columns = [
cell: cellProps => {
const { assignedAgent } = cellProps.row.original;
if (assignedAgent) {
return h(UserAvatarWithName, { user: assignedAgent });
return h(UserAvatarWithName, {
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
@@ -105,7 +114,7 @@ const columns = [
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400,
cell: defaulSpanRender,
cell: defaultSpanRender,
}),
columnHelper.accessor('conversationId', {
header: '',
@@ -18,13 +18,34 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
getFilteredConversations: ({
allConversations,
chatSortFilter,
appliedFilters,
}) => {
getFilteredConversations: (
{ allConversations, chatSortFilter, appliedFilters },
_,
__,
rootGetters
) => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return allConversations
.filter(conversation => matchesFilters(conversation, appliedFilters))
.filter(conversation => {
const matchesFilterResult = matchesFilters(
conversation,
appliedFilters
);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return matchesFilterResult && allowedForRole;
})
.sort((a, b) => sortComparator(a, b, chatSortFilter));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
@@ -325,4 +325,308 @@ describe('#getters', () => {
});
});
});
describe('#getFilteredConversations', () => {
const mockConversations = [
{
id: 1,
status: 'open',
meta: { assignee: { id: 1 } },
last_activity_at: 1000,
},
{
id: 2,
status: 'open',
meta: {},
last_activity_at: 2000,
},
{
id: 3,
status: 'resolved',
meta: { assignee: { id: 2 } },
last_activity_at: 3000,
},
];
const mockRootGetters = {
getCurrentUser: {
id: 1,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
getCurrentAccountId: 1,
};
it('filters conversations based on role permissions for administrator', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'administrator', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations based on role permissions for agent', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_unassigned_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should include conversation assigned to user (id: 1) and unassigned conversation
expect(result).toEqual([mockConversations[1], mockConversations[0]]);
});
it('filters conversations for custom role with conversation_participating_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('filters conversations for custom role with no permissions', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: [],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should return empty array as user has no permissions
expect(result).toEqual([]);
});
it('applies filters and role permissions together', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
},
],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include open conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('returns empty array when no conversations match filters', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending'],
query_operator: 'and',
},
],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([]);
});
it('sorts filtered conversations according to chatSortFilter', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_asc',
appliedFilters: [],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([
mockConversations[0],
mockConversations[1],
mockConversations[2],
]);
});
});
});
+3 -3
View File
@@ -64,11 +64,11 @@ export default {
:selected="modelValue"
:name="name"
:class="{
'text-ash-400': !modelValue,
'text-ash-900': modelValue,
'text-n-slate-9': !modelValue,
'text-n-slate-12': modelValue,
'pl-9': icon,
}"
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm outline-none appearance-none rounded-xl select-caret ring-ash-200 ring-1 ring-inset placeholder:text-ash-900 focus:ring-2 focus:ring-inset focus:ring-primary-500 text-sm leading-6"
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm appearance-none rounded-xl select-caret leading-6"
@input="onInput"
>
<option value="" disabled selected class="hidden">