Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 1b7e6e5ab8 Merge branch 'develop' into chore-global-config-mixin 2024-09-11 14:42:39 +05:30
Muhsin KelothandGitHub 2c17c95eab feat: Add the ability to paste images to editor (#10072) 2024-09-11 09:44:13 +05:30
Sojan JoseandGitHub bb74c621b5 chore: Bump up character limit for email channel (#10082) 2024-09-07 08:41:18 +05:30
Muhsin KelothandGitHub 185a122ec7 fix: Contact filter attribute groups (#10080) 2024-09-06 13:41:48 +05:30
Sivin VargheseandGitHub 05dece826d fix: Editor controls not working after newline (Shift+Enter) (#10079) 2024-09-06 12:38:45 +05:30
Muhsin KelothandGitHub 528b984a8d feat: Extend upload API end point to support external images (#10062) 2024-09-05 10:42:54 +05:30
Sojan JoseandGitHub db0e654c03 chore: [Snyk] Security upgrade meta_request from 0.8.2 to 0.8.3 (#10061)
snyk security updates
2024-09-04 17:04:07 -07:00
e99e6a8443 chore: [Snyk] Security upgrade omniauth-google-oauth2 from 1.1.2 to 1.1.3 (#10058)
- Security updates from snyk 

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-09-04 16:14:50 -07:00
Sojan JoseandGitHub d6fd3ce048 chore: Fix flaky permissions test (#10074)
- the permissions tests were failing when the random number turns out to
be 0, fixing this case and ensuring that its always between, 1-4.
2024-09-04 16:13:29 -07:00
Sivin VargheseandGitHub 8a2f652b94 fix: TypeError cannot read properties of undefined (reading 'click') (#10067)
Fixes https://linear.app/chatwoot/issue/CW-3535/typeerror-cannot-read-properties-of-undefined-reading-click
2024-09-04 11:34:08 +05:30
Sivin VargheseandGitHub a3732c8f51 feat: Adds support for selecting emojis using the keyboard (#10055) 2024-09-04 11:32:54 +05:30
Fayaz AhmedandGitHub 4a0f0e759f Merge branch 'develop' into chore-global-config-mixin 2024-09-03 00:57:02 +05:30
3a0e68030a chore: [Snyk] Fix for 1 vulnerabilities (#10038)
fix for vulnerabilties

------
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-08-29 18:10:13 -07:00
PranavandGitHub f087461abc feat: Update the design for the webhook management page (#10050) 2024-08-29 20:29:27 +05:30
Fayaz AhmedandGitHub 8b6fd7ae26 Merge branch 'develop' into chore-global-config-mixin 2024-08-29 20:01:18 +05:30
3a47b7e3d1 feat(design): Update the design for the custom attribute console (#10049)
This PR continues the design update series, updates the design for the custom attributes management page. This PR improves the interaction in the Add Custom Attribute feature. Now, the attribute model in the add attribute form will default to the currently selected tab.


---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-29 19:06:11 +05:30
Muhsin KelothandGitHub 6dda1e8c8f feat: Added support for copying and pasting images in article editor (#10044) 2024-08-29 18:50:52 +05:30
098825c149 fix: default return in blocked domains (#10046)
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2024-08-29 15:36:57 +05:30
31e7663258 feat: Update the design for the Inbox management console (#10043)
This is the continuation of the design update PR. This changes the design for the inbox pages.
---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-29 11:19:32 +05:30
Fayaz AhmedandGitHub 5104cdb458 Merge branch 'develop' into chore-global-config-mixin 2024-08-28 00:53:42 +05:30
3b5f5b41ad chore: Replace campaign mixin with composable [CW-3463] (#9987)
# Pull Request Template

## Description

Repalces campaignMixin and its usage with the new useCampaign mixin

Fixes
https://linear.app/chatwoot/issue/CW-3463/rewrite-campaignmixin-mixin-to-a-composable

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-08-28 00:53:18 +05:30
Fayaz Ahmed ab509b7024 Remove unnecessary imports of global config mixin 2024-08-27 08:31:00 +05:30
Shivam MishraandGitHub c55e159c03 Merge branch 'develop' into chore-global-config-mixin 2024-08-27 08:07:57 +05:30
Fayaz Ahmed 9e3783f349 fix: the name change 2024-08-26 16:49:25 +05:30
Fayaz AhmedandGitHub 1c70da86e3 Merge branch 'develop' into chore-global-config-mixin 2024-08-26 16:46:22 +05:30
Fayaz Ahmed 3901abc009 refactor: Rewrite the composable to a helper method 2024-08-26 14:28:07 +05:30
Fayaz AhmedandGitHub 73fbfd5782 Merge branch 'develop' into chore-global-config-mixin 2024-08-26 13:55:29 +05:30
Fayaz AhmedandGitHub 2c1658715e Merge branch 'develop' into chore-global-config-mixin 2024-08-21 08:22:41 +05:30
Fayaz Ahmed c798474f2b chore: Replace globalConfig Mixin with useGlobalConfig Composable 2024-08-20 07:53:21 +05:30
85 changed files with 1660 additions and 993 deletions
+4 -4
View File
@@ -96,12 +96,12 @@ gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2'
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3'
gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
@@ -200,7 +200,7 @@ group :development do
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.0'
gem 'meta_request', '>= 0.8.3'
end
group :test do
+18 -18
View File
@@ -257,7 +257,7 @@ GEM
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.1.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
@@ -271,7 +271,7 @@ GEM
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
gapic-common (0.18.0)
gapic-common (0.20.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14)
@@ -301,15 +301,15 @@ GEM
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.23.0)
gapic-common (>= 0.18.0, < 2.a)
google-cloud-dialogflow-v2 (0.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-location (0.4.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-location (0.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
@@ -319,17 +319,17 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.3)
google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
@@ -457,7 +457,7 @@ GEM
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.2)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
@@ -522,7 +522,7 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.2)
omniauth-google-oauth2 (1.1.3)
jwt (>= 2.0)
oauth2 (~> 2.0)
omniauth (~> 2.0)
@@ -881,9 +881,9 @@ DEPENDENCIES
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow-v2
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-storage
google-cloud-translate-v3
google-cloud-translate-v3 (>= 0.7.0)
groupdate
grpc
haikunator
@@ -903,14 +903,14 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request (>= 0.8.0)
meta_request (>= 0.8.3)
mock_redis
neighbor
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg
+2 -2
View File
@@ -94,8 +94,8 @@ class AccountBuilder
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
domains.split("\n").map(&:strip) if domains.present?
return [] if domains.blank?
[]
domains.split("\n").map(&:strip)
end
end
@@ -1,13 +1,68 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
file_blob.save!
result = if params[:attachment].present?
create_from_file
elsif params[:external_url].present?
create_from_url
else
render_error('No file or URL provided', :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
end
private
def create_from_file
attachment = params[:attachment]
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
ActiveStorage::Blob.create_and_upload!(
io: io,
filename: filename,
content_type: content_type
)
end
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
def render_error(message, status)
render json: { error: message }, status: status
end
end
@@ -117,35 +117,41 @@ export default {
lastConversationIndex,
};
};
const handlePreviousConversation = () => {
const { allConversations, activeConversationIndex } =
getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
};
const handleNextConversation = () => {
const handleConversationNavigation = direction => {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
// Determine the new index based on the direction
const newIndex =
direction === 'previous'
? activeConversationIndex - 1
: activeConversationIndex + 1;
// Check if the new index is within the valid range
if (
allConversations.length > 0 &&
newIndex >= 0 &&
newIndex <= lastConversationIndex
) {
// Click the conversation at the new index
allConversations[newIndex].click();
} else if (allConversations.length > 0) {
// If the new index is out of range, click the first or last conversation based on the direction
const fallbackIndex =
direction === 'previous' ? 0 : lastConversationIndex;
allConversations[fallbackIndex].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handlePreviousConversation(),
action: () => handleConversationNavigation('previous'),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleNextConversation(),
action: () => handleConversationNavigation('next'),
allowOnFocusedInput: true,
},
};
@@ -331,10 +331,12 @@ export default {
::v-deep {
.selector-wrap {
@apply m-0 top-1;
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
@@ -1,9 +1,7 @@
<script>
/* eslint no-console: 0 */
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
items: {
type: Array,
@@ -1,38 +1,34 @@
<script>
export default {
props: {
text: {
type: String,
default: '',
},
limit: {
type: Number,
default: 120,
},
},
data() {
return {
showMore: false,
};
},
computed: {
textToBeDisplayed() {
if (this.showMore || this.text.length <= this.limit) {
return this.text;
}
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
return this.text.slice(0, this.limit) + '...';
},
buttonLabel() {
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
},
const props = defineProps({
text: {
type: String,
default: '',
},
methods: {
toggleShowMore() {
this.showMore = !this.showMore;
},
limit: {
type: Number,
default: 120,
},
});
const { t } = useI18n();
const showMore = ref(false);
const textToBeDisplayed = computed(() => {
if (showMore.value || props.text.length <= props.limit) {
return props.text;
}
return props.text.slice(0, props.limit) + '...';
});
const buttonLabel = computed(() => {
const i18nKey = !showMore.value ? 'SHOW_MORE' : 'SHOW_LESS';
return t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
});
const toggleShowMore = () => {
showMore.value = !showMore.value;
};
</script>
@@ -41,16 +37,10 @@ export default {
{{ textToBeDisplayed }}
<button
v-if="text.length > limit"
class="show-more--button"
class="text-woot-500 !p-0 !border-0 align-top"
@click="toggleShowMore"
>
{{ buttonLabel }}
</button>
</span>
</template>
<style scoped>
.show-more--button {
color: var(--w-500);
}
</style>
@@ -17,6 +17,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue';
import VariableList from '../conversation/VariableList.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import {
appendSignature,
removeSignature,
@@ -24,6 +26,7 @@ import {
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -35,10 +38,8 @@ import {
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useUISettings } from 'dashboard/composables/useUISettings';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { createTypingIndicator } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -71,7 +72,12 @@ const createState = (
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList },
components: {
TagAgents,
CannedResponse,
VariableList,
KeyboardEmojiSelector,
},
mixins: [keyboardEventListenerMixins],
props: {
value: { type: String, default: '' },
@@ -119,9 +125,11 @@ export default {
showUserMentions: false,
showCannedMenu: false,
showVariables: false,
showEmojiMenu: false,
mentionSearchKey: '',
cannedSearchTerm: '',
variableSearchTerm: '',
emojiSearchTerm: '',
editorView: null,
range: null,
state: undefined,
@@ -169,7 +177,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.mentionSearchKey = args.text.replace('@', '');
this.mentionSearchKey = args.text;
return false;
},
@@ -198,7 +206,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.cannedSearchTerm = args.text.replace('/', '');
this.cannedSearchTerm = args.text;
return false;
},
onExit: () => {
@@ -226,7 +234,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.variableSearchTerm = args.text.replace('{{', '');
this.variableSearchTerm = args.text;
return false;
},
onExit: () => {
@@ -238,6 +246,31 @@ export default {
return event.keyCode === 13 && this.showVariables;
},
}),
suggestionsPlugin({
matcher: triggerCharacters(':', 1), // Trigger after ':' and at least 1 characters
suggestionClass: '',
onEnter: args => {
this.showEmojiMenu = true;
this.emojiSearchTerm = args.text || '';
this.range = args.range;
this.editorView = args.view;
return false;
},
onChange: args => {
this.editorView = args.view;
this.range = args.range;
this.emojiSearchTerm = args.text;
return false;
},
onExit: () => {
this.emojiSearchTerm = '';
this.showEmojiMenu = false;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && this.showEmojiMenu;
},
}),
];
},
sendWithSignature() {
@@ -267,6 +300,8 @@ export default {
},
editorId() {
this.showCannedMenu = false;
this.showEmojiMenu = false;
this.showVariables = false;
this.cannedSearchTerm = '';
this.reloadState(this.value);
},
@@ -517,57 +552,36 @@ export default {
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
insertMentionNode(mentionItem) {
/**
* Inserts special content (mention, canned response, variable, emoji) into the editor.
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
* @param {Object|string} content - The content to insert, depending on the type.
*/
insertSpecialContent(type, content) {
if (!this.editorView) {
return null;
}
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
return;
}
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
let { node, from, to } = getContentNode(
this.editorView,
type,
content,
this.range,
this.variables
);
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
if (!node) return;
this.insertNodeIntoEditor(node, from, to);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
return false;
const event_map = {
mention: CONVERSATION_EVENTS.USED_MENTIONS,
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
};
this.$track(event_map[type]);
},
openFileBrowser() {
this.$refs.imageUpload.click();
@@ -687,17 +701,22 @@ export default {
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="insertMentionNode"
@click="content => insertSpecialContent('mention', content)"
/>
<CannedResponse
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
@click="content => insertSpecialContent('cannedResponse', content)"
/>
<VariableList
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="insertVariable"
@click="content => insertSpecialContent('variable', content)"
/>
<KeyboardEmojiSelector
v-if="showEmojiMenu"
:search-key="emojiSearchTerm"
@click="emoji => insertSpecialContent('emoji', emoji)"
/>
<input
ref="imageUpload"
@@ -8,6 +8,7 @@ import {
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -55,7 +56,7 @@ export default {
return {
editorView: null,
state: undefined,
plugins: [],
plugins: [imagePastePlugin(this.handleImageUpload)],
};
},
computed: {
@@ -76,6 +77,7 @@ export default {
this.reloadState();
},
},
created() {
this.state = createState(
this.value,
@@ -95,6 +97,24 @@ export default {
openFileBrowser() {
this.$refs.imageUploadInput.click();
},
async handleImageUpload(url) {
try {
const fileUrl = await this.$store.dispatch(
'articles/uploadExternalImage',
{
portalSlug: this.$route.params.portalSlug,
url,
}
);
return fileUrl;
} catch (error) {
useAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
);
return '';
}
},
onFileChange() {
const file = this.$refs.imageUploadInput.files[0];
@@ -120,7 +140,6 @@ export default {
if (fileUrl) {
this.onImageUploadStart(fileUrl);
}
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.SUCCESS'));
} catch (error) {
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
}
@@ -173,6 +192,18 @@ export default {
blur: () => {
this.onBlur();
},
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
data.forEach(file => {
// Check if the file is an image
if (file.type.includes('image')) {
this.uploadImageToStorage(file);
}
});
event.preventDefault();
}
},
},
});
},
@@ -0,0 +1,68 @@
<script setup>
import { shallowRef, computed, onMounted } from 'vue';
import emojis from 'shared/components/emoji/emojisGroup.json';
import MentionBox from '../mentions/MentionBox.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['click']);
const allEmojis = shallowRef([]);
const items = computed(() => {
if (!props.searchKey) return [];
const searchTerm = props.searchKey.toLowerCase();
return allEmojis.value.filter(emoji =>
emoji.searchString.includes(searchTerm)
);
});
function loadEmojis() {
allEmojis.value = emojis.flatMap(group =>
group.emojis.map(emoji => ({
...emoji,
searchString: `${emoji.slug} ${emoji.name}`.toLowerCase(),
}))
);
}
function handleMentionClick(item = {}) {
emit('click', item.emoji);
}
onMounted(() => {
loadEmojis();
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="emoji"
:items="items"
@mentionSelect="handleMentionClick"
>
<template #default="{ item, selected }">
<span
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
>
{{ item.emoji }}
<p
class="relative mb-0 truncate bottom-px"
:class="{
'text-woot-500 dark:text-woot-500': selected,
'font-normal': !selected,
}"
>
:{{ item.slug }}
</p>
</span>
</template>
</MentionBox>
</template>
@@ -41,14 +41,11 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
:items="items"
@mentionSelect="handleMentionClick"
>
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>
</MentionBox>
/>
</template>
@@ -338,7 +338,11 @@ export default {
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
grouped-filters
:error-message="validationErrors[`filter_${i}`]"
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
/>
@@ -237,6 +237,9 @@ export default {
if (this.isASmsInbox) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isAnEmailChannel) {
return MESSAGE_MAX_LENGTH.EMAIL;
}
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
@@ -56,20 +56,14 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="variable"
:items="items"
@mentionSelect="handleVariableClick"
>
<template slot-scope="{ item }">
<span class="text-capitalize variable--list-label">
{{ item.description }}
</span>
({{ item.label }})
</template>
</MentionBox>
/>
</template>
<style scoped>
@@ -90,22 +90,24 @@ const variableKey = (item = {}) => {
}"
@click="onListItemSelection(index)"
>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
<slot :item="item" :index="index" :selected="index === selectedIndex">
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
</slot>
</button>
</woot-dropdown-item>
</ul>
@@ -5,6 +5,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
TRANSLATE_A_MESSAGE: 'Translated a message',
INSERTED_A_VARIABLE: 'Inserted a variable',
INSERTED_AN_EMOJI: 'Inserted an emoji',
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
@@ -3,6 +3,8 @@ import {
MessageMarkdownTransformer,
MessageMarkdownSerializer,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/browser';
/**
@@ -281,3 +283,92 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
}
/**
* Content Node Creation Helper Functions for
* - mention
* - canned response
* - variable
* - emoji
*/
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
*/
const createNode = (editorView, nodeType, content) => {
const { state } = editorView;
switch (nodeType) {
case 'mention':
return state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
});
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
default:
return null;
}
};
/**
* Object mapping types to their respective node creation functions.
*/
const nodeCreators = {
mention: (editorView, content, from, to) => ({
node: createNode(editorView, 'mention', content),
from,
to,
}),
cannedResponse: (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = createNode(editorView, 'cannedResponse', updatedMessage);
return {
node,
from: node.textContent === updatedMessage ? from : from - 1,
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
from,
to,
}),
emoji: (editorView, content, from, to) => ({
node: createNode(editorView, 'emoji', content),
from,
to,
}),
};
/**
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
* @param {Object} editorView - The editor view instance.
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {string|Object} content - The content to be transformed into a node.
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
*/
export const getContentNode = (
editorView,
type,
content,
{ from, to },
variables
) => {
const creator = nodeCreators[type];
return creator
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
@@ -9,6 +9,7 @@ const FEATURE_HELP_URLS = {
custom_attributes: 'https://chwt.app/hc/custom-attributes',
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
help_center: 'https://chwt.app/hc/help-center',
inboxes: 'https://chwt.app/hc/inboxes',
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
@@ -16,6 +17,7 @@ const FEATURE_HELP_URLS = {
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks',
};
export function getHelpUrlForFeature(featureName) {
@@ -0,0 +1,127 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
replaceVariablesInMessage: vi.fn(),
}));
describe('getContentNode', () => {
let editorView;
beforeEach(() => {
editorView = {
state: {
schema: {
nodes: {
mention: {
create: vi.fn(),
},
},
text: vi.fn(),
},
},
};
});
describe('getMentionNode', () => {
it('should create a mention node', () => {
const content = { id: 1, name: 'John Doe' };
const from = 0;
const to = 10;
getContentNode(editorView, 'mention', content, {
from,
to,
});
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
{
userId: content.id,
userFullName: content.name,
}
);
});
});
describe('getCannedResponseNode', () => {
it('should create a canned response node', () => {
const content = 'Hello {{name}}';
const variables = { name: 'John' };
const from = 0;
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
const { node } = getContentNode(
editorView,
'cannedResponse',
content,
{ from, to },
variables
);
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
});
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
});
});
describe('getEmojiNode', () => {
it('should create an emoji node', () => {
const content = '😊';
const from = 0;
const to = 2;
getContentNode(editorView, 'emoji', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
});
});
describe('getContentNode', () => {
it('should return null for invalid type', () => {
const content = 'invalid';
const from = 0;
const to = 10;
const { node } = getContentNode(editorView, 'invalid', content, {
from,
to,
});
expect(node).toBeNull();
});
});
});
@@ -1,52 +1,93 @@
import { uploadFile } from '../uploadHelper';
import axios from 'axios';
import { uploadExternalImage, uploadFile } from '../uploadHelper';
global.axios = axios;
vi.mock('axios');
describe('#Upload Helpers', () => {
describe('Upload Helpers', () => {
afterEach(() => {
// Cleaning up the mock after each test
axios.post.mockReset();
});
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
describe('uploadFile', () => {
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadFile(mockFile, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
const result = await uploadFile(mockFile, '1602');
axios.post.mockRejectedValueOnce(mockError);
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
});
});
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
describe('uploadExternalImage', () => {
it('should send a POST request with correct data', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadExternalImage(mockUrl, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
{ external_url: mockUrl },
{ headers: { 'Content-Type': 'application/json' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
it('should handle errors', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Failed to upload');
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadExternalImage(mockUrl)).rejects.toThrow(
'Failed to upload'
);
});
});
});
@@ -19,26 +19,47 @@ const HEADERS = {
* The function uses FormData to wrap the file and axios to send the request.
*
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
*/
export async function uploadFile(file, accountId) {
// Create a new FormData instance.
let formData = new FormData();
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
// Append the file to the FormData instance under the key 'attachment'.
let formData = new FormData();
formData.append('attachment', file);
// Use axios to send a POST request to the upload endpoint.
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
formData,
{
headers: HEADERS,
}
{ headers: HEADERS }
);
return {
fileUrl: data.file_url,
blobKey: data.blob_key,
blobId: data.blob_id,
};
}
/**
* Uploads an image from an external URL.
*
* @param {string} url - The external URL of the image.
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response.
*/
export async function uploadExternalImage(url, accountId) {
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
{ external_url: url },
{ headers: { 'Content-Type': 'application/json' } }
);
return {
@@ -55,6 +55,9 @@
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity"
},
"ERRORS": {
"VALUE_REQUIRED": "Value is required"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard filters",
"ADDITIONAL_FILTERS": "Additional filters",
@@ -3,7 +3,8 @@
"HEADER": "Custom Attributes",
"HEADER_BTN_TXT": "Add Custom Attribute",
"LOADING": "Fetching custom attributes",
"SIDEBAR_TXT": "<p><b>Custom Attributes</b> <p>A custom attribute tracks facts about your contacts/conversation — like the subscription plan, or when they ordered the first item etc. <br /><br />For creating a Custom Attribute, just click on the <b>Add Custom Attribute.</b> You can also edit or delete an existing Custom Attribute by clicking on the Edit or Delete button.</p>",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -91,7 +92,12 @@
"CONTACT": "Contact"
},
"LIST": {
"TABLE_HEADER": ["Name", "Description", "Type", "Key"],
"TABLE_HEADER": [
"Name",
"Description",
"Type",
"Key"
],
"BUTTONS": {
"EDIT": "Edit",
"DELETE": "Delete"
@@ -30,6 +30,9 @@
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ERRORS": {
"VALUE_REQUIRED": "Value is required"
},
"ATTRIBUTES": {
"NAME": "Name",
"EMAIL": "Email",
@@ -41,6 +41,7 @@
"UPLOADING": "Uploading...",
"SUCCESS": "Image uploaded successfully",
"ERROR": "Error while uploading image",
"UN_AUTHORIZED_ERROR": "You are not authorized to upload images",
"ERROR_FILE_SIZE": "Image size should be less than {size}MB",
"ERROR_FILE_FORMAT": "Image format should be jpg, jpeg or png",
"ERROR_FILE_DIMENSIONS": "Image dimensions should be less than 2000 x 2000"
@@ -1,7 +1,8 @@
{
"INBOX_MGMT": {
"HEADER": "Inboxes",
"SIDEBAR_TXT": "<p><b>Inbox</b></p> <p> When you connect a website or a facebook Page to Chatwoot, it is called an <b>Inbox</b>. You can have unlimited inboxes in your Chatwoot account. </p><p> Click on <b>Add Inbox</b> to connect a website or a Facebook Page. </p><p> In the Dashboard, you can see all the conversations from all your inboxes in a single place and respond to them under the `Conversations` tab. </p><p> You can also see conversations specific to an inbox by clicking on the inbox name on the left pane of the dashboard. </p>",
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"LIST": {
@@ -745,6 +746,18 @@
"MICROSOFT": "Microsoft",
"GOOGLE": "Google",
"OTHER_PROVIDERS": "Other Providers"
},
"CHANNELS": {
"MESSENGER": "Messenger",
"WEB_WIDGET": "Website",
"TWITTER_PROFILE": "Twitter",
"TWILIO_SMS": "Twilio SMS",
"WHATSAPP": "WhatsApp",
"SMS": "SMS",
"EMAIL": "Email",
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel"
}
}
}
@@ -12,6 +12,7 @@
},
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"FORM": {
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
@@ -3,7 +3,7 @@ import WidgetHead from './WidgetHead.vue';
import WidgetBody from './WidgetBody.vue';
import WidgetFooter from './WidgetFooter.vue';
import InputRadioGroup from 'dashboard/routes/dashboard/settings/inbox/components/InputRadioGroup.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import { mapGetters } from 'vuex';
export default {
@@ -14,7 +14,6 @@ export default {
WidgetFooter,
InputRadioGroup,
},
mixins: [globalConfigMixin],
props: {
welcomeHeading: {
type: String,
@@ -144,6 +143,7 @@ export default {
this.isWidgetVisible = !this.isWidgetVisible;
this.isDefaultScreen = true;
},
useInstallationName,
},
};
</script>
@@ -308,7 +308,11 @@ export default {
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
:error-message="validationErrors[`filter_${i}`]"
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
/>
@@ -1,5 +1,4 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SettingsHeader from 'dashboard/routes/dashboard/settings/SettingsHeader.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
@@ -8,7 +7,6 @@ export default {
SettingsHeader,
SettingIntroBanner,
},
mixins: [globalConfigMixin],
computed: {
currentPortal() {
const slug = this.$route.params.portalSlug;
@@ -1,12 +1,11 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import SettingsHeader from 'dashboard/routes/dashboard/settings/SettingsHeader.vue';
export default {
components: {
SettingsHeader,
},
mixins: [globalConfigMixin],
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -38,6 +37,9 @@ export default {
return '';
},
},
methods: {
useInstallationName,
},
};
</script>
@@ -22,17 +22,21 @@ defineProps({
<template>
<div class="flex flex-col w-full h-full gap-10 font-inter">
<slot name="header" />
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
</slot>
<p
v-else-if="noRecordsFound"
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
>
{{ noRecordsMessage }}
</p>
<slot v-else name="body" />
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
<slot />
<!-- Added to render any templates that should be rendered before body -->
<div>
<slot name="preBody" />
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
</slot>
<p
v-else-if="noRecordsFound"
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
>
{{ noRecordsMessage }}
</p>
<slot v-else name="body" />
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
<slot />
</div>
</div>
</template>
@@ -12,6 +12,12 @@ export default {
type: Function,
default: () => {},
},
// Passes 0 or 1 based on the selected AttributeModel tab selected in the UI
// Needs a better data type, todo: refactor this component later
selectedAttributeModelTab: {
type: Number,
default: 0,
},
},
setup() {
return { v$: useVuelidate() };
@@ -20,7 +26,10 @@ export default {
return {
displayName: '',
description: '',
attributeModel: 0,
// Using the prop as default. There is no side effect here as the component
// is destroyed completely when the modal is closed. The prop doesn't change
// dynamically when the modal is active.
attributeModel: this.selectedAttributeModelTab || 0,
attributeType: 0,
attributeKey: '',
regexPattern: null,
@@ -280,13 +289,16 @@ export default {
padding: 0 var(--space-small) var(--space-small) 0;
font-family: monospace;
}
.multiselect--wrap {
margin-bottom: var(--space-normal);
.error-message {
color: var(--r-400);
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
}
.invalid {
::v-deep {
.multiselect__tags {
@@ -295,13 +307,16 @@ export default {
}
}
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: var(--border-radius-normal);
}
@@ -1,195 +1,141 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import EditAttribute from './EditAttribute.vue';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
const props = defineProps({
attributeModel: {
type: String,
default: 'conversation_attribute',
},
});
export default {
components: {
EditAttribute,
},
data() {
return {
selectedTabIndex: 0,
showEditPopup: false,
showDeletePopup: false,
selectedAttribute: {},
};
},
computed: {
...mapGetters({
uiFlags: 'attributes/getUIFlags',
}),
attributes() {
const attributeModel = this.selectedTabIndex
? 'contact_attribute'
: 'conversation_attribute';
const { t } = useI18n();
return this.$store.getters['attributes/getAttributesByModel'](
attributeModel
);
},
tabs() {
return [
{
key: 0,
name: this.$t('ATTRIBUTES_MGMT.TABS.CONVERSATION'),
},
{
key: 1,
name: this.$t('ATTRIBUTES_MGMT.TABS.CONTACT'),
},
];
},
deleteConfirmText() {
return `${this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
this.selectedAttribute.attribute_display_name
}`;
},
deleteRejectText() {
return this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO');
},
confirmDeleteTitle() {
return this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: this.selectedAttribute.attribute_display_name,
});
},
confirmPlaceHolderText() {
return `${this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: this.selectedAttribute.attribute_display_name,
})}`;
},
},
mounted() {
this.fetchAttributes(this.selectedTabIndex);
},
methods: {
onClickTabChange(index) {
this.selectedTabIndex = index;
this.fetchAttributes(index);
},
fetchAttributes(index) {
this.$store.dispatch('attributes/get', index);
},
async deleteAttributes({ id }) {
try {
await this.$store.dispatch('attributes/delete', id);
useAlert(this.$t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
this.$t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
},
openEditPopup(response) {
this.showEditPopup = true;
this.selectedAttribute = response;
},
hideEditPopup() {
this.showEditPopup = false;
},
confirmDeletion() {
this.deleteAttributes(this.selectedAttribute);
this.closeDelete();
},
openDelete(value) {
this.showDeletePopup = true;
this.selectedAttribute = value;
},
closeDelete() {
this.showDeletePopup = false;
this.selectedAttribute = {};
},
},
const showEditPopup = ref(false);
const showDeletePopup = ref(false);
const selectedAttribute = ref({});
const getters = useStoreGetters();
const store = useStore();
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(props.attributeModel)
);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const attributeDisplayName = computed(
() => selectedAttribute.value.attribute_display_name
);
const deleteConfirmText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${attributeDisplayName.value}`
);
const deleteRejectText = computed(() => t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'));
const confirmDeleteTitle = computed(() =>
t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: attributeDisplayName.value,
})
);
const confirmPlaceHolderText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: attributeDisplayName.value,
})}`
);
const deleteAttributes = async ({ id }) => {
try {
await store.dispatch('attributes/delete', id);
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const openEditPopup = response => {
showEditPopup.value = true;
selectedAttribute.value = response;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedAttribute.value = {};
};
const confirmDeletion = () => {
deleteAttributes(selectedAttribute.value);
closeDelete();
};
const openDelete = value => {
showDeletePopup.value = true;
selectedAttribute.value = value;
};
</script>
<template>
<div class="flex flex-row gap-4 p-8">
<div class="w-full lg:w-3/5">
<woot-tabs :index="selectedTabIndex" @change="onClickTabChange">
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
<div class="w-full">
<p
v-if="!uiFlags.isFetching && !attributes.length"
class="flex items-center justify-center mt-12"
<div class="flex flex-col">
<table class="min-w-full overflow-x-auto">
<thead>
<th
v-for="tableHeader in $t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER')"
:key="tableHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-slate-700 dark:text-slate-300"
>
{{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('ATTRIBUTES_MGMT.LOADING')"
/>
<table
v-if="!uiFlags.isFetching && attributes.length"
class="w-full mt-2 table-fixed woot-table"
>
<thead>
<th
v-for="tableHeader in $t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER')"
:key="tableHeader"
class="pl-0 max-w-[6.25rem] min-w-[5rem]"
>
{{ tableHeader }}
</th>
</thead>
<tbody>
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
<td
class="pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_name }}
</td>
<td
class="pl-0 max-w-[10rem] min-w-[6.25rem] overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_description }}
</td>
<td
class="pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_type }}
</td>
<td
class="attribute-key pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_key }}
</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class-names="grey-btn"
icon="edit"
@click="openEditPopup(attribute)"
/>
<woot-button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
@click="openDelete(attribute)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="hidden w-1/3 lg:block">
<span v-dompurify-html="$t('ATTRIBUTES_MGMT.SIDEBAR_TXT')" />
</div>
{{ tableHeader }}
</th>
</thead>
<tbody
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
>
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_name }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
{{ attribute.attribute_description }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_type }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_key }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1">
<woot-button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class-names="grey-btn"
icon="edit"
@click="openEditPopup(attribute)"
/>
<woot-button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
@click="openDelete(attribute)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
@@ -216,17 +162,4 @@ export default {
.attribute-key {
font-family: monospace;
}
::v-deep {
.tabs--container {
.tabs {
@apply p-0;
}
}
.tabs-title a {
font-weight: var(--font-weight-medium);
padding-top: 0;
}
}
</style>
@@ -1,40 +1,112 @@
<script>
<script setup>
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
export default {
components: {
AddAttribute,
CustomAttribute,
},
data() {
return {
showAddPopup: false,
};
},
methods: {
openAddPopup() {
this.showAddPopup = true;
import SettingsLayout from '../SettingsLayout.vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
const { t } = useI18n();
const getters = useStoreGetters();
const store = useStore();
const showAddPopup = ref(false);
const selectedTabIndex = ref(0);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const openAddPopup = () => {
showAddPopup.value = true;
};
const hideAddPopup = () => {
showAddPopup.value = false;
};
const tabs = computed(() => {
return [
{
key: 0,
name: t('ATTRIBUTES_MGMT.TABS.CONVERSATION'),
},
hideAddPopup() {
this.showAddPopup = false;
{
key: 1,
name: t('ATTRIBUTES_MGMT.TABS.CONTACT'),
},
},
];
});
onMounted(() => {
store.dispatch('attributes/get');
});
const attributeModel = computed(() =>
selectedTabIndex.value ? 'contact_attribute' : 'conversation_attribute'
);
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(attributeModel.value)
);
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
<template>
<div class="flex-1 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup()"
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!attributes.length"
:no-records-message="$t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('ATTRIBUTES_MGMT.HEADER')"
:description="$t('ATTRIBUTES_MGMT.DESCRIPTION')"
:link-text="$t('ATTRIBUTES_MGMT.LEARN_MORE')"
feature-name="custom_attributes"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
</template>
<template #preBody>
<woot-tabs
class="font-medium [&_.tabs]:p-0 mb-4"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</template>
<template #body>
<CustomAttribute
:key="attributeModel"
:attribute-model="attributeModel"
/>
</template>
<woot-modal
v-if="showAddPopup"
:show.sync="showAddPopup"
:on-close="hideAddPopup"
>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
</woot-button>
<CustomAttribute />
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddAttribute :on-close="hideAddPopup" />
<AddAttribute
:on-close="hideAddPopup"
:selected-attribute-model-tab="selectedTabIndex"
/>
</woot-modal>
</div>
</SettingsLayout>
</template>
@@ -1,17 +1,12 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/custom-attributes'),
component: SettingsContent,
props: {
headerTitle: 'ATTRIBUTES_MGMT.HEADER',
icon: 'code',
showNewButton: false,
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -4,7 +4,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
import { CAMPAIGNS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -14,9 +14,9 @@ export default {
WootDateTimePicker,
WootMessageEditor,
},
mixins: [campaignMixin],
setup() {
return { v$: useVuelidate() };
const { campaignType, isOngoingType, isOneOffType } = useCampaign();
return { v$: useVuelidate(), campaignType, isOngoingType, isOneOffType };
},
data() {
return {
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import CampaignsTable from './CampaignsTable.vue';
import EditCampaign from './EditCampaign.vue';
export default {
@@ -9,13 +9,16 @@ export default {
CampaignsTable,
EditCampaign,
},
mixins: [campaignMixin],
props: {
type: {
type: String,
default: '',
},
},
setup() {
const { campaignType } = useCampaign();
return { campaignType };
},
data() {
return {
showEditPopup: false,
@@ -1,7 +1,7 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import CampaignCard from './CampaignCard.vue';
export default {
@@ -10,9 +10,6 @@ export default {
Spinner,
CampaignCard,
},
mixins: [campaignMixin],
props: {
campaigns: {
type: Array,
@@ -27,7 +24,10 @@ export default {
default: false,
},
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
computed: {
currentInboxId() {
return this.$route.params.inboxId;
@@ -4,14 +4,13 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
},
mixins: [campaignMixin],
props: {
selectedCampaign: {
type: Object,
@@ -19,7 +18,8 @@ export default {
},
},
setup() {
return { v$: useVuelidate() };
const { isOngoingType } = useCampaign();
return { v$: useVuelidate(), isOngoingType };
},
data() {
return {
@@ -1,5 +1,5 @@
<script>
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import Campaign from './Campaign.vue';
import AddCampaign from './AddCampaign.vue';
@@ -8,7 +8,10 @@ export default {
Campaign,
AddCampaign,
},
mixins: [campaignMixin],
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
data() {
return { showAddPopup: false };
},
@@ -3,14 +3,13 @@ import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
export default {
components: {
ChannelItem,
PageHeader,
},
mixins: [globalConfigMixin],
data() {
return {
enabledFeatures: {},
@@ -56,6 +55,7 @@ export default {
};
router.push({ name: 'settings_inboxes_page_channel', params });
},
useInstallationName,
},
};
</script>
@@ -1,9 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
export default {
mixins: [globalConfigMixin],
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -18,6 +17,9 @@ export default {
}));
},
},
methods: {
useInstallationName,
},
};
</script>
@@ -1,219 +1,177 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAccount } from 'dashboard/composables/useAccount';
import Settings from './Settings.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import ChannelName from './components/ChannelName.vue';
export default {
components: {
Settings,
},
mixins: [globalConfigMixin],
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
data() {
return {
loading: {},
showSettings: false,
showDeletePopup: false,
selectedInbox: {},
};
},
computed: {
...mapGetters({
inboxesList: 'inboxes/getInboxes',
globalConfig: 'globalConfig/get',
}),
// Delete Modal
deleteConfirmText() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.YES')} ${
this.selectedInbox.name
}`;
},
deleteRejectText() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.NO')} ${
this.selectedInbox.name
}`;
},
confirmDeleteMessage() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.MESSAGE')} ${
this.selectedInbox.name
}?`;
},
confirmPlaceHolderText() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
inboxName: this.selectedInbox.name,
})}`;
},
},
methods: {
twilioChannelName(item) {
const { medium = '' } = item;
if (medium === 'whatsapp') return 'WhatsApp';
return 'Twilio SMS';
},
openSettings(inbox) {
this.showSettings = true;
this.selectedInbox = inbox;
},
closeSettings() {
this.showSettings = false;
this.selectedInbox = {};
},
async deleteInbox({ id }) {
try {
await this.$store.dispatch('inboxes/delete', id);
useAlert(this.$t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE'));
}
},
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const { isAdmin } = useAdmin();
confirmDeletion() {
this.deleteInbox(this.selectedInbox);
this.closeDelete();
},
openDelete(inbox) {
this.showDeletePopup = true;
this.selectedInbox = inbox;
},
closeDelete() {
this.showDeletePopup = false;
this.selectedInbox = {};
},
},
const showDeletePopup = ref(false);
const selectedInbox = ref({});
const inboxesList = computed(() => getters['inboxes/getInboxes'].value);
const uiFlags = computed(() => getters['inboxes/getUIFlags'].value);
const deleteConfirmText = computed(
() => `${t('INBOX_MGMT.DELETE.CONFIRM.YES')} ${selectedInbox.value.name}`
);
const deleteRejectText = computed(
() => `${t('INBOX_MGMT.DELETE.CONFIRM.NO')} ${selectedInbox.value.name}`
);
const confirmDeleteMessage = computed(
() => `${t('INBOX_MGMT.DELETE.CONFIRM.MESSAGE')} ${selectedInbox.value.name}?`
);
const confirmPlaceHolderText = computed(
() =>
`${t('INBOX_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
inboxName: selectedInbox.value.name,
})}`
);
const deleteInbox = async ({ id }) => {
try {
await store.dispatch('inboxes/delete', id);
useAlert(t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE'));
}
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedInbox.value = {};
};
const confirmDeletion = () => {
deleteInbox(selectedInbox.value);
closeDelete();
};
const openDelete = inbox => {
showDeletePopup.value = true;
selectedInbox.value = inbox;
};
</script>
<template>
<div class="flex-1 overflow-auto">
<div class="flex flex-row gap-4 p-8">
<div class="w-full lg:w-3/5">
<p
v-if="!inboxesList.length"
class="flex flex-col items-center justify-center h-full"
>
{{ $t('INBOX_MGMT.LIST.404') }}
<SettingsLayout
:no-records-found="!inboxesList.length"
:no-records-message="$t('INBOX_MGMT.LIST.404')"
:is-loading="uiFlags.isFetching"
>
<template #header>
<BaseSettingsHeader
:title="$t('INBOX_MGMT.HEADER')"
:description="$t('INBOX_MGMT.DESCRIPTION')"
:link-text="$t('INBOX_MGMT.LEARN_MORE')"
feature-name="inboxes"
>
<template #actions>
<router-link
v-if="isAdmin"
:to="accountScopedUrl('settings/inboxes/new')"
class="button nice rounded-md"
:to="{ name: 'settings_inbox_new' }"
>
<fluent-icon icon="add-circle" />
{{ $t('SETTINGS.INBOXES.NEW_INBOX') }}
</router-link>
</p>
<table v-if="inboxesList.length" class="woot-table">
<tbody>
<tr v-for="item in inboxesList" :key="item.id">
<td>
<img
v-if="item.avatar_url"
class="woot-thumbnail"
:src="item.avatar_url"
alt="No Page Image"
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<tbody
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
>
<tr v-for="inbox in inboxesList" :key="inbox.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<Thumbnail
v-if="inbox.avatar_url"
class="bg-black-50 dark:bg-black-800 rounded-full p-2 ring ring-opacity-20 dark:ring-opacity-80 ring-black-100 dark:ring-black-900 border border-slate-100 dark:border-slate-700/50 shadow-sm"
:src="inbox.avatar_url"
:username="inbox.name"
size="48px"
/>
<img
<div
v-else
class="woot-thumbnail"
src="~dashboard/assets/images/flag.svg"
alt="No Page Image"
/>
</td>
<!-- Short Code -->
<td>
<span class="agent-name">{{ item.name }}</span>
<span v-if="item.channel_type === 'Channel::FacebookPage'">
{{ 'Facebook' }}
</span>
<span v-if="item.channel_type === 'Channel::WebWidget'">
{{ 'Website' }}
</span>
<span v-if="item.channel_type === 'Channel::TwitterProfile'">
{{ 'Twitter' }}
</span>
<span v-if="item.channel_type === 'Channel::TwilioSms'">
{{ twilioChannelName(item) }}
</span>
<span v-if="item.channel_type === 'Channel::Whatsapp'">
{{ 'Whatsapp' }}
</span>
<span v-if="item.channel_type === 'Channel::Sms'">
{{ 'Sms' }}
</span>
<span v-if="item.channel_type === 'Channel::Email'">
{{ 'Email' }}
</span>
<span v-if="item.channel_type === 'Channel::Telegram'">
{{ 'Telegram' }}
</span>
<span v-if="item.channel_type === 'Channel::Line'">
{{ 'Line' }}
</span>
<span v-if="item.channel_type === 'Channel::Api'">
{{ globalConfig.apiChannelName || 'API' }}
</span>
</td>
<!-- Action Buttons -->
<td>
<div class="button-wrapper">
<router-link
:to="accountScopedUrl(`settings/inboxes/${item.id}`)"
class="w-12 h-12 bg-black-50 dark:bg-black-800 rounded-full p-2 ring ring-opacity-20 dark:ring-opacity-80 ring-black-100 dark:ring-black-900 border border-slate-100 dark:border-slate-700/50 shadow-sm block"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
class="opacity-80 p-1"
>
<woot-button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
variant="smooth"
size="tiny"
icon="settings"
color-scheme="secondary"
class-names="grey-btn"
<path
fill="currentColor"
d="M1 12c0-5.185 0-7.778 1.61-9.39C4.223 1 6.816 1 12 1s7.778 0 9.39 1.61C23 4.223 23 6.816 23 12s0 7.778-1.61 9.39C19.777 23 17.184 23 12 23s-7.778 0-9.39-1.61C1 19.777 1 17.184 1 12"
opacity=".35"
/>
</router-link>
<woot-button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
class-names="grey-btn"
:is-loading="loading[item.id]"
icon="dismiss-circle"
@click="openDelete(item)"
<path
fill="currentColor"
d="M2.61 21.389c1.612 1.61 4.205 1.61 9.39 1.61s7.778 0 9.39-1.61c1.492-1.493 1.601-3.829 1.61-8.29h-3.476c-.996 0-1.494 0-1.931.202c-.438.201-.762.58-1.41 1.335l-.666.777c-.648.756-.972 1.134-1.41 1.335s-.935.202-1.93.202h-.353c-.996 0-1.494 0-1.931-.202c-.438-.2-.762-.579-1.41-1.335l-.666-.777c-.648-.756-.972-1.134-1.41-1.335s-.935-.201-1.93-.201H1c.008 4.46.118 6.796 1.61 8.289"
/>
</svg>
</div>
<div>
<span class="block font-medium capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</td>
<div class="hidden w-1/3 lg:block">
<span
v-dompurify-html="
useInstallationName(
$t('INBOX_MGMT.SIDEBAR_TXT'),
globalConfig.installationName
)
"
/>
</div>
</div>
<Settings
v-if="showSettings"
:show.sync="showSettings"
:on-close="closeSettings"
:inbox="selectedInbox"
/>
<td class="py-4">
<div class="flex gap-1 justify-end">
<router-link
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<woot-button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
variant="smooth"
size="tiny"
icon="settings"
color-scheme="secondary"
class-names="grey-btn"
/>
</router-link>
<woot-button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
class-names="grey-btn"
icon="dismiss-circle"
@click="openDelete(inbox)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<woot-confirm-delete-modal
v-if="showDeletePopup"
@@ -227,5 +185,5 @@ export default {
@onConfirm="confirmDeletion"
@onClose="closeDelete"
/>
</div>
</template>
</SettingsLayout>
</template>
@@ -10,7 +10,7 @@ import { mapGetters } from 'vuex';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/browser';
@@ -20,7 +20,6 @@ export default {
LoadingState,
PageHeader,
},
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
return {
@@ -74,6 +73,7 @@ export default {
},
methods: {
useInstallationName,
async startLogin() {
this.hasLoginStarted = true;
try {
@@ -0,0 +1,55 @@
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
const props = defineProps({
channelType: {
type: String,
required: true,
},
medium: {
type: String,
default: '',
},
});
const getters = useStoreGetters();
const { t } = useI18n();
const globalConfig = computed(() => getters['globalConfig/get'].value);
const i18nMap = {
'Channel::FacebookPage': 'MESSENGER',
'Channel::WebWidget': 'WEB_WIDGET',
'Channel::TwitterProfile': 'TWITTER_PROFILE',
'Channel::TwilioSms': 'TWILIO_SMS',
'Channel::Whatsapp': 'WHATSAPP',
'Channel::Sms': 'SMS',
'Channel::Email': 'EMAIL',
'Channel::Telegram': 'TELEGRAM',
'Channel::Line': 'LINE',
'Channel::Api': 'API',
};
const twilioChannelName = () => {
if (props.medium === 'whatsapp') {
return t(`INBOX_MGMT.CHANNELS.WHATSAPP`);
}
return t(`INBOX_MGMT.CHANNELS.TWILIO_SMS`);
};
const readableChannelName = computed(() => {
if (props.channelType === 'Channel::Api') {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
});
</script>
<template>
<span>
{{ readableChannelName }}
</span>
</template>
@@ -1,8 +1,8 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../../helper/URLHelper';
import channelFactory from './channel-factory';
const SettingsContent = () => import('../Wrapper.vue');
const SettingWrapper = () => import('../SettingsWrapper.vue');
const InboxHome = () => import('./Index.vue');
const Settings = () => import('./Settings.vue');
const InboxChannel = () => import('./InboxChannels.vue');
@@ -12,6 +12,24 @@ const FinishSetup = () => import('./FinishSetup.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/inboxes'),
component: SettingWrapper,
children: [
{
path: '',
redirect: 'list',
},
{
path: 'list',
name: 'settings_inbox_list',
component: InboxHome,
meta: {
permissions: ['administrator'],
},
},
],
},
{
path: frontendURL('accounts/:accountId/settings/inboxes'),
component: SettingsContent,
@@ -26,18 +44,6 @@ export default {
};
},
children: [
{
path: '',
redirect: 'list',
},
{
path: 'list',
name: 'settings_inbox_list',
component: InboxHome,
meta: {
permissions: ['administrator'],
},
},
{
path: 'new',
component: InboxChannel,
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
export default {
@@ -12,7 +11,6 @@ export default {
DashboardAppModal,
DashboardAppsRow,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -2,10 +2,9 @@
import { mapGetters } from 'vuex';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
export default {
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
@@ -30,6 +29,7 @@ export default {
}),
},
methods: {
useInstallationName,
frontendURL,
openDeletePopup() {
this.showDeleteConfirmationPopup = true;
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
const props = defineProps({
id: {
@@ -1,5 +1,4 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import IntegrationHelpText from './IntegrationHelpText.vue';
@@ -8,7 +7,6 @@ export default {
Integration,
IntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
integrationId: {
@@ -1,6 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
@@ -12,7 +11,6 @@ export default {
SelectChannelWarning,
SlackIntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
code: { type: String, default: '' },
},
@@ -1,11 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
export default {
mixins: [globalConfigMixin],
props: {
hasConnectedAChannel: {
type: Boolean,
@@ -33,6 +32,7 @@ export default {
},
},
methods: {
useInstallationName,
async fetchChannels() {
try {
this.availableChannels = await this.$store.dispatch(
@@ -3,16 +3,19 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import WebhookRow from './WebhookRow.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
export default {
components: {
SettingsLayout,
BaseSettingsHeader,
NewWebhook,
EditWebhook,
WebhookRow,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -26,13 +29,16 @@ export default {
...mapGetters({
records: 'webhooks/getWebhooks',
uiFlags: 'webhooks/getUIFlags',
globalConfig: 'globalConfig/get',
}),
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
},
mounted() {
this.$store.dispatch('webhooks/get');
},
methods: {
useInstallationName,
openAddPopup() {
this.showAddPopup = true;
},
@@ -75,69 +81,59 @@ export default {
</script>
<template>
<div class="flex-1 p-4 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT') }}
</woot-button>
<div class="flex flex-row gap-4">
<div class="w-full lg:w-3/5">
<p
v-if="!uiFlags.fetchingList && !records.length"
class="flex flex-col items-center justify-center h-full"
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
:no-records-found="!records.length"
>
<template #header>
<BaseSettingsHeader
v-if="integration.name"
:title="integration.name"
:description="integration.description"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-slate-75 dark:divide-slate-700">
<thead>
<th
v-for="thHeader in $t(
'INTEGRATION_SETTINGS.WEBHOOK.LIST.TABLE_HEADER'
)"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-slate-700 dark:text-slate-300 last:text-right last:pr-4"
>
{{ thHeader }}
</th>
</thead>
<tbody
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
/>
<table
v-if="!uiFlags.fetchingList && records.length"
class="woot-table"
>
<thead>
<th
v-for="thHeader in $t(
'INTEGRATION_SETTINGS.WEBHOOK.LIST.TABLE_HEADER'
)"
:key="thHeader"
class="last:text-right"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<WebhookRow
v-for="(webHookItem, index) in records"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
<div class="hidden w-1/3 lg:block">
<span
v-dompurify-html="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.SIDEBAR_TXT'),
globalConfig.installationName
)
"
/>
</div>
</div>
<WebhookRow
v-for="(webHookItem, index) in records"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
@@ -163,5 +159,5 @@ export default {
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
/>
</div>
</SettingsLayout>
</template>
@@ -1,12 +1,11 @@
<script>
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [globalConfigMixin],
props: {
onClose: {
type: Function,
@@ -20,6 +19,7 @@ export default {
}),
},
methods: {
useInstallationName,
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
@@ -1,8 +1,8 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, url, minLength } from '@vuelidate/validators';
import webhookMixin from './webhookMixin';
import wootConstants from 'dashboard/constants/globals';
import { getEventNamei18n } from './webhookHelper';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -18,7 +18,6 @@ const SUPPORTED_WEBHOOK_EVENTS = [
];
export default {
mixins: [webhookMixin],
props: {
value: {
type: Object,
@@ -70,6 +69,7 @@ export default {
subscriptions: this.subscriptions,
});
},
getEventNamei18n,
},
};
</script>
@@ -105,10 +105,10 @@ export default {
type="checkbox"
:value="event"
name="subscriptions"
class="checkbox"
class="mr-2"
/>
<label :for="event" class="text-sm">
{{ `${getEventLabel(event)} (${event})` }}
{{ `${$t(getEventNamei18n(event))} (${event})` }}
</label>
</div>
</div>
@@ -129,9 +129,3 @@ export default {
</div>
</form>
</template>
<style lang="scss" scoped>
.checkbox {
@apply mr-2;
}
</style>
@@ -1,59 +1,58 @@
<script>
import webhookMixin from './webhookMixin';
<script setup>
import { computed } from 'vue';
import { getEventNamei18n } from './webhookHelper';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import { useI18n } from 'dashboard/composables/useI18n';
export default {
components: { ShowMore },
mixins: [webhookMixin],
props: {
webhook: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
const props = defineProps({
webhook: {
type: Object,
required: true,
},
computed: {
subscribedEvents() {
const { subscriptions } = this.webhook;
return subscriptions.map(event => this.getEventLabel(event)).join(', ');
},
index: {
type: Number,
required: true,
},
};
});
const { t } = useI18n();
const subscribedEvents = computed(() => {
const { subscriptions } = props.webhook;
return subscriptions.map(event => t(getEventNamei18n(event))).join(', ');
});
</script>
<template>
<tr class="space-x-2">
<td class="max-w-2xl">
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="font-medium break-words text-slate-700 dark:text-slate-100">
{{ webhook.url }}
</div>
<span class="text-xs text-slate-500 dark:text-slate-400">
<div class="text-sm text-slate-500 dark:text-slate-400 block mt-1">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
<ShowMore :text="subscribedEvents" :limit="60" />
</span>
</div>
</td>
<td class="min-w-[7rem] flex gap-1 justify-end flex-shrink-0">
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="$emit('edit', webhook)"
/>
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
@click="$emit('delete', webhook, index)"
/>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="$emit('edit', webhook)"
/>
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
@click="$emit('delete', webhook, index)"
/>
</div>
</td>
</tr>
</template>
@@ -0,0 +1,9 @@
import { getEventNamei18n } from '../webhookHelper';
describe('#getEventNamei18n', () => {
it('returns correct i18n translation text', () => {
expect(getEventNamei18n('message_created')).toEqual(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED`
);
});
});
@@ -1,26 +0,0 @@
import { createWrapper } from '@vue/test-utils';
import webhookMixin from '../webhookMixin';
import Vue from 'vue';
describe('webhookMixin', () => {
describe('#getEventLabel', () => {
it('returns correct i18n translation:', () => {
const Component = {
render() {},
title: 'WebhookComponent',
mixins: [webhookMixin],
methods: {
$t(text) {
return text;
},
},
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.getEventLabel('message_created')).toEqual(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED`
);
});
});
});
@@ -0,0 +1,4 @@
export const getEventNamei18n = event => {
const eventName = event.toUpperCase();
return `INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`;
};
@@ -1,10 +0,0 @@
export default {
methods: {
getEventLabel(event) {
const eventName = event.toUpperCase();
return this.$t(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`
);
},
},
};
@@ -30,6 +30,14 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'webhook',
component: Webhook,
name: 'settings_integrations_webhook',
meta: {
permissions: ['administrator'],
},
},
],
},
{
@@ -49,14 +57,6 @@ export default {
};
},
children: [
{
path: 'webhook',
component: Webhook,
name: 'settings_integrations_webhook',
meta: {
permissions: ['administrator'],
},
},
{
path: 'slack',
name: 'settings_integrations_slack',
@@ -4,7 +4,7 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -27,7 +27,6 @@ export default {
AudioNotifications,
AccessToken,
},
mixins: [globalConfigMixin],
setup() {
const { uiSettings, updateUISettings, isEditorHotKeyEnabled } =
useUISettings();
@@ -86,6 +85,7 @@ export default {
}
},
methods: {
useInstallationName,
initializeUser() {
this.name = this.currentUser.name;
this.email = this.currentUser.email;
@@ -1,5 +1,5 @@
import articlesAPI from 'dashboard/api/helpCenter/articles';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import types from '../../mutation-types';
@@ -132,6 +132,11 @@ export const actions = {
return fileUrl;
},
uploadExternalImage: async (_, { url }) => {
const { fileUrl } = await uploadExternalImage(url);
return fileUrl;
},
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
try {
await articlesAPI.reorderArticles({
@@ -1,7 +1,7 @@
import axios from 'axios';
import { actions } from '../actions';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import * as types from '../../../mutation-types';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { actions } from '../actions';
vi.mock('dashboard/helper/uploadHelper');
@@ -187,33 +187,53 @@ describe('#actions', () => {
describe('attachImage', () => {
it('should upload the file and return the fileUrl', async () => {
// Given
const mockFile = new Blob(['test'], { type: 'image/png' });
mockFile.name = 'test.png';
const mockFileUrl = 'https://test.com/test.png';
uploadFile.mockResolvedValueOnce({ fileUrl: mockFileUrl });
// When
const result = await actions.attachImage({}, { file: mockFile });
// Then
expect(uploadFile).toHaveBeenCalledWith(mockFile);
expect(result).toBe(mockFileUrl);
});
it('should throw an error if the upload fails', async () => {
// Given
const mockFile = new Blob(['test'], { type: 'image/png' });
mockFile.name = 'test.png';
const mockError = new Error('Upload failed');
uploadFile.mockRejectedValueOnce(mockError);
// When & Then
await expect(actions.attachImage({}, { file: mockFile })).rejects.toThrow(
'Upload failed'
);
});
});
describe('uploadExternalImage', () => {
it('should upload the image from external URL and return the fileUrl', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockFileUrl = 'https://uploaded.example.com/image.jpg';
uploadExternalImage.mockResolvedValueOnce({ fileUrl: mockFileUrl });
// When
const result = await actions.uploadExternalImage({}, { url: mockUrl });
// Then
expect(uploadExternalImage).toHaveBeenCalledWith(mockUrl);
expect(result).toBe(mockFileUrl);
});
it('should throw an error if the upload fails', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Upload failed');
uploadExternalImage.mockRejectedValueOnce(mockError);
await expect(
actions.uploadExternalImage({}, { url: mockUrl })
).rejects.toThrow('Upload failed');
});
});
});
@@ -1,5 +1,5 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
const {
LOGO_THUMBNAIL: logoThumbnail,
@@ -8,7 +8,6 @@ const {
} = window.globalConfig || {};
export default {
mixins: [globalConfigMixin],
props: {
disableBranding: {
type: Boolean,
@@ -41,6 +40,9 @@ export default {
return '';
},
},
methods: {
useInstallationName,
},
};
</script>
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest';
import { useCampaign } from '../useCampaign';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../../constants/campaign';
// Mock the useRoute composable
vi.mock('dashboard/composables/route', () => ({
useRoute: vi.fn(),
}));
describe('useCampaign', () => {
it('returns the correct campaign type for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONGOING);
});
it('returns the correct campaign type for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONE_OFF);
});
it('isOngoingType returns true for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(true);
});
it('isOngoingType returns false for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(false);
});
it('isOneOffType returns true for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(true);
});
it('isOneOffType returns false for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { computed } from 'vue';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../constants/campaign';
/**
* Composable to manage campaign types.
*
* @returns {Object} - Computed properties for campaign type and its checks.
*/
export const useCampaign = () => {
const route = useRoute();
/**
* Computed property to determine the current campaign type based on the route name.
*/
const campaignType = computed(() => {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[route.name];
});
/**
* Computed property to check if the current campaign type is 'ongoing'.
*/
const isOngoingType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONGOING;
});
/**
* Computed property to check if the current campaign type is 'one-off'.
*/
const isOneOffType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONE_OFF;
});
return {
campaignType,
isOngoingType,
isOneOffType,
};
};
@@ -1,7 +1,8 @@
import wootConstants from 'dashboard/constants/globals';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { filterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
import { filterAttributeGroups as conversationFilterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
import { filterAttributeGroups as contactFilterAttributeGroups } from 'dashboard/routes/dashboard/contacts/contactFilterItems';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
const customAttributeInputType = key => {
@@ -42,6 +43,11 @@ export const useFilter = ({ filteri18nKey, attributeModel }) => {
const { t: $t } = useI18n();
const { getters } = useStore();
const filterAttributeGroups =
attributeModel === 'contact_attribute'
? contactFilterAttributeGroups
: conversationFilterAttributeGroups;
const setFilterAttributes = () => {
const allCustomAttributes =
getters['attributes/getAttributesByModel'](attributeModel);
@@ -7,4 +7,5 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 1000,
TWILIO_SMS: 320,
TWILIO_WHATSAPP: 1600,
EMAIL: 25000,
};
@@ -0,0 +1,14 @@
/**
* Replaces occurrences of "Chatwoot" with the provided installation name in a given string.
*
* @param {string} str - The original string that may contain "Chatwoot".
* @param {string} installationName - The name to replace "Chatwoot" with.
* @returns {string} The modified string with "Chatwoot" replaced by the installation name,
* or the original string if either input is falsy.
*/
export const useInstallationName = (str, installationName) => {
if (str && installationName) {
return str.replace(/Chatwoot/g, installationName);
}
return str;
};
@@ -0,0 +1,35 @@
import { useInstallationName } from '../installationNameHelper';
describe('installationNameHelper', () => {
it('should return the string with the installation name', () => {
const str = 'Chatwoot is awesome';
const installationName = 'Acme Inc';
expect(useInstallationName(str, installationName)).toBe(
'Acme Inc is awesome'
);
});
it('should return the string without the installation name', () => {
const str = 'Chatwoot is awesome';
const installationName = '';
expect(useInstallationName(str, installationName)).toBe(
'Chatwoot is awesome'
);
});
it('should handle null installation name properly', () => {
const str = 'Chatwoot is super cool';
const installationName = null;
expect(useInstallationName(str, installationName)).toBe(
'Chatwoot is super cool'
);
});
it('should handle undefined installation name properly', () => {
const str = 'Chatwoot is super cool';
const installationName = undefined;
expect(useInstallationName(str, installationName)).toBe(
'Chatwoot is super cool'
);
});
});
@@ -1,19 +0,0 @@
import { CAMPAIGN_TYPES } from '../constants/campaign';
export default {
computed: {
campaignType() {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[this.$route.name];
},
isOngoingType() {
return this.campaignType === CAMPAIGN_TYPES.ONGOING;
},
isOneOffType() {
return this.campaignType === CAMPAIGN_TYPES.ONE_OFF;
},
},
};
@@ -1,12 +0,0 @@
export const useInstallationName = (str, installationName) => {
if (str && installationName) {
return str.replace(/Chatwoot/g, installationName);
}
return str;
};
export default {
methods: {
useInstallationName,
},
};
@@ -1,34 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import campaignMixin from '../campaignMixin';
const buildComponent = routeName => ({
template: '<div></div>',
computed: {
$route() {
return { name: routeName };
},
},
mixins: [campaignMixin],
});
describe('campaignMixin', () => {
beforeEach(() => {
global.window = Object.create(window);
});
it('returns the correct campaign type', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.campaignType).toBe('one_off');
});
it('isOneOffType returns true if path is one_off', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOneOffType).toBe(true);
});
it('isOngoingType returns true if path is ongoing_campaigns', () => {
const Component = buildComponent('ongoing_campaigns');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOngoingType).toBe(true);
});
});
@@ -3,14 +3,13 @@ import { useVuelidate } from '@vuelidate/core';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { required, minLength, email } from '@vuelidate/validators';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import FormInput from '../../../../components/Form/Input.vue';
import { resetPassword } from '../../../../api/auth';
import SubmitButton from '../../../../components/Button/SubmitButton.vue';
export default {
components: { FormInput, SubmitButton },
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
},
@@ -37,6 +36,7 @@ export default {
},
},
methods: {
useInstallationName,
showAlertMessage(message) {
// Reset loading, current selected agent
this.resetPassword.showLoading = false;
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import SignupForm from './components/Signup/Form.vue';
import Testimonials from './components/Testimonials/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -11,7 +11,6 @@ export default {
Spinner,
Testimonials,
},
mixins: [globalConfigMixin],
data() {
return { isLoading: false };
},
@@ -25,6 +24,7 @@ export default {
this.isLoading = this.isAChatwootInstance;
},
methods: {
useInstallationName,
resizeContainers() {
this.isLoading = false;
},
@@ -3,7 +3,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
@@ -20,7 +19,6 @@ export default {
SubmitButton,
VueHcaptcha,
},
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
},
+3 -2
View File
@@ -2,7 +2,8 @@
import { useVuelidate } from '@vuelidate/core';
import { required, email } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import SubmitButton from '../../components/Button/SubmitButton.vue';
import { mapGetters } from 'vuex';
import { parseBoolean } from '@chatwoot/utils';
@@ -22,7 +23,6 @@ export default {
Spinner,
SubmitButton,
},
mixins: [globalConfigMixin],
props: {
ssoAuthToken: { type: String, default: '' },
ssoAccountId: { type: String, default: '' },
@@ -88,6 +88,7 @@ export default {
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
//
useInstallationName,
requestIdleCallbackPolyfill(callback) {
if (window.requestIdleCallback) {
window.requestIdleCallback(callback);
+1 -1
View File
@@ -31,7 +31,7 @@
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.0.11",
"@chatwoot/prosemirror-schema": "1.0.17",
"@chatwoot/utils": "^0.0.25",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0",
@@ -1,42 +1,112 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
describe 'POST /api/v1/account/1/upload/' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:upload_url) { "/api/v1/accounts/#{account.id}/upload/" }
it 'uploads the image when authorized' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
describe 'POST /api/v1/accounts/:account_id/upload/' do
context 'when uploading a file' do
let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
post "/api/v1/accounts/#{account.id}/upload/",
headers: user.create_new_auth_token,
params: { attachment: file }
it 'uploads the image when authorized' do
post upload_url,
headers: user.create_new_auth_token,
params: { attachment: file }
expect(response).to have_http_status(:success)
blob = response.parsed_body
expect(response).to have_http_status(:success)
blob = response.parsed_body
expect(blob['errors']).to be_nil
expect(blob['file_url']).to be_present
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
end
expect(blob['errors']).to be_nil
it 'does not upload when unauthorized' do
post upload_url,
headers: {},
params: { attachment: file }
expect(blob['file_url']).to be_present
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
expect(response).to have_http_status(:unauthorized)
blob = response.parsed_body
expect(blob['errors']).to be_present
expect(blob['file_url']).to be_nil
expect(blob['blob_key']).to be_nil
expect(blob['blob_id']).to be_nil
end
end
it 'does not upload when un-authorized' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
context 'when uploading from a URL' do
let(:valid_external_url) { 'http://example.com/image.jpg' }
post "/api/v1/accounts/#{account.id}/upload/",
headers: {},
params: { attachment: file }
before do
stub_request(:get, valid_external_url)
.to_return(status: 200, body: File.new(Rails.root.join('spec/assets/avatar.png')), headers: { 'Content-Type' => 'image/png' })
end
expect(response).to have_http_status(:unauthorized)
blob = response.parsed_body
it 'uploads the image from URL when authorized' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: valid_external_url }
expect(blob['errors']).to be_present
expect(response).to have_http_status(:success)
blob = response.parsed_body
expect(blob['error']).to be_nil
expect(blob['file_url']).to be_present
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
end
expect(blob['file_url']).to be_nil
expect(blob['blob_key']).to be_nil
expect(blob['blob_id']).to be_nil
it 'handles invalid URL format' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'not_a_url' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'handles URL with unsupported protocol' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'ftp://example.com/image.jpg' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'handles unreachable URLs' do
stub_request(:get, 'http://nonexistent.example.com')
.to_raise(SocketError.new('Failed to open TCP connection'))
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://nonexistent.example.com' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'handles HTTP errors' do
stub_request(:get, 'http://error.example.com')
.to_return(status: 404)
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://error.example.com' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
end
end
it 'returns an error when no file or URL is provided' do
post upload_url,
headers: user.create_new_auth_token,
params: {}
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('No file or URL provided')
end
end
end
@@ -69,7 +69,8 @@ RSpec.describe 'Custom Roles API', type: :request do
describe 'POST #create' do
let(:valid_params) do
{ custom_role: { name: 'Support', description: 'Support role', permissions: CustomRole::PERMISSIONS.sample(SecureRandom.random_number(4)) } }
{ custom_role: { name: 'Support', description: 'Support role',
permissions: CustomRole::PERMISSIONS.sample(SecureRandom.random_number(1..4)) } }
end
context 'when it is an authenticated administrator' do
+1 -1
View File
@@ -3,6 +3,6 @@ FactoryBot.define do
account
name { Faker::Name.name }
description { Faker::Lorem.sentence }
permissions { CustomRole::PERMISSIONS.sample(SecureRandom.random_number(4)) }
permissions { CustomRole::PERMISSIONS.sample(SecureRandom.random_number(1..4)) }
end
end
+4 -4
View File
@@ -2914,10 +2914,10 @@
hotkeys-js "3.8.7"
lit "2.2.6"
"@chatwoot/prosemirror-schema@1.0.11":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.11.tgz#b66201be8b09cd4c6370a60607cc5d4f9d924bdb"
integrity sha512-OAUa1CuHtetEPh/ZhRkMLVQHY2/eesCRb2IRdVzjh0z+4spp3fG826y8FbOEsa7m4hisNirnGF/mDH+NMmYetw==
"@chatwoot/prosemirror-schema@1.0.17":
version "1.0.17"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.17.tgz#5c04a94c8200b303d943369b2534730d5ba15a64"
integrity sha512-3hwBSMdb5a+Ilct0fNSlAvQu/9+pnyI/FoGshVRC9Hn1tCdZdS7LUlxOuDweta3HneVclhE+SxJN5lM33v5WGw==
dependencies:
markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4"