# Pull Request Template ## Description This PR fixes a crash where opening a conversation threw `TypeError: Cannot read properties of null (reading 'localeCompare')` and prevented the agent assignment dropdown from rendering. Since #14866, agent bots are included in the assignable agents list. `AgentBot#name` is not presence-validated, so system bots (account-less, global) can have a `null` name. Those nameless bots flowed into name-based operations that assumed a string, causing crashes and warnings across multiple surfaces: * **Assignment dropdown sort:** `getAgentsByAvailability` called `a.name.localeCompare(b.name)`, causing a `localeCompare` `TypeError`. * **Dropdown search:** `MultiselectDropdownItems` called `option.name.toLowerCase()`, causing a `toLowerCase` `TypeError`. * **Agent Bots settings:** `Avatar` received `name=null` for a `String` prop, triggering a Vue prop validation warning. ### What changed * Keep nameless agent bots in the assignment dropdown and render a `-` fallback label in `useAgentsList`. These are still valid, assignable-by-ID records: the assignable agents API includes accessible bots, and `Conversations::AssignmentService` assigns them by ID. Preserving them avoids hiding valid assignment targets. Bots are still included only when `includeAgentBots` is enabled. * Make the sort in `getAgentsByAvailability` null-safe by coercing missing names to an empty string (defense in depth). * Make the search filter in `MultiselectDropdownItems` null-safe (defense in depth). * Pass a null-safe `name` prop to `Avatar` in the Agent Bots settings list to eliminate the Vue prop validation warning. Fixes https://linear.app/chatwoot/issue/CW-7670/agent-assignment-dropdown-crashes-with-cannot-read-properties-of-null ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? 1. Have a system agent bot (`name: null`) that is assignable to an inbox. 2. Open any conversation in that inbox. * The agent assignment dropdown renders without console errors. * The nameless bot is listed with a `-` label and can be assigned. 3. Go to **Settings → Agent Bots**. * The page renders without the `Avatar` prop validation warning. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
170 lines
5.0 KiB
Vue
170 lines
5.0 KiB
Vue
<script setup>
|
|
import { computed } 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 Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.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',
|
|
},
|
|
showEmojiIcon: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
const hasIcon = computed(() => {
|
|
return props.selectedItem?.icon || false;
|
|
});
|
|
|
|
const isAgentBot = computed(
|
|
() => props.selectedItem?.assignee_type === 'AgentBot'
|
|
);
|
|
|
|
const selectedItemName = computed(() =>
|
|
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
|
|
);
|
|
|
|
const selectedThumbnail = computed(
|
|
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
|
);
|
|
</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="selectedItemName"
|
|
>
|
|
{{ selectedItemName }}
|
|
</h4>
|
|
</div>
|
|
<Avatar
|
|
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
|
:src="selectedThumbnail"
|
|
:status="selectedItem.availability_status"
|
|
:name="selectedItemName"
|
|
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
|
:size="24"
|
|
hide-offline-status
|
|
rounded-full
|
|
>
|
|
<template v-if="isAgentBot && selectedThumbnail" #badge>
|
|
<div
|
|
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3.5"
|
|
>
|
|
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2.5" />
|
|
</div>
|
|
</template>
|
|
</Avatar>
|
|
<div
|
|
v-else-if="hasValue && hasIcon && showEmojiIcon"
|
|
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
|
|
>
|
|
<EmojiIcon
|
|
:value="selectedItem.icon"
|
|
:color="selectedItem.icon_color"
|
|
class="size-3.5 !text-sm"
|
|
/>
|
|
</div>
|
|
<Icon
|
|
v-else-if="hasValue && hasIcon"
|
|
:icon="selectedItem.icon"
|
|
class="size-5 text-n-slate-11"
|
|
/>
|
|
</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"
|
|
:show-emoji-icon="showEmojiIcon"
|
|
@select="onClickSelectItem"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</OnClickOutside>
|
|
</template>
|