feat: Inline edit support for contact info (#13976)

# Pull Request Template

## Description

This PR adds inline editing support for contact name, phone number,
email, and company fields in the conversation contact sidebar

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Screencast**


https://github.com/user-attachments/assets/e9f8e37d-145b-4736-b27a-eb9ea66847bd



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] 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
- [ ] 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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sivin Varghese
2026-04-14 18:23:40 +05:30
committed by GitHub
parent 72c9e1775b
commit 64f6bfc811
6 changed files with 196 additions and 6 deletions

View File

@@ -36,7 +36,13 @@ const props = defineProps({
},
});
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
const emit = defineEmits([
'enterPress',
'escapePress',
'input',
'blur',
'focus',
]);
const modelValue = defineModel({
type: [String, Number],
@@ -49,6 +55,10 @@ const onEnterPress = () => {
emit('enterPress');
};
const onEscapePress = () => {
emit('escapePress');
};
const handleInput = event => {
emit('input', event.target.value);
modelValue.value = event.target.value;
@@ -102,6 +112,7 @@ defineExpose({
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
@keydown.escape.prevent="onEscapePress"
/>
</div>
</template>

View File

@@ -20,6 +20,7 @@
"CALL": "Call",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},

View File

@@ -1,6 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
@@ -12,6 +16,7 @@ import ComposeConversation from 'dashboard/components-next/NewConversation/Compo
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import {
isAConversationRoute,
@@ -30,6 +35,7 @@ export default {
SocialIcons,
ContactMergeModal,
VoiceCallButton,
InlineInput,
},
props: {
contact: {
@@ -52,6 +58,8 @@ export default {
return {
showEditModal: false,
showDeleteModal: false,
isEditingName: false,
editName: '',
};
},
computed: {
@@ -173,6 +181,58 @@ export default {
openMergeModal() {
this.$refs.mergeModal?.open();
},
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
this.$nextTick(() => {
this.$refs.nameInput?.focus();
});
},
saveNameEdit() {
if (!this.isEditingName) return;
this.isEditingName = false;
const trimmed = this.editName.trim();
if (trimmed && trimmed !== this.contact.name) {
this.updateContactField({ name: trimmed });
}
},
cancelNameEdit() {
this.isEditingName = false;
},
onFieldUpdate(field, value) {
this.updateContactField({ [field]: value });
},
async updateContactField(attrs) {
const contactId = this.contact.id;
try {
await this.$store.dispatch('contacts/update', {
id: contactId,
...attrs,
});
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
await this.$store.dispatch('contacts/fetchContactableInbox', contactId);
} catch (error) {
if (error instanceof DuplicateContactException) {
const detail = error.contactErrorDetail;
if (detail) {
useAlert(detail);
} else {
const invalidAttrs = Array.isArray(error.data) ? error.data : [];
if (invalidAttrs.includes('email')) {
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
} else if (invalidAttrs.includes('phone_number')) {
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
},
};
</script>
@@ -194,10 +254,26 @@ export default {
<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">
<InlineInput
v-if="isEditingName"
ref="nameInput"
v-model="editName"
custom-input-class="!text-base !font-medium"
class="!w-fit"
@enter-press="saveNameEdit"
@escape-press="cancelNameEdit"
@blur="saveNameEdit"
/>
<h3
class="flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12"
v-else
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
@click="startEditingName"
>
{{ contact.name }}
<span
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
/>
</h3>
<div class="flex flex-row items-center gap-2">
<span
@@ -231,6 +307,8 @@ export default {
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
editable
@update="value => onFieldUpdate('email', value)"
/>
<ContactInfoRow
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
@@ -239,6 +317,8 @@ export default {
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
editable
@update="value => onFieldUpdate('phone_number', value)"
/>
<ContactInfoRow
v-if="contact.identifier"
@@ -252,6 +332,16 @@ export default {
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
editable
@update="
value =>
updateContactField({
additional_attributes: {
...additionalAttributes,
company_name: value,
},
})
"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"

View File

@@ -3,11 +3,13 @@ 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';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
export default {
components: {
EmojiOrIcon,
NextButton,
InlineInput,
},
props: {
href: {
@@ -30,6 +32,21 @@ export default {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
},
emits: ['update'],
data() {
return {
isEditing: false,
editValue: '',
};
},
methods: {
async onCopy(e) {
@@ -37,14 +54,53 @@ export default {
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
startEditing() {
if (!this.editable) return;
this.editValue = this.value || '';
this.isEditing = true;
this.$nextTick(() => {
this.$refs.editInput?.focus();
});
},
saveEdit() {
if (!this.isEditing) return;
this.isEditing = false;
const trimmed = this.editValue.trim();
if (trimmed !== (this.value || '')) {
this.$emit('update', trimmed);
}
},
cancelEdit() {
this.isEditing = false;
},
},
};
</script>
<template>
<div class="w-full h-5 ltr:-ml-1 rtl:-mr-1">
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
<!-- Inline edit mode -->
<div v-if="isEditing" class="flex items-center gap-2">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<InlineInput
ref="editInput"
v-model="editValue"
:placeholder="title"
class="!w-fit"
@enter-press="saveEdit"
@escape-press="cancelEdit"
@blur="saveEdit"
/>
</div>
<!-- Read mode with link -->
<a
v-if="href"
v-else-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
@@ -73,8 +129,18 @@ export default {
icon="i-lucide-clipboard"
@click="onCopy"
/>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click.prevent="startEditing"
/>
</a>
<!-- Read mode without link -->
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
@@ -90,6 +156,15 @@ export default {
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click="startEditing"
/>
</div>
</div>
</template>

View File

@@ -36,7 +36,11 @@ const buildContactFormData = contactParams => {
export const handleContactOperationErrors = error => {
if (error.response?.status === 422) {
throw new DuplicateContactException(error.response.data.attributes);
const exception = new DuplicateContactException(
error.response.data.attributes
);
exception.message = error.response.data.message || exception.message;
throw exception;
} else if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else {

View File

@@ -1,10 +1,19 @@
/* eslint-disable max-classes-per-file */
export class DuplicateContactException extends Error {
static DEFAULT_MESSAGE = 'DUPLICATE_CONTACT';
constructor(data) {
super('DUPLICATE_CONTACT');
super(DuplicateContactException.DEFAULT_MESSAGE);
this.data = data;
this.name = 'DuplicateContactException';
}
/** Server or client may assign `message` after construction; otherwise still DEFAULT_MESSAGE. */
get contactErrorDetail() {
return this.message === DuplicateContactException.DEFAULT_MESSAGE
? null
: this.message;
}
}
export class ExceptionWithMessage extends Error {
constructor(data) {