Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 202 additions & 7 deletions frontend/apps/main/src/features/conversation/list/ConversationList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,113 @@
<span class="text-xl font-semibold">{{ title }}</span>
</div>

<!-- Filters -->
<div class="p-2 flex justify-between items-center">
<!-- Bulk Action Toolbar (when items selected) -->
<div
v-if="hasSelection"
role="toolbar"
:aria-label="t('conversation.bulkActions.toolbar')"
class="p-2 flex items-center gap-1 border-b bg-muted/30"
>
<Checkbox
:checked="conversationStore.allSelected"
@update:checked="toggleSelectAll"
:aria-label="t('conversation.bulkActions.selectAll')"
class="ml-1 mr-1"
/>
<span class="text-xs font-medium whitespace-nowrap mr-1" aria-live="polite">
{{ t('conversation.bulkActions.selected', conversationStore.selectedCount, { count: conversationStore.selectedCount }) }}
</span>

<!-- Assign dropdown -->
<DropdownMenu v-if="canAssignAgent || canAssignTeam">
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="bulkLoading">
{{ t('conversation.bulkActions.assign') }}
<ChevronDown class="w-3 h-3 ml-1 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="max-h-60 overflow-y-auto">
<template v-if="canAssignAgent">
<DropdownMenuLabel class="text-xs text-muted-foreground">
{{ t('globals.terms.agent', 2) }}
</DropdownMenuLabel>
<DropdownMenuItem
v-for="agent in usersStore.options"
:key="'agent-' + agent.value"
@click="bulkAssignAgent(agent.value)"
>
{{ agent.label }}
</DropdownMenuItem>
</template>
<DropdownMenuSeparator v-if="canAssignAgent && canAssignTeam" />
<template v-if="canAssignTeam">
<DropdownMenuLabel class="text-xs text-muted-foreground">
{{ t('globals.terms.team', 2) }}
</DropdownMenuLabel>
<DropdownMenuItem
v-for="team in teamsStore.options"
:key="'team-' + team.value"
@click="bulkAssignTeam(team.value)"
>
{{ team.label }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>

<!-- Status dropdown -->
<DropdownMenu v-if="canUpdateStatus">
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="bulkLoading">
{{ t('globals.terms.status', 1) }}
<ChevronDown class="w-3 h-3 ml-1 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="status in conversationStore.statusOptionsNoSnooze"
:key="status.value"
@click="bulkUpdateStatus(status.label)"
>
{{ status.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

<!-- Priority dropdown -->
<DropdownMenu v-if="canUpdatePriority">
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="bulkLoading">
{{ t('globals.terms.priority', 1) }}
<ChevronDown class="w-3 h-3 ml-1 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="priority in conversationStore.priorityOptions"
:key="priority.value"
@click="bulkUpdatePriority(priority.label)"
>
{{ priority.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

<Loader2 v-if="bulkLoading" class="w-4 h-4 animate-spin text-muted-foreground ml-2" />

<Button
variant="ghost"
size="sm"
class="h-7 text-xs ml-auto"
:aria-label="t('conversation.bulkActions.clearSelection')"
@click="conversationStore.clearSelection()"
>
<X class="w-3 h-3" />
</Button>
</div>

<!-- Filters (hidden when bulk selecting) -->
<div v-else class="p-2 flex justify-between items-center">
<!-- Status dropdown-menu, hidden when a view is selected as views are pre-filtered -->
<DropdownMenu v-if="!route.params.viewID">
<DropdownMenuTrigger asChild>
Expand Down Expand Up @@ -149,26 +254,61 @@
</template>

<script setup>
import { computed } from 'vue'
import { useConversationStore } from '../../../stores/conversation'
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown, Loader2 } from 'lucide-vue-next'
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown, Loader2, X } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import { SidebarTrigger } from '@shared-ui/components/ui/sidebar'
import { useConversationStore } from '@/stores/conversation'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useUserStore } from '@/stores/user'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { permissions as p } from '@/constants/permissions'
import api from '@/api'
import EmptyList from '@/features/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/features/conversation/list/ConversationListItem.vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ConversationListItemSkeleton from '@/features/conversation/list/ConversationListItemSkeleton.vue'

const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamsStore = useTeamStore()
const userStore = useUserStore()
const route = useRoute()
const { t } = useI18n()
const emitter = useEmitter()
const bulkLoading = ref(false)

const canAssignAgent = computed(() => userStore.can(p.CONVERSATIONS_UPDATE_USER_ASSIGNEE))
const canAssignTeam = computed(() => userStore.can(p.CONVERSATIONS_UPDATE_TEAM_ASSIGNEE))
const canUpdateStatus = computed(() => userStore.can(p.CONVERSATIONS_UPDATE_STATUS))
const canUpdatePriority = computed(() => userStore.can(p.CONVERSATIONS_UPDATE_PRIORITY))

onMounted(() => {
if (canAssignAgent.value) usersStore.fetchUsers()
if (canAssignTeam.value) teamsStore.fetchTeams()
})

const hasSelection = computed(() => conversationStore.selectedCount > 0)

const toggleSelectAll = () => {
if (conversationStore.allSelected) {
conversationStore.clearSelection()
} else {
conversationStore.selectAll()
}
}

const title = computed(() => {
const typeKey = route.meta?.typeKey?.(route)
Expand All @@ -192,6 +332,61 @@ const loadNextPage = () => {
conversationStore.fetchNextConversations()
}

// Bulk action helpers
const runBulkAction = async (actionFn) => {
const uuids = [...conversationStore.selectedUUIDs]
bulkLoading.value = true
const results = await Promise.allSettled(uuids.map((uuid) => actionFn(uuid)))
bulkLoading.value = false

const successCount = results.filter((r) => r.status === 'fulfilled').length
const errorCount = results.length - successCount

if (errorCount > 0) {
const failures = results
.map((r, i) => ({ uuid: uuids[i], reason: r.reason }))
.filter((f) => f.reason)
if (failures.length) {
console.warn('Bulk action failures:', failures)
}
}

conversationStore.clearSelection()
conversationStore.fetchFirstPageConversations()

if (errorCount > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
title: t('globals.terms.error', 1),
description: t('conversation.bulkActions.failedToast', {
success: successCount,
failed: errorCount,
total: uuids.length
})
})
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('conversation.bulkActions.successToast', successCount, { count: successCount })
})
}
}

const bulkAssignAgent = (agentId) => {
runBulkAction((uuid) => api.updateAssignee(uuid, 'user', { assignee_id: parseInt(agentId, 10) }))
}

const bulkAssignTeam = (teamId) => {
runBulkAction((uuid) => api.updateAssignee(uuid, 'team', { assignee_id: parseInt(teamId, 10) }))
}

const bulkUpdateStatus = (status) => {
runBulkAction((uuid) => api.updateConversationStatus(uuid, { status }))
}

const bulkUpdatePriority = (priority) => {
runBulkAction((uuid) => api.updateConversationPriority(uuid, { priority }))
}

const hasConversations = computed(() => conversationStore.conversationsList.length !== 0)
const hasErrored = computed(() => !!conversationStore.conversations.errorMessage)
const isLoading = computed(() => conversationStore.conversations.loading)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@
:to="conversationRoute"
class="group relative block px-3 py-3 transition-all duration-200 ease-in-out cursor-pointer hover:bg-accent/20 dark:hover:bg-accent/60"
:class="{
'bg-accent/60': conversation.uuid === currentConversation?.uuid
'bg-accent/60': conversation.uuid === currentConversation?.uuid,
'bg-primary/5': isItemSelected && conversation.uuid !== currentConversation?.uuid
}"
>
<div class="flex items-start gap-2">
<!-- Selection checkbox -->
<div class="flex items-center pt-2" @click.prevent.stop="handleCheckboxClick">
<Checkbox
:checked="isItemSelected"
:aria-label="t('conversation.bulkActions.selectConversation')"
/>
</div>

<!-- Avatar with channel indicator -->
<div class="relative flex-shrink-0">
<Avatar class="w-10 h-10 rounded-full">
Expand Down Expand Up @@ -135,12 +144,15 @@ import {
ContextMenuTrigger
} from '@shared-ui/components/ui/context-menu'
import SlaBadge from '@main/features/sla/SlaBadge.vue'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { useConversationStore } from '@main/stores/conversation'
import { useI18n } from 'vue-i18n'

let timer = null
const now = ref(new Date())
const route = useRoute()
const conversationStore = useConversationStore()
const { t } = useI18n()
const frdStatus = ref('')
const rdStatus = ref('')
const nrdStatus = ref('')
Expand Down Expand Up @@ -210,4 +222,12 @@ const draftPreview = computed(() => {
const text = draft.content.replace(/<[^>]*>/g, '').trim()
return text.length > 120 ? text.slice(0, 120) + '...' : text
})

const isItemSelected = computed(() => {
return conversationStore.isSelected(props.conversation.uuid)
})

const handleCheckboxClick = (event) => {
conversationStore.toggleSelect(props.conversation.uuid, event.shiftKey)
}
</script>
58 changes: 57 additions & 1 deletion frontend/apps/main/src/stores/conversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const useConversationStore = defineStore('conversation', () => {
const macros = ref({})
const drafts = ref(new Map())

// Bulk selection state
const selectedUUIDs = ref(new Set())

// Options for select fields
const priorityOptions = computed(() => {
return priorities.value.map(p => ({ label: p.name, value: p.id }))
Expand All @@ -40,6 +43,51 @@ export const useConversationStore = defineStore('conversation', () => {
}))
)

// Bulk selection methods
let lastClickedUUID = null

const selectedCount = computed(() => selectedUUIDs.value.size)
const allSelected = computed(() => {
const list = conversationsList.value
return list.length > 0 && selectedUUIDs.value.size === list.length
})

function toggleSelect (uuid, shiftKey = false) {
const next = new Set(selectedUUIDs.value)

if (shiftKey && lastClickedUUID && lastClickedUUID !== uuid) {
const list = conversationsList.value
const lastIdx = list.findIndex(c => c.uuid === lastClickedUUID)
const curIdx = list.findIndex(c => c.uuid === uuid)
if (lastIdx !== -1 && curIdx !== -1) {
const start = Math.min(lastIdx, curIdx)
const end = Math.max(lastIdx, curIdx)
for (let i = start; i <= end; i++) {
next.add(list[i].uuid)
}
}
} else {
if (next.has(uuid)) next.delete(uuid)
else next.add(uuid)
}

lastClickedUUID = uuid
selectedUUIDs.value = next
}

function selectAll () {
selectedUUIDs.value = new Set(conversationsList.value.map(c => c.uuid))
}

function clearSelection () {
selectedUUIDs.value = new Set()
lastClickedUUID = null
}

function isSelected (uuid) {
return selectedUUIDs.value.has(uuid)
}

// TODO: Move to constants.
const sortFieldMap = {
oldest: {
Expand Down Expand Up @@ -798,6 +846,7 @@ export const useConversationStore = defineStore('conversation', () => {
conversations.data = []
conversations.page = 1
seenConversationUUIDs = new Map()
clearSelection()
}

/** Macros set for new conversation or an open conversation **/
Expand Down Expand Up @@ -975,6 +1024,13 @@ export const useConversationStore = defineStore('conversation', () => {
hasDraft,
addPendingMessage,
replacePendingMessage,
removePendingMessage
removePendingMessage,
selectedUUIDs,
selectedCount,
allSelected,
toggleSelect,
selectAll,
clearSelection,
isSelected
}
})
8 changes: 8 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,14 @@
"contextLink.urlTemplateHelp": "{'{{token}}'} is a base64-encoded AES-256-GCM encrypted blob containing all contact and agent fields (requires secret). Individual variables like {'{{email}}'}, {'{{phone}}'}, {'{{external_user_id}}'}, {'{{contact_id}}'}, {'{{first_name}}'}, {'{{last_name}}'}, {'{{conversation_uuid}}'} are passed as plain text.",
"conversation.agentAssigned": "Agent assigned",
"conversation.allLoaded": "All conversations loaded",
"conversation.bulkActions.assign": "Assign",
"conversation.bulkActions.clearSelection": "Clear selection",
"conversation.bulkActions.failedToast": "Updated {success}, failed {failed} of {total} conversations",
"conversation.bulkActions.selectAll": "Select all conversations",
"conversation.bulkActions.selectConversation": "Select conversation",
"conversation.bulkActions.selected": "No conversations selected | {count} selected | {count} selected",
"conversation.bulkActions.successToast": "Updated {count} conversation | Updated {count} conversations",
"conversation.bulkActions.toolbar": "Bulk actions toolbar",
"conversation.couldNotFetch": "Could not fetch conversations",
"conversation.hideQuotedText": "Hide quoted text",
"conversation.mentions": "Mentions",
Expand Down
Loading