Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f8c94d6a | ||
|
|
8fcdfe28a1 | ||
|
|
4f503f33e1 | ||
|
|
fd8919b901 | ||
|
|
0d490640f2 | ||
|
|
02216471c3 | ||
|
|
68d8e62a5c |
@@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def settings_params
|
||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||
conversation_required_attributes: [])
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createNewContact,
|
||||
@@ -226,6 +228,8 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (showComposeNewConversation.value) {
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
@@ -163,6 +164,20 @@ const keyboardEvents = {
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
const onPaste = e => {
|
||||
if (!props.isEmailOrWebWidgetInbox) return;
|
||||
|
||||
const files = e.clipboardData?.files;
|
||||
if (!files?.length) return;
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
});
|
||||
};
|
||||
|
||||
useEventListener(document, 'paste', onPaste);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
@@ -9,6 +10,8 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
@@ -35,7 +38,7 @@ const onClick = () => {
|
||||
v-for="(document, index) in documentsList.slice(0, 5)"
|
||||
:id="document.id"
|
||||
:key="`document-${index}`"
|
||||
:name="document.name"
|
||||
:name="replaceInstallationName(document.name)"
|
||||
:assistant="document.assistant"
|
||||
:external-link="document.external_link"
|
||||
:created-at="document.created_at"
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
@@ -26,6 +27,7 @@ const isApproved = computed(() => props.variant === 'approved');
|
||||
const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -63,8 +65,8 @@ const onClearFilters = () => {
|
||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||
:id="response.id"
|
||||
:key="`response-${index}`"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:question="replaceInstallationName(response.question)"
|
||||
:answer="replaceInstallationName(response.answer)"
|
||||
:status="response.status"
|
||||
:assistant="response.assistant"
|
||||
:created-at="response.created_at"
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -97,6 +99,15 @@ const closeMobileSidebar = () => {
|
||||
emit('closeMobileSidebar');
|
||||
};
|
||||
|
||||
const onComposeOpen = toggleFn => {
|
||||
toggleFn();
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onComposeClose = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
};
|
||||
|
||||
const newReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
@@ -624,14 +635,14 @@ const menuItems = computed(() => {
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<ComposeConversation align-position="right">
|
||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||
@click="toggle"
|
||||
@click="onComposeOpen(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
|
||||
@@ -28,7 +28,6 @@ const { t } = useI18n();
|
||||
|
||||
const isGenerating = ref(false);
|
||||
const shareImageUrl = ref(null);
|
||||
const shareImageBlob = ref(null);
|
||||
|
||||
const generateImage = async () => {
|
||||
if (!props.slideElement) return;
|
||||
@@ -57,15 +56,19 @@ const generateImage = async () => {
|
||||
const bgColor = colorMap[props.slideBackground] || '#ffffff';
|
||||
|
||||
const dataUrl = await toPng(slideElement, {
|
||||
quality: 2,
|
||||
pixelRatio: 1.2,
|
||||
backgroundColor: bgColor,
|
||||
// Skip font/CSS embedding to avoid CORS issues with CDN stylesheets
|
||||
// See: https://github.com/bubkoo/html-to-image/issues/49#issuecomment-762222100
|
||||
fontEmbedCSS: '',
|
||||
cacheBust: true,
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise(resolve => {
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
@@ -92,7 +95,7 @@ const generateImage = async () => {
|
||||
);
|
||||
|
||||
const logo = new Image();
|
||||
logo.src = window.location.origin + '/brand-assets/logo.svg';
|
||||
logo.src = '/brand-assets/logo.svg';
|
||||
await new Promise(resolve => {
|
||||
logo.onload = resolve;
|
||||
});
|
||||
@@ -105,9 +108,6 @@ const generateImage = async () => {
|
||||
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
|
||||
|
||||
shareImageUrl.value = finalCanvas.toDataURL('image/png');
|
||||
shareImageBlob.value = await new Promise(resolve => {
|
||||
finalCanvas.toBlob(resolve, 'image/png');
|
||||
});
|
||||
} catch (err) {
|
||||
// Handle errors silently for now
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -130,18 +130,11 @@ const shareImage = async () => {
|
||||
if (!shareImageUrl.value) return;
|
||||
|
||||
try {
|
||||
if (!shareImageBlob.value) {
|
||||
downloadImage();
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File(
|
||||
[shareImageBlob.value],
|
||||
`chatwoot-year-in-review-${props.year}.png`,
|
||||
{
|
||||
type: 'image/png',
|
||||
}
|
||||
);
|
||||
const response = await fetch(shareImageUrl.value);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
if (
|
||||
navigator.share &&
|
||||
@@ -167,7 +160,6 @@ const shareImage = async () => {
|
||||
|
||||
const close = () => {
|
||||
shareImageUrl.value = null;
|
||||
shareImageBlob.value = null;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
|
||||
@@ -688,6 +688,10 @@ export default {
|
||||
);
|
||||
},
|
||||
onPaste(e) {
|
||||
// Don't handle paste if compose new conversation modal is open
|
||||
if (this.newConversationModalActive) {
|
||||
return;
|
||||
}
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput?.$el?.blur();
|
||||
|
||||
@@ -37,7 +37,11 @@ class Account < ApplicationRecord
|
||||
'auto_resolve_message': { 'type': %w[string null] },
|
||||
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] }
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
}
|
||||
},
|
||||
'required': [],
|
||||
'additionalProperties': true
|
||||
@@ -55,7 +59,7 @@ class Account < ApplicationRecord
|
||||
attribute_resolver: ->(record) { record.settings }
|
||||
|
||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
@@ -45,6 +45,7 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
belongs_to :account
|
||||
after_update :update_widget_pre_chat_custom_fields
|
||||
after_destroy :sync_widget_pre_chat_custom_fields
|
||||
after_destroy :cleanup_conversation_required_attributes
|
||||
|
||||
private
|
||||
|
||||
@@ -56,6 +57,13 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
|
||||
end
|
||||
|
||||
def cleanup_conversation_required_attributes
|
||||
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
|
||||
|
||||
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
|
||||
account.save!
|
||||
end
|
||||
|
||||
def attribute_must_not_conflict
|
||||
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
|
||||
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CustomAttributeDefinition do
|
||||
describe 'callbacks' do
|
||||
describe '#cleanup_conversation_required_attributes' do
|
||||
let(:account) { create(:account) }
|
||||
let(:attribute_key) { 'test_attribute' }
|
||||
let!(:custom_attribute) do
|
||||
create(:custom_attribute_definition,
|
||||
account: account,
|
||||
attribute_key: attribute_key,
|
||||
attribute_model: 'conversation_attribute')
|
||||
end
|
||||
|
||||
context 'when conversation attribute is in required attributes list' do
|
||||
before do
|
||||
account.update!(conversation_required_attributes: [attribute_key, 'other_attribute'])
|
||||
end
|
||||
|
||||
it 'removes the attribute from conversation_required_attributes when destroyed' do
|
||||
expect { custom_attribute.destroy! }
|
||||
.to change { account.reload.conversation_required_attributes }
|
||||
.from([attribute_key, 'other_attribute'])
|
||||
.to(['other_attribute'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attribute is contact_attribute' do
|
||||
let!(:contact_attribute) do
|
||||
create(:custom_attribute_definition,
|
||||
account: account,
|
||||
attribute_key: attribute_key,
|
||||
attribute_model: 'contact_attribute')
|
||||
end
|
||||
|
||||
before do
|
||||
account.update!(conversation_required_attributes: [attribute_key])
|
||||
end
|
||||
|
||||
it 'does not modify conversation_required_attributes when destroyed' do
|
||||
expect { contact_attribute.destroy! }
|
||||
.not_to(change { account.reload.conversation_required_attributes })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
tags:
|
||||
- Contacts
|
||||
operationId: contactMerge
|
||||
summary: Merge Contacts
|
||||
security:
|
||||
- userApiKey: []
|
||||
description: |
|
||||
Merge two contacts into a single contact. The base contact remains and receives all
|
||||
data from the mergee contact. After the merge, the mergee contact is permanently deleted.
|
||||
|
||||
This action is irreversible. All conversations, labels, and custom attributes from the
|
||||
mergee contact will be moved to the base contact.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- base_contact_id
|
||||
- mergee_contact_id
|
||||
properties:
|
||||
base_contact_id:
|
||||
type: integer
|
||||
description: ID of the contact that will remain after the merge and receive all data
|
||||
example: 1
|
||||
mergee_contact_id:
|
||||
type: integer
|
||||
description: ID of the contact that will be merged into the base contact and deleted
|
||||
example: 2
|
||||
responses:
|
||||
'200':
|
||||
description: Contacts merged successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/contact_base'
|
||||
'400':
|
||||
description: Bad request - invalid contact IDs or contacts cannot be merged
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
'404':
|
||||
description: One or both contacts not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
@@ -275,6 +275,12 @@
|
||||
/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes:
|
||||
$ref: ./application/contactable_inboxes/get.yml
|
||||
|
||||
# Contact Merge
|
||||
/api/v1/accounts/{account_id}/actions/contact_merge:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/account_id'
|
||||
post:
|
||||
$ref: ./application/contacts/merge.yml
|
||||
|
||||
# Automation Rule
|
||||
/api/v1/accounts/{account_id}/automation_rules:
|
||||
|
||||
@@ -3424,6 +3424,94 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/actions/contact_merge": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"tags": [
|
||||
"Contacts"
|
||||
],
|
||||
"operationId": "contactMerge",
|
||||
"summary": "Merge Contacts",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Merge two contacts into a single contact. The base contact remains and receives all\ndata from the mergee contact. After the merge, the mergee contact is permanently deleted.\n\nThis action is irreversible. All conversations, labels, and custom attributes from the\nmergee contact will be moved to the base contact.\n",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"base_contact_id",
|
||||
"mergee_contact_id"
|
||||
],
|
||||
"properties": {
|
||||
"base_contact_id": {
|
||||
"type": "integer",
|
||||
"description": "ID of the contact that will remain after the merge and receive all data",
|
||||
"example": 1
|
||||
},
|
||||
"mergee_contact_id": {
|
||||
"type": "integer",
|
||||
"description": "ID of the contact that will be merged into the base contact and deleted",
|
||||
"example": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Contacts merged successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/contact_base"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - invalid contact IDs or contacts cannot be merged",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "One or both contacts not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/automation_rules": {
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -1967,6 +1967,94 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/actions/contact_merge": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"tags": [
|
||||
"Contacts"
|
||||
],
|
||||
"operationId": "contactMerge",
|
||||
"summary": "Merge Contacts",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Merge two contacts into a single contact. The base contact remains and receives all\ndata from the mergee contact. After the merge, the mergee contact is permanently deleted.\n\nThis action is irreversible. All conversations, labels, and custom attributes from the\nmergee contact will be moved to the base contact.\n",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"base_contact_id",
|
||||
"mergee_contact_id"
|
||||
],
|
||||
"properties": {
|
||||
"base_contact_id": {
|
||||
"type": "integer",
|
||||
"description": "ID of the contact that will remain after the merge and receive all data",
|
||||
"example": 1
|
||||
},
|
||||
"mergee_contact_id": {
|
||||
"type": "integer",
|
||||
"description": "ID of the contact that will be merged into the base contact and deleted",
|
||||
"example": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Contacts merged successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/contact_base"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - invalid contact IDs or contacts cannot be merged",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "One or both contacts not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/automation_rules": {
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user