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:
@@ -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({
|
const modelValue = defineModel({
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
@@ -49,6 +55,10 @@ const onEnterPress = () => {
|
|||||||
emit('enterPress');
|
emit('enterPress');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onEscapePress = () => {
|
||||||
|
emit('escapePress');
|
||||||
|
};
|
||||||
|
|
||||||
const handleInput = event => {
|
const handleInput = event => {
|
||||||
emit('input', event.target.value);
|
emit('input', event.target.value);
|
||||||
modelValue.value = event.target.value;
|
modelValue.value = event.target.value;
|
||||||
@@ -102,6 +112,7 @@ defineExpose({
|
|||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@keydown.enter.prevent="onEnterPress"
|
@keydown.enter.prevent="onEnterPress"
|
||||||
|
@keydown.escape.prevent="onEscapePress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"CALL": "Call",
|
"CALL": "Call",
|
||||||
"CALL_INITIATED": "Calling the contact…",
|
"CALL_INITIATED": "Calling the contact…",
|
||||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||||
|
"CLICK_TO_EDIT": "Click to edit",
|
||||||
"VOICE_INBOX_PICKER": {
|
"VOICE_INBOX_PICKER": {
|
||||||
"TITLE": "Choose a voice inbox"
|
"TITLE": "Choose a voice inbox"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import {
|
||||||
|
DuplicateContactException,
|
||||||
|
ExceptionWithMessage,
|
||||||
|
} from 'shared/helpers/CustomErrors';
|
||||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
import ContactInfoRow from './ContactInfoRow.vue';
|
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 { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||||
|
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isAConversationRoute,
|
isAConversationRoute,
|
||||||
@@ -30,6 +35,7 @@ export default {
|
|||||||
SocialIcons,
|
SocialIcons,
|
||||||
ContactMergeModal,
|
ContactMergeModal,
|
||||||
VoiceCallButton,
|
VoiceCallButton,
|
||||||
|
InlineInput,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
contact: {
|
contact: {
|
||||||
@@ -52,6 +58,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
showDeleteModal: false,
|
showDeleteModal: false,
|
||||||
|
isEditingName: false,
|
||||||
|
editName: '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -173,6 +181,58 @@ export default {
|
|||||||
openMergeModal() {
|
openMergeModal() {
|
||||||
this.$refs.mergeModal?.open();
|
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>
|
</script>
|
||||||
@@ -194,10 +254,26 @@ export default {
|
|||||||
|
|
||||||
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
|
<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">
|
<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
|
<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 }}
|
{{ 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>
|
</h3>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -231,6 +307,8 @@ export default {
|
|||||||
emoji="✉️"
|
emoji="✉️"
|
||||||
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
|
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
|
||||||
show-copy
|
show-copy
|
||||||
|
editable
|
||||||
|
@update="value => onFieldUpdate('email', value)"
|
||||||
/>
|
/>
|
||||||
<ContactInfoRow
|
<ContactInfoRow
|
||||||
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
|
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
|
||||||
@@ -239,6 +317,8 @@ export default {
|
|||||||
emoji="📞"
|
emoji="📞"
|
||||||
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
|
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
|
||||||
show-copy
|
show-copy
|
||||||
|
editable
|
||||||
|
@update="value => onFieldUpdate('phone_number', value)"
|
||||||
/>
|
/>
|
||||||
<ContactInfoRow
|
<ContactInfoRow
|
||||||
v-if="contact.identifier"
|
v-if="contact.identifier"
|
||||||
@@ -252,6 +332,16 @@ export default {
|
|||||||
icon="building-bank"
|
icon="building-bank"
|
||||||
emoji="🏢"
|
emoji="🏢"
|
||||||
:title="$t('CONTACT_PANEL.COMPANY')"
|
:title="$t('CONTACT_PANEL.COMPANY')"
|
||||||
|
editable
|
||||||
|
@update="
|
||||||
|
value =>
|
||||||
|
updateContactField({
|
||||||
|
additional_attributes: {
|
||||||
|
...additionalAttributes,
|
||||||
|
company_name: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<ContactInfoRow
|
<ContactInfoRow
|
||||||
v-if="location || additionalAttributes.location"
|
v-if="location || additionalAttributes.location"
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
|
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
|
||||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
EmojiOrIcon,
|
EmojiOrIcon,
|
||||||
NextButton,
|
NextButton,
|
||||||
|
InlineInput,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
href: {
|
href: {
|
||||||
@@ -30,6 +32,21 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
editable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['update'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isEditing: false,
|
||||||
|
editValue: '',
|
||||||
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async onCopy(e) {
|
async onCopy(e) {
|
||||||
@@ -37,14 +54,53 @@ export default {
|
|||||||
await copyTextToClipboard(this.value);
|
await copyTextToClipboard(this.value);
|
||||||
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<a
|
||||||
v-if="href"
|
v-else-if="href"
|
||||||
:href="href"
|
:href="href"
|
||||||
class="flex items-center gap-2 text-n-slate-11 hover:underline"
|
class="flex items-center gap-2 text-n-slate-11 hover:underline"
|
||||||
>
|
>
|
||||||
@@ -73,8 +129,18 @@ export default {
|
|||||||
icon="i-lucide-clipboard"
|
icon="i-lucide-clipboard"
|
||||||
@click="onCopy"
|
@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>
|
</a>
|
||||||
|
|
||||||
|
<!-- Read mode without link -->
|
||||||
<div v-else class="flex items-center gap-2 text-n-slate-11">
|
<div v-else class="flex items-center gap-2 text-n-slate-11">
|
||||||
<EmojiOrIcon
|
<EmojiOrIcon
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
@@ -90,6 +156,15 @@ export default {
|
|||||||
<span v-else class="text-sm text-n-slate-11">
|
<span v-else class="text-sm text-n-slate-11">
|
||||||
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ const buildContactFormData = contactParams => {
|
|||||||
|
|
||||||
export const handleContactOperationErrors = error => {
|
export const handleContactOperationErrors = error => {
|
||||||
if (error.response?.status === 422) {
|
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) {
|
} else if (error.response?.data?.message) {
|
||||||
throw new ExceptionWithMessage(error.response.data.message);
|
throw new ExceptionWithMessage(error.response.data.message);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
export class DuplicateContactException extends Error {
|
export class DuplicateContactException extends Error {
|
||||||
|
static DEFAULT_MESSAGE = 'DUPLICATE_CONTACT';
|
||||||
|
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
super('DUPLICATE_CONTACT');
|
super(DuplicateContactException.DEFAULT_MESSAGE);
|
||||||
this.data = data;
|
this.data = data;
|
||||||
this.name = 'DuplicateContactException';
|
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 {
|
export class ExceptionWithMessage extends Error {
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
|
|||||||
Reference in New Issue
Block a user