feat: Update Captain pages UI (#13184)

This commit is contained in:
Sivin Varghese
2026-01-07 00:44:07 +05:30
committed by GitHub
parent 68fc2e36aa
commit d6d7aba465
25 changed files with 398 additions and 316 deletions
@@ -37,7 +37,7 @@ const updateCurrentPage = page => {
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -100,7 +100,7 @@ const togglePortalSwitcher = () => {
<slot name="content" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
@@ -20,11 +20,11 @@ const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
slate: 'bg-n-slate-1 outline-n-slate-5 text-n-slate-12',
amber: 'bg-n-amber-1 outline-n-amber-5 text-n-amber-12',
teal: 'bg-n-teal-1 outline-n-teal-5 text-n-teal-12',
ruby: 'bg-n-ruby-1 outline-n-ruby-5 text-n-ruby-12',
blue: 'bg-n-blue-1 outline-n-blue-5 text-n-blue-12',
};
return classMap[props.color];
@@ -32,11 +32,11 @@ const bannerClass = computed(() => {
const buttonClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-4 hover:bg-n-slate-5 text-n-slate-11',
amber: 'bg-n-amber-4 hover:bg-n-amber-5 text-n-amber-11',
teal: 'bg-n-teal-4 hover:bg-n-teal-5 text-n-teal-11',
ruby: 'bg-n-ruby-4 hover:bg-n-ruby-5 text-n-ruby-11',
blue: 'bg-n-blue-4 hover:bg-n-blue-5 text-n-blue-11',
slate: 'hover:bg-n-slate-4 text-n-slate-11',
amber: 'hover:bg-n-amber-4 text-n-amber-11',
teal: 'hover:bg-n-teal-4 text-n-teal-11',
ruby: 'hover:bg-n-ruby-4 text-n-ruby-11',
blue: 'hover:bg-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
@@ -49,7 +49,7 @@ const triggerAction = () => {
<template>
<div
class="text-sm rounded-xl flex items-center justify-between gap-2 border"
class="text-sm rounded-xl flex items-center justify-between min-h-12 gap-2 outline outline-1 -outline-offset-1"
:class="[
bannerClass,
{
@@ -64,11 +64,11 @@ const triggerAction = () => {
<div>
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
class="px-3 py-1 w-auto flex items-center justify-center rounded-lg flex-shrink-0"
:class="buttonClass"
@click="triggerAction"
>
{{ actionLabel }}
<span class="line-clamp-2 text-button">{{ actionLabel }}</span>
</button>
</div>
</div>
@@ -130,7 +130,7 @@ const handleCreateAssistant = () => {
<div class="flex items-center gap-2">
<span
v-if="!isFetchingAssistants"
class="text-xl font-medium truncate text-n-slate-12"
class="text-heading-1 truncate text-n-slate-12"
>
{{ activeAssistantName }}
</span>
@@ -162,11 +162,16 @@ const handleCreateAssistant = () => {
<div class="flex items-center gap-4">
<div
v-if="showAssistantSwitcher && !showPaywall && headerTitle"
class="w-0.5 h-4 rounded-2xl bg-n-weak"
class="w-px h-3 rounded-xl bg-n-weak"
/>
<span
v-if="headerTitle"
class="text-xl font-medium text-n-slate-12"
class="text-n-slate-12"
:class="
showAssistantSwitcher && !showPaywall
? 'text-body-main'
: 'text-heading-1'
"
>
{{ headerTitle }}
</span>
@@ -174,7 +179,7 @@ const handleCreateAssistant = () => {
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<div class="w-px h-3 rounded-xl bg-n-weak" />
<slot name="knowMore" />
</div>
</div>
@@ -222,7 +227,7 @@ const handleCreateAssistant = () => {
<slot />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
@@ -20,6 +20,11 @@ const props = defineProps({
type: String,
default: 'Delete',
},
animationDirection: {
type: String,
default: 'horizontal',
validator: value => ['horizontal', 'vertical'].includes(value),
},
});
const emit = defineEmits(['bulkDelete']);
@@ -49,19 +54,32 @@ const bulkCheckboxState = computed({
modelValue.value = newSelectedIds;
},
});
const animationClasses = computed(() => {
if (props.animationDirection === 'vertical') {
return {
enterFrom: 'opacity-0 transform -translate-y-4',
enterTo: 'opacity-100 transform translate-y-0',
};
}
return {
enterFrom: 'opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4',
enterTo: 'opacity-100 transform translate-x-0',
};
});
</script>
<template>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
:enter-from-class="animationClasses.enterFrom"
:enter-to-class="animationClasses.enterTo"
leave-active-class="hidden opacity-0"
>
<div
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
class="flex items-center gap-3 py-2 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1 shadow-sm"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 min-w-0">
@@ -1,17 +1,15 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isPdfDocument,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
id: {
@@ -37,88 +35,59 @@ const props = defineProps({
});
const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
},
];
if (checkPermissions(['administrator'])) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
});
}
return allOptions;
});
const createdAt = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const linkIcon = computed(() =>
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
isPdfDocument(props.externalLink) ? 'i-lucide-file-text' : 'i-lucide-link'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
const handleAction = (action, value) => {
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<CardLayout class="[&>div]:px-5 [&>div]:gap-2">
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
<span class="text-heading-3 text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
>
<Button
icon="i-woot-menu-list"
slate
sm
ghost
@click="handleAction('viewRelatedQuestions', 'viewRelatedQuestions')"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Policy :permissions="['administrator']">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
icon="i-woot-bin"
slate
sm
ghost
@click="handleAction('delete', 'delete')"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
@action="handleAction($event)"
/>
</div>
</Policy>
</div>
</div>
<div class="flex gap-4 justify-between items-center w-full">
<span
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<i class="i-woot-captain" />
<Icon icon="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<span
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<i :class="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate text-body-main">{{ displayLink }}</span>
</span>
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
<div class="text-label-small shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
</div>
@@ -1,14 +1,11 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { INBOX_TYPES, getChannelTypeDisplayName } from 'dashboard/helper/inbox';
const props = defineProps({
id: {
@@ -23,10 +20,6 @@ const props = defineProps({
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const inboxName = computed(() => {
const inbox = props.inbox;
if (!inbox?.name) {
@@ -49,56 +42,47 @@ const inboxName = computed(() => {
return inbox.name;
});
const menuItems = computed(() => [
{
label: t('CAPTAIN.INBOXES.OPTIONS.DISCONNECT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const icon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'line');
const channelType = computed(() => {
const { channel_type: type, medium } = props.inbox;
return getChannelTypeDisplayName(type, medium);
});
const handleAction = ({ action, value }) => {
toggleDropdown(false);
const handleAction = (action, value) => {
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<CardLayout
class="ltr:[&>div]:pl-4 ltr:[&>div]:pr-3 rtl:[&>div]:pl-3 rtl:[&>div]:pr-4 [&>div]:gap-2 [&>div]:py-4"
>
<div class="flex justify-between w-full gap-1">
<span
class="text-base text-n-slate-12 line-clamp-1 flex items-center gap-2"
>
<Icon :icon="icon" class="size-5" />
{{ inboxName }}
</span>
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
<div class="flex items-center gap-3 min-w-0">
<div
class="size-8 rounded-[0.625rem] flex items-center justify-center outline outline-1 outline-n-weak -outline-offset-1 flex-shrink-0"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
<ChannelIcon
:inbox="inbox"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</Policy>
</div>
<span class="text-heading-3 text-n-slate-12 line-clamp-1 min-w-0">
{{ inboxName }}
</span>
<div class="w-px h-3 bg-n-weak rounded-lg flex-shrink-0" />
<span class="text-body-main text-n-slate-11 flex-shrink-0">{{
channelType
}}</span>
</div>
<Policy :permissions="['administrator']">
<Button
icon="i-lucide-unlink"
slate
sm
ghost
@click="handleAction('delete', 'delete')"
/>
</Policy>
</div>
</CardLayout>
</template>
@@ -1,11 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
@@ -36,10 +32,6 @@ const props = defineProps({
type: Object,
default: null,
},
assistant: {
type: Object,
default: () => ({}),
},
updatedAt: {
type: Number,
required: true,
@@ -52,10 +44,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
@@ -66,53 +54,18 @@ const props = defineProps({
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const emit = defineEmits(['action', 'navigate', 'select']);
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const statusAction = computed(() => {
if (props.status === 'pending') {
return [
{
label: t('CAPTAIN.RESPONSES.OPTIONS.APPROVE'),
value: 'approve',
action: 'approve',
icon: 'i-lucide-circle-check-big',
},
];
}
return [];
});
const menuItems = computed(() => [
...statusAction.value,
{
label: t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const timestamp = computed(() =>
dynamicTime(props.updatedAt || props.createdAt)
);
const handleAssistantAction = ({ action, value }) => {
toggleDropdown(false);
const handleAssistantAction = (action, value) => {
emit('action', { action, value, id: props.id });
};
@@ -125,43 +78,103 @@ const handleDocumentableClick = () => {
</script>
<template>
<CardLayout
selectable
class="relative"
:class="{ 'rounded-md': compact }"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<div class="py-4 flex flex-col gap-2">
<div
v-if="!compact"
class="flex items-start gap-3 h-6"
:class="{ 'justify-between w-full': !showActions }"
>
<div class="inline-flex items-center gap-2 min-w-0">
<!-- <span
v-if="status === 'approved'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<Icon icon="i-woot-captain" class="size-3.5" />
{{ assistant?.name || '' }}
</span> -->
<div
v-if="documentable"
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1.5 min-w-0"
>
<Icon
v-if="documentable.type === 'Captain::Document'"
icon="i-lucide-files"
class="size-3.5 flex-shrink-0"
/>
<Icon
v-else-if="documentable.type === 'User'"
icon="i-lucide-circle-user-round"
class="size-3.5 flex-shrink-0"
/>
<Icon
v-else-if="documentable.type === 'Conversation'"
icon="i-lucide-message-circle"
class="size-3.5 flex-shrink-0"
/>
<span
v-if="documentable.type === 'Captain::Document'"
class="truncate text-body-main text-n-slate-11"
:title="documentable.name"
>
{{ documentable.name }}
</span>
<span
v-else-if="documentable.type === 'User'"
class="truncate text-body-main text-n-slate-11"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="hover:underline truncate cursor-pointer text-body-main text-n-slate-11"
role="button"
@click="handleDocumentableClick"
>
{{ documentable.display_id }}
</span>
</div>
<div class="w-px h-3 rounded-lg bg-n-strong" />
<span class="shrink-0 text-body-main text-n-slate-11">
{{ timestamp }}
</span>
</div>
<div v-if="!compact && showMenu">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
class="gap-2 flex items-center flex-shrink-0"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
icon="i-lucide-pencil-line"
slate
sm
outline
@click="handleAssistantAction('edit', 'edit')"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAssistantAction($event)"
<Button
icon="i-lucide-trash"
slate
sm
outline
@click="handleAssistantAction('delete', 'delete')"
/>
</Policy>
</div>
</div>
<span class="text-n-slate-11 text-sm line-clamp-5">
<div class="flex justify-between items-center w-full gap-1">
<div class="flex items-center gap-1.5">
<div v-if="!compact" class="size-5 flex items-center">
<Checkbox v-model="modelValue" />
</div>
<span class="text-n-slate-12 text-heading-3 line-clamp-1">
{{ question }}
</span>
</div>
</div>
<span
class="text-n-slate-11 text-body-main line-clamp-5"
:class="{ 'ltr:pl-[1.625rem] rtl:pr-[1.625rem]': !compact }"
>
{{ answer }}
</span>
<div
@@ -177,9 +190,7 @@ const handleDocumentableClick = () => {
sm
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'approve', value: 'approve' })
"
@click="handleAssistantAction('approve', 'approve')"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
@@ -188,12 +199,7 @@ const handleDocumentableClick = () => {
slate
link
class="hover:!no-underline"
@click="
handleAssistantAction({
action: 'edit',
value: 'edit',
})
"
@click="handleAssistantAction('edit', 'edit')"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
@@ -202,78 +208,10 @@ const handleDocumentableClick = () => {
ruby
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'delete', value: 'delete' })
"
@click="handleAssistantAction('delete', 'delete')"
/>
</div>
</Policy>
<div
class="flex items-center gap-3"
:class="{ 'justify-between w-full': !showActions }"
>
<div class="inline-flex items-center gap-3 min-w-0">
<span
v-if="status === 'approved'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<Icon icon="i-woot-captain" class="size-3.5" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
>
<Icon
v-if="documentable.type === 'Captain::Document'"
icon="i-ph-files-light"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'User'"
icon="i-ph-user-circle-plus"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'Conversation'"
icon="i-ph-chat-circle-dots"
class="size-3.5"
/>
<span
v-if="documentable.type === 'Captain::Document'"
class="truncate"
:title="documentable.name"
>
{{ documentable.name }}
</span>
<span
v-else-if="documentable.type === 'User'"
class="truncate"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="hover:underline truncate cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
{{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id,
})
}}
</span>
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
>
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
{{ timestamp }}
</div>
</div>
</div>
</CardLayout>
</div>
</template>
@@ -51,7 +51,7 @@ defineExpose({ dialogRef });
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-3 min-h-48">
<div v-else class="flex flex-col divide-y divide-n-weak min-h-48">
<ResponseCard
v-for="response in responses"
:id="response.id"
@@ -60,7 +60,7 @@ const onClearFilters = () => {
:show-backdrop="isApproved"
>
<template v-if="isApproved" #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<div class="grid grid-cols-1 divide-y divide-n-weak p-px overflow-hidden">
<ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)"
:id="response.id"
@@ -68,6 +68,7 @@ const onClearFilters = () => {
:question="replaceInstallationName(response.question)"
:answer="replaceInstallationName(response.answer)"
:status="response.status"
:documentable="response.documentable"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.created_at"
@@ -141,6 +141,11 @@ export const responsesList = [
id: 87,
question: 'Why is my Messenger in Chatwoot deactivated?',
status: 'pending',
documentable: {
id: 1,
type: 'Captain::Document',
name: 'Document 1',
},
assistant: {
account_id: 1,
config: { product_name: 'Chatwoot' },
@@ -157,6 +162,11 @@ export const responsesList = [
created_at: 1736283340,
id: 88,
question: 'How do I integrate WhatsApp with Chatwoot?',
documentable: {
id: 1,
type: 'User',
available_name: 'John Doe',
},
assistant: {
account_id: 2,
config: { product_name: 'Chatwoot' },
@@ -173,6 +183,11 @@ export const responsesList = [
created_at: 1736283350,
id: 89,
question: 'How can I reset my password in Chatwoot?',
documentable: {
id: 1,
type: 'Conversation',
display_id: '12345',
},
assistant: {
account_id: 3,
config: { product_name: 'Chatwoot' },
@@ -189,6 +204,11 @@ export const responsesList = [
created_at: 1736283360,
id: 90,
question: 'How do I enable dark mode in Chatwoot?',
documentable: {
id: 1,
type: 'Captain::Document',
name: 'Document 1',
},
assistant: {
account_id: 4,
config: { product_name: 'Chatwoot' },
@@ -205,6 +225,11 @@ export const responsesList = [
created_at: 1736283370,
id: 91,
question: 'How do I add a new team member in Chatwoot?',
documentable: {
id: 1,
type: 'User',
available_name: 'John Doe',
},
assistant: {
account_id: 5,
config: { product_name: 'Chatwoot' },
@@ -221,6 +246,11 @@ export const responsesList = [
created_at: 1736283380,
id: 92,
question: 'What are campaigns in Chatwoot?',
documentable: {
id: 1,
type: 'User',
available_name: 'John Doe',
},
assistant: {
account_id: 6,
config: { product_name: 'Chatwoot' },
+27
View File
@@ -171,3 +171,30 @@ export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
}
return '';
};
export const getChannelTypeDisplayName = (type, medium) => {
// Handle Twilio special case
if (type === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? 'Twilio WhatsApp'
: 'Twilio SMS';
}
// Map channel types to display names
const channelTypeMap = {
[INBOX_TYPES.WEB]: 'Website',
[INBOX_TYPES.FB]: 'Messenger',
[INBOX_TYPES.TWITTER]: 'Twitter',
[INBOX_TYPES.WHATSAPP]: 'WhatsApp',
[INBOX_TYPES.API]: 'API',
[INBOX_TYPES.EMAIL]: 'Email',
[INBOX_TYPES.TELEGRAM]: 'Telegram',
[INBOX_TYPES.LINE]: 'Line',
[INBOX_TYPES.SMS]: 'SMS',
[INBOX_TYPES.INSTAGRAM]: 'Instagram',
[INBOX_TYPES.TIKTOK]: 'TikTok',
[INBOX_TYPES.VOICE]: 'Voice',
};
return channelTypeMap[type] || 'Chat';
};
@@ -3,6 +3,7 @@ import {
getInboxClassByType,
getInboxIconByType,
getInboxWarningIconClass,
getChannelTypeDisplayName,
} from '../inbox';
describe('#Inbox Helpers', () => {
@@ -166,4 +167,86 @@ describe('#Inbox Helpers', () => {
);
});
});
describe('getChannelTypeDisplayName', () => {
it('returns "Website" for web widget', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.WEB)).toBe('Website');
});
it('returns "Messenger" for Facebook', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.FB)).toBe('Messenger');
});
it('returns "Twitter" for Twitter', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.TWITTER)).toBe('Twitter');
});
it('returns "WhatsApp" for WhatsApp', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.WHATSAPP)).toBe('WhatsApp');
});
it('returns "API" for API', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.API)).toBe('API');
});
it('returns "Email" for Email', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.EMAIL)).toBe('Email');
});
it('returns "Telegram" for Telegram', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.TELEGRAM)).toBe('Telegram');
});
it('returns "Line" for Line', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.LINE)).toBe('Line');
});
it('returns "SMS" for SMS', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.SMS)).toBe('SMS');
});
it('returns "Instagram" for Instagram', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.INSTAGRAM)).toBe(
'Instagram'
);
});
it('returns "TikTok" for TikTok', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.TIKTOK)).toBe('TikTok');
});
it('returns "Voice" for Voice', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.VOICE)).toBe('Voice');
});
it('returns "Chat" for unknown type', () => {
expect(getChannelTypeDisplayName('UNKNOWN_TYPE')).toBe('Chat');
});
describe('Twilio cases', () => {
it('returns "Twilio WhatsApp" for Twilio with whatsapp medium', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'Twilio WhatsApp'
);
});
it('returns "Twilio SMS" for Twilio with sms medium', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.TWILIO, 'sms')).toBe(
'Twilio SMS'
);
});
it('returns "Twilio SMS" for Twilio with undefined medium', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.TWILIO, undefined)).toBe(
'Twilio SMS'
);
});
it('returns "Twilio SMS" for Twilio with empty medium', () => {
expect(getChannelTypeDisplayName(INBOX_TYPES.TWILIO, '')).toBe(
'Twilio SMS'
);
});
});
});
});
@@ -867,6 +867,7 @@
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"CLEAR_SELECTION": "Clear selection",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
@@ -212,7 +212,7 @@ const addAllExample = () => {
</template>
</SuggestedRules>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex mt-7 flex-col gap-4 pb-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
@@ -221,7 +221,7 @@ const addAllExample = async () => {
</template>
</SuggestedRules>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex mt-7 flex-col gap-4 pb-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
@@ -73,7 +73,7 @@ onMounted(() =>
</template>
<template #body>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 pb-4">
<InboxCard
v-for="captainInbox in captainInboxes"
:id="captainInbox.id"
@@ -277,7 +277,7 @@ onMounted(() => {
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.SEARCH_EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<div v-else class="flex flex-col gap-2 pb-4">
<ScenariosCard
v-for="scenario in filteredScenarios"
:id="scenario.id"
@@ -129,7 +129,7 @@ onMounted(() => {
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 pb-4">
<DocumentCard
v-for="doc in documents"
:id="doc.id"
@@ -7,6 +7,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
@@ -113,7 +114,6 @@ const fetchResponses = (page = 1) => {
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const buildSelectedCountLabel = computed(() => {
const count = responses.value?.length || 0;
@@ -129,10 +129,6 @@ const selectedCountLabel = computed(() => {
});
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
@@ -157,6 +153,10 @@ const fetchResponseAfterBulkAction = () => {
bulkSelectedIds.value = new Set();
};
const clearSelection = () => {
bulkSelectedIds.value = new Set();
};
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
@@ -252,12 +252,23 @@ onMounted(() => {
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{
'mb-2': bulkSelectedIds.size > 0,
'mt-2': bulkSelectedIds.size > 0,
}"
class="justify-between"
animation-direction="vertical"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
/>
>
<template #secondary-actions>
<Button
sm
ghost
:label="t('CAPTAIN.RESPONSES.CLEAR_SELECTION')"
class="!px-1"
@click="clearSelection"
/>
</template>
</BulkSelectBar>
</template>
<template #emptyState>
@@ -271,16 +282,16 @@ onMounted(() => {
<template #body>
<LimitBanner class="mb-5" />
<Banner
v-if="pendingCount > 0"
v-if="pendingCount > 0 && bulkSelectedIds.size === 0"
color="blue"
class="mb-4 -mt-3"
class="mb-4 -mt-2"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@action="navigateToPendingFAQs"
>
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
</Banner>
<div class="flex flex-col gap-4">
<div class="flex flex-col divide-y divide-n-weak">
<ResponseCard
v-for="response in responses"
:id="response.id"
@@ -293,13 +304,11 @@ onMounted(() => {
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
:show-actions="false"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
/>
</div>
</template>
@@ -140,7 +140,6 @@ const fetchResponses = (page = 1) => {
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
@@ -156,10 +155,6 @@ const selectedCountLabel = computed(() => {
});
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
@@ -196,6 +191,10 @@ const handleBulkApprove = async () => {
}
};
const clearSelection = () => {
bulkSelectedIds.value = new Set();
};
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
@@ -291,10 +290,11 @@ onMounted(() => {
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{
'mb-2': bulkSelectedIds.size > 0,
}"
class="justify-between"
animation-direction="vertical"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template #secondary-actions>
@@ -306,6 +306,14 @@ onMounted(() => {
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
sm
ghost
:label="$t('CAPTAIN.RESPONSES.CLEAR_SELECTION')"
class="!px-1"
@click="clearSelection"
/>
</template>
</BulkSelectBar>
</template>
@@ -325,7 +333,7 @@ onMounted(() => {
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<div class="flex flex-col divide-y divide-n-weak">
<ResponseCard
v-for="response in filteredResponses"
:id="response.id"
@@ -338,13 +346,11 @@ onMounted(() => {
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="false"
:show-actions="!bulkSelectedIds.has(response.id)"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
/>
</div>
</template>
@@ -100,7 +100,7 @@ onMounted(() => {
</template>
<template #body>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 pb-4">
<CustomToolCard
v-for="tool in customTools"
:id="tool.id"
@@ -88,7 +88,8 @@ const handleAssignLabels = labels => {
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-2 rtl:!pl-2 justify-between"
animation-direction="vertical"
class="justify-between"
>
<template #secondary-actions>
<Button
@@ -105,7 +106,7 @@ const handleAssignLabels = labels => {
type="contact"
:is-loading="isLoading"
:disabled="!selectedCount"
class="[&>button]:!text-n-blue-11 [&>button]:!px-2"
class="[&>button]:!text-n-blue-11 [&>button>span]:!text-n-blue-11 [&>button]:!px-2"
@assign="handleAssignLabels"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
@@ -438,7 +438,7 @@ onMounted(async () => {
<template v-else>
<ContactsBulkActionBar
v-if="hasSelection"
v-show="hasSelection"
:visible-contact-ids="visibleContactIds"
:selected-contact-ids="selectedContactIds"
:is-loading="isBulkActionLoading"