From f58f08a40d498feaed4020acbebc3a4c7a202d31 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Wed, 5 Aug 2026 17:07:26 +0530
Subject: [PATCH] fix: prevent avatar upload overlay from showing in
conversation list (#15332)
# Pull Request Template
## Description
This PR fixed the conversation list was incorrectly rendering the avatar
upload overlay on every conversation card, even though uploads aren't
supported there. Clicking it could throw a `TypeError: Cannot read
properties of null (reading 'click')`. Conversation cards now only show
the selection checkbox, while avatar uploads continue to work everywhere
they're supported.
### Cause
`ConversationCard` always passed the `#overlay` slot, but the checkbox
inside it was wrapped in `v-if`. When the checkbox wasn't rendered, Vue
treated the slot as empty and fell back to the default upload overlay
from `Avatar`.
That overlay's click handler expects a file input, but the file input is
only rendered when `allowUpload` is enabled. Since conversation cards
never enable uploads, clicking the overlay could dereference a null file
input and throw.
### How to reproduce
This isn't reliably reproducible manually. It only happens when the
upload overlay becomes visible while the card's internal hover state is
out of sync with the browser's CSS `:hover` state. In normal
interaction, entering the card immediately updates the hover state and
shows the checkbox instead, so the issue effectively self-recovers.
The new test reproduces this state directly and verifies the fix.
## What changed
* Moved `v-if="allowUpload"` from the hidden file input to the upload
overlay itself, so the overlay and file input are always mounted
together.
* Added `Avatar.spec.js` coverage for the overlay slot, including the
empty-slot case that triggered this bug, along with the existing upload,
delete, badge, sizing, initials, and image fallback behavior.
Fixes
https://linear.app/chatwoot/issue/CW-7726/typeerror-cannot-read-properties-of-null-reading-click
https://chatwoot-p3.sentry.io/issues/7291677410/?project=4507182691975168&referrer=Linear
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screenshots
**Before**
**After**
## 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
---
.../components-next/avatar/Avatar.vue | 2 +-
.../avatar/specs/Avatar.spec.js | 334 ++++++++++++++++++
2 files changed, 335 insertions(+), 1 deletion(-)
create mode 100644 app/javascript/dashboard/components-next/avatar/specs/Avatar.spec.js
diff --git a/app/javascript/dashboard/components-next/avatar/Avatar.vue b/app/javascript/dashboard/components-next/avatar/Avatar.vue
index c98c8d43a..8d47649d2 100644
--- a/app/javascript/dashboard/components-next/avatar/Avatar.vue
+++ b/app/javascript/dashboard/components-next/avatar/Avatar.vue
@@ -280,6 +280,7 @@ watch(
:handle-image-upload="handleImageUpload"
>
',
+ },
+ },
+};
+
+const createAvatar = (props = {}, options = {}) =>
+ mount(Avatar, { props: { name: 'John Doe', ...props }, global, ...options });
+
+describe('Avatar', () => {
+ describe('initials', () => {
+ it('uses the first letter of a single word name', () => {
+ expect(createAvatar({ name: 'John' }).find(INITIALS).text()).toBe('J');
+ });
+
+ it('uses the first letter of the first two words', () => {
+ expect(
+ createAvatar({ name: 'john ronald reuel tolkien' })
+ .find(INITIALS)
+ .text()
+ ).toBe('JR');
+ });
+
+ it('ignores emoji in the name', () => {
+ expect(createAvatar({ name: '😀 Jane Doe' }).find(INITIALS).text()).toBe(
+ 'JD'
+ );
+ });
+
+ it('falls back to the user icon when there is no name or image', () => {
+ const wrapper = createAvatar({ name: '' });
+
+ expect(wrapper.find('.i-lucide-user').exists()).toBe(true);
+ expect(wrapper.find(INITIALS).exists()).toBe(false);
+ });
+
+ it('prefers a custom icon over initials', () => {
+ const wrapper = createAvatar({ iconName: 'i-lucide-bot' });
+
+ expect(wrapper.find('.i-lucide-bot').exists()).toBe(true);
+ expect(wrapper.find(INITIALS).exists()).toBe(false);
+ });
+ });
+
+ describe('image source', () => {
+ it('renders the image when a src is given', () => {
+ const img = createAvatar({ src: 'avatar.png' }).find('img');
+
+ expect(img.exists()).toBe(true);
+ expect(img.attributes('alt')).toBe('John Doe');
+ });
+
+ it('falls back to initials when the image fails to load', async () => {
+ const wrapper = createAvatar({ src: 'broken.png' });
+ await wrapper.find('img').trigger('error');
+
+ expect(wrapper.find('img').exists()).toBe(false);
+ expect(wrapper.find(INITIALS).text()).toBe('JD');
+ });
+
+ it('retries when the src changes after a failure', async () => {
+ const wrapper = createAvatar({ src: 'broken.png' });
+ await wrapper.find('img').trigger('error');
+ await wrapper.setProps({ src: 'working.png' });
+
+ expect(wrapper.find('img').exists()).toBe(true);
+ });
+ });
+
+ describe('sizing', () => {
+ it('applies the size to the container', () => {
+ const style = createAvatar({ size: 48 }).attributes('style');
+
+ expect(style).toContain('width: 48px');
+ expect(style).toContain('height: 48px');
+ });
+
+ it('scales the initials with the avatar, capped at 24px', () => {
+ expect(
+ createAvatar({ size: 40 }).find(INITIALS).attributes('style')
+ ).toContain('font-size: 16px');
+ expect(
+ createAvatar({ size: 200 }).find(INITIALS).attributes('style')
+ ).toContain('font-size: 24px');
+ });
+
+ it.each([
+ [16, 'rounded'],
+ [24, 'rounded-md'],
+ [32, 'rounded-lg'],
+ [48, 'rounded-xl'],
+ [64, 'rounded-2xl'],
+ ])('uses a %ipx-appropriate radius', (size, expected) => {
+ expect(createAvatar({ size }).find(AVATAR_BOX).classes()).toContain(
+ expected
+ );
+ });
+
+ it('honours roundedFull regardless of size', () => {
+ expect(
+ createAvatar({ size: 64, roundedFull: true }).find(AVATAR_BOX).classes()
+ ).toContain('rounded-full');
+ });
+ });
+
+ describe('status badge', () => {
+ it.each([
+ ['online', 'bg-n-teal-10'],
+ ['busy', 'bg-n-amber-10'],
+ ['offline', 'bg-n-slate-10'],
+ ])('renders the %s badge', (status, expected) => {
+ expect(createAvatar({ status }).find(`.${expected}`).exists()).toBe(true);
+ });
+
+ it('hides the offline badge when hideOfflineStatus is set', () => {
+ const wrapper = createAvatar({
+ status: 'offline',
+ hideOfflineStatus: true,
+ });
+
+ expect(wrapper.find('.bg-n-slate-10').exists()).toBe(false);
+ });
+
+ it('accepts only known availability statuses', () => {
+ const { validator } = Avatar.props.status;
+
+ expect(validator('online')).toBe(true);
+ expect(validator(null)).toBe(true);
+ expect(validator('sleeping')).toBe(false);
+ });
+
+ it('renders the channel icon when an inbox is given', () => {
+ const wrapper = createAvatar({ inbox: { channel_type: 'Channel::Api' } });
+
+ expect(wrapper.find('.channel-icon').exists()).toBe(true);
+ });
+
+ it('prefers the status badge over the channel icon', () => {
+ const wrapper = createAvatar({
+ status: 'online',
+ inbox: { channel_type: 'Channel::Api' },
+ });
+
+ expect(wrapper.find('.bg-n-teal-10').exists()).toBe(true);
+ expect(wrapper.find('.channel-icon').exists()).toBe(false);
+ });
+
+ it('lets a consumer replace the badge', () => {
+ const wrapper = createAvatar(
+ { status: 'online' },
+ { slots: { badge: '' } }
+ );
+
+ expect(wrapper.find('.custom-badge').exists()).toBe(true);
+ expect(wrapper.find('.bg-n-teal-10').exists()).toBe(false);
+ });
+ });
+
+ describe('upload', () => {
+ it('renders no upload affordance by default', () => {
+ const wrapper = createAvatar();
+
+ expect(wrapper.find(UPLOAD_OVERLAY).exists()).toBe(false);
+ expect(wrapper.find('input[type="file"]').exists()).toBe(false);
+ });
+
+ it('opens the file picker when the overlay is clicked', async () => {
+ const errors = [];
+ const wrapper = createAvatar(
+ { allowUpload: true },
+ { global: { ...global, config: { errorHandler: e => errors.push(e) } } }
+ );
+
+ const input = wrapper.find('input[type="file"]');
+ expect(input.exists()).toBe(true);
+
+ const click = vi.fn();
+ input.element.click = click;
+ await wrapper.find(UPLOAD_OVERLAY).trigger('click');
+
+ expect(click).toHaveBeenCalled();
+ expect(errors).toEqual([]);
+ });
+
+ it('emits the selected file with a preview url', async () => {
+ // jsdom does not implement createObjectURL, so there is nothing to spy on
+ const original = URL.createObjectURL;
+ URL.createObjectURL = vi.fn(() => 'blob:preview');
+ const wrapper = createAvatar({ allowUpload: true });
+ const file = new File(['x'], 'avatar.png', { type: 'image/png' });
+ const input = wrapper.find('input[type="file"]');
+ Object.defineProperty(input.element, 'files', { value: [file] });
+
+ await input.trigger('change');
+
+ expect(wrapper.emitted('upload')[0][0]).toEqual({
+ file,
+ url: 'blob:preview',
+ });
+ URL.createObjectURL = original;
+ });
+
+ it('does not emit when the picker is dismissed', async () => {
+ const wrapper = createAvatar({ allowUpload: true });
+ const input = wrapper.find('input[type="file"]');
+ Object.defineProperty(input.element, 'files', { value: [] });
+
+ await input.trigger('change');
+
+ expect(wrapper.emitted('upload')).toBeUndefined();
+ });
+ });
+
+ describe('delete', () => {
+ it('offers delete only when there is an image to remove', () => {
+ expect(
+ createAvatar({ allowUpload: true }).find(DELETE_BUTTON).exists()
+ ).toBe(false);
+ expect(createAvatar({ src: 'a.png' }).find(DELETE_BUTTON).exists()).toBe(
+ false
+ );
+ expect(
+ createAvatar({ src: 'a.png', allowUpload: true })
+ .find(DELETE_BUTTON)
+ .exists()
+ ).toBe(true);
+ });
+
+ it('emits delete and does not bubble to the surrounding row', async () => {
+ const onRowClick = vi.fn();
+ const Row = {
+ components: { Avatar },
+ template: `
+
+
`,
+ setup: () => ({ onRowClick, onDelete: vi.fn() }),
+ };
+ const wrapper = mount(Row, { global });
+
+ await wrapper.find(DELETE_BUTTON).trigger('click');
+
+ expect(wrapper.findComponent(Avatar).emitted('delete')).toHaveLength(1);
+ expect(onRowClick).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('overlay slot', () => {
+ // ConversationCard.vue's shape: #overlay is ALWAYS passed and the v-if sits
+ // on the inner node, so an unhovered card yields only a comment node.
+ const ConversationCardLike = {
+ components: { Avatar },
+ props: { hovered: { type: Boolean, default: false } },
+ template: `
+
+
+
+
+
+ `,
+ };
+
+ // CardAvatar.vue / ContactsCard.vue's shape: v-if on the itself.
+ const CardAvatarLike = {
+ components: { Avatar },
+ props: { hovered: { type: Boolean, default: false } },
+ template: `
+
+
+
+
+
+ `,
+ };
+
+ it('renders the consumer overlay instead of the upload one', () => {
+ const wrapper = mount(ConversationCardLike, {
+ props: { hovered: true },
+ global,
+ });
+
+ expect(wrapper.find('.selection-checkbox').exists()).toBe(true);
+ expect(wrapper.find(UPLOAD_OVERLAY).exists()).toBe(false);
+ });
+
+ it('does not fall back to the upload overlay when the slot renders nothing', () => {
+ const wrapper = mount(ConversationCardLike, {
+ props: { hovered: false },
+ global,
+ });
+
+ expect(wrapper.find('.selection-checkbox').exists()).toBe(false);
+ expect(wrapper.find(UPLOAD_OVERLAY).exists()).toBe(false);
+ expect(wrapper.find('.i-lucide-upload').exists()).toBe(false);
+ });
+
+ it('does not fall back when the v-if is on the template', () => {
+ const wrapper = mount(CardAvatarLike, {
+ props: { hovered: false },
+ global,
+ });
+
+ expect(wrapper.find(UPLOAD_OVERLAY).exists()).toBe(false);
+ });
+
+ it('passes the upload handles to the slot', () => {
+ const slotProps = {};
+ mount(Avatar, {
+ props: { name: 'John Doe', size: 48, allowUpload: true },
+ global,
+ slots: {
+ overlay: props => {
+ Object.assign(slotProps, props);
+ return '';
+ },
+ },
+ });
+
+ expect(slotProps.size).toBe(48);
+ expect(typeof slotProps.handleUpload).toBe('function');
+ expect(typeof slotProps.handleImageUpload).toBe('function');
+ });
+ });
+});