feat: Update conversation side panel layout and styling (#12983)
This commit is contained in:
@@ -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 {
|
||||
|
||||
+20
-22
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+108
-131
@@ -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>
|
||||
Reference in New Issue
Block a user