feat: Update conversation side panel layout and styling (#12983)

This commit is contained in:
Sivin Varghese
2025-12-04 13:55:07 +05:30
committed by GitHub
parent 832d61c004
commit 604d4a9c30
68 changed files with 2336 additions and 2553 deletions
@@ -46,14 +46,14 @@ body {
}
.custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23E2E3E7' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.dark .custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23343434' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
}
@layer utilities {
@@ -48,10 +48,26 @@ onMounted(() => {
</script>
<template>
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<div class="flex flex-col gap-1.5 border-b border-n-strong group/note">
<div class="flex items-center justify-between w-full gap-1.5 min-w-0 h-9">
<div class="min-w-0 truncate text-n-slate-11 text-xs font-440">
{{ dynamicTime(note.createdAt) }}
</div>
<div class="flex items-center gap-1">
<Button
v-if="allowDelete"
ghost
ruby
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
<Avatar
v-tooltip.left="{
content: writtenBy,
delay: { show: 500, hide: 0 },
}"
:name="note?.user?.name || 'Bot'"
:src="
note?.user?.name
@@ -61,30 +77,12 @@ onMounted(() => {
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-12">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
ref="noteContentRef"
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
class="mb-0 font-420 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
:class="{
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
}"
@@ -101,6 +101,7 @@ const onPickInbox = async inbox => {
:label="label"
:icon="icon"
:size="size"
class="!px-2"
@click="onClick"
/>
@@ -44,7 +44,7 @@ const onSelectConversation = checked => {
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-all duration-200"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-all duration-150"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
@@ -62,7 +62,7 @@ const selectedModel = computed({
<template>
<div
class="relative cursor-pointer group transition-all duration-200 grid gap-4 items-center px-2 h-12"
class="relative cursor-pointer group transition-all duration-150 grid gap-4 items-center px-2 h-12"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
() => uiSettings.value.is_contact_sidebar_open || false
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
() => uiSettings.value.is_copilot_panel_open || false
);
const toggleConversationSidebarToggle = () => {
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
is_copilot_panel_open: !isCopilotPanelOpen.value,
});
};
@@ -96,7 +96,7 @@ defineExpose({
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
/>
</div>
<template v-else />
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
@@ -19,28 +19,35 @@ const emit = defineEmits(['update', 'delete']);
const attributeValue = ref(Boolean(props.attribute.value));
const isAttributePresent = computed(() => {
return (
props.attribute.value !== undefined &&
props.attribute.value !== null &&
props.attribute.value !== ''
);
});
const handleChange = value => {
emit('update', value);
};
const handleDelete = () => {
attributeValue.value = false;
emit('delete');
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full gap-2 justify-end">
<Switch v-model="attributeValue" @change="handleChange" />
<Button
v-if="isEditingView"
variant="faded"
color="ruby"
v-if="isEditingView && isAttributePresent"
ghost
ruby
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="handleDelete"
/>
</div>
</template>
@@ -75,20 +75,15 @@ const handleInputUpdate = async () => {
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView,
'text-n-slate-12 truncate': isEditingView && attribute.value,
'text-n-slate-10 truncate': isEditingView && !attribute.value,
}"
@click="toggleEditValue(!isEditingView)"
>
@@ -100,19 +95,20 @@ const handleInputUpdate = async () => {
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
ghost
slate
icon="i-lucide-pen-line"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
v-if="attribute.value"
ghost
ruby
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="emit('delete')"
/>
</div>
@@ -125,7 +121,7 @@ const handleInputUpdate = async () => {
<Input
v-model="defaultDateValue"
type="date"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
class="w-full [&>p]:absolute [&>p]:mt-1 [&>p]:top-6 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
:message="
hasError
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
@@ -133,14 +129,15 @@ const handleInputUpdate = async () => {
"
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
custom-input-class="h-7 py-1 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
type="button"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none !h-7"
@click="handleInputUpdate"
/>
</div>
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
attribute: {
@@ -40,61 +41,55 @@ const handleAttributeAction = async action => {
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center"
class="relative flex items-center min-w-0 flex-1 justify-end gap-2 cursor-pointer"
@click="toggleAttributeListDropdown()"
>
<span
class="min-w-0 text-sm"
v-tooltip.top="{
content: attribute.value,
delay: { show: 1000, hide: 0 },
}"
class="min-w-0 text-sm truncate font-420"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
'text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
'text-n-slate-12': isEditingView && attribute.value,
'text-n-slate-10': isEditingView && !attribute.value,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<Icon
:icon="
showAttributeListDropdown
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
class="text-n-slate-11 flex-shrink-0 size-4"
/>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full"
:class="{
'ltr:right-0 rtl:left-0': !isEditingView,
'ltr:left-0 rtl:right-0': isEditingView,
}"
class="w-48 mt-2 top-full ltr:right-0 rtl:left-0"
@action="handleAttributeAction($event)"
/>
</div>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<Button
v-if="isEditingView && attribute.value"
ghost
ruby
icon="i-lucide-trash"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="emit('delete')"
/>
</div>
</template>
@@ -114,24 +114,24 @@ const handleInputUpdate = async () => {
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
v-tooltip.top="{
content: !isAttributeTypeLink ? attribute.value : '',
delay: { show: 1000, hide: 0 },
}"
@click="toggleEditValue(!isEditingView)"
class="min-w-0 text-sm cursor-pointer font-420"
:class="{
'text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate':
isEditingView && !isAttributeTypeLink && attribute.value,
'text-n-slate-10 truncate': isEditingView && !attribute.value,
'truncate hover:text-n-brand text-n-blue-10':
isEditingView && isAttributeTypeLink && attribute.value,
}"
@click="toggleEditValue(true)"
>
<a
v-if="isAttributeTypeLink && attribute.value && isEditingView"
@@ -156,19 +156,20 @@ const handleInputUpdate = async () => {
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
ghost
slate
icon="i-lucide-pen-line"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
v-if="attribute.value"
ghost
ruby
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="emit('delete')"
/>
</div>
@@ -182,18 +183,19 @@ const handleInputUpdate = async () => {
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
class="w-full [&>p]:absolute [&>p]:mt-1 [&>p]:top-6 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
custom-input-class="h-7 py-1 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
type="button"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none !h-7"
@click="handleInputUpdate"
/>
</div>
@@ -20,11 +20,11 @@ const handleButtonClick = button => {
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
class="flex flex-col items-start gap-3 justify-between ltr:pl-4 ltr:pr-2 rtl:pl-2 rtl:pr-4 my-2 py-2"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<div class="flex items-center justify-between gap-2 flex-1 w-full">
<span class="font-medium text-base text-n-slate-12">{{ title }}</span>
<div class="flex items-center h-6">
<Button
v-for="button in buttons"
:key="button.key"
@@ -43,5 +43,6 @@ const handleButtonClick = button => {
/>
</div>
</div>
<slot />
</div>
</template>
@@ -121,8 +121,7 @@ const borderRadiusClass = computed(() => {
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
return 'rounded-lg'; // 16px
});
const avatarStyles = computed(() => ({
@@ -280,7 +279,8 @@ watch(
:handle-image-upload="handleImageUpload"
>
<div
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"
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 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
:class="borderRadiusClass"
@click="handleUploadAvatar"
>
<Icon
@@ -1,6 +1,6 @@
<script setup>
import { computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
usersList: {
@@ -11,11 +11,11 @@ const props = defineProps({
type: Number,
default: 24,
},
showMoreThumbnailsCount: {
showMoreCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
moreCountText: {
type: String,
default: '',
},
@@ -36,7 +36,7 @@ const gapClass = computed(() => {
return 'ltr:[&:not(:first-child)]:-ml-1 rtl:[&:not(:first-child)]:-mr-1';
});
const moreThumbnailsClass = computed(() => {
const morePlaceholderClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:-ml-2 rtl:-mr-2';
}
@@ -59,11 +59,11 @@ const moreThumbnailsClass = computed(() => {
rounded-full
/>
<span
v-if="showMoreThumbnailsCount"
class="text-n-slate-11 bg-n-slate-4 outline outline-1 outline-n-background text-xs font-medium rounded-full px-2 inline-flex items-center shadow relative"
:class="moreThumbnailsClass"
v-if="showMoreCount"
class="text-n-slate-11 bg-n-button-color outline outline-1 outline-n-weak text-xs font-420 rounded-full px-2 inline-flex items-center shadow relative"
:class="morePlaceholderClass"
>
{{ moreThumbnailsText }}
{{ moreCountText }}
</span>
</div>
</template>
@@ -255,7 +255,7 @@ const animationClasses = computed(() => {
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
<slot v-if="label || $slots.default" name="default">
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
<span v-if="label" class="min-w-0 font-420 truncate">{{ label }}</span>
</slot>
</button>
</template>
@@ -11,7 +11,13 @@ const props = defineProps({
type: Array,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
return value.every(
item =>
item.action &&
item.value !== undefined &&
item.value !== null &&
item.label
);
},
},
menuSections: {
@@ -22,6 +28,10 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -48,7 +58,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action', 'search', 'empty']);
const { t } = useI18n();
@@ -100,9 +110,13 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
};
const handleAction = item => {
@@ -194,22 +208,26 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
@@ -241,22 +259,26 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
</template>
@@ -1,28 +1,19 @@
<script setup>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { defineEmits } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: {
type: String,
required: true,
},
compact: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
emoji: {
type: String,
default: '',
},
isOpen: {
type: Boolean,
default: true,
},
compact: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle']);
@@ -33,31 +24,30 @@ const onToggle = () => {
</script>
<template>
<div class="text-sm">
<div class="text-sm py-4 grid gap-2">
<button
class="flex items-center select-none w-full rounded-lg bg-n-slate-2 outline outline-1 outline-n-weak m-0 cursor-grab justify-between py-2 px-4 drag-handle"
:class="{ 'rounded-bl-none rounded-br-none': isOpen }"
class="flex items-center select-none w-full m-0 cursor-grab justify-between drag-handle px-4 py-0 focus-visible:outline-none outline-none !transition-none"
@click.stop="onToggle"
>
<div class="flex justify-between">
<EmojiOrIcon class="inline-block w-5" :icon="icon" :emoji="emoji" />
<h5 class="text-n-slate-12 text-sm mb-0 py-0 pr-2 pl-0">
{{ title }}
</h5>
</div>
<div class="flex flex-row">
<slot name="button" />
<div class="flex justify-end w-3 text-n-blue-text cursor-pointer">
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<fluent-icon v-else size="24" icon="add" type="solid" />
</div>
<h5 class="text-n-slate-12 text-sm font-medium mb-0 py-0 pr-2 pl-0">
{{ title }}
</h5>
<div
class="flex justify-end w-3 text-n-blue-text flex-shrink-0 cursor-pointer size-4"
>
<Icon
v-if="isOpen"
icon="i-lucide-chevron-up"
class="size-4 text-n-slate-11"
/>
<Icon
v-else
icon="i-lucide-chevron-down"
class="size-4 text-n-slate-11"
/>
</div>
</button>
<div
v-if="isOpen"
class="outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
:class="compact ? 'p-0' : 'px-2 py-4'"
>
<div v-if="isOpen" :class="compact ? 'px-1' : 'px-4'">
<slot />
</div>
</div>
@@ -82,7 +82,7 @@ useKeyboardEvents({
>
<Virtualizer
:data="items"
class="[&>div]:after:content-[''] [&>div]:after:absolute [&>div]:after:bottom-0 [&>div]:after:left-0 [&>div]:after:right-0 [&>div]:after:h-px [&>div]:after:bg-n-weak [&>div]:after:pointer-events-none [&>div]:after:transition-colors [&>div]:after:duration-150 [&>div:has(*:hover)]:after:!bg-n-surface-1 [&>div:has(+_*:hover)]:after:!bg-n-surface-1 [&>div:has(.active)]:after:!bg-n-surface-1 [&>div:has(+_*_.active)]:after:!bg-n-surface-1 [&>div:has(.selected)]:after:!bg-n-surface-1 [&>div:has(+_*_.selected)]:after:!bg-n-surface-1"
class="[&>div]:after:content-[''] [&>div]:after:absolute [&>div]:after:bottom-0 [&>div]:after:left-0 [&>div]:after:right-0 [&>div]:after:h-px [&>div]:after:bg-n-weak [&>div]:after:pointer-events-none [&>div:has(*:hover)]:after:!bg-n-surface-1 [&>div:has(+_*:hover)]:after:!bg-n-surface-1 [&>div:has(.active)]:after:!bg-n-surface-1 [&>div:has(+_*_.active)]:after:!bg-n-surface-1 [&>div:has(.selected)]:after:!bg-n-surface-1 [&>div:has(+_*_.selected)]:after:!bg-n-surface-1"
>
<template #default="{ item }">
<div :data-id="item.id">
@@ -1,351 +0,0 @@
<script>
import { format, parseISO } from 'date-fns';
import { required, url } from '@vuelidate/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
MultiselectDropdown,
HelperTextPopup,
NextButton,
},
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false },
attributeType: { type: String, default: 'text' },
attributeRegex: {
type: String,
default: null,
},
regexCue: { type: String, default: null },
attributeKey: { type: String, required: true },
contactId: { type: Number, default: null },
},
emits: ['update', 'delete', 'copy'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
isEditing: false,
editedValue: null,
};
},
computed: {
displayValue() {
if (this.isAttributeTypeDate) {
return this.value
? new Date(this.value || new Date()).toLocaleDateString()
: '---';
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
return this.hasValue ? this.value : '---';
},
formattedValue() {
return this.isAttributeTypeDate
? format(this.value ? new Date(this.value) : new Date(), DATE_FORMAT)
: this.value;
},
listOptions() {
return this.values.map((value, index) => ({
id: index + 1,
name: value,
}));
},
selectedItem() {
const id = this.values.indexOf(this.editedValue) + 1;
return { id, name: this.editedValue };
},
isAttributeTypeCheckbox() {
return this.attributeType === 'checkbox';
},
isAttributeTypeList() {
return this.attributeType === 'list';
},
isAttributeTypeLink() {
return this.attributeType === 'link';
},
isAttributeTypeDate() {
return this.attributeType === 'date';
},
hasValue() {
return this.value !== null && this.value !== '';
},
urlValue() {
return isValidURL(this.value) ? this.value : '---';
},
hrefURL() {
return isValidURL(this.value) ? this.value : '';
},
notAttributeTypeCheckboxAndList() {
return !this.isAttributeTypeCheckbox && !this.isAttributeTypeList;
},
inputType() {
return this.isAttributeTypeLink ? 'url' : this.attributeType;
},
shouldShowErrorMessage() {
return this.v$.editedValue.$error;
},
errorMessage() {
if (this.v$.editedValue.url) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (!this.v$.editedValue.regexValidation) {
return this.regexCue
? this.regexCue
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
},
watch: {
value() {
this.isEditing = false;
this.editedValue = this.formattedValue;
},
contactId() {
// Fix to solve validation not resetting when contactId changes in contact page
this.v$.$reset();
},
},
validations() {
if (this.isAttributeTypeLink) {
return {
editedValue: { required, url },
};
}
return {
editedValue: {
required,
regexValidation: value => {
return !(
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
);
},
},
};
},
mounted() {
this.editedValue = this.formattedValue;
emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
unmounted() {
emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
if (this.attributeKey === focusAttributeKey) {
this.onEdit();
}
},
focusInput() {
if (this.$refs.inputfield) {
this.$refs.inputfield.focus();
}
},
onClickAway() {
this.v$.$reset();
this.isEditing = false;
},
onEdit() {
this.isEditing = true;
this.$nextTick(() => {
this.focusInput();
});
},
onUpdateListValue(value) {
if (value) {
this.editedValue = value.name;
this.onUpdate();
}
},
onUpdate() {
const updatedValue =
this.attributeType === 'date'
? parseISO(this.editedValue)
: this.editedValue;
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
this.isEditing = false;
this.$emit('update', this.attributeKey, updatedValue);
},
onDelete() {
this.isEditing = false;
this.v$.$reset();
this.$emit('delete', this.attributeKey);
},
onCopy() {
this.$emit('copy', this.value);
},
},
};
</script>
<template>
<div class="px-4 py-3">
<div class="flex items-center mb-1">
<h4 class="flex items-center w-full m-0 text-sm error">
<div v-if="isAttributeTypeCheckbox" class="flex items-center">
<input
v-model="editedValue"
class="!my-0 ltr:mr-2 ltr:ml-0 rtl:mr-0 rtl:ml-2"
type="checkbox"
@change="onUpdate"
/>
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full inline-flex gap-1.5 items-start font-medium whitespace-nowrap text-sm mb-0"
:class="
v$.editedValue.$error ? 'text-n-ruby-11' : 'text-n-slate-12'
"
>
{{ label }}
<HelperTextPopup
v-if="description"
:message="description"
class="mt-0.5"
/>
</span>
<NextButton
v-if="showActions && hasValue"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
slate
sm
link
icon="i-lucide-trash-2"
@click="onDelete"
/>
</div>
</h4>
</div>
<div v-if="notAttributeTypeCheckboxAndList">
<div v-if="isEditing" v-on-clickaway="onClickAway">
<div class="flex items-center w-full mb-2">
<input
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="!h-8 ltr:!rounded-r-none rtl:!rounded-l-none !mb-0 !text-sm"
autofocus="true"
:class="{ error: v$.editedValue.$error }"
@blur="v$.editedValue.$touch"
@keyup.enter="onUpdate"
/>
<div>
<NextButton
sm
icon="i-lucide-check"
class="ltr:rounded-l-none rtl:rounded-r-none h-[34px]"
@click="onUpdate"
/>
</div>
</div>
<span
v-if="shouldShowErrorMessage"
class="block w-full -mt-px text-sm font-normal text-n-ruby-11"
>
{{ errorMessage }}
</span>
</div>
<div
v-show="!isEditing"
class="flex group"
:class="{ 'is-editable': showActions }"
>
<a
v-if="isAttributeTypeLink"
:href="hrefURL"
target="_blank"
rel="noopener noreferrer"
class="group-hover:bg-n-slate-3 group-hover:dark:bg-n-solid-3 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ urlValue }}
</a>
<p
v-else
class="group-hover:bg-n-slate-3 group-hover:dark:bg-n-solid-3 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ displayValue }}
</p>
<div
class="flex items-center max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0"
>
<NextButton
v-if="showActions && hasValue"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
xs
slate
ghost
icon="i-lucide-clipboard"
class="hidden group-hover:flex flex-shrink-0"
@click="onCopy"
/>
<NextButton
v-if="showActions"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
xs
slate
ghost
icon="i-lucide-pen"
class="hidden group-hover:flex flex-shrink-0"
@click="onEdit"
/>
</div>
</div>
</div>
<div v-if="isAttributeTypeList">
<MultiselectDropdown
:options="listOptions"
:selected-item="selectedItem"
:has-thumbnail="false"
:multiselector-placeholder="
$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.PLACEHOLDER')
"
:no-search-result="
$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.NO_RESULT')
"
:input-placeholder="
$t(
'CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.SEARCH_INPUT_PLACEHOLDER'
)
"
@select="onUpdateListValue"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
::v-deep {
.selector-wrap {
@apply m-0 top-1;
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
}
</style>
@@ -7,8 +7,6 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
@@ -186,34 +184,30 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="border rounded-lg shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-10 bg-n-alpha-3 backdrop-blur-[100px] absolute block left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem] [&_ul>li]:mb-0"
class="border rounded-xl shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-10 bg-n-alpha-3 backdrop-blur-[100px] absolute block left-auto top-full mt-0.5 start-0 lg:start-auto lg:end-0 max-w-[12.5rem] min-w-[9.75rem] [&_ul>li]:mb-0"
>
<WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending">
<Button
:label="t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL')"
ghost
slate
sm
start
icon="i-lucide-alarm-clock-minus"
class="w-full"
@click="() => openSnoozeModal()"
/>
</WootDropdownItem>
<WootDropdownItem v-if="!isPending">
<Button
:label="t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING')"
ghost
slate
sm
start
icon="i-lucide-circle-dot-dashed"
class="w-full"
@click="() => toggleStatus(wootConstants.STATUS_TYPE.PENDING)"
/>
</WootDropdownItem>
</WootDropdownMenu>
<Button
v-if="!isPending"
:label="t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL')"
ghost
slate
sm
start
icon="i-lucide-alarm-clock-minus"
class="w-full"
@click="() => openSnoozeModal()"
/>
<Button
v-if="!isPending"
:label="t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING')"
ghost
slate
sm
start
icon="i-lucide-circle-dot-dashed"
class="w-full"
@click="() => toggleStatus(wootConstants.STATUS_TYPE.PENDING)"
/>
</div>
</div>
</template>
@@ -133,7 +133,7 @@ onMounted(() => {
<div
v-if="shouldShowCopilotPanel"
v-on-click-outside="() => closeCopilotPanel()"
class="bg-n-surface-2 h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
class="bg-n-surface-2 h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[360px] md:min-w-[360px] ltr:border-l rtl:border-r border-n-weak shadow-lg md:shadow-none"
:class="[
{
'md:flex': shouldShowCopilotPanel,
@@ -5,8 +5,6 @@ import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import FeatureToggle from './widgets/FeatureToggle.vue';
import Input from './widgets/forms/Input.vue';
import PhoneInput from './widgets/forms/PhoneInput.vue';
@@ -25,8 +23,6 @@ const WootUIKit = {
ConfirmDeleteModal,
ConfirmModal,
DeleteModal,
DropdownItem,
DropdownMenu,
FeatureToggle,
Input,
PhoneInput,
@@ -1,4 +1,6 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
message: {
type: String,
@@ -8,16 +10,12 @@ defineProps({
</script>
<template>
<div class="relative group w-[inherit] whitespace-normal z-20">
<fluent-icon
icon="info"
size="14"
class="mt-0.5 text-n-slate-11 absolute"
/>
<div class="relative group flex-shrink-0 flex items-center">
<Icon icon="i-lucide-info" class="text-n-slate-11 size-3.5 cursor-help" />
<div
class="bg-n-background w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-n-weak rounded-lg shadow-md"
class="hidden group-hover:block absolute z-50 ltr:left-5 rtl:right-5 top-0 w-max max-w-64 bg-n-alpha-3 backdrop-blur-[50px] border border-n-weak rounded-xl shadow-lg px-3 py-2"
>
<p class="text-n-slate-12 mb-0 text-xs">
<p class="text-n-slate-12 text-xs leading-relaxed mb-0 whitespace-normal">
{{ message }}
</p>
</div>
@@ -92,6 +92,7 @@ onMounted(() => {
:chat="currentChat"
:is-on-expanded-view="isOnExpandedLayout"
:show-back-button="isOnExpandedLayout && !isInboxView"
:class="{ 'border-b border-n-weak !pb-3': !dashboardApps.length }"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
@@ -12,6 +12,7 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
chat: {
@@ -35,6 +36,15 @@ const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const { uiSettings } = useUISettings();
const isSidepanelOpen = computed(
() =>
uiSettings.value.is_contact_sidebar_open ||
uiSettings.value.is_copilot_panel_open ||
false
);
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@@ -91,20 +101,24 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
ref="conversationHeader"
class="flex flex-col items-start gap-x-6 gap-y-2 w-full min-w-0 pt-3 pb-2 ltr:pl-4 rtl:pr-4 ltr:pr-1 rtl:pl-1"
:class="{
'md:flex-row md:items-center': isOnExpandedView,
'lg:flex-row lg:items-center': !isOnExpandedView,
'md:flex-row md:items-center': isOnExpandedView && !isSidepanelOpen,
'lg:flex-row lg:items-center': isOnExpandedView && isSidepanelOpen,
'lg:flex-row lg:items-center': !isOnExpandedView && !isSidepanelOpen,
'xl:flex-row xl:items-center': !isOnExpandedView && isSidepanelOpen,
}"
>
<div
class="flex items-center gap-2 min-w-0 w-full"
:class="{
'md:flex-1 md:basis-0': isOnExpandedView,
'lg:flex-1 lg:basis-0': !isOnExpandedView,
'md:flex-1 md:basis-0': isOnExpandedView && !isSidepanelOpen,
'lg:flex-1 lg:basis-0': isOnExpandedView && isSidepanelOpen,
'lg:flex-1 lg:basis-0': !isOnExpandedView && !isSidepanelOpen,
'xl:flex-1 xl:basis-0': !isOnExpandedView && isSidepanelOpen,
}"
>
<BackButton v-if="showBackButton" :back-url="backButtonUrl" />
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0 ltr:mr-2 rtl:ml-2">
<span class="text-base font-medium text-n-slate-12 truncate min-w-0">
{{ currentContact.name }}
</span>
@@ -120,8 +134,14 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
<div
class="flex items-center gap-2 min-w-0 w-full"
:class="{
'md:flex-1 md:basis-0 md:justify-end': isOnExpandedView,
'lg:flex-1 lg:basis-0 lg:justify-end': !isOnExpandedView,
'md:flex-1 md:basis-0 md:justify-end':
isOnExpandedView && !isSidepanelOpen,
'lg:flex-1 lg:basis-0 lg:justify-end':
isOnExpandedView && isSidepanelOpen,
'lg:flex-1 lg:basis-0 lg:justify-end':
!isOnExpandedView && !isSidepanelOpen,
'xl:flex-1 xl:basis-0 xl:justify-end':
!isOnExpandedView && isSidepanelOpen,
}"
>
<div v-if="isSnoozed" class="flex items-center gap-1 min-w-0 shrink">
@@ -42,7 +42,7 @@ const closeContactPanel = () => {
<template>
<div
v-on-click-outside="() => closeContactPanel()"
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static ltr:border-l rtl:border-r border-n-weak md:min-w-[360px] md:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': activeTab === 0,
@@ -4,6 +4,7 @@ import { useFunctionGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ShopifyAPI from '../../../api/integrations/shopify';
import ShopifyOrderItem from './ShopifyOrderItem.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
contactId: {
@@ -47,7 +48,7 @@ watch(
</script>
<template>
<div class="px-4 py-2 text-n-slate-12">
<div class="py-2 px-3 text-n-slate-12">
<div v-if="!hasSearchableInfo" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
@@ -57,8 +58,10 @@ watch(
<div v-else-if="error" class="text-center text-n-ruby-12">
{{ error }}
</div>
<div v-else-if="!orders.length" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
<div v-else-if="!orders.length" class="mt-2">
<SidePanelEmptyState
:message="$t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS')"
/>
</div>
<div v-else>
<ShopifyOrderItem
@@ -67,7 +67,7 @@ const handleCancel = () => {
};
onMounted(() => {
store.dispatch('teams/fetch');
store.dispatch('teams/get');
});
</script>
@@ -8,6 +8,7 @@ import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import LinearIssueItem from './LinearIssueItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -86,10 +87,10 @@ onMounted(() => {
<template>
<div>
<div class="px-4 pt-3 pb-2">
<div class="pt-3 px-3 pb-2">
<NextButton
ghost
xs
sm
slate
icon="i-lucide-plus"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
@click="openCreateModal"
@@ -100,17 +101,17 @@ onMounted(() => {
<Spinner />
</div>
<div v-else-if="!hasIssues" class="flex justify-center p-4">
<p class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
</p>
<div v-else-if="!hasIssues" class="mt-2">
<SidePanelEmptyState
:message="$t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES')"
/>
</div>
<div v-else class="max-h-[300px] overflow-y-auto">
<div v-else class="max-h-[300px] overflow-y-auto px-2">
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
class="px-1 pt-3 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
@@ -73,11 +73,11 @@ const variableKey = (item = {}) => {
class="bg-n-solid-1 p-1 rounded-xl overflow-auto absolute w-full z-20 shadow-md left-0 bottom-full max-h-[9.75rem] border border-solid border-n-strong mention--box"
>
<ul class="mb-0 vertical dropdown menu">
<woot-dropdown-item
<li
v-for="(item, index) in items"
:id="`mention-item-${index}`"
:key="item.key"
class="!mb-1"
class="mb-1 list-none"
@mouseover="onHover(index)"
>
<button
@@ -106,7 +106,7 @@ const variableKey = (item = {}) => {
</p>
</slot>
</button>
</woot-dropdown-item>
</li>
</ul>
</div>
</template>
@@ -0,0 +1,284 @@
import { ref, nextTick } from 'vue';
import { useDropdownPosition } from '../useDropdownPosition';
// Mock VueUse composables
vi.mock('@vueuse/core', () => ({
useElementBounding: vi.fn(elementRef => {
// Create reactive refs that update based on element
const top = ref(0);
const right = ref(0);
const bottom = ref(100);
const left = ref(0);
const width = ref(200);
const height = ref(50);
const update = vi.fn(() => {
// Update bounds from element when called
if (elementRef.value?.getBoundingClientRect) {
const rect = elementRef.value.getBoundingClientRect();
top.value = rect.top;
right.value = rect.right;
bottom.value = rect.bottom;
left.value = rect.left;
width.value = rect.width;
height.value = rect.height;
}
});
// Initial update
update();
return {
top,
right,
bottom,
left,
width,
height,
x: left,
y: top,
update,
};
}),
useWindowSize: vi.fn(() => ({
width: ref(1024),
height: ref(768),
})),
}));
describe('useDropdownPosition', () => {
let triggerRef;
let dropdownRef;
let enabled;
let querySelectorSpy;
beforeEach(() => {
// Create mock DOM elements
triggerRef = ref({
getBoundingClientRect: () => ({
top: 100,
right: 250,
bottom: 150,
left: 50,
width: 200,
height: 50,
}),
});
dropdownRef = ref({
getBoundingClientRect: () => ({
top: 0,
right: 0,
bottom: 0,
left: 0,
width: 250,
height: 200,
}),
});
enabled = ref(true);
// Spy on document.querySelector for RTL check
querySelectorSpy = vi.spyOn(document, 'querySelector').mockReturnValue({
getAttribute: () => 'ltr',
});
});
afterEach(() => {
querySelectorSpy.mockRestore();
});
it('should return default position classes when disabled', () => {
enabled.value = false;
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toBe('top-full mt-2 ltr:left-0 rtl:right-0');
});
it('should return default position classes when refs are null', () => {
triggerRef.value = null;
dropdownRef.value = null;
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toBe('top-full mt-2 ltr:left-0 rtl:right-0');
});
it('should calculate position when enabled and refs exist', () => {
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
// Should return some position classes (not the default)
expect(positionClasses.value).toBeTruthy();
expect(typeof positionClasses.value).toBe('string');
});
it('should position dropdown below trigger when enough space', () => {
// Trigger at top of screen, plenty of space below
// Window height: 768, Trigger bottom: 150
// Space below: 768 - 150 = 618px
// Dropdown height: 200px + SAFE_MARGIN (16px) = 216px
// 618 > 216 = true, so should position below (top-full)
triggerRef.value.getBoundingClientRect = () => ({
top: 100,
right: 250,
bottom: 150,
left: 50,
width: 200,
height: 50,
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toContain('top-full');
expect(positionClasses.value).not.toContain('bottom-full');
});
it('should position dropdown above trigger when insufficient space below', () => {
// Trigger near bottom of screen
// Window height: 768, Trigger bottom: 750
// Space below: 768 - 750 = 18px
// Dropdown height: 200px + SAFE_MARGIN (16px) = 216px
// 18 < 216 = true, so should position above (bottom-full)
triggerRef.value.getBoundingClientRect = () => ({
top: 700,
right: 250,
bottom: 750,
left: 50,
width: 200,
height: 50,
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toContain('bottom-full');
expect(positionClasses.value).not.toContain('top-full');
});
it('should align dropdown to left by default (LTR)', () => {
// LTR layout, trigger at left: 50
// Window width: 1024, Dropdown width: 250
// Trigger left: 50, Dropdown width: 250, SAFE_MARGIN: 16
// Would overflow: 50 + 250 + 16 = 316 < 1024 = false
// So should align left (ltr:left-0)
querySelectorSpy.mockReturnValue({
getAttribute: () => 'ltr',
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toContain('ltr:left-0');
expect(positionClasses.value).not.toContain('ltr:right-0');
});
it('should align dropdown to right when would overflow (LTR)', () => {
// Trigger near right edge
// Window width: 1024, Dropdown width: 250
// Trigger left: 800, Dropdown width: 250, SAFE_MARGIN: 16
// Would overflow: 800 + 250 + 16 = 1066 > 1024 = true
// So should align right (ltr:right-0)
triggerRef.value.getBoundingClientRect = () => ({
top: 100,
right: 1000,
bottom: 150,
left: 800,
width: 200,
height: 50,
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toContain('ltr:right-0');
expect(positionClasses.value).not.toContain('ltr:left-0');
});
it('should handle RTL layout', () => {
querySelectorSpy.mockReturnValue({
getAttribute: () => 'rtl',
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toContain('rtl:');
});
it('should reactively update when enabled changes', async () => {
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
// Initially enabled should calculate position
const initialValue = positionClasses.value;
expect(initialValue).toBeTruthy();
expect(typeof initialValue).toBe('string');
// Disable - should return default
enabled.value = false;
await nextTick();
expect(positionClasses.value).toBe('top-full mt-2 ltr:left-0 rtl:right-0');
// Re-enable - should calculate again
enabled.value = true;
await nextTick();
expect(positionClasses.value).toBeTruthy();
expect(typeof positionClasses.value).toBe('string');
});
it('should provide updatePosition function', () => {
const { updatePosition } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(updatePosition).toBeDefined();
expect(typeof updatePosition).toBe('function');
// Should not throw when called
expect(() => updatePosition()).not.toThrow();
});
it('should handle optional enabled parameter', () => {
// Should work without enabled parameter (backward compatibility)
const { positionClasses } = useDropdownPosition(triggerRef, dropdownRef);
// Should default to enabled (not return default classes)
expect(positionClasses.value).toBeTruthy();
});
});
@@ -0,0 +1,63 @@
import { computed } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
/**
* Auto-position dropdown based on available space
* @param {Ref} triggerRef - Trigger element ref
* @param {Ref} dropdownRef - Dropdown element ref
* @param {Ref} enabled - Whether to calculate position
* @returns {Object} { positionClasses, updatePosition }
*/
export function useDropdownPosition(triggerRef, dropdownRef, enabled) {
const SAFE_MARGIN = 16;
const RTL = 'rtl';
const triggerBounds = useElementBounding(triggerRef);
const dropdownBounds = useElementBounding(dropdownRef);
const { height: windowHeight, width: windowWidth } = useWindowSize();
const positionClasses = computed(() => {
// Don't calculate if not enabled
if (!enabled?.value) {
return 'top-full mt-2 ltr:left-0 rtl:right-0';
}
if (!triggerRef.value || !dropdownRef.value) {
return 'top-full mt-2 ltr:left-0 rtl:right-0';
}
const spaceBelow = windowHeight.value - triggerBounds.bottom.value;
const dropdownHeight = dropdownBounds.height.value || 200;
const dropdownWidth = dropdownBounds.width.value || 200;
// Check if RTL by checking app div with dir attribute
const appDiv = document.querySelector('#app[dir]');
const isRTL = appDiv?.getAttribute('dir') === RTL;
// Use appropriate edge based on text direction
const triggerEdge = isRTL
? triggerBounds.right.value
: triggerBounds.left.value;
const wouldOverflow = isRTL
? triggerEdge - dropdownWidth - SAFE_MARGIN < 0
: triggerEdge + dropdownWidth + SAFE_MARGIN > windowWidth.value;
const vertical =
spaceBelow >= dropdownHeight + SAFE_MARGIN
? 'top-full mt-2'
: 'bottom-full mb-2';
const horizontal = wouldOverflow
? 'ltr:right-0 rtl:left-0'
: 'ltr:left-0 rtl:right-0';
return `${vertical} ${horizontal}`;
});
const updatePosition = () => {
triggerBounds.update();
dropdownBounds.update();
};
return { positionClasses, updatePosition };
}
@@ -1,8 +1,8 @@
{
"CONTACT_PANEL": {
"NOT_AVAILABLE": "Not Available",
"EMAIL_ADDRESS": "Email Address",
"PHONE_NUMBER": "Phone number",
"EMAIL_ADDRESS": "Email",
"PHONE_NUMBER": "Phone",
"IDENTIFIER": "Identifier",
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
"COMPANY": "Company",
@@ -16,10 +16,16 @@
"INITIATED_AT": "Initiated at",
"IP_ADDRESS": "IP Address",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"CALL": "Call",
"BUTTON_LABEL": {
"NEW_MESSAGE": "Message",
"CALL": "Call",
"EDIT": "Edit",
"MERGE": "Merge",
"DELETE": "Delete"
},
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
@@ -197,6 +203,7 @@
}
},
"CUSTOM_ATTRIBUTES": {
"TITLE": "Custom Attributes",
"BUTTON": "Add custom attribute",
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
"SHOW_MORE": "Show all attributes",
@@ -273,7 +273,7 @@
}
},
"SIDEBAR": {
"CONTACT": "Contact",
"CONTACT": "Contact info",
"COPILOT": "Copilot"
}
},
@@ -323,9 +323,10 @@
}
},
"CONVERSATION_SIDEBAR": {
"ASSIGNEE_LABEL": "Assigned Agent",
"SELF_ASSIGN": "Assign to me",
"TEAM_LABEL": "Assigned Team",
"ASSIGNEE_LABEL": "Agent",
"SELF_ASSIGN": "Self-assign",
"TEAM_LABEL": "Team",
"PARTICIPANTS_LABEL": "Participants",
"SELECT": {
"PLACEHOLDER": "None"
},
@@ -333,7 +334,7 @@
"CONTACT_DETAILS": "Contact Details",
"CONVERSATION_ACTIONS": "Conversation Actions",
"CONVERSATION_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information",
"CONVERSATION_INFO": "Conversation Attributes",
"CONTACT_NOTES": "Contact Notes",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
@@ -361,6 +362,9 @@
}
}
},
"CONVERSATION_ATTRIBUTES": {
"NO_RECORDS_FOUND": "No conversation info found"
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Create attribute",
"NO_RECORDS_FOUND": "No attributes found",
@@ -398,10 +402,10 @@
"ADD_PARTICIPANTS": "Select participants",
"REMANING_PARTICIPANTS_TEXT": "+{count} others",
"REMANING_PARTICIPANT_TEXT": "+{count} other",
"TOTAL_PARTICIPANTS_TEXT": "{count} people are participating.",
"TOTAL_PARTICIPANT_TEXT": "{count} person is participating.",
"NO_PARTICIPANTS_TEXT": "No one is participating!.",
"TOTAL_PARTICIPANT_TEXT": "{n} person participating | {n} people participating",
"NO_PARTICIPANTS_TEXT": "No participants",
"WATCH_CONVERSATION": "Join conversation",
"JOIN": "Join",
"YOU_ARE_WATCHING": "You are participating",
"API": {
"ERROR_MESSAGE": "Could not update, try again!",
@@ -72,6 +72,7 @@
},
"EXECUTE": {
"BUTTON_TOOLTIP": "Execute",
"RUN": "Run",
"PREVIEW": "Preview Macro",
"EXECUTED_SUCCESSFULLY": "Macro executed successfully"
},
@@ -0,0 +1,264 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
agentsList: {
type: Array,
default: () => [],
},
});
const { t } = useI18n();
const store = useStore();
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openAgentsList, toggleAgentsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openAgentsList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openAgentsList.value) {
toggleAgentsList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const assignedAgent = computed({
get() {
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
store.dispatch('setCurrentChatAssignee', agent);
store
.dispatch('assignAgent', {
conversationId: currentChat.value.id,
agentId,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_AGENT'));
});
},
});
const showSelfAssign = computed(() => {
if (!assignedAgent.value) {
return true;
}
if (assignedAgent.value.id !== currentUser.value.id) {
return true;
}
return false;
});
const assignedAgentName = computed(() => {
return (
assignedAgent.value?.name ||
assignedAgent.value?.available_name ||
t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')
);
});
const assignedAgentThumbnail = computed(() => {
return assignedAgent.value?.thumbnail;
});
const agentMenuItems = computed(() => {
const items = [];
// Add "Self Assign" option if applicable
if (showSelfAssign.value) {
items.push({
label: t('CONVERSATION_SIDEBAR.SELF_ASSIGN'),
value: currentUser.value.id,
icon: 'i-lucide-user-round-check',
isSelected: false,
action: 'selfAssign',
});
}
// Map all agents and sort with "None" (id: 0) first, then selected, then alphabetically
const agentItems = props.agentsList
.map(agent => ({
label: agent.name || agent.available_name,
value: agent.id,
thumbnail: {
name: agent.name || agent.available_name,
src: agent.thumbnail,
},
isSelected: assignedAgent.value?.id === agent.id,
action: agent.id === 0 ? 'unassignAgent' : 'assignAgent',
}))
.toSorted((a, b) => {
// "None" option (id: 0) always first
if (a.value === 0) return -1;
if (b.value === 0) return 1;
// Then sort by selection
if (a.isSelected !== b.isSelected) {
return Number(b.isSelected) - Number(a.isSelected);
}
// Finally sort alphabetically
return a.label.localeCompare(b.label);
});
return [...items, ...agentItems];
});
const onSelfAssign = () => {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = currentUser.value;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
assignedAgent.value = selfAssign;
toggleAgentsList(false);
};
const handleAgentAction = ({ action, value }) => {
if (action === 'unassignAgent') {
assignedAgent.value = null;
toggleAgentsList(false);
} else if (action === 'selfAssign') {
// Self assign current user
onSelfAssign();
} else if (action === 'assignAgent') {
// Assign selected agent
const selectedAgent = props.agentsList.find(agent => agent.id === value);
if (assignedAgent.value && assignedAgent.value.id === value) {
assignedAgent.value = null;
} else {
assignedAgent.value = selectedAgent;
}
toggleAgentsList(false);
}
};
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL') }}
</span>
<div
v-on-click-outside="() => toggleAgentsList(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
slate
:variant="openAgentsList ? 'faded' : 'ghost'"
:label="assignedAgentName"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="toggleAgentsList()"
>
<template #icon>
<Avatar
v-if="assignedAgent"
:name="assignedAgentName"
:src="assignedAgentThumbnail"
:size="16"
rounded-full
/>
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ assignedAgentName }}
</span>
<Icon
:icon="
openAgentsList ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<DropdownMenu
v-if="openAgentsList"
ref="dropdownRef"
:menu-items="agentMenuItems"
show-search
:thumbnail-size="16"
:rounded-thumbnail="false"
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleAgentAction"
>
<template #icon="{ item }">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-4 font-420"
:class="
item.action === 'selfAssign'
? 'text-n-blue-11'
: 'text-n-slate-11'
"
/>
</template>
<template #label="{ item }">
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="item.action === 'selfAssign' ? 'text-n-blue-11' : ''"
>
{{ item.label }}
</span>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -3,6 +3,7 @@ import { computed, watch, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
contactId: {
@@ -47,13 +48,14 @@ onMounted(() => {
</script>
<template>
<div v-if="!uiFlags.isFetching" class="max-h-96 overflow-y-auto">
<div v-if="!previousConversations.length" class="no-label-message px-4 p-3">
<span>
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
</span>
<div v-if="!uiFlags.isFetching" class="max-h-96 overflow-y-auto px-3">
<div v-if="!previousConversations.length" class="mt-2">
<SidePanelEmptyState
:message="$t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND')"
/>
</div>
<div v-else class="px-3 divide-y divide-n-weak">
<div v-else>
<ConversationCard
v-for="conversation in previousConversations"
:key="conversation.id"
@@ -71,9 +73,3 @@ onMounted(() => {
<Spinner />
</div>
</template>
<style lang="scss" scoped>
.no-label-message {
@apply text-n-slate-11 mb-4;
}
</style>
@@ -1,21 +1,24 @@
<script>
export default {
props: {
title: { type: String, required: true },
value: { type: [String, Number], default: '' },
compact: { type: Boolean, default: false },
},
};
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, default: '' },
icon: { type: String, default: '' },
value: { type: [String, Number], default: '' },
});
</script>
<template>
<div class="overflow-auto" :class="compact ? 'py-0 px-0' : 'py-3 px-4'">
<div class="items-center flex justify-between mb-1.5">
<span class="text-sm font-medium text-n-slate-12">
{{ title }}
</span>
<slot name="button" />
</div>
<div class="flex items-start gap-2 py-2 min-h-9">
<Icon
v-if="icon"
v-tooltip.top="{
content: title,
delay: { show: 500, hide: 0 },
}"
:icon="icon"
class="text-n-slate-11 size-4 flex-shrink-0 mt-0.5"
/>
<div v-if="value" class="break-words">
<slot>
{{ value }}
@@ -7,15 +7,16 @@ import {
} from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
@@ -83,6 +84,9 @@ const contactAdditionalAttributes = computed(
() => contact.value.additional_attributes || {}
);
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId));
const getContactDetails = () => {
if (contactId.value) {
store.dispatch('contacts/show', { id: contactId.value });
@@ -122,17 +126,36 @@ onMounted(() => {
<div class="w-full">
<SidebarActionsHeader
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
class="border-b-0"
@close="closeContactPanel"
/>
>
<div class="flex items-center gap-1.5">
<div
class="ltr:pl-1.5 ltr:pr-2 rtl:pl-2 rtl:pr-1.5 h-6 rounded-md bg-n-button-color outline outline-1 outline-n-container flex items-center"
>
<InboxName
v-if="inbox?.id"
:inbox="inbox"
class="gap-1 [&>span]:text-n-slate-12"
/>
</div>
<div
class="px-1.5 h-6 rounded-md bg-n-button-color outline outline-1 outline-n-container flex items-center gap-0.6"
>
<Icon icon="i-lucide-hash" class="size-3.5 text-n-slate-10" />
<span class="text-xs font-440">{{ conversationId }}</span>
</div>
</div>
</SidebarActionsHeader>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="px-2 pb-8 list-group">
<div class="pb-8 list-group border-t border-n-weak">
<Draggable
:list="conversationSidebarItems"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="name"
class="flex flex-col gap-3"
class="flex flex-col divide-y divide-n-weak"
@start="dragging = true"
@end="onDragEnd"
>
@@ -154,29 +177,10 @@ onMounted(() => {
/>
</AccordionItem>
</div>
<div
v-else-if="element.name === 'conversation_participants'"
class="conversation--actions"
>
<AccordionItem
:title="$t('CONVERSATION_PARTICIPANTS.SIDEBAR_TITLE')"
:is-open="isContactSidebarItemOpen('is_conv_participants_open')"
@toggle="
value =>
toggleSidebarUIState('is_conv_participants_open', value)
"
>
<ConversationParticipant
:conversation-id="conversationId"
:inbox-id="inboxId"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'conversation_info'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_INFO')"
:is-open="isContactSidebarItemOpen('is_conv_details_open')"
compact
@toggle="
value => toggleSidebarUIState('is_conv_details_open', value)
"
@@ -191,7 +195,6 @@ onMounted(() => {
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_contact_attributes_open')"
compact
@toggle="
value =>
toggleSidebarUIState('is_contact_attributes_open', value)
@@ -1,285 +1,33 @@
<!-- eslint-disable vue/v-slot-style -->
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ContactDetailsItem from './ContactDetailsItem.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import ConversationLabels from './labels/LabelBox.vue';
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AgentAssignment from './AgentAssignment.vue';
import TeamAssignment from './TeamAssignment.vue';
import PriorityAssignment from './PriorityAssignment.vue';
import ParticipantAssignment from './ParticipantAssignment.vue';
import LabelAssignment from './LabelAssignment.vue';
export default {
components: {
ContactDetailsItem,
MultiselectDropdown,
ConversationLabels,
NextButton,
defineProps({
conversationId: {
type: [Number, String],
required: true,
},
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
setup() {
const { agentsList } = useAgentsList();
return {
agentsList,
};
},
data() {
return {
priorityOptions: [
{
id: null,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
thumbnail: `/assets/images/dashboard/priority/none.svg`,
},
{
id: CONVERSATION_PRIORITY.URGENT,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.URGENT}.svg`,
},
{
id: CONVERSATION_PRIORITY.HIGH,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.HIGH}.svg`,
},
{
id: CONVERSATION_PRIORITY.MEDIUM,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.MEDIUM}.svg`,
},
{
id: CONVERSATION_PRIORITY.LOW,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.LOW}.svg`,
},
],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
teams: 'teams/getTeams',
}),
hasAnAssignedTeam() {
return !!this.currentChat?.meta?.team;
},
teamsList() {
if (this.hasAnAssignedTeam) {
return [
{ id: 0, name: this.$t('TEAMS_SETTINGS.LIST.NONE') },
...this.teams,
];
}
return this.teams;
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
},
assignedTeam: {
get() {
return this.currentChat.meta.team;
},
set(team) {
const conversationId = this.currentChat.id;
const teamId = team ? team.id : 0;
this.$store.dispatch('setCurrentChatTeam', { team, conversationId });
this.$store
.dispatch('assignTeam', { conversationId, teamId })
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_TEAM'));
});
},
},
assignedPriority: {
get() {
const selectedOption = this.priorityOptions.find(
opt => opt.id === this.currentChat.priority
);
});
return selectedOption || this.priorityOptions[0];
},
set(priorityItem) {
const conversationId = this.currentChat.id;
const oldValue = this.currentChat?.priority;
const priority = priorityItem ? priorityItem.id : null;
const { agentsList } = useAgentsList();
this.$store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
this.$store
.dispatch('assignPriority', { conversationId, priority })
.then(() => {
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
oldValue,
newValue: priority,
from: 'Conversation Sidebar',
});
useAlert(
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority: priorityItem.name,
conversationId,
})
);
});
},
},
showSelfAssign() {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
return false;
},
},
methods: {
onSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
}
},
const teams = useMapGetter('teams/getTeams');
onClickAssignTeam(selectedItemTeam) {
if (this.assignedTeam && this.assignedTeam.id === selectedItemTeam.id) {
this.assignedTeam = null;
} else {
this.assignedTeam = selectedItemTeam;
}
},
onClickAssignPriority(selectedPriorityItem) {
const isSamePriority =
this.assignedPriority &&
this.assignedPriority.id === selectedPriorityItem.id;
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
},
},
};
const teamsList = computed(() => teams.value);
</script>
<template>
<div>
<div class="multiselect-wrap--small">
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL')"
>
<template #button>
<NextButton
v-if="showSelfAssign"
link
xs
icon="i-lucide-arrow-right"
class="!gap-1"
:label="$t('CONVERSATION_SIDEBAR.SELF_ASSIGN')"
@click="onSelfAssign"
/>
</template>
</ContactDetailsItem>
<MultiselectDropdown
:options="agentsList"
:selected-item="assignedAgent"
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.AGENT')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.AGENT')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
@select="onClickAssignAgent"
/>
</div>
<div class="multiselect-wrap--small">
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.TEAM_LABEL')"
/>
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
@select="onClickAssignTeam"
/>
</div>
<div class="multiselect-wrap--small">
<ContactDetailsItem compact :title="$t('CONVERSATION.PRIORITY.TITLE')" />
<MultiselectDropdown
:options="priorityOptions"
:selected-item="assignedPriority"
:multiselector-title="$t('CONVERSATION.PRIORITY.TITLE')"
:multiselector-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SELECT_PLACEHOLDER')
"
:no-search-result="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.NO_RESULTS')
"
:input-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.INPUT_PLACEHOLDER')
"
@select="onClickAssignPriority"
/>
</div>
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_LABELS')"
/>
<ConversationLabels :conversation-id="conversationId" />
<AgentAssignment :agents-list="agentsList" />
<TeamAssignment :teams-list="teamsList" />
<PriorityAssignment />
<ParticipantAssignment :conversation-id="conversationId" />
<LabelAssignment />
</div>
</template>
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import ContactDetailsItem from './ContactDetailsItem.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
conversationAttributes: {
@@ -49,66 +50,79 @@ const staticElements = computed(() =>
title: 'CONTACT_PANEL.INITIATED_AT',
key: 'static-initiated-at',
type: 'static_attribute',
icon: 'i-lucide-timer',
},
{
content: browserLanguage,
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
key: 'static-browser-language',
type: 'static_attribute',
icon: 'i-lucide-languages',
},
{
content: referer,
title: 'CONTACT_PANEL.INITIATED_FROM',
key: 'static-referer',
type: 'static_attribute',
icon: 'i-lucide-link',
},
{
content: browserName,
title: 'CONTACT_PANEL.BROWSER',
key: 'static-browser',
type: 'static_attribute',
icon: 'i-woot-website',
},
{
content: platformName,
title: 'CONTACT_PANEL.OS',
key: 'static-platform',
type: 'static_attribute',
icon: 'i-woot-monitor',
},
{
content: createdAtIp,
title: 'CONTACT_PANEL.IP_ADDRESS',
key: 'static-ip-address',
type: 'static_attribute',
icon: 'i-woot-ip-address',
},
].filter(attribute => !!attribute.content.value)
);
</script>
<template>
<div class="conversation--details">
<CustomAttributes
:static-elements="staticElements"
attribute-class="conversation--attribute"
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
>
<template #staticItem="{ element }">
<ContactDetailsItem
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
<!-- Static Conversation Attributes -->
<div v-if="staticElements.length > 0" class="mt-2">
<div v-for="element in staticElements" :key="element.key">
<ContactDetailsItem
:title="$t(element.title)"
:value="element.content.value"
:icon="element.icon"
>
<a
v-if="element.key === 'static-referer'"
:href="element.content.value"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-n-brand"
>
<a
v-if="element.key === 'static-referer'"
:href="element.content.value"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-n-brand"
>
{{ element.content.value }}
</a>
</ContactDetailsItem>
</template>
</CustomAttributes>
{{ element.content.value }}
</a>
</ContactDetailsItem>
</div>
</div>
<div v-else class="mt-2">
<SidePanelEmptyState
:message="$t('CONVERSATION_ATTRIBUTES.NO_RECORDS_FOUND')"
/>
</div>
<!-- Custom Attributes -->
<CustomAttributes
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
:empty-state-message="$t('CONVERSATION_CUSTOM_ATTRIBUTES.NO_RECORDS_FOUND')"
show-title
/>
</template>
@@ -1,229 +0,0 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ThumbnailGroup from 'dashboard/components/widgets/ThumbnailGroup.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Spinner,
ThumbnailGroup,
MultiselectDropdownItems,
NextButton,
},
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
setup() {
const { agentsList } = useAgentsList(false);
return {
agentsList,
};
},
data() {
return {
selectedWatchers: [],
showDropDown: false,
};
},
computed: {
...mapGetters({
watchersUiFlas: 'conversationWatchers/getUIFlags',
currentUser: 'getCurrentUser',
}),
watchersFromStore() {
return this.$store.getters['conversationWatchers/getByConversationId'](
this.conversationId
);
},
watchersList: {
get() {
return this.selectedWatchers;
},
set(participants) {
this.selectedWatchers = [...participants];
const userIds = participants.map(el => el.id);
this.updateParticipant(userIds);
},
},
isUserWatching() {
return this.selectedWatchers.some(
watcher => watcher.id === this.currentUser.id
);
},
thumbnailList() {
return this.selectedWatchers.slice(0, 4);
},
moreAgentCount() {
const maxThumbnailCount = 4;
return this.watchersList.length - maxThumbnailCount;
},
moreThumbnailsText() {
if (this.moreAgentCount > 1) {
return this.$t('CONVERSATION_PARTICIPANTS.REMANING_PARTICIPANTS_TEXT', {
count: this.moreAgentCount,
});
}
return this.$t('CONVERSATION_PARTICIPANTS.REMANING_PARTICIPANT_TEXT', {
count: 1,
});
},
showMoreThumbs() {
return this.moreAgentCount > 0;
},
totalWatchersText() {
if (this.selectedWatchers.length > 1) {
return this.$t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANTS_TEXT', {
count: this.selectedWatchers.length,
});
}
return this.$t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANT_TEXT', {
count: 1,
});
},
},
watch: {
conversationId() {
this.fetchParticipants();
},
watchersFromStore(participants = []) {
this.selectedWatchers = [...participants];
},
},
mounted() {
this.fetchParticipants();
this.$store.dispatch('agents/get');
},
methods: {
fetchParticipants() {
const conversationId = this.conversationId;
this.$store.dispatch('conversationWatchers/show', { conversationId });
},
async updateParticipant(userIds) {
const conversationId = this.conversationId;
let alertMessage = this.$t(
'CONVERSATION_PARTICIPANTS.API.SUCCESS_MESSAGE'
);
try {
await this.$store.dispatch('conversationWatchers/update', {
conversationId,
userIds,
});
} catch (error) {
alertMessage =
error?.message ||
this.$t('CONVERSATION_PARTICIPANTS.API.ERROR_MESSAGE');
} finally {
useAlert(alertMessage);
}
this.fetchParticipants();
},
onOpenDropdown() {
this.showDropDown = true;
},
onCloseDropdown() {
this.showDropDown = false;
},
onClickItem(agent) {
const isAgentSelected = this.watchersList.some(
participant => participant.id === agent.id
);
if (isAgentSelected) {
const updatedList = this.watchersList.filter(
participant => participant.id !== agent.id
);
this.watchersList = [...updatedList];
} else {
this.watchersList = [...this.watchersList, agent];
}
},
onSelfAssign() {
this.watchersList = [...this.selectedWatchers, this.currentUser];
},
},
};
</script>
<template>
<div class="relative">
<div class="flex justify-between">
<div class="flex justify-between w-full mb-1">
<div>
<p v-if="watchersList.length" class="m-0 text-sm total-watchers">
<Spinner v-if="watchersUiFlas.isFetching" size="tiny" />
{{ totalWatchersText }}
</p>
<p v-else class="m-0 text-sm text-n-slate-10">
{{ $t('CONVERSATION_PARTICIPANTS.NO_PARTICIPANTS_TEXT') }}
</p>
</div>
<NextButton
v-tooltip.left="$t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS')"
slate
ghost
sm
icon="i-lucide-settings"
class="relative -top-1"
:title="$t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS')"
@click="onOpenDropdown"
/>
</div>
</div>
<div class="flex items-center justify-between">
<ThumbnailGroup
:more-thumbnails-text="moreThumbnailsText"
:show-more-thumbnails-count="showMoreThumbs"
:users-list="thumbnailList"
/>
<p v-if="isUserWatching" class="m-0 text-sm text-n-slate-10">
{{ $t('CONVERSATION_PARTICIPANTS.YOU_ARE_WATCHING') }}
</p>
<NextButton
v-else
link
xs
icon="i-lucide-arrow-right"
class="!gap-1"
:label="$t('CONVERSATION_PARTICIPANTS.WATCH_CONVERSATION')"
@click="onSelfAssign"
/>
</div>
<div
v-on-clickaway="
() => {
onCloseDropdown();
}
"
:class="{
'block visible': showDropDown,
'hidden invisible': !showDropDown,
}"
class="border rounded-lg shadow-lg bg-n-alpha-3 absolute backdrop-blur-[100px] border-n-strong dark:border-n-strong p-2 z-[9999] box-border top-8 w-full"
>
<div class="flex items-center justify-between mb-1">
<h4
class="m-0 overflow-hidden text-sm whitespace-nowrap text-ellipsis text-n-slate-12"
>
{{ $t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS') }}
</h4>
<NextButton ghost slate xs icon="i-lucide-x" @click="onCloseDropdown" />
</div>
<MultiselectDropdownItems
:options="agentsList"
:selected-items="selectedWatchers"
has-thumbnail
@select="onClickItem"
/>
</div>
</div>
</template>
@@ -0,0 +1,213 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useMapGetter } from 'dashboard/composables/store';
import { useAdmin } from 'dashboard/composables/useAdmin';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
const { t } = useI18n();
const {
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const { isAdmin } = useAdmin();
const conversationUiFlags = useMapGetter('conversationLabels/getUIFlags');
const triggerRef = ref(null);
const dropdownRef = ref(null);
const searchQuery = ref('');
const [openLabelsList, toggleLabels] = useToggle(false);
const [createModalVisible, toggleCreateModal] = useToggle(false);
const [hasEmptySearchResults, setEmptySearchResults] = useToggle(false);
const { positionClasses, updatePosition } = useDropdownPosition(
triggerRef,
dropdownRef,
openLabelsList
);
// Update position of dropdown when labels change
watch(
activeLabels,
async () => {
if (openLabelsList.value) {
await nextTick();
updatePosition();
}
},
{ deep: true }
);
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (openLabelsList.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const labelMenuItems = computed(() => {
return accountLabels.value.map(label => ({
label: label.title,
value: label.id,
color: label.color,
isSelected: activeLabels.value.some(active => active.id === label.id),
action: 'toggleLabel',
}));
});
const shouldShowCreateButton = computed(() => {
return isAdmin.value && searchQuery.value && hasEmptySearchResults.value;
});
const handleLabelAction = ({ value }) => {
const label = accountLabels.value.find(l => l.id === value);
if (!label) return;
const isSelected = activeLabels.value.some(active => active.id === value);
if (isSelected) {
removeLabelFromConversation(label.title);
} else {
addLabelToConversation(label);
}
};
const handleSearchUpdate = query => {
searchQuery.value = query;
setEmptySearchResults(false);
};
const handleEmptyResults = () => {
setEmptySearchResults(true);
};
const showCreateModal = () => {
toggleCreateModal(true);
};
const hideCreateModal = () => {
toggleCreateModal(false);
};
</script>
<template>
<div class="flex flex-wrap gap-3 w-full items-start pt-3">
<Spinner
v-if="conversationUiFlags.isFetching"
:size="22"
class="text-n-slate-10"
/>
<div v-else class="flex flex-wrap gap-2.5">
<div
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:title="label.description"
class="bg-n-button-color px-2.5 h-8 gap-1.5 rounded-lg -outline-offset-1 outline outline-1 outline-n-container inline-flex items-center flex-shrink-0"
>
<span
class="rounded-sm size-2 flex-shrink-0"
:style="{ background: label.color }"
/>
<span class="font-420 text-sm text-n-slate-12 whitespace-nowrap">
{{ label.title }}
</span>
</div>
<div
v-on-click-outside="() => toggleLabels(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
:label="$t('CONTACT_PANEL.LABELS.CONVERSATION.ADD_BUTTON')"
slate
sm
icon="i-lucide-plus"
:variant="openLabelsList ? 'faded' : 'solid'"
class="font-460 !-outline-offset-1"
@click="toggleLabels()"
/>
<DropdownMenu
v-if="openLabelsList"
ref="dropdownRef"
:menu-items="labelMenuItems"
show-search
:search-placeholder="
$t('CONTACT_PANEL.LABELS.LABEL_SELECT.PLACEHOLDER')
"
class="z-[100] w-56 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleLabelAction"
@search="handleSearchUpdate"
@empty="handleEmptyResults"
>
<template #thumbnail="{ item }">
<span
class="rounded-sm size-2 flex-shrink-0"
:style="{ background: item.color }"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
<template #footer>
<div
v-if="shouldShowCreateButton"
class="flex pt-1 w-full border-t border-n-weak"
>
<Button
icon="i-lucide-plus"
slate
sm
ghost
:label="`${t('CONTACT_PANEL.LABELS.LABEL_SELECT.CREATE_LABEL')}: ${searchQuery}`"
class="w-full"
@click="showCreateModal"
/>
</div>
</template>
</DropdownMenu>
<woot-modal
v-model:show="createModalVisible"
:on-close="hideCreateModal"
>
<AddLabelModal
:prefill-title="searchQuery"
@close="hideCreateModal"
/>
</woot-modal>
</div>
</div>
</div>
</template>
@@ -8,6 +8,7 @@ import Draggable from 'vuedraggable';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import MacroItem from './MacroItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
defineProps({
conversationId: {
@@ -68,16 +69,14 @@ onMounted(() => {
<template>
<div>
<div v-if="!uiFlags.isFetching && !macros.length" class="p-3">
<p class="flex flex-col items-center justify-center h-full">
{{ $t('MACROS.LIST.404') }}
</p>
<SidePanelEmptyState :message="$t('MACROS.LIST.404')" class="mb-2" />
<router-link :to="accountScopedUrl('settings/macros')">
<NextButton
faded
xs
link
sm
icon="i-lucide-plus"
class="mt-1"
:label="$t('MACROS.HEADER_BTN_TXT')"
class="!text-n-slate-11 hover:!text-n-slate-12 hover:!no-underline !py-2"
/>
</router-link>
</div>
@@ -91,7 +90,6 @@ onMounted(() => {
<Draggable
v-if="!uiFlags.isFetching && macros.length"
v-model="orderedMacros"
class="p-1"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { CONVERSATION_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MacroPreview from './MacroPreview.vue';
@@ -25,6 +26,14 @@ const { t } = useI18n();
const isExecuting = ref(false);
const showPreview = ref(false);
const triggerRef = ref(null);
const dropdownRef = ref(null);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
showPreview
);
const executeMacro = async macro => {
try {
@@ -53,38 +62,42 @@ const closeMacroPreview = () => {
<template>
<div
class="relative flex items-center justify-between leading-4 rounded-md h-10 pl-3 pr-2"
class="relative flex items-center justify-between leading-4 rounded-md h-10 ltr:pl-1.5 ltr:pr-3 rtl:pl-3 rtl:pr-1.5"
:class="showPreview ? 'cursor-default' : 'drag-handle cursor-grab'"
>
<span
class="overflow-hidden whitespace-nowrap text-ellipsis font-medium text-n-slate-12"
>
{{ macro.name }}
</span>
<div class="flex items-center gap-1 justify-end">
<div class="flex items-center justify-start gap-1">
<NextButton
v-tooltip.left-start="$t('MACROS.EXECUTE.PREVIEW')"
ref="triggerRef"
v-tooltip.top-start="$t('MACROS.EXECUTE.PREVIEW')"
icon="i-lucide-info"
slate
faded
:variant="!showPreview ? 'ghost' : 'faded'"
xs
class="[&>span]:size-3.5"
@click="toggleMacroPreview"
/>
<NextButton
v-tooltip.left-start="$t('MACROS.EXECUTE.BUTTON_TOOLTIP')"
icon="i-lucide-play"
slate
faded
xs
:is-loading="isExecuting"
@click="executeMacro(macro)"
/>
<span class="font-420 text-ellipsis text-n-slate-12 truncate">
{{ macro.name }}
</span>
</div>
<NextButton
:label="$t('MACROS.EXECUTE.RUN')"
slate
link
sm
class="hover:!no-underline !text-n-slate-11 !py-2"
:is-loading="isExecuting"
@click="executeMacro(macro)"
/>
<transition name="menu-slide">
<MacroPreview
v-if="showPreview"
ref="dropdownRef"
v-on-clickaway="closeMacroPreview"
:macro="macro"
class="ltr:ml-8 rtl:mr-8 !-mt-8"
:class="positionClasses"
/>
</transition>
</div>
@@ -8,6 +8,8 @@ import {
resolveAgents,
} from 'dashboard/routes/dashboard/settings/macros/macroHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
macro: {
type: Object,
@@ -47,37 +49,34 @@ const resolvedMacro = computed(() => {
<template>
<div
class="macro-preview absolute border border-n-weak max-h-[22.5rem] z-50 w-64 rounded-md bg-n-alpha-3 backdrop-blur-[100px] shadow-lg bottom-8 right-8 overflow-y-auto p-4 text-left rtl:text-right"
class="macro-preview absolute w-72 outline outline-n-weak max-h-72 z-50 rounded-xl bg-n-alpha-3 backdrop-blur-[50px] shadow-lg overflow-y-auto px-3 py-2"
>
<h6 class="mb-4 text-sm text-n-slate-12">
{{ macro.name }}
</h6>
<div class="flex items-center gap-3 h-9">
<Icon icon="i-lucide-zap" class="text-n-slate-11 size-4 text-base" />
<span class="text-sm font-medium text-n-slate-12">
{{ macro.name }}
</span>
</div>
<div
v-for="(action, i) in resolvedMacro"
:key="i"
class="relative pl-4 macro-block"
class="relative ltr:pl-7 rtl:pr-7 py-3 after:content-[''] after:absolute ltr:after:left-1.5 rtl:after:right-1.5 after:w-px after:bg-n-weak"
:class="{
'after:top-1 after:h-3.5': resolvedMacro.length === 1,
'after:top-2 after:-bottom-5':
resolvedMacro.length > 1 && i !== resolvedMacro.length - 1,
}"
>
<div
v-if="i !== macro.actions.length - 1"
class="top-[0.390625rem] absolute -bottom-1 left-0 w-px bg-n-slate-6"
class="absolute ltr:left-[2.5px] rtl:right-[2.5px] top-[18px] w-2 h-2 rounded-full bg-n-surface-1 border-2 border-n-weak z-10"
/>
<div
class="absolute -left-[0.21875rem] top-[0.2734375rem] w-2 h-2 rounded-full bg-n-solid-1 border-2 border-solid border-n-weak dark:border-n-slate-6"
/>
<p class="mb-1 text-xs text-n-slate-11">
<p class="mb-1 text-sm font-medium text-n-slate-11">
{{ $t(`MACROS.ACTIONS.${action.actionName}`) }}
</p>
<p class="text-n-slate-12 text-sm">{{ action.actionValue }}</p>
<p class="text-n-slate-12 text-sm font-420">{{ action.actionValue }}</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.macro-preview {
.macro-block {
&:not(:last-child) {
@apply pb-2;
}
}
}
</style>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import AvatarGroup from 'dashboard/components-next/avatar/AvatarGroup.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { agentsList } = useAgentsList(false);
const selectedParticipants = ref([]);
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openParticipantsList, toggleParticipantsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openParticipantsList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openParticipantsList.value) {
toggleParticipantsList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentUser = useMapGetter('getCurrentUser');
const watchersFromStore = computed(() => {
return store.getters['conversationWatchers/getByConversationId'](
props.conversationId
);
});
const participantsLabel = computed(() => {
const count = selectedParticipants.value.length;
if (count === 0) {
return t('CONVERSATION_PARTICIPANTS.NO_PARTICIPANTS_TEXT');
}
return t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANT_TEXT', { n: count });
});
const thumbnailList = computed(() => {
return selectedParticipants.value.slice(0, 3);
});
const moreParticipantsCount = computed(() => {
const maxThumbnailCount = 3;
return selectedParticipants.value.length - maxThumbnailCount;
});
const moreParticipantsText = computed(() => {
if (moreParticipantsCount.value > 0) {
return `+${moreParticipantsCount.value}`;
}
return '';
});
const isUserWatching = computed(() => {
return selectedParticipants.value.some(
participant => participant.id === currentUser.value.id
);
});
const participantMenuItems = computed(() => {
return agentsList.value.map(agent => ({
label: agent.name || agent.available_name,
value: agent.id,
thumbnail: { name: agent.name, src: agent.thumbnail },
isSelected: selectedParticipants.value.some(p => p.id === agent.id),
action: 'toggleParticipant',
}));
});
const fetchParticipants = () => {
store.dispatch('conversationWatchers/show', {
conversationId: props.conversationId,
});
};
const updateParticipants = async userIds => {
let alertMessage = t('CONVERSATION_PARTICIPANTS.API.SUCCESS_MESSAGE');
try {
await store.dispatch('conversationWatchers/update', {
conversationId: props.conversationId,
userIds,
});
} catch (error) {
alertMessage =
error?.message || t('CONVERSATION_PARTICIPANTS.API.ERROR_MESSAGE');
} finally {
useAlert(alertMessage);
}
fetchParticipants();
};
const handleParticipantAction = ({ value }) => {
const isSelected = selectedParticipants.value.some(p => p.id === value);
if (isSelected) {
// Remove participant
selectedParticipants.value = selectedParticipants.value.filter(
p => p.id !== value
);
} else {
// Add participant
const agent = agentsList.value.find(a => a.id === value);
if (agent) {
selectedParticipants.value = [...selectedParticipants.value, agent];
}
}
const userIds = selectedParticipants.value.map(p => p.id);
updateParticipants(userIds);
};
const onSelfAssign = () => {
if (!isUserWatching.value) {
selectedParticipants.value = [
...selectedParticipants.value,
currentUser.value,
];
const userIds = selectedParticipants.value.map(p => p.id);
updateParticipants(userIds);
}
};
watch(
() => props.conversationId,
() => {
fetchParticipants();
}
);
watch(watchersFromStore, participants => {
selectedParticipants.value = [...(participants || [])];
});
onMounted(() => {
fetchParticipants();
store.dispatch('agents/get');
});
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION_SIDEBAR.PARTICIPANTS_LABEL') }}
</span>
<div
v-on-click-outside="() => toggleParticipantsList(false)"
class="relative w-fit"
>
<div class="flex items-center gap-1">
<Button
ref="triggerRef"
slate
:variant="openParticipantsList ? 'faded' : 'ghost'"
:label="participantsLabel"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="toggleParticipantsList()"
>
<template #icon>
<AvatarGroup
v-if="selectedParticipants.length > 0"
:users-list="thumbnailList"
:size="16"
:show-more-count="moreParticipantsCount > 0"
:more-count-text="moreParticipantsText"
gap="tight"
/>
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ participantsLabel }}
</span>
<Icon
:icon="
openParticipantsList
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<div
v-if="!isUserWatching"
class="w-px mx-1 h-3 bg-n-weak rounded-lg flex-shrink-0"
/>
<Button
v-if="!isUserWatching"
link
:label="$t('CONVERSATION_PARTICIPANTS.JOIN')"
sm
class="flex-shrink-0 !text-n-blue-11 hover:!no-underline"
@click="onSelfAssign"
/>
</div>
<DropdownMenu
v-if="openParticipantsList"
ref="dropdownRef"
:menu-items="participantMenuItems"
show-search
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleParticipantAction"
>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -0,0 +1,195 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useTrack } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
const { t } = useI18n();
const store = useStore();
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openPriorityList, togglePriorityList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openPriorityList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openPriorityList.value) {
togglePriorityList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentChat = useMapGetter('getSelectedChat');
const priorityOptions = [
{
id: 0,
name: t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
priority: null,
},
{
id: CONVERSATION_PRIORITY.URGENT,
name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
priority: CONVERSATION_PRIORITY.URGENT,
},
{
id: CONVERSATION_PRIORITY.HIGH,
name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
priority: CONVERSATION_PRIORITY.HIGH,
},
{
id: CONVERSATION_PRIORITY.MEDIUM,
name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
priority: CONVERSATION_PRIORITY.MEDIUM,
},
{
id: CONVERSATION_PRIORITY.LOW,
name: t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
priority: CONVERSATION_PRIORITY.LOW,
},
];
const assignedPriority = computed({
get() {
const selectedOption = priorityOptions.find(
opt => opt.priority === currentChat.value?.priority
);
return selectedOption || priorityOptions[0];
},
set(priorityItem) {
const conversationId = currentChat.value.id;
const oldValue = currentChat.value?.priority;
const priority = priorityItem ? priorityItem.priority : null;
store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
oldValue,
newValue: priority,
from: 'Conversation Sidebar',
});
useAlert(
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority: priorityItem.name,
conversationId,
})
);
});
},
});
const assignedPriorityName = computed(() => {
return (
assignedPriority.value?.name || t('CONVERSATION.PRIORITY.OPTIONS.NONE')
);
});
const priorityMenuItems = computed(() => {
return priorityOptions.map(option => ({
label: option.name,
value: option.id,
priority: option.priority,
isSelected: assignedPriority.value?.id === option.id,
action: 'assignPriority',
}));
});
const handlePriorityAction = ({ value }) => {
const selectedPriority = priorityOptions.find(opt => opt.id === value);
if (assignedPriority.value?.id === value) {
assignedPriority.value = priorityOptions[0]; // Set to "None"
} else {
assignedPriority.value = selectedPriority;
}
togglePriorityList(false);
};
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION.PRIORITY.TITLE') }}
</span>
<div
v-on-click-outside="() => togglePriorityList(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
slate
:variant="openPriorityList ? 'faded' : 'ghost'"
:label="assignedPriorityName"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="togglePriorityList()"
>
<template #icon>
<CardPriorityIcon :priority="assignedPriority.priority" show-empty />
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ assignedPriorityName }}
</span>
<Icon
:icon="
openPriorityList ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<DropdownMenu
v-if="openPriorityList"
ref="dropdownRef"
:menu-items="priorityMenuItems"
:search-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.INPUT_PLACEHOLDER')
"
class="z-[100] w-48 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handlePriorityAction"
>
<template #icon="{ item }">
<CardPriorityIcon
:priority="item.priority"
show-empty
class="flex-shrink-0"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="custom-dashed-border p-4 rounded-xl">
<p class="text-center text-n-slate-10 font-420">{{ message }}</p>
</div>
</template>
@@ -0,0 +1,181 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
teamsList: {
type: Array,
default: () => [],
},
});
const { t } = useI18n();
const store = useStore();
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openTeamsList, toggleTeamsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openTeamsList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openTeamsList.value) {
toggleTeamsList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentChat = useMapGetter('getSelectedChat');
const assignedTeam = computed({
get() {
return currentChat.value?.meta?.team;
},
set(team) {
const conversationId = currentChat.value.id;
const teamId = team ? team.id : 0;
store.dispatch('setCurrentChatTeam', { team, conversationId });
store.dispatch('assignTeam', { conversationId, teamId }).then(() => {
useAlert(t('CONVERSATION.CHANGE_TEAM'));
});
},
});
const assignedTeamName = computed(() => {
return assignedTeam.value?.name || t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER');
});
const teamMenuItems = computed(() => {
const items = [];
// Add "None" option as first item
items.push({
label: t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER'),
value: 0,
isSelected: !assignedTeam.value,
thumbnail: { name: t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER') },
action: 'unassignTeam',
});
// Add all teams, sorted by selection and then by name
const teamItems = props.teamsList
.filter(team => team.id !== 0) // Filter out the "None" option if it exists in teamsList
.map(team => ({
label: team.name,
value: team.id,
thumbnail: { name: team.name },
isSelected: assignedTeam.value?.id === team.id,
action: 'assignTeam',
}))
.toSorted((a, b) => {
if (a.isSelected !== b.isSelected) {
return Number(b.isSelected) - Number(a.isSelected);
}
return a.label.localeCompare(b.label);
});
return [...items, ...teamItems];
});
const handleTeamAction = ({ action, value }) => {
if (action === 'unassignTeam') {
// Unassign team (set to null)
assignedTeam.value = null;
toggleTeamsList(false);
} else if (action === 'assignTeam') {
// Assign selected team
const selectedTeam = props.teamsList.find(team => team.id === value);
if (assignedTeam.value && assignedTeam.value.id === value) {
assignedTeam.value = null;
} else {
assignedTeam.value = selectedTeam;
}
toggleTeamsList(false);
}
};
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION_SIDEBAR.TEAM_LABEL') }}
</span>
<div
v-on-click-outside="() => toggleTeamsList(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
slate
:variant="openTeamsList ? 'faded' : 'ghost'"
:label="assignedTeamName"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="toggleTeamsList()"
>
<template #icon>
<Avatar
v-if="assignedTeam"
:name="assignedTeamName"
:size="16"
rounded-full
/>
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ assignedTeamName }}
</span>
<Icon
:icon="
openTeamsList ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<DropdownMenu
v-if="openTeamsList"
ref="dropdownRef"
:menu-items="teamMenuItems"
show-search
:thumbnail-size="16"
:rounded-thumbnail="false"
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleTeamAction"
>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -1,11 +1,15 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, watch } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import Icon from 'next/icon/Icon.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
@@ -17,253 +21,220 @@ import {
isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute,
} from '../../../../helper/routeHelpers';
} from 'dashboard/helper/routeHelpers';
import { emitter } from 'shared/helpers/mitt';
export default {
components: {
NextButton,
ContactInfoRow,
EditContact,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
VoiceCallButton,
const props = defineProps({
contact: {
type: Object,
default: () => ({}),
},
props: {
contact: {
type: Object,
default: () => ({}),
},
showAvatar: {
type: Boolean,
default: true,
},
showAvatar: {
type: Boolean,
default: true,
},
emits: ['panelClose'],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showEditModal: false,
showMergeModal: false,
showDeleteModal: false,
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
});
if (!cityAndCountry) {
return '';
}
return this.findCountryFlag(countryCode, cityAndCountry);
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
return {
twitter: twitterScreenName,
telegram: telegramUsername,
...(socialProfiles || {}),
};
},
// Delete Modal
confirmDeleteMessage() {
return ` ${this.contact.name}?`;
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
openComposeConversationModal(toggleFn) {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
},
closeComposeConversationModal() {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
confirmDeletion() {
this.deleteContact(this.contact);
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
this.showEditModal = false;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const emit = defineEmits(['panelClose']);
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
},
async deleteContact({ id }) {
try {
await this.$store.dispatch('contacts/delete', id);
this.$emit('panelClose');
useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { isAdmin } = useAdmin();
if (isAConversationRoute(this.$route.name)) {
this.$router.push({
name: getConversationDashboardRoute(this.$route.name),
});
} else if (isAInboxViewRoute(this.$route.name)) {
this.$router.push({
name: 'inbox_view',
});
} else if (this.$route.name !== 'contacts_dashboard') {
this.$router.push({
name: 'contacts_dashboard',
});
}
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
}
},
closeMergeModal() {
this.showMergeModal = false;
},
openMergeModal() {
this.showMergeModal = true;
},
},
const [showEditModal, toggleEditModal] = useToggle(false);
const [showMergeModal, toggleMergeModal] = useToggle(false);
const [showDeleteModal, toggleDeleteModal] = useToggle(false);
const uiFlags = useMapGetter('contacts/getUIFlags');
const contactProfileLink = computed(
() => `/app/accounts/${route.params.accountId}/contacts/${props.contact.id}`
);
const additionalAttributes = computed(
() => props.contact.additional_attributes || {}
);
const findCountryFlag = (countryCode, cityAndCountry) => {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
};
const deleteContact = async ({ id }) => {
try {
await store.dispatch('contacts/delete', id);
emit('panelClose');
useAlert(t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
if (isAConversationRoute(route.name)) {
router.push({
name: getConversationDashboardRoute(route.name),
});
} else if (isAInboxViewRoute(route.name)) {
router.push({
name: 'inbox_view',
});
} else if (route.name !== 'contacts_dashboard') {
router.push({
name: 'contacts_dashboard',
});
}
} catch (error) {
useAlert(
error.message ? error.message : t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
}
};
const location = computed(() => {
const {
country = '',
city = '',
country_code: countryCode,
} = additionalAttributes.value;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
if (!cityAndCountry) {
return '';
}
return findCountryFlag(countryCode, cityAndCountry);
});
watch(
() => props.contact.id,
id => store.dispatch('contacts/fetchContactableInbox', id),
{ immediate: true }
);
const openComposeConversationModal = toggleFn => {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const closeComposeConversationModal = () => {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const closeDelete = () => {
toggleDeleteModal(false);
toggleEditModal(false);
};
const confirmDeletion = () => {
deleteContact(props.contact);
closeDelete();
};
const closeMergeModal = () => {
toggleMergeModal(false);
};
const openMergeModal = () => {
toggleMergeModal(true);
};
</script>
<template>
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<div class="relative items-center w-full px-4 pb-4 pt-3">
<div class="flex flex-col w-full gap-3 text-start">
<div class="flex flex-row gap-4 md:gap-8">
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
:name="contact.name"
:status="contact.availability_status"
:size="48"
:size="64"
hide-offline-status
rounded-full
/>
</div>
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div v-if="showAvatar" class="flex items-center w-full min-w-0 gap-3">
<h3
class="flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12"
>
{{ contact.name }}
</h3>
<div class="flex flex-row items-center gap-2">
<span
v-if="contact.created_at"
v-tooltip.left="
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
contact.created_at
)}`
"
class="i-lucide-info text-sm text-n-slate-10"
/>
<div
v-if="showAvatar"
class="flex flex-col justify-center min-w-0 flex-1"
>
<div class="flex items-center gap-1">
<h3
:title="contact.name"
class="flex-shrink font-medium max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 line-clamp-2"
>
{{ contact.name }}
</h3>
<div class="w-px h-2 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1" />
<a
:href="contactProfileLink"
target="_blank"
rel="noopener nofollow noreferrer"
class="leading-3"
class="flex-shrink-0 flex items-center"
>
<span class="i-lucide-external-link text-sm text-n-slate-10" />
<Icon
icon="i-lucide-arrow-up-right"
class="size-4 text-n-slate-10 hover:text-n-slate-12 transition-colors"
/>
</a>
</div>
<p
v-if="contact"
class="text-sm text-n-slate-11 font-420 m-0 truncate"
>
{{ contact.email || contact.phone_number || '---' }}
</p>
</div>
</div>
<p v-if="additionalAttributes.description" class="break-words mb-0.5">
<div class="flex flex-col items-start gap-3 min-w-0 w-full">
<p
v-if="additionalAttributes.description"
class="break-words text-sm text-n-slate-11 font-420 mb-0"
>
{{ additionalAttributes.description }}
</p>
<div class="flex flex-col items-start w-full gap-2">
<div class="flex flex-col items-start w-full">
<ContactInfoRow
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="mail"
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
:title="t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
:title="t('CONTACT_PANEL.COMPANY')"
/>
<ContactInfoRow
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number"
icon="call"
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
:title="t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
/>
<ContactInfoRow
v-if="contact.identifier"
:value="contact.identifier"
icon="contact-identify"
emoji="🪪"
:title="$t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
:title="t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
:title="t('CONTACT_PANEL.LOCATION')"
/>
<ContactInfoRow
v-if="contact.created_at"
:value="dynamicTime(contact.created_at)"
:title="t('CONTACT_PANEL.CREATED_AT_LABEL')"
/>
<SocialIcons :social-profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<div class="flex items-center w-full mb-1 gap-3">
<ComposeConversation
:contact-id="String(contact.id)"
is-modal
@@ -271,11 +242,10 @@ export default {
>
<template #trigger="{ toggle }">
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
:label="t('CONTACT_PANEL.BUTTON_LABEL.NEW_MESSAGE')"
slate
faded
sm
class="!px-2"
@click="openComposeConversationModal(toggle)"
/>
</template>
@@ -283,46 +253,42 @@ export default {
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
:label="t('CONTACT_PANEL.BUTTON_LABEL.CALL')"
slate
faded
class="!px-2"
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
:label="t('CONTACT_PANEL.BUTTON_LABEL.EDIT')"
slate
faded
sm
@click="toggleEditModal"
class="!px-2"
@click="() => toggleEditModal()"
/>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
:label="t('CONTACT_PANEL.BUTTON_LABEL.MERGE')"
slate
faded
sm
class="!px-2"
:disabled="uiFlags.isMerging"
@click="openMergeModal"
/>
<NextButton
v-if="isAdmin"
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
:label="t('CONTACT_PANEL.BUTTON_LABEL.DELETE')"
slate
faded
sm
ruby
class="!px-2"
:disabled="uiFlags.isDeleting"
@click="toggleDeleteModal"
@click="() => toggleDeleteModal()"
/>
</div>
<EditContact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
@cancel="() => toggleEditModal()"
/>
<ContactMergeModal
v-if="showMergeModal"
@@ -336,11 +302,11 @@ export default {
v-model:show="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="$t('DELETE_CONTACT.CONFIRM.MESSAGE')"
:message-value="confirmDeleteMessage"
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
:title="t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="t('DELETE_CONTACT.CONFIRM.MESSAGE')"
:message-value="contact?.name || ''"
:confirm-text="t('DELETE_CONTACT.CONFIRM.YES')"
:reject-text="t('DELETE_CONTACT.CONFIRM.NO')"
/>
</div>
</template>
@@ -1,95 +1,76 @@
<script>
<script setup>
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
EmojiOrIcon,
NextButton,
const props = defineProps({
href: {
type: String,
default: '',
},
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
emoji: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
title: {
type: String,
required: true,
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const onCopy = async e => {
e.preventDefault();
await copyTextToClipboard(props.value);
useAlert(t('CONTACT_PANEL.COPY_SUCCESSFUL'));
};
</script>
<template>
<div class="w-full h-5 ltr:-ml-1 rtl:-mr-1">
<a
v-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
<div class="grid grid-cols-[30%_1fr] gap-4 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ title }}
</span>
<div class="min-w-0 flex items-center gap-1">
<a
v-if="href"
:href="href"
class="hover:underline min-w-0 truncate"
:title="value"
>
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<span v-if="value" class="text-sm font-420 text-n-slate-12">
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-10">
{{ '---' }}
</span>
</a>
<div v-else class="text-n-slate-12 min-w-0 truncate">
<span
v-if="value"
v-dompurify-html="value"
class="text-sm font-420 text-n-slate-12 [&>span]:ltr:ml-1.5 [&>span]:rtl:mr-1.5"
/>
<span v-else class="text-sm text-n-slate-10">{{ '---' }}</span>
</div>
<NextButton
v-if="showCopy"
v-if="showCopy && value"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
class="flex-shrink-0"
@click="onCopy"
/>
</a>
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
/>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
</div>
</div>
</template>
@@ -8,6 +8,7 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
contactId: { type: [String, Number], required: true },
@@ -98,10 +99,10 @@ watch(
<template>
<div>
<div class="px-4 pt-3 pb-2">
<div class="px-3 pt-3 pb-2">
<NextButton
ghost
xs
sm
slate
icon="i-lucide-plus"
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
:disabled="!contactId || isFetchingNotes"
@@ -117,12 +118,12 @@ watch(
</div>
<div
v-else-if="notes.length"
class="flex flex-col max-h-[300px] overflow-y-auto"
class="flex flex-col px-3 max-h-[300px] overflow-y-auto"
>
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="py-4 last-of-type:border-b-0 px-4"
class="pb-3 last-of-type:border-b-0 !border-0"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@@ -130,9 +131,11 @@ watch(
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE') }}
</p>
<div v-else class="mt-2 px-3">
<SidePanelEmptyState
:message="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE')"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
@@ -7,9 +7,14 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
attributeType: {
@@ -25,11 +30,9 @@ const props = defineProps({
type: String,
default: '',
},
// Combine static elements with custom attributes components
// To allow for custom ordering
staticElements: {
type: Array,
default: () => [],
showTitle: {
type: Boolean,
default: false,
},
});
@@ -82,110 +85,84 @@ const filteredCustomAttributes = computed(() =>
);
return {
...attribute,
id: attribute.id,
type: 'custom_attribute',
key: attribute.attribute_key,
attributeKey: attribute.attribute_key,
attributeDisplayName: attribute.attribute_display_name,
attributeDisplayType: attribute.attribute_display_type,
attributeValues: attribute.attribute_values,
attributeDescription: attribute.attribute_description,
regexPattern: attribute.regex_pattern,
regexCue: attribute.regex_cue,
// Set value from customAttributes if it exists, otherwise use ''
value: hasValue ? customAttributes.value[attribute.attribute_key] : '',
};
})
);
// Order key name for UI settings
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const getAttributeComponent = attributeType =>
componentMap[attributeType] || componentMap.default;
const orderKey = computed(
() => `conversation_elements_order_${props.attributeFrom}`
);
const combinedElements = computed(() => {
// Get saved order from UI settings
const orderedCustomAttributes = computed(() => {
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const allElements = [
...props.staticElements,
...filteredCustomAttributes.value,
];
if (!savedOrder.length) return filteredCustomAttributes.value;
// If no saved order exists, return in default order
if (!savedOrder.length) return allElements;
return allElements.sort((a, b) => {
// Find positions of elements in saved order
const aPosition = savedOrder.indexOf(a.key);
const bPosition = savedOrder.indexOf(b.key);
// Handle cases where elements are not in saved order:
// - New elements (not in saved order) go to the end
// - If both elements are new, maintain their relative order
if (aPosition === -1 && bPosition === -1) return 0;
if (aPosition === -1) return 1;
if (bPosition === -1) return -1;
return aPosition - bPosition;
return [...filteredCustomAttributes.value].sort((a, b) => {
const aPos = savedOrder.indexOf(a.key);
const bPos = savedOrder.indexOf(b.key);
// Both new: maintain relative order, new items go to end, otherwise sort by saved position
if (aPos === -1 && bPos === -1) return 0;
if (aPos === -1) return 1;
if (bPos === -1) return -1;
return aPos - bPos;
});
});
const displayedElements = computed(() => {
if (showAllAttributes.value || combinedElements.value.length <= 5) {
return combinedElements.value;
}
const displayedCustomAttributes = computed(() =>
showAllAttributes.value || orderedCustomAttributes.value.length <= 5
? orderedCustomAttributes.value
: orderedCustomAttributes.value.slice(0, 5)
);
// Show first 5 elements in the order they appear
return combinedElements.value.slice(0, 5);
const localOrder = ref([]);
const draggableList = computed({
get() {
const saved = uiSettings.value[orderKey.value] ?? [];
if (localOrder.value.length && saved.length) {
return localOrder.value;
}
return displayedCustomAttributes.value;
},
set(newOrder) {
localOrder.value = newOrder;
},
});
// Reorder elements with static elements position preserved
// There is case where all the static elements will not be available (API, Email channels, etc).
// In that case, we need to preserve the order of the static elements and
// insert them in the correct position.
const reorderElementsWithStaticPreservation = (
savedOrder = [],
currentOrder = []
) => {
const finalOrder = [...currentOrder];
const visibleKeys = new Set(currentOrder);
// Process hidden static elements from saved order
savedOrder
// Find static elements that aren't currently visible
.filter(key => key.startsWith('static-') && !visibleKeys.has(key))
.forEach(staticKey => {
// Find next visible element after this static element in saved order
const nextVisible = savedOrder
.slice(savedOrder.indexOf(staticKey))
.find(key => visibleKeys.has(key));
// If next visible element found, insert before it; otherwise add to end
if (nextVisible) {
finalOrder.splice(finalOrder.indexOf(nextVisible), 0, staticKey);
} else {
finalOrder.push(staticKey);
}
});
return finalOrder;
};
const onDragEnd = () => {
dragging.value = false;
// Get the saved and current saved order
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const currentOrder = combinedElements.value.map(({ key }) => key);
const finalOrder = reorderElementsWithStaticPreservation(
savedOrder,
currentOrder
);
updateUISettings({
[orderKey.value]: finalOrder,
[orderKey.value]: localOrder.value.map(({ key }) => key),
});
localOrder.value = [];
};
const initializeSettings = () => {
const currentOrder = uiSettings.value[orderKey.value];
if (!currentOrder) {
const initialOrder = combinedElements.value.map(element => element.key);
if (!uiSettings.value[orderKey.value]) {
updateUISettings({
[orderKey.value]: initialOrder,
[orderKey.value]: orderedCustomAttributes.value.map(({ key }) => key),
});
}
@@ -209,7 +186,7 @@ const onUpdate = async (key, value) => {
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/update', {
await store.dispatch('contacts/update', {
id: props.contactId,
customAttributes: updatedAttributes,
});
@@ -231,7 +208,7 @@ const onDelete = async key => {
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/deleteCustomAttributes', {
await store.dispatch('contacts/deleteCustomAttributes', {
id: props.contactId,
customAttributes: [key],
});
@@ -244,89 +221,89 @@ const onDelete = async key => {
}
};
const onCopy = async attributeValue => {
await copyTextToClipboard(attributeValue);
useAlert(t('CUSTOM_ATTRIBUTES.COPY_SUCCESSFUL'));
};
onMounted(() => {
initializeSettings();
});
const evenClass = [
'[&>*:nth-child(odd)]:!bg-n-surface-1 [&>*:nth-child(even)]:!bg-n-slate-1',
'dark:[&>*:nth-child(odd)]:!bg-n-surface-2 dark:[&>*:nth-child(even)]:!bg-n-surface-1',
];
</script>
<template>
<div class="conversation--details">
<div
v-if="displayedCustomAttributes.length > 0"
:class="{ 'mt-2': !showTitle }"
>
<div v-if="showTitle" class="py-4 flex items-center gap-2">
<h3 class="text-xs font-medium uppercase text-n-slate-10">
{{ $t('CUSTOM_ATTRIBUTES.TITLE') }}
</h3>
<div class="flex-1 border-b border-dashed border-n-strong" />
</div>
<Draggable
:list="displayedElements"
v-model="draggableList"
:disabled="!showAllAttributes"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="key"
class="last:rounded-b-lg"
:class="evenClass"
class="mb-1"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="drag-handle relative border-b border-n-weak/50 dark:border-n-weak/90"
class="drag-handle relative"
:class="{
'cursor-grab': showAllAttributes,
'last:border-transparent dark:last:border-transparent':
combinedElements.length <= 5,
}"
>
<template v-if="element.type === 'static_attribute'">
<slot name="staticItem" :element="element" />
</template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2 min-h-10"
>
<div class="flex items-center gap-1.5 min-w-0">
<HelperTextPopup
v-if="element.attributeDescription"
:message="element.attributeDescription"
/>
<span class="text-sm font-420 truncate text-n-slate-12">
{{ element.attributeDisplayName }}
</span>
</div>
<template v-else>
<CustomAttribute
:key="element.id"
:attribute-key="element.attribute_key"
:attribute-type="element.attribute_display_type"
:values="element.attribute_values"
:label="element.attribute_display_name"
:description="element.attribute_description"
:value="element.value"
show-actions
:attribute-regex="element.regex_pattern"
:regex-cue="element.regex_cue"
:contact-id="contactId"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
<component
:is="getAttributeComponent(element.attributeDisplayType)"
:attribute="element"
is-editing-view
@update="value => onUpdate(element.attributeKey, value)"
@delete="onDelete(element.attributeKey)"
/>
</template>
</div>
</div>
</template>
</Draggable>
<p
v-if="!displayedElements.length && emptyStateMessage"
class="p-3 text-center"
<!-- Show more and show less buttons -->
<div
v-if="orderedCustomAttributes.length > 5"
class="flex items-center h-10"
>
{{ emptyStateMessage }}
</p>
<!-- Show more and show less buttons show it if the combinedElements length is greater than 5 -->
<div v-if="combinedElements.length > 5" class="flex items-center px-2 py-2">
<NextButton
ghost
xs
link
sm
:icon="
showAllAttributes ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
:label="toggleButtonText"
class="!text-n-slate-11 hover:!text-n-slate-12 hover:!no-underline !py-2"
@click="onClickToggle"
/>
</div>
</div>
<!-- Empty state -->
<SidePanelEmptyState
v-if="!displayedCustomAttributes.length && emptyStateMessage"
:message="emptyStateMessage"
/>
</template>
<style lang="scss" scoped>
@@ -1,139 +0,0 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
export default {
components: {
Spinner,
LabelDropdown,
AddLabel,
},
setup() {
const { isAdmin } = useAdmin();
const {
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const showSearchDropdownLabel = ref(false);
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (showSearchDropdownLabel.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
isAdmin,
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
showSearchDropdownLabel,
closeDropdownLabel,
toggleLabels,
};
},
data() {
return {
selectedLabels: [],
};
},
computed: {
...mapGetters({
conversationUiFlags: 'conversationLabels/getUIFlags',
}),
},
};
</script>
<template>
<div class="sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
>
<div
v-on-clickaway="closeDropdownLabel"
class="label-wrap flex flex-wrap"
@keyup.esc="closeDropdownLabel"
>
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in activeLabels"
:key="label.id"
:title="label.title"
:description="label.description"
show-close
:color="label.color"
variant="smooth"
class="max-w-[calc(100%-0.5rem)]"
@remove="removeLabelFromConversation"
/>
<div
:class="{
'block visible': showSearchDropdownLabel,
'hidden invisible': !showSearchDropdownLabel,
}"
class="border rounded-lg bg-n-alpha-3 top-6 backdrop-blur-[100px] absolute w-full shadow-lg border-n-strong dark:border-n-strong p-2 box-border z-[9999]"
>
<LabelDropdown
v-if="showSearchDropdownLabel"
:account-labels="accountLabels"
:selected-labels="savedLabels"
:allow-creation="isAdmin"
@add="addLabelToConversation"
@remove="removeLabelFromConversation"
/>
</div>
</div>
</div>
<Spinner v-else />
</div>
</template>
<style lang="scss" scoped>
.sidebar-labels-wrap {
margin-bottom: 0;
}
.contact-conversation--list {
width: 100%;
.label-wrap {
line-height: 1.5rem;
position: relative;
}
}
</style>
@@ -153,9 +153,8 @@ export default {
attribute_display_type: this.attributeType,
attribute_key: this.attributeKey,
attribute_values: this.attributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
// Store as plain string - new RegExp().toString() causes double escaping
regex_pattern: this.regexPattern || null,
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
@@ -151,9 +151,8 @@ export default {
attribute_description: this.description,
attribute_display_name: this.displayName,
attribute_values: this.updatedAttributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
// Store as plain string - new RegExp().toString() causes double escaping
regex_pattern: this.regexPattern || null,
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
@@ -1,122 +0,0 @@
<script setup>
import { computed, defineEmits } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
const props = defineProps({
options: {
type: Array,
default: () => [],
},
selectedItem: {
type: Object,
default: () => ({}),
},
hasThumbnail: {
type: Boolean,
default: true,
},
multiselectorTitle: {
type: String,
default: '',
},
multiselectorPlaceholder: {
type: String,
default: 'None',
},
noSearchResult: {
type: String,
default: 'No results found',
},
inputPlaceholder: {
type: String,
default: 'Search',
},
});
const emit = defineEmits(['select']);
const [showSearchDropdown, toggleDropdown] = useToggle(false);
const onCloseDropdown = () => toggleDropdown(false);
const onClickSelectItem = value => {
emit('select', value);
onCloseDropdown();
};
const hasValue = computed(() => {
if (props.selectedItem && props.selectedItem.id) {
return true;
}
return false;
});
</script>
<template>
<OnClickOutside @trigger="onCloseDropdown">
<div class="relative w-full mb-2" @keyup.esc="onCloseDropdown">
<Button
slate
outline
trailing-icon
:icon="
showSearchDropdown ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="w-full !px-2"
@click="
() => toggleDropdown() // ensure that the event is not passed to the button
"
>
<div class="flex items-center justify-between w-full min-w-0">
<h4 v-if="!hasValue" class="text-sm text-ellipsis text-n-slate-12">
{{ multiselectorPlaceholder }}
</h4>
<h4
v-else
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
:title="selectedItem.name"
>
{{ selectedItem.name }}
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail"
:src="selectedItem.thumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
:size="24"
hide-offline-status
rounded-full
/>
</Button>
<div
:class="{
'block visible': showSearchDropdown,
'hidden invisible': !showSearchDropdown,
}"
class="box-border top-[2.625rem] w-full border rounded-lg bg-n-alpha-3 backdrop-blur-[100px] absolute shadow-lg border-n-strong dark:border-n-strong p-2 z-[9999]"
>
<div class="flex items-center justify-between mb-1">
<h4
class="m-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
>
{{ multiselectorTitle }}
</h4>
<Button ghost slate xs icon="i-lucide-x" @click="onCloseDropdown" />
</div>
<MultiselectDropdownItems
v-if="showSearchDropdown"
:options="options"
:selected-items="[selectedItem]"
:has-thumbnail="hasThumbnail"
:input-placeholder="inputPlaceholder"
:no-search-result="noSearchResult"
@select="onClickSelectItem"
/>
</div>
</div>
</OnClickOutside>
</template>
@@ -1,151 +0,0 @@
<script>
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Avatar from 'next/avatar/Avatar.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
Avatar,
NextButton,
},
props: {
options: {
type: Array,
default: () => [],
},
selectedItems: {
type: Array,
default: () => [],
},
hasThumbnail: {
type: Boolean,
default: true,
},
inputPlaceholder: {
type: String,
default: 'Search',
},
noSearchResult: {
type: String,
default: 'No results found',
},
},
emits: ['select'],
data() {
return {
search: '',
};
},
computed: {
filteredOptions() {
return this.options.filter(option => {
return option.name.toLowerCase().includes(this.search.toLowerCase());
});
},
noResult() {
return this.filteredOptions.length === 0 && this.search !== '';
},
},
mounted() {
this.focusInput();
},
methods: {
onclick(option) {
this.$emit('select', option);
},
focusInput() {
this.$refs.searchbar.focus();
},
isActive(option) {
return this.selectedItems.some(item => item && option.id === item.id);
},
},
};
</script>
<template>
<div class="dropdown-wrap">
<div class="flex-auto flex-grow-0 flex-shrink-0 mb-2 max-h-8">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="inputPlaceholder"
/>
</div>
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
<div class="w-full max-h-[10rem]">
<WootDropdownMenu>
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
<NextButton
slate
:variant="isActive(option) ? 'faded' : 'ghost'"
trailing-icon
:icon="isActive(option) ? 'i-lucide-check' : ''"
class="w-full !px-2.5"
@click="() => onclick(option)"
>
<div
class="flex items-center justify-between w-full min-w-0 gap-2"
>
<span
class="my-0 overflow-hidden text-sm leading-4 whitespace-nowrap text-ellipsis"
:title="option.name"
>
{{ option.name }}
</span>
</div>
<Avatar
v-if="hasThumbnail"
:src="option.thumbnail"
:name="option.name"
:status="option.availability_status"
:size="24"
hide-offline-status
rounded-full
/>
</NextButton>
</WootDropdownItem>
</WootDropdownMenu>
<h4
v-if="noResult"
class="w-full justify-center items-center flex text-n-slate-10 py-2 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis text-sm"
>
{{ noSearchResult }}
</h4>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.dropdown-wrap {
@apply w-full flex flex-col max-h-[12.5rem];
}
.search-input {
@apply m-0 w-full border border-solid border-transparent h-8 text-sm text-n-slate-12 rounded-md focus:border-n-brand bg-n-background dark:bg-n-background;
}
.multiselect-dropdown--item {
@apply justify-between w-full;
&.active {
@apply bg-n-slate-2 dark:bg-n-solid-3 border-n-weak/50 dark:border-n-weak font-medium;
}
&:hover {
@apply bg-n-slate-2 dark:bg-n-solid-3 text-n-slate-12;
}
}
</style>
@@ -1,20 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['add']);
const addLabel = () => {
emit('add');
};
</script>
<template>
<Button
faded
xs
icon="i-lucide-plus"
class="mb-0.5 ltr:mr-0.5 rtl:ml-0.5 !rounded-[4px]"
:label="$t('CONTACT_PANEL.LABELS.CONVERSATION.ADD_BUTTON')"
@click="addLabel"
/>
</template>
@@ -1,11 +0,0 @@
<script>
export default {};
</script>
<template>
<li
class="list-none my-1 mx-0 border-b border-n-weak"
:tabindex="null"
:aria-disabled="true"
/>
</template>
@@ -1,22 +0,0 @@
<script>
export default {
componentName: 'WootDropdownMenu',
props: {
title: {
type: String,
default: '',
},
},
};
</script>
<template>
<li class="inline-flex list-none" :tabindex="null" :aria-disabled="true">
<span
class="text-xs text-n-slate-12 mt-1 font-medium w-full block text-left rtl:text-right whitespace-nowrap p-2"
>
{{ title }}
</span>
<slot />
</li>
</template>
@@ -1,46 +0,0 @@
<script>
export default {
name: 'WootDropdownItem',
componentName: 'WootDropdownMenu',
props: {
disabled: {
type: Boolean,
default: false,
},
},
};
</script>
<template>
<li
class="mb-1 list-none dropdown-menu__item"
:class="{
'is-disabled': disabled,
}"
:tabindex="disabled ? null : -1"
:aria-disabled="disabled"
>
<slot />
</li>
</template>
<style lang="scss" scoped>
.dropdown-menu__item {
::v-deep {
a,
.button {
@apply inline-flex whitespace-nowrap w-full text-left rtl:text-right;
}
}
}
// A hacky fix to remove the background that came from the foundation styles node module file
// Can be removed once we remove the foundation styles node module
.dropdown.menu {
// Top-level item
> li > a {
background: transparent;
padding: 4px 10.8px;
}
}
</style>
@@ -1,66 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
defineProps({
placement: {
type: String,
default: 'top',
},
});
const dropdownMenuRef = ref(null);
const dropdownMenuButtons = () => {
return dropdownMenuRef.value.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
};
const getActiveButtonIndex = menuButtons => {
const focusedButton = dropdownMenuRef.value.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
};
const focusButton = (menuButtons, newIndex) => {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
};
const focusPreviousButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
focusButton(menuButtons, newIndex);
};
const focusNextButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
focusButton(menuButtons, newIndex);
};
const keyboardEvents = {
ArrowUp: {
action: () => focusPreviousButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
ArrowDown: {
action: () => focusNextButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<ul
ref="dropdownMenuRef"
class="dropdown menu vertical"
:class="[placement && `dropdown--${placement}`]"
>
<slot />
</ul>
</template>
@@ -1,27 +0,0 @@
<script>
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
components: {
WootDropdownHeader,
},
props: {
title: {
type: String,
default: '',
},
},
};
</script>
<template>
<li class="!mt-0.5">
<ul class="!m-0">
<WootDropdownHeader v-if="title" :title="title" />
<slot />
</ul>
</li>
</template>
@@ -1,184 +0,0 @@
<script>
import LabelDropdownItem from './LabelDropdownItem.vue';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
import { picoSearch } from '@scmmishra/pico-search';
import { sanitizeLabel } from 'shared/helpers/sanitizeData';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
LabelDropdownItem,
AddLabelModal,
Hotkey,
NextButton,
},
props: {
accountLabels: {
type: Array,
default: () => [],
},
selectedLabels: {
type: Array,
default: () => [],
},
allowCreation: {
type: Boolean,
default: false,
},
},
emits: ['update', 'add', 'remove'],
data() {
return {
search: '',
createModalVisible: false,
};
},
computed: {
createLabelPlaceholder() {
const label = this.$t('CONTACT_PANEL.LABELS.LABEL_SELECT.CREATE_LABEL');
return this.search ? `${label}:` : label;
},
filteredActiveLabels() {
if (!this.search) return this.accountLabels;
return picoSearch(this.accountLabels, this.search, ['title'], {
threshold: 0.9,
});
},
noResult() {
return this.filteredActiveLabels.length === 0;
},
hasExactMatchInResults() {
return this.filteredActiveLabels.some(
label => label.title === this.search
);
},
shouldShowCreate() {
return this.allowCreation && this.filteredActiveLabels.length < 3;
},
parsedSearch() {
return sanitizeLabel(this.search);
},
},
mounted() {
this.focusInput();
},
methods: {
focusInput() {
this.$refs.searchbar.focus();
},
updateLabels(label) {
this.$emit('update', label);
},
onAdd(label) {
this.$emit('add', label);
},
onRemove(label) {
this.$emit('remove', label);
},
onAddRemove(label) {
if (this.selectedLabels.includes(label.title)) {
this.onRemove(label.title);
} else {
this.onAdd(label);
}
},
showCreateModal() {
this.createModalVisible = true;
},
hideCreateModal() {
this.createModalVisible = false;
},
},
};
</script>
<template>
<div class="flex flex-col w-full max-h-[12.5rem]">
<div class="flex items-center justify-center mb-1">
<h4
class="flex-grow m-0 overflow-hidden text-sm text-n-slate-12 whitespace-nowrap text-ellipsis"
>
{{ $t('CONTACT_PANEL.LABELS.LABEL_SELECT.TITLE') }}
</h4>
<Hotkey
custom-class="border border-solid text-n-slate-12 bg-n-slate-2 text-xxs border-n-strong flex-shrink-0"
>
{{ 'L' }}
</Hotkey>
</div>
<div class="flex-auto flex-grow-0 flex-shrink-0 mb-2 max-h-8">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="$t('CONTACT_PANEL.LABELS.LABEL_SELECT.PLACEHOLDER')"
/>
</div>
<div
class="flex items-start justify-start flex-auto flex-grow flex-shrink overflow-auto"
>
<div class="w-full my-1">
<woot-dropdown-menu>
<LabelDropdownItem
v-for="label in filteredActiveLabels"
:key="label.title"
:title="label.title"
:color="label.color"
:selected="selectedLabels.includes(label.title)"
@select-label="onAddRemove(label)"
/>
</woot-dropdown-menu>
<div
v-if="noResult"
class="flex justify-center py-4 px-2.5 font-medium text-xs text-n-slate-11"
>
{{ $t('CONTACT_PANEL.LABELS.LABEL_SELECT.NO_RESULT') }}
</div>
<div
v-if="allowCreation && shouldShowCreate"
class="flex pt-1 border-t border-solid border-n-weak"
>
<NextButton
icon="i-lucide-plus"
slate
sm
ghost
:label="`${createLabelPlaceholder} ${parsedSearch}`"
:disabled="hasExactMatchInResults"
@click="showCreateModal"
/>
<woot-modal
v-model:show="createModalVisible"
:on-close="hideCreateModal"
>
<AddLabelModal
:prefill-title="parsedSearch"
@close="hideCreateModal"
/>
</woot-modal>
</div>
</div>
</div>
</div>
</template>
@@ -1,59 +0,0 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
title: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
},
emits: ['selectLabel'],
methods: {
onClick() {
this.$emit('selectLabel', this.title);
},
},
};
</script>
<template>
<woot-dropdown-item>
<NextButton
slate
ghost
blue
trailing-icon
:icon="selected ? 'i-lucide-circle-check' : ''"
class="w-full !px-2.5 justify-between"
:class="{ '!flex-row': !selected }"
@click="onClick"
>
<div class="flex items-center min-w-0 gap-2">
<div
v-if="color"
class="size-3 flex-shrink-0 rounded-full outline outline-1 outline-n-weak"
:style="{ backgroundColor: color }"
/>
<span
class="overflow-hidden text-ellipsis whitespace-nowrap leading-[1.1]"
:title="title"
>
{{ title }}
</span>
</div>
</NextButton>
</woot-dropdown-item>
</template>