chore: Improve new conversation form (#10687)

This commit is contained in:
Sivin Varghese 2025-01-15 13:10:09 +05:30 committed by GitHub
parent d070743383
commit a899c2b5a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 226 additions and 157 deletions

View File

@ -41,6 +41,8 @@ const props = defineProps({
default: 'info', default: 'info',
validator: value => ['info', 'error', 'success'].includes(value), validator: value => ['info', 'error', 'success'].includes(value),
}, },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@ -116,6 +118,8 @@ watch(
:placeholder="placeholder" :placeholder="placeholder"
:focus-on-mount="focusOnMount" :focus-on-mount="focusOnMount"
:disabled="disabled" :disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"

View File

@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
@ -30,6 +31,8 @@ const props = defineProps({
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const contacts = ref([]); const contacts = ref([]);
const selectedContact = ref(null); const selectedContact = ref(null);
const targetInbox = ref(null); const targetInbox = ref(null);
@ -43,6 +46,11 @@ const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags'); const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature');
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
);
const directUploadsEnabled = computed( const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled () => globalConfig.value.directUploadsEnabled
@ -202,6 +210,8 @@ useKeyboardEvents(keyboardEvents);
:contact-conversations-ui-flags="uiFlags" :contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags" :contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass" :class="composePopoverClass"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch" @search-contacts="onContactSearch"
@reset-contact-search="resetContacts" @reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact" @update-selected-contact="handleSelectedContact"

View File

@ -1,6 +1,5 @@
<script setup> <script setup>
import { defineAsyncComponent, ref, computed } from 'vue'; import { defineAsyncComponent, ref, computed, watch, nextTick } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload'; import { useFileUpload } from 'dashboard/composables/useFileUpload';
@ -8,51 +7,24 @@ import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component'; import FileUpload from 'vue-upload-component';
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue'; import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({ const props = defineProps({
attachedFiles: { attachedFiles: { type: Array, default: () => [] },
type: Array, isWhatsappInbox: { type: Boolean, default: false },
default: () => [], isEmailOrWebWidgetInbox: { type: Boolean, default: false },
}, isTwilioSmsInbox: { type: Boolean, default: false },
isWhatsappInbox: { messageTemplates: { type: Array, default: () => [] },
type: Boolean, channelType: { type: String, default: '' },
default: false, isLoading: { type: Boolean, default: false },
}, disableSendButton: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { hasSelectedInbox: { type: Boolean, default: false },
type: Boolean, hasNoInbox: { type: Boolean, default: false },
default: false, isDropdownActive: { type: Boolean, default: false },
}, messageSignature: { type: String, default: '' },
isTwilioSmsInbox: {
type: Boolean,
default: false,
},
messageTemplates: {
type: Array,
default: () => [],
},
channelType: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
disableSendButton: {
type: Boolean,
default: false,
},
hasNoInbox: {
type: Boolean,
default: false,
},
isDropdownActive: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -74,8 +46,11 @@ const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue') () => import('shared/components/emoji/EmojiInput.vue')
); );
const messageSignature = useMapGetter('getMessageSignature'); const signatureToApply = computed(() =>
const signatureToApply = computed(() => messageSignature.value); props.isEmailOrWebWidgetInbox
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature)
);
const { const {
fetchSignatureFlagFromUISettings, fetchSignatureFlagFromUISettings,
@ -87,13 +62,9 @@ const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType); return fetchSignatureFlagFromUISettings(props.channelType);
}); });
const isSignatureEnabledForInbox = computed(() => {
return props.isEmailOrWebWidgetInbox && sendWithSignature.value;
});
const setSignature = () => { const setSignature = () => {
if (signatureToApply.value) { if (signatureToApply.value) {
if (isSignatureEnabledForInbox.value) { if (sendWithSignature.value) {
emit('addSignature', signatureToApply.value); emit('addSignature', signatureToApply.value);
} else { } else {
emit('removeSignature', signatureToApply.value); emit('removeSignature', signatureToApply.value);
@ -106,6 +77,18 @@ const toggleMessageSignature = () => {
setSignature(); setSignature();
}; };
// Added this watch to dynamically set signature.
// Only targetInbox has value and is Advance Editor(used by isEmailOrWebWidgetInbox)
// Set the signature only if the inbox based flag is true
watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
});
}
);
const onClickInsertEmoji = emoji => { const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji); emit('insertEmoji', emoji);
}; };
@ -213,7 +196,7 @@ useKeyboardEvents(keyboardEvents);
/> />
</FileUpload> </FileUpload>
<Button <Button
v-if="isEmailOrWebWidgetInbox" v-if="hasSelectedInbox && !isWhatsappInbox"
icon="i-lucide-signature" icon="i-lucide-signature"
color="slate" color="slate"
size="sm" size="sm"

View File

@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { import {
appendSignature, appendSignature,
removeSignature, removeSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { import {
buildContactableInboxesList, buildContactableInboxesList,
@ -33,6 +34,8 @@ const props = defineProps({
isDirectUploadsEnabled: { type: Boolean, default: false }, isDirectUploadsEnabled: { type: Boolean, default: false },
contactConversationsUiFlags: { type: Object, default: null }, contactConversationsUiFlags: { type: Object, default: null },
contactsUiFlags: { type: Object, default: null }, contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -184,6 +187,14 @@ const handleInboxAction = ({ value, action, ...rest }) => {
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
// Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
state.message = removeSignature(state.message, signatureToRemove);
}
emit('updateTargetInbox', value); emit('updateTargetInbox', value);
state.attachedFiles = []; state.attachedFiles = [];
}; };
@ -302,6 +313,8 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<MessageEditor <MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert" v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-model="state.message" v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0" :has-attachments="state.attachedFiles.length > 0"
@ -322,8 +335,10 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:channel-type="inboxChannelType" :channel-type="inboxChannelType"
:is-loading="isCreating" :is-loading="isCreating"
:disable-send-button="isCreating" :disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:has-no-inbox="showNoInboxAlert" :has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive" :is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji" @insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature" @add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature" @remove-signature="handleRemoveSignature"

View File

@ -1,22 +1,22 @@
<script setup> <script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import {
appendSignature,
extractTextFromMarkdown,
removeSignature,
} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
defineProps({ const props = defineProps({
isEmailOrWebWidgetInbox: { isEmailOrWebWidgetInbox: { type: Boolean, required: true },
type: Boolean, hasErrors: { type: Boolean, default: false },
required: true, hasAttachments: { type: Boolean, default: false },
}, sendWithSignature: { type: Boolean, default: false },
hasErrors: { messageSignature: { type: String, default: '' },
type: Boolean,
default: false,
},
hasAttachments: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@ -25,14 +25,58 @@ const modelValue = defineModel({
type: String, type: String,
default: '', default: '',
}); });
const state = ref({
hasSlashCommand: false,
showMentions: false,
mentionSearchKey: '',
});
const plainTextSignature = computed(() =>
extractTextFromMarkdown(props.messageSignature)
);
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, plainTextSignature.value)
: '';
// Check if message starts with slash
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Update slash command and mentions state
state.value = {
...state.value,
hasSlashCommand: startsWithSlash,
showMentions: startsWithSlash,
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
};
},
{ immediate: true }
);
const hideMention = () => {
state.value.showMentions = false;
};
const replaceText = async message => {
// Only append signature on replace if sendWithSignature is true
const finalMessage = props.sendWithSignature
? appendSignature(message, plainTextSignature.value)
: message;
await nextTick();
modelValue.value = finalMessage;
};
</script> </script>
<template> <template>
<div <div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
v-if="isEmailOrWebWidgetInbox" <template v-if="isEmailOrWebWidgetInbox">
class="flex-1 h-full"
:class="!hasAttachments && 'min-h-[200px]'"
>
<Editor <Editor
v-model="modelValue" v-model="modelValue"
:placeholder=" :placeholder="
@ -44,22 +88,35 @@ const modelValue = defineModel({
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9' ? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: '' : ''
" "
enable-variables
:show-character-count="false" :show-character-count="false"
/> />
</div> </template>
<div v-else class="flex-1 h-full" :class="!hasAttachments && 'min-h-[200px]'"> <template v-else>
<TextArea <TextArea
v-model="modelValue" v-model="modelValue"
:placeholder=" :placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
" "
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent" class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
auto-height
:custom-text-area-class=" :custom-text-area-class="
hasErrors hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9' ? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: '' : ''
" "
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/> />
</TextArea>
</template>
</div> </div>
</template> </template>

View File

@ -1,72 +1,35 @@
<script setup> <script setup>
import { computed, ref, onMounted, nextTick, watch } from 'vue'; import { computed, ref, onMounted, nextTick, watch } from 'vue';
import {
appendSignature,
removeSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: { type: String, default: '' },
type: String, label: { type: String, default: '' },
default: '', placeholder: { type: String, default: '' },
}, maxLength: { type: Number, default: 200 },
label: { id: { type: String, default: '' },
type: String, disabled: { type: Boolean, default: false },
default: '', customTextAreaClass: { type: String, default: '' },
}, customTextAreaWrapperClass: { type: String, default: '' },
placeholder: { showCharacterCount: { type: Boolean, default: false },
type: String, autoHeight: { type: Boolean, default: false },
default: '', resize: { type: Boolean, default: false },
}, minHeight: { type: String, default: '4rem' },
maxLength: { maxHeight: { type: String, default: '12rem' },
type: Number, autofocus: { type: Boolean, default: false },
default: 200, message: { type: String, default: '' },
},
id: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
customTextAreaClass: {
type: String,
default: '',
},
customTextAreaWrapperClass: {
type: String,
default: '',
},
showCharacterCount: {
type: Boolean,
default: false,
},
autoHeight: {
type: Boolean,
default: false,
},
resize: {
type: Boolean,
default: false,
},
minHeight: {
type: String,
default: '4rem',
},
maxHeight: {
type: String,
default: '12rem',
},
autofocus: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: { messageType: {
type: String, type: String,
default: 'info', default: 'info',
validator: value => ['info', 'error', 'success'].includes(value), validator: value => ['info', 'error', 'success'].includes(value),
}, },
signature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false }, // add this as a prop, so that we won't have to add useUISettings
allowSignature: { type: Boolean, default: false }, // allowSignature is a kill switch, ensuring no signature methods are triggered except when this flag is true
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@ -75,6 +38,9 @@ const textareaRef = ref(null);
const isFocused = ref(false); const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length); const characterCount = computed(() => props.modelValue.length);
const cleanedSignature = computed(() =>
extractTextFromMarkdown(props.signature)
);
const messageClass = computed(() => { const messageClass = computed(() => {
switch (props.messageType) { switch (props.messageType) {
@ -97,6 +63,32 @@ const adjustHeight = () => {
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`; textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
}; };
const setCursor = () => {
if (!textareaRef.value) return;
const bodyWithoutSignature = removeSignature(
props.modelValue,
cleanedSignature.value
);
const bodyEndsAt = bodyWithoutSignature.trimEnd().length;
textareaRef.value.focus();
textareaRef.value.setSelectionRange(bodyEndsAt, bodyEndsAt);
};
const toggleSignatureInEditor = signatureEnabled => {
if (!props.allowSignature) return;
const valueWithSignature = signatureEnabled
? appendSignature(props.modelValue, cleanedSignature.value)
: removeSignature(props.modelValue, cleanedSignature.value);
emit('update:modelValue', valueWithSignature);
nextTick(() => {
adjustHeight();
setCursor();
});
};
const handleInput = event => { const handleInput = event => {
emit('update:modelValue', event.target.value); emit('update:modelValue', event.target.value);
if (props.autoHeight) { if (props.autoHeight) {
@ -126,13 +118,20 @@ watch(
} }
); );
watch(
() => props.sendWithSignature,
newValue => {
if (props.allowSignature) toggleSignatureInEditor(newValue);
}
);
onMounted(() => { onMounted(() => {
if (props.autoHeight) { if (props.autoHeight) {
nextTick(adjustHeight); nextTick(adjustHeight);
} }
if (props.autofocus) { if (props.autofocus) {
textareaRef.value.focus(); textareaRef.value?.focus();
} }
}); });
</script> </script>
@ -161,6 +160,7 @@ onMounted(() => {
}, },
]" ]"
> >
<slot /><!-- Slot for adding popover -->
<textarea <textarea
:id="id" :id="id"
ref="textareaRef" ref="textareaRef"