Compare commits

..
Author SHA1 Message Date
Shivam Mishra 01e09fbf6a feat: remove storybook 2024-08-27 14:09:15 +05:30
158 changed files with 1166 additions and 5040 deletions
-1
View File
@@ -3,7 +3,6 @@ module.exports = {
'airbnb-base/legacy',
'prettier',
'plugin:vue/recommended',
'plugin:storybook/recommended',
'plugin:cypress/recommended',
],
parserOptions: {
-2
View File
@@ -74,5 +74,3 @@ yalc.lock
/yarn-error.log
yarn-debug.log*
.yarn-integrity
/storybook-static
-56
View File
@@ -1,56 +0,0 @@
const path = require('path');
const resolve = require('../config/webpack/resolve');
// Chatwoot's webpack.config.js
process.env.NODE_ENV = 'development';
const custom = require('../config/webpack/environment');
module.exports = {
stories: [
'../stories/**/*.stories.mdx',
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
{
name: '@storybook/addon-docs',
options: {
vueDocgenOptions: {
alias: {
'@': path.resolve(__dirname, '../'),
},
},
},
},
'@storybook/addon-links',
'@storybook/addon-essentials',
{
/**
* Fix Storybook issue with PostCSS@8
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
*/
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
webpackFinal: config => {
const newConfig = {
...config,
resolve: {
...config.resolve,
modules: custom.resolvedModules.map(i => i.value),
},
};
newConfig.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
include: path.resolve(__dirname, '../app/javascript'),
});
return newConfig;
},
};
-46
View File
@@ -1,46 +0,0 @@
import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Multiselect from 'vue-multiselect';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
addDecorator(() => ({
template: '<story/>',
i18n: i18nConfig,
store,
beforeCreate: function () {
this.$root._i18n = this.$i18n;
},
}));
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
+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', '>= 0.24.0'
gem 'google-cloud-dialogflow-v2'
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', '>= 0.7.0'
gem 'google-cloud-translate-v3'
##-- 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.3'
gem 'omniauth-google-oauth2', '>= 1.1.2'
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.3'
gem 'meta_request', '>= 0.8.0'
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.2.1)
faraday-retry (2.1.0)
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.20.0)
gapic-common (0.18.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.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-dialogflow-v2 (0.23.0)
gapic-common (>= 0.18.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.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-location (0.4.0)
gapic-common (>= 0.17.1, < 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.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 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.6.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
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.3)
meta_request (0.8.2)
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.3)
omniauth-google-oauth2 (1.1.2)
jwt (>= 2.0)
oauth2 (~> 2.0)
omniauth (~> 2.0)
@@ -881,9 +881,9 @@ DEPENDENCIES
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-dialogflow-v2
google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0)
google-cloud-translate-v3
groupdate
grpc
haikunator
@@ -903,14 +903,14 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request (>= 0.8.3)
meta_request (>= 0.8.0)
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.3)
omniauth-google-oauth2 (>= 1.1.2)
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', '')
return [] if domains.blank?
domains.split("\n").map(&:strip) if domains.present?
domains.split("\n").map(&:strip)
[]
end
end
@@ -1,68 +1,13 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create
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
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
end
file_blob.save!
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
@@ -1,39 +0,0 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import 'vue-multiselect/dist/vue-multiselect.min.css';
@import '~shared/assets/stylesheets/ionicons';
@import 'mixins';
@import 'helper-classes';
@import 'typography';
@import 'layout';
@import 'animations';
@import 'widgets/buttons';
@import 'widgets/base';
@import 'plugins/multiselect';
@import 'widget/assets/scss/reset';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'widget/assets/scss/utilities';
html,
body {
font-family: 'PlusJakarta', sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
}
@@ -1,30 +0,0 @@
import { action } from '@storybook/addon-actions';
import AccordionItemComponent from './AccordionItem';
export default {
title: 'Components/Generic/Accordion',
component: AccordionItemComponent,
argTypes: {
title: {
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AccordionItem: AccordionItemComponent },
template: `
<accordion-item v-bind="$props" @click="onClick">
This is a sample content you can pass as a slot
</accordion-item>
`,
});
export const AccordionItem = Template.bind({});
AccordionItem.args = {
onClick: action('Added'),
title: 'Title of the accordion item',
};
@@ -117,41 +117,35 @@ export default {
lastConversationIndex,
};
};
const handleConversationNavigation = direction => {
const handlePreviousConversation = () => {
const { allConversations, activeConversationIndex } =
getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
};
const handleNextConversation = () => {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = getKeyboardListenerParams();
// 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();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handleConversationNavigation('previous'),
action: () => handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleConversationNavigation('next'),
action: () => handleNextConversation(),
allowOnFocusedInput: true,
},
};
@@ -331,12 +331,10 @@ export default {
::v-deep {
.selector-wrap {
@apply m-0 top-1;
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
@@ -1,46 +0,0 @@
import { action } from '@storybook/addon-actions';
import WootAnnouncementPopup from '../AnnouncementPopup.vue';
export default {
title: 'Components/Popup/Announcement Popup',
argTypes: {
popupMessage: {
defaultValue:
'Now a new key shortcut (⌘ + ↵) is available to send messages. You can enable it in the',
control: {
type: 'text',
},
},
routeText: {
defaultValue: 'profile settings',
control: {
type: 'text',
},
},
hasCloseButton: {
defaultValue: true,
control: {
type: 'boolean',
},
},
closeButtonText: {
defaultValue: 'Got it',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootAnnouncementPopup },
template:
'<woot-announcement-popup v-bind="$props" @open="onClickOpenPath" @close="onClickClose"></woot-announcement-popup>',
});
export const AnnouncementPopup = Template.bind({});
AnnouncementPopup.args = {
onClickOpenPath: action('opened path'),
onClickClose: action('closed the popup'),
};
@@ -1,50 +0,0 @@
import { action } from '@storybook/addon-actions';
import WootButton from '../WootButton.vue';
export default {
title: 'Components/Button',
component: WootButton,
argTypes: {
colorScheme: {
control: {
type: 'select',
options: ['primary', 'secondary', 'success', 'alert', 'warning'],
},
},
size: {
control: {
type: 'select',
options: ['tiny', 'small', 'medium', 'large', 'expanded'],
},
},
variant: {
control: {
type: 'select',
options: ['hollow', 'clear'],
},
},
isLoading: {
control: {
type: 'boolean',
},
},
isDisabled: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootButton },
template:
'<woot-button v-bind="$props" @click="onClick">{{label}}</woot-button>',
});
export const Primary = Template.bind({});
Primary.args = {
label: 'New message',
onClick: action('Hello'),
};
@@ -1,38 +0,0 @@
import { action } from '@storybook/addon-actions';
import WootDateRangePicker from '../DateRangePicker.vue';
export default {
title: 'Components/Date Picker/Date Range Picker',
argTypes: {
confirmText: {
defaultValue: 'Apply',
control: {
type: 'text',
},
},
placeholder: {
defaultValue: 'Select date range',
control: {
type: 'text',
},
},
value: {
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootDateRangePicker },
template:
'<woot-date-range-picker v-bind="$props" @change="onChange"></woot-date-range-picker>',
});
export const DateRangePicker = Template.bind({});
DateRangePicker.args = {
onChange: action('applied'),
value: new Date(),
};
@@ -1,38 +0,0 @@
import { action } from '@storybook/addon-actions';
import WootDateTimePicker from '../DateTimePicker.vue';
export default {
title: 'Components/Date Picker/Date Time Picker',
argTypes: {
confirmText: {
defaultValue: 'Apply',
control: {
type: 'text',
},
},
placeholder: {
defaultValue: 'Select date time',
control: {
type: 'text',
},
},
value: {
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootDateTimePicker },
template:
'<woot-date-time-picker v-bind="$props" @change="onChange"></woot-date-time-picker>',
});
export const DateTimePicker = Template.bind({});
DateTimePicker.args = {
onChange: action('applied'),
value: new Date(),
};
@@ -1,64 +0,0 @@
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Label',
argTypes: {
title: {
defaultValue: 'sales',
control: {
type: 'text',
},
},
colorScheme: {
control: {
type: 'select',
options: ['primary', 'secondary', 'success', 'alert', 'warning'],
},
},
description: {
defaultValue: 'label',
control: {
type: 'text',
},
},
href: {
control: {
type: 'text',
},
},
bgColor: {
defaultValue: '#a83262',
control: {
type: 'text',
},
},
small: {
defaultValue: false,
control: {
type: 'boolean',
},
},
showClose: {
defaultValue: false,
control: {
type: 'boolean',
},
},
icon: {
defaultValue: 'ion-close',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
template: '<woot-label v-bind="$props" @click="onClick"></woot-label>',
});
export const DefaultLabel = Template.bind({});
DefaultLabel.args = {
onClick: action('clicked'),
};
@@ -1,30 +0,0 @@
import TimeAgo from 'dashboard/components/ui/TimeAgo';
export default {
title: 'Components/TimeAgo',
component: TimeAgo,
argTypes: {
isAutoRefreshEnabled: {
control: {
type: 'boolean',
},
},
timestamp: {
control: {
type: 'text, date, number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { TimeAgo },
template: '<time-ago v-bind="$props"></time-ago>',
});
export const TimeAgoView = Template.bind({});
TimeAgoView.args = {
timestamp: 1549843200,
isAutoRefreshEnabled: false,
};
@@ -1,37 +0,0 @@
import InboxDropdownItem from './InboxDropdownItem';
export default {
title: 'Components/DropDowns/InboxDropdownItem',
component: InboxDropdownItem,
argTypes: {
name: {
defaultValue: 'My new inbox',
control: {
type: 'text',
},
},
inboxIdentifier: {
defaultValue: 'nithin@mail.com',
control: {
type: 'text',
},
},
channelType: {
defaultValue: 'email',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { InboxDropdownItem },
template: '<inbox-dropdown-item v-bind="$props" ></inbox-dropdown-item>',
});
export const Banner = Template.bind({});
Banner.args = {};
@@ -1,67 +0,0 @@
import { action } from '@storybook/addon-actions';
import LabelSelector from './LabelSelector';
export default {
title: 'Components/Label/Contact Label',
component: LabelSelector,
argTypes: {
contactId: {
control: {
type: 'text ,number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { LabelSelector },
template:
'<label-selector v-bind="$props" @add="onAdd" @remove="onRemove"></label-selector>',
});
export const ContactLabel = Template.bind({});
ContactLabel.args = {
onAdd: action('Added'),
onRemove: action('Removed'),
allLabels: [
{
id: '1',
title: 'sales',
description: '',
color: '#0a5dd1',
},
{
id: '2',
title: 'refund',
description: '',
color: '#8442f5',
},
{
id: '3',
title: 'testing',
description: '',
color: '#f542f5',
},
{
id: '4',
title: 'scheduled',
description: '',
color: '#42d1f5',
},
],
savedLabels: [
{
id: '2',
title: 'refund',
description: '',
color: '#8442f5',
},
{
id: '4',
title: 'scheduled',
description: '',
color: '#42d1f5',
},
],
};
@@ -1,30 +0,0 @@
import SettingIntroBanner from './SettingIntroBanner';
export default {
title: 'Components/Settings/Banner',
component: SettingIntroBanner,
argTypes: {
headerTitle: {
defaultValue: 'Acme Support',
control: {
type: 'text',
},
},
headerContent: {
defaultValue:
'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { SettingIntroBanner },
template: '<setting-intro-banner v-bind="$props" ></setting-intro-banner>',
});
export const Banner = Template.bind({});
Banner.args = {};
@@ -1,34 +1,38 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
const props = defineProps({
text: {
type: String,
default: '',
<script>
export default {
props: {
text: {
type: String,
default: '',
},
limit: {
type: Number,
default: 120,
},
},
limit: {
type: Number,
default: 120,
data() {
return {
showMore: false,
};
},
});
const { t } = useI18n();
const showMore = ref(false);
computed: {
textToBeDisplayed() {
if (this.showMore || this.text.length <= this.limit) {
return this.text;
}
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;
return this.text.slice(0, this.limit) + '...';
},
buttonLabel() {
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
},
},
methods: {
toggleShowMore() {
this.showMore = !this.showMore;
},
},
};
</script>
@@ -37,10 +41,16 @@ const toggleShowMore = () => {
{{ textToBeDisplayed }}
<button
v-if="text.length > limit"
class="text-woot-500 !p-0 !border-0 align-top"
class="show-more--button"
@click="toggleShowMore"
>
{{ buttonLabel }}
</button>
</span>
</template>
<style scoped>
.show-more--button {
color: var(--w-500);
}
</style>
@@ -1,61 +0,0 @@
import Thumbnail from './Thumbnail.vue';
export default {
title: 'Components/Thumbnail',
component: Thumbnail,
argTypes: {
src: {
control: {
type: 'text',
},
},
size: {
control: {
type: 'text',
},
},
badge: {
control: {
type: 'select',
options: ['fb', 'whatsapp', 'sms', 'twitter-tweet', 'twitter-dm'],
},
},
variant: {
control: {
type: 'select',
options: ['circle', 'square'],
},
},
username: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
status: {
defaultValue: 'circle',
control: {
type: 'select',
options: ['online', 'busy'],
},
},
hasBorder: {
control: {
type: 'boolean',
},
},
shouldShowStatusAlways: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Thumbnail },
template: '<thumbnail v-bind="$props" @click="onClick">{{label}}</thumbnail>',
});
export const Primary = Template.bind({});
@@ -17,8 +17,6 @@ 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,
@@ -26,7 +24,6 @@ import {
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -38,8 +35,10 @@ import {
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { createTypingIndicator } from '@chatwoot/utils';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -72,12 +71,7 @@ const createState = (
export default {
name: 'WootMessageEditor',
components: {
TagAgents,
CannedResponse,
VariableList,
KeyboardEmojiSelector,
},
components: { TagAgents, CannedResponse, VariableList },
mixins: [keyboardEventListenerMixins],
props: {
value: { type: String, default: '' },
@@ -125,11 +119,9 @@ export default {
showUserMentions: false,
showCannedMenu: false,
showVariables: false,
showEmojiMenu: false,
mentionSearchKey: '',
cannedSearchTerm: '',
variableSearchTerm: '',
emojiSearchTerm: '',
editorView: null,
range: null,
state: undefined,
@@ -177,7 +169,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.mentionSearchKey = args.text;
this.mentionSearchKey = args.text.replace('@', '');
return false;
},
@@ -206,7 +198,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.cannedSearchTerm = args.text;
this.cannedSearchTerm = args.text.replace('/', '');
return false;
},
onExit: () => {
@@ -234,7 +226,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.variableSearchTerm = args.text;
this.variableSearchTerm = args.text.replace('{{', '');
return false;
},
onExit: () => {
@@ -246,31 +238,6 @@ 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() {
@@ -300,8 +267,6 @@ export default {
},
editorId() {
this.showCannedMenu = false;
this.showEmojiMenu = false;
this.showVariables = false;
this.cannedSearchTerm = '';
this.reloadState(this.value);
},
@@ -552,36 +517,57 @@ export default {
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
/**
* 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) {
insertMentionNode(mentionItem) {
if (!this.editorView) {
return;
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;
}
let { node, from, to } = getContentNode(
this.editorView,
type,
content,
this.range,
this.variables
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
);
if (!node) return;
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;
this.insertNodeIntoEditor(node, from, to);
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]);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
return false;
},
openFileBrowser() {
this.$refs.imageUpload.click();
@@ -701,22 +687,17 @@ export default {
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="content => insertSpecialContent('mention', content)"
@click="insertMentionNode"
/>
<CannedResponse
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="content => insertSpecialContent('cannedResponse', content)"
@click="insertCannedResponse"
/>
<VariableList
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="content => insertSpecialContent('variable', content)"
/>
<KeyboardEmojiSelector
v-if="showEmojiMenu"
:search-key="emojiSearchTerm"
@click="emoji => insertSpecialContent('emoji', emoji)"
@click="insertVariable"
/>
<input
ref="imageUpload"
@@ -8,7 +8,6 @@ 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';
@@ -56,7 +55,7 @@ export default {
return {
editorView: null,
state: undefined,
plugins: [imagePastePlugin(this.handleImageUpload)],
plugins: [],
};
},
computed: {
@@ -77,7 +76,6 @@ export default {
this.reloadState();
},
},
created() {
this.state = createState(
this.value,
@@ -97,24 +95,6 @@ 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];
@@ -140,6 +120,7 @@ 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'));
}
@@ -192,18 +173,6 @@ 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();
}
},
},
});
},
@@ -1,68 +0,0 @@
<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,11 +41,14 @@ 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,11 +338,7 @@ export default {
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
grouped-filters
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
:error-message="validationErrors[`filter_${i}`]"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
/>
@@ -237,9 +237,6 @@ 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,14 +56,20 @@ 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>
@@ -1,28 +0,0 @@
import ContactBubble from '../bubble/Contact.vue';
export default {
title: 'Components/Messaging/ContactBubble',
component: ContactBubble,
argTypes: {
name: {
defaultValue: 'Eden Hazard',
control: {
type: 'string',
},
},
phoneNumber: {
defaultValue: '+517554433220',
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactBubble },
template: '<contact-bubble v-bind="$props" />',
});
export const ContactBubbleView = Template.bind({});
@@ -1,34 +0,0 @@
import LocationBubble from '../bubble/Location.vue';
export default {
title: 'Components/Help Center',
component: LocationBubble,
argTypes: {
latitude: {
defaultValue: 1,
control: {
type: 'number',
},
},
longitude: {
defaultValue: 1,
control: {
type: 'number',
},
},
name: {
defaultValue: '420, Dope street',
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { LocationBubble },
template: '<location-bubble v-bind="$props" />',
});
export const LocationBubbleView = Template.bind({});
@@ -1,27 +0,0 @@
import ReplyEmailHead from '../ReplyEmailHead';
export default {
title: 'Components/ReplyBox/EmailHead',
component: ReplyEmailHead,
argTypes: {
ccEmails: {
control: {
type: 'string',
},
},
bccEmails: {
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ReplyEmailHead },
template:
'<reply-email-head v-bind="$props" @add="onAdd" @click="onClick"></reply-email-head>',
});
export const Add = Template.bind({});
@@ -90,24 +90,22 @@ const variableKey = (item = {}) => {
}"
@click="onListItemSelection(index)"
>
<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>
<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>
</button>
</woot-dropdown-item>
</ul>
@@ -1,17 +0,0 @@
import WootKeyboardShortcutModal from './WootKeyShortcutModal.vue';
export default {
title: 'Components/Shortcuts/Keyboard Shortcut',
component: WootKeyboardShortcutModal,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootKeyboardShortcutModal },
template:
'<woot-keyboard-shortcut-modal v-bind="$props"></woot-keyboard-shortcut-modal>',
});
export const KeyboardShortcut = Template.bind({});
KeyboardShortcut.args = {};
@@ -1,69 +0,0 @@
import ThumbnailGroup from '../ThumbnailGroup.vue';
export default {
title: 'Components/ThumbnailGroup',
component: ThumbnailGroup,
argTypes: {
usersList: {
defaultValue: [
{
name: 'John',
id: 1,
thumbnail: '',
},
{
name: 'John',
id: 2,
thumbnail: '',
},
{
name: 'John',
id: 3,
thumbnail: '',
},
{
name: 'John',
id: 4,
thumbnail: '',
},
{
name: 'John',
id: 5,
thumbnail: '',
},
{
name: 'John',
id: 6,
thumbnail: '',
},
],
control: {
type: 'object',
},
},
size: {
control: {
type: 'text',
},
},
moreThumbnailsText: {
control: {
type: 'text',
default: '2 more',
},
},
showMoreThumbnailsCount: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ThumbnailGroup },
template: '<ThumbnailGroup v-bind="$props"/>',
});
export const Primary = Template.bind({});
@@ -5,7 +5,6 @@ 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,8 +3,6 @@ import {
MessageMarkdownTransformer,
MessageMarkdownSerializer,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/browser';
/**
@@ -283,92 +281,3 @@ 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,7 +9,6 @@ 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',
@@ -17,7 +16,6 @@ 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) {
@@ -1,127 +0,0 @@
// 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,93 +1,52 @@
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();
});
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',
});
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',
},
};
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
axios.post.mockResolvedValueOnce(mockResponse);
axios.post.mockRejectedValueOnce(mockError);
const result = await uploadFile(mockFile, '1602');
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
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',
});
});
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',
});
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
it('should handle errors', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadExternalImage(mockUrl)).rejects.toThrow(
'Failed to upload'
);
});
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
});
});
@@ -19,47 +19,26 @@ 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 }
);
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' } }
{
headers: HEADERS,
}
);
return {
@@ -55,9 +55,6 @@
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity"
},
"ERRORS": {
"VALUE_REQUIRED": "Value is required"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard filters",
"ADDITIONAL_FILTERS": "Additional filters",
@@ -3,8 +3,7 @@
"HEADER": "Custom Attributes",
"HEADER_BTN_TXT": "Add Custom Attribute",
"LOADING": "Fetching custom attributes",
"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",
"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>",
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -92,12 +91,7 @@
"CONTACT": "Contact"
},
"LIST": {
"TABLE_HEADER": [
"Name",
"Description",
"Type",
"Key"
],
"TABLE_HEADER": ["Name", "Description", "Type", "Key"],
"BUTTONS": {
"EDIT": "Edit",
"DELETE": "Delete"
@@ -30,9 +30,6 @@
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ERRORS": {
"VALUE_REQUIRED": "Value is required"
},
"ATTRIBUTES": {
"NAME": "Name",
"EMAIL": "Email",
@@ -41,7 +41,6 @@
"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,8 +1,7 @@
{
"INBOX_MGMT": {
"HEADER": "Inboxes",
"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",
"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>",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"LIST": {
@@ -746,18 +745,6 @@
"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,7 +12,6 @@
},
"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.",
@@ -1,32 +0,0 @@
import AddCustomAttribute from '../components/AddCustomAttribute';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Contact/AddCustomAttribute',
component: AddCustomAttribute,
argTypes: {
show: {
defaultValue: true,
control: {
type: 'boolean',
},
},
isCreating: {
defaultValue: false,
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AddCustomAttribute },
template: '<add-custom-attribute v-bind="$props" @create="onCreate" />',
});
export const DefaultAttribute = Template.bind({});
DefaultAttribute.args = {
onCreate: action('edit'),
};
@@ -1,43 +0,0 @@
import ContactAttribute from '../components/ContactAttribute';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Contact/ContactAttribute',
component: ContactAttribute,
argTypes: {
label: {
defaultValue: 'Email',
control: {
type: 'text',
},
},
value: {
defaultValue: 'dwight@schrute.farms',
control: {
type: 'text',
},
},
icon: {
defaultValue: 'ion-email',
control: {
type: 'text',
},
},
showEdit: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactAttribute },
template: '<contact-attribute v-bind="$props" @update="onEdit" />',
});
export const DefaultAttribute = Template.bind({});
DefaultAttribute.args = {
onEdit: action('edit'),
};
@@ -1,43 +0,0 @@
import ContactFields from '../components/ContactFields';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Contact/ContactFields',
component: ContactFields,
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactFields },
template:
'<contact-fields v-bind="$props" :contact="contact" @update="onUpdate" @create-attribute="onCreate" />',
});
export const DefaultContactFields = Template.bind({});
DefaultContactFields.args = {
contact: {
id: 979442,
name: 'Eden Hazard',
title: 'Playmaker',
thumbnail: 'https://randomuser.me/api/portraits/men/19.jpg',
company: {
id: 10,
name: 'Chelsea',
},
email: 'hazard@chelsea.com',
availability_status: 'offline',
phone_number: '',
custom_attributes: {},
additional_attributes: {
description:
'Known for his dribbling, he is considered to be one of the best players in the world.',
social_profiles: {
twitter: 'hazardeden10',
facebook: 'hazardeden10',
linkedin: 'hazardeden10',
},
},
},
onUpdate: action('update'),
onCreate: action('create'),
};
@@ -1,43 +0,0 @@
import ContactIntro from '../components/ContactIntro';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Contact/ContactIntro',
component: ContactIntro,
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactIntro },
template:
'<contact-intro v-bind="$props" @edit="onEdit" @message="onNewMessage" />',
});
export const DefaultContactIntro = Template.bind({});
DefaultContactIntro.args = {
contact: {
id: 979442,
name: 'Eden Hazard',
title: 'Playmaker',
thumbnail: 'https://randomuser.me/api/portraits/men/19.jpg',
company: {
id: 10,
name: 'Chelsea',
},
email: 'hazard@chelsea.com',
availability_status: 'offline',
phone_number: '',
custom_attributes: {},
additional_attributes: {
description:
'Known for his dribbling, he is considered to be one of the best players in the world.',
social_profiles: {
twitter: 'hazardeden10',
facebook: 'hazardeden10',
linkedin: 'hazardeden10',
},
},
},
onEdit: action('edit'),
onNewMessage: action('new message 💬'),
};
@@ -1,32 +0,0 @@
import { action } from '@storybook/addon-actions';
import MergeContact from 'dashboard/modules/contact/components/MergeContact';
export default {
title: 'Components/Contact/MergeContacts',
component: MergeContact,
argTypes: {
'primary-contact': {
defaultValue: '{}',
control: {
type: 'object',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { MergeContact },
template:
'<merge-contact v-bind="$props" @submit="onSearch" @search="onSubmit"></merge-contact>',
});
export const List = Template.bind({});
List.args = {
primaryContact: {
id: 12,
name: 'Mason Mount',
},
onSearch: action('Search'),
onSubmit: action('Submit'),
};
@@ -1,19 +0,0 @@
import { action } from '@storybook/addon-actions';
import AddNote from '../components/AddNote.vue';
export default {
title: 'Components/Notes/Add',
component: AddNote,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AddNote },
template: '<add-note v-bind="$props" @add="onAdd"></add-note>',
});
export const Add = Template.bind({});
Add.args = {
onAdd: action('Added'),
};
@@ -1,52 +0,0 @@
import { action } from '@storybook/addon-actions';
import ContactNote from '../components/ContactNote.vue';
export default {
title: 'Components/Notes/Note',
component: ContactNote,
argTypes: {
id: {
control: {
type: 'number',
},
},
note: {
defaultValue:
'A copy and paste musical notes symbols & music symbols collection for easy access.',
control: {
type: 'text',
},
},
userName: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
timeStamp: {
defaultValue: 1618046084,
control: {
type: 'number',
},
},
thumbnail: {
defaultValue: 'https://randomuser.me/api/portraits/men/62.jpg',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactNote },
template:
'<contact-note v-bind="$props" @edit="onEdit" @delete="onDelete"></contact-note>',
});
export const Note = Template.bind({});
Note.args = {
onEdit: action('Edit'),
onDelete: action('Delete'),
};
@@ -1,45 +0,0 @@
import { action } from '@storybook/addon-actions';
import NoteList from '../components/NoteList';
export default {
title: 'Components/Notes/List',
component: NoteList,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { NoteList },
template:
'<note-list v-bind="$props" @addNote="onAddNote" @editNote="onEditNote" @deleteNote="onDeleteNote" @show="onClick"></note-list>',
});
export const List = Template.bind({});
List.args = {
onClick: action('show'),
onAddNote: action('added'),
onEditNote: action('edit'),
onDeleteNote: action('deleted'),
notes: [
{
id: '12345',
content:
'It is a long established fact that a reader will be distracted.',
user: {
name: 'John Doe',
thumbnail: 'https://randomuser.me/api/portraits/men/69.jpg',
},
created_at: 1618046084,
},
{
id: '12346',
content:
'It is simply dummy text of the printing and typesetting industry.',
user: {
name: 'Pearl Cruz',
thumbnail: 'https://randomuser.me/api/portraits/women/29.jpg',
},
created_at: 1616046076,
},
],
};
@@ -1,4 +1,3 @@
/* eslint-disable storybook/default-exports */
import { frontendURL } from '../../helper/URLHelper';
const SearchView = () => import('./components/SearchView.vue');
@@ -1,28 +0,0 @@
import MessageContent from '../components/MessageContent.vue';
export default {
title: 'Components/Search/MessageContent',
component: MessageContent,
argTypes: {
content: {
defaultValue: '123',
control: {
type: 'text',
},
},
author: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { MessageContent },
template: '<message-content v-bind="$props"></message-content>',
});
export const MessageResultItem = Template.bind({});
@@ -1,53 +0,0 @@
import SearchResultContactItem from '../components/SearchResultContactItem.vue';
export default {
title: 'Components/Search/SearchResultContactItem',
component: SearchResultContactItem,
argTypes: {
id: {
defaultValue: '123',
control: {
type: 'text',
},
},
name: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
email: {
defaultValue: 'johndoe@faster.com',
control: {
type: 'text',
},
},
phone: {
defaultValue: '+1 123 456 7890',
control: {
type: 'text',
},
},
accountId: {
defaultValue: '7890',
control: {
type: 'text',
},
},
thumbnail: {
defaultValue: 'https://randomuser.me/api/portraits/men/62.jpg',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { SearchResultContactItem },
template:
'<search-result-contact-item v-bind="$props"></search-result-contact-item>',
});
export const ResultContactItem = Template.bind({});
@@ -1,50 +0,0 @@
import SearchResultConversationItem from '../components/SearchResultConversationItem.vue';
export default {
title: 'Components/Search/SearchResultConversationItem',
component: SearchResultConversationItem,
argTypes: {
id: {
defaultValue: '123',
control: {
type: 'text',
},
},
name: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
inbox: {
defaultValue: {
name: 'Livechat',
channel_type: 'Channel::WebWidget',
},
control: {
type: 'object',
},
},
accountId: {
defaultValue: '7890',
control: {
type: 'text',
},
},
createdAt: {
defaultValue: '1677672962',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { SearchResultConversationItem },
template:
'<search-result-conversation-item v-bind="$props"></search-result-conversation-item>',
});
export const ResultConversationItem = Template.bind({});
@@ -1,29 +0,0 @@
import Widget from '../components/Widget';
const ReplyTime = {
'In a few minutes': 'in_a_few_minutes',
'In a few hours': 'in_a_few_hours',
'In a few day': 'in_a_day',
};
export default {
title: 'components/Widget',
component: Widget,
argTypes: {
replyTime: {
control: {
type: 'select',
options: ReplyTime,
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Widget },
template: '<Widget v-bind="$props" />',
});
export const DefaultWidget = Template.bind({});
DefaultWidget.args = {};
@@ -1,41 +0,0 @@
import { action } from '@storybook/addon-actions';
import AddReminder from './AddReminder';
export default {
title: 'Components/Reminder/Add',
component: AddReminder,
argTypes: {
options: {
control: {
type: 'object',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AddReminder },
template:
'<add-reminder v-bind="$props" @add="onAdd" @click="onClick"></add-reminder>',
});
export const Add = Template.bind({});
Add.args = {
onAdd: action('Added'),
onClick: action('Label'),
options: [
{
id: '12345',
name: 'calls',
},
{
id: '12346',
name: 'meeting',
},
{
id: '12347',
name: 'review',
},
],
};
@@ -308,11 +308,7 @@ 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}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
:error-message="validationErrors[`filter_${i}`]"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
/>
@@ -1,52 +0,0 @@
import { action } from '@storybook/addon-actions';
import ReminderItem from './ReminderItem';
export default {
title: 'Components/Reminder/Item',
component: ReminderItem,
argTypes: {
id: {
control: {
type: 'number',
},
},
text: {
defaultValue:
'A copy and paste musical notes symbols & music symbols collection for easy access.',
control: {
type: 'text',
},
},
isCompleted: {
control: {
type: 'boolean',
},
},
date: {
defaultValue: '03/06/2020',
control: {
type: 'text',
},
},
label: {
defaultValue: 'Call',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ReminderItem },
template:
'<reminder-item v-bind="$props" @completed="onClick" @edit="onEdit" @delete="onDelete"></reminder-item>',
});
export const Item = Template.bind({});
Item.args = {
onClick: action('Marked'),
onEdit: action('Edit'),
onDelete: action('Delete'),
};
@@ -1,22 +0,0 @@
import { action } from '@storybook/addon-actions';
import SectionHeader from './SectionHeader';
export default {
title: 'Components/Events/Section',
component: SectionHeader,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { SectionHeader },
template:
'<section-header v-bind="$props" @notes="onClickNotes" @events="onClickEvents" @conversation="onClickConversation"></section-header>',
});
export const Section = Template.bind({});
Section.args = {
onClickNotes: action('notes'),
onClickEvents: action('events'),
onClickConversation: action('conversation'),
};
@@ -1,45 +0,0 @@
import { action } from '@storybook/addon-actions';
import TimelineCard from './TimelineCard';
export default {
title: 'Components/Events/Timeline',
component: TimelineCard,
argTypes: {
eventType: {
defaultValue: 'Commented',
control: {
type: 'text',
},
},
eventPath: {
defaultValue: 'chatwoot/chatwoot',
control: {
type: 'text',
},
},
eventBody: {
defaultValue:
'Commentedmany variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour',
control: {
type: 'text',
},
},
timeStamp: {
defaultValue: '1618046084',
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { TimelineCard },
template: '<timeline-card v-bind="$props" @more="onClick"></timeline-card>',
});
export const Timeline = Template.bind({});
Timeline.args = {
onClick: action('more'),
};
@@ -1,30 +0,0 @@
import AddCategoryComponent from '../../pages/categories/AddCategory.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Help Center',
component: AddCategoryComponent,
argTypes: {
show: {
defaultValue: true,
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AddCategoryComponent },
template:
'<add-category-component v-bind="$props" @create="onCreate" @cancel="onClose" />',
});
export const AddCategory = Template.bind({});
AddCategory.args = {
portalName: 'Chatwoot help center',
locale: 'En-US',
onCreate: action('create'),
onClose: action('cancel'),
};
@@ -1,34 +0,0 @@
import { action } from '@storybook/addon-actions';
import ArticleEditor from '../../components/ArticleEditor.vue';
export default {
title: 'Components/Help Center',
component: ArticleEditor,
argTypes: {
article: {
defaultValue: {},
control: {
type: 'object',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleEditor },
template:
'<article-editor v-bind="$props" @focus="onFocus" @blur="onBlur"></article-editor>',
});
export const EditArticleView = Template.bind({});
EditArticleView.args = {
article: {
id: '1',
title: 'Lorem ipsum',
content:
'L**orem ipsum** dolor sit amet, consectetur adipiscing elit. Congue diam orci tellus *varius per cras turpis aliquet commodo dolor justo* rutrum lorem venenatis aliquet orci curae hac. Sagittis ultrices felis **`ante placerat condimentum parturient erat consequat`** sollicitudin *sagittis potenti sollicitudin* quis velit at placerat mi torquent. Dignissim luctus nulla suspendisse purus cras commodo ipsum orci tempus morbi metus conubia et hac potenti quam suspendisse feugiat. Turpis eros dictum tellus natoque laoreet lacus dolor cras interdum **vitae gravida tincidunt ultricies tempor convallis tortor rhoncus suspendisse.** Nisi lacinia etiam vivamus tellus sed taciti potenti quam praesent congue euismod mauris est eu risus convallis taciti etiam. Inceptos iaculis turpis leo porta pellentesque dictum `bibendum blandit parturient nulla leo pretium` rhoncus litora dapibus fringilla hac litora.',
},
onFocus: action('focus'),
onBlur: action('blur'),
};
@@ -1,44 +0,0 @@
import { action } from '@storybook/addon-actions';
import ArticleHeader from '../Header/ArticleHeader';
export default {
title: 'Components/Help Center/Header',
component: ArticleHeader,
argTypes: {
headerTitle: {
defaultValue: 'All articles',
control: {
type: 'text',
},
},
count: {
defaultValue: 112,
control: {
type: 'number',
},
},
selectedValue: {
defaultValue: 'Status',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleHeader },
template:
'<article-header v-bind="$props" @openModal="openFilterModal" @open="openDropdown" @close="closeDropdown" ></article-header>',
});
export const ArticleHeaderView = Template.bind({});
ArticleHeaderView.args = {
headerTitle: 'All articles',
count: 112,
selectedValue: 'Status',
openFilterModal: action('openedFilterModal'),
openDropdown: action('opened'),
closeDropdown: action('closed'),
};
@@ -1,63 +0,0 @@
import ArticleItemComponent from '../ArticleItem.vue';
const STATUS_LIST = {
published: 'published',
draft: 'draft',
archived: 'archived',
};
export default {
title: 'Components/Help Center',
component: ArticleItemComponent,
argTypes: {
title: {
defaultValue: 'Setup your account',
control: {
type: 'text',
},
},
views: {
defaultValue: 13,
control: {
type: 'number',
},
},
category: {
defaultValue: 'Getting started',
control: {
type: 'text',
},
},
status: {
defaultValue: 'Status',
control: {
type: 'select',
options: STATUS_LIST,
},
},
updatedAt: {
defaultValue: '1657255863',
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleItemComponent },
template:
'<article-item-component v-bind="$props" ></article-item-component>',
});
export const ArticleItem = Template.bind({});
ArticleItem.args = {
title: 'Setup your account',
author: {
name: 'John Doe',
},
category: 'Getting started',
views: 12,
status: 'published',
updatedAt: 1657255863,
};
@@ -1,33 +0,0 @@
import ArticleSearchArticleView from '../ArticleSearch/ArticleView';
export default {
title: 'Components/Help Center/ArticleSearch/ArticleView',
component: ArticleSearchArticleView,
argTypes: {
title: {
defaultValue: 'Insert article',
control: {
type: 'text',
},
},
selectedValue: {
defaultValue: 'Status',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleSearchArticleView },
template:
'<article-search-article-view v-bind="$props"></article-search-article-view>',
});
export const ArticleHeaderView = Template.bind({});
ArticleHeaderView.args = {
title: 'Insert article',
url: 'https://staging.chatwoot.com/hc/home-rental-app/articles/filter-your-housing-search-results-0',
};
@@ -1,31 +0,0 @@
import ArticleSearchHeader from '../ArticleSearch/Header';
export default {
title: 'Components/Help Center/ArticleSearch/Header',
component: ArticleSearchHeader,
argTypes: {
title: {
defaultValue: 'Insert article',
control: {
type: 'text',
},
},
selectedValue: {
defaultValue: 'Status',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleSearchHeader },
template: '<article-search-header v-bind="$props"></article-search-header>',
});
export const ArticleHeaderView = Template.bind({});
ArticleHeaderView.args = {
title: 'Insert article',
};
@@ -1,56 +0,0 @@
import ArticleSearchResultItem from '../ArticleSearch/ArticleSearchResultItem.vue';
export default {
title: 'Components/Help Center/ArticleSearchResultItem',
component: ArticleSearchResultItem,
argTypes: {
title: {
defaultValue: 'Setup your account',
control: {
type: 'text',
},
},
body: {
defaultValue:
'You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.',
control: {
type: 'text',
},
},
category: {
defaultValue: 'Getting started',
control: {
type: 'text',
},
},
locale: {
defaultValue: 'en-US',
control: {
type: 'text',
},
},
url: {
defaultValue: '/app/accounts/1/conversations/23842',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleSearchResultItem },
template:
'<ArticleSearchResultItem v-bind="$props" ></ArticleSearchResultItem>',
});
export const ArticleSearchResultItemStory = Template.bind({});
ArticleSearchResultItemStory.args = {
title: 'Setup your account',
body: `You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.
You can manage your conversations and collaborate with your team on the go with Chatwoot mobile apps (available for Android and iOS).
In this user guide, weve explained the features, capabilities, modes of operation, and step-by-step procedures for easily using the Chatwoot platform.`,
};
@@ -1,72 +0,0 @@
import ArticleTableComponent from '../ArticleTable.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Help Center',
component: ArticleTableComponent,
argTypes: {
articles: {
defaultValue: [],
control: {
type: 'array',
},
},
articleCount: {
defaultValue: 10,
control: {
type: 'number',
},
},
currentPage: {
defaultValue: 1,
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleTableComponent },
template:
'<article-table-component @onPageChange="onPageChange" v-bind="$props" ></article-table-component>',
});
export const ArticleTable = Template.bind({});
ArticleTable.args = {
articles: [
{
title: 'Setup your account',
author: {
name: 'John Doe',
},
readCount: 13,
category: 'Getting started',
status: 'published',
updatedAt: 1657255863,
},
{
title: 'Docker Configuration',
author: {
name: 'Sam Manuel',
},
readCount: 13,
category: 'Engineering',
status: 'draft',
updatedAt: 1656658046,
},
{
title: 'Campaigns',
author: {
name: 'Sam Manuel',
},
readCount: 28,
category: 'Engineering',
status: 'archived',
updatedAt: 1657590446,
},
],
articleCount: 10,
currentPage: 1,
onPageChange: action('onPageChange'),
};
@@ -1,39 +0,0 @@
import { action } from '@storybook/addon-actions';
import EditArticleHeader from '../Header/EditArticleHeader';
export default {
title: 'Components/Help Center/Header',
component: EditArticleHeader,
argTypes: {
backButtonLabel: {
defaultValue: 'Articles',
control: {
type: 'text',
},
},
draftState: {
defaultValue: 'saving',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { EditArticleHeader },
template:
'<edit-article-header v-bind="$props" @back="onClickGoBack" @show="showPreview" @add="onClickAdd" @open="openSidebar" @close="closeSidebar" ></edit-article-header>',
});
export const EditArticleHeaderView = Template.bind({});
EditArticleHeaderView.args = {
backButtonLabel: 'Articles',
draftState: 'saving',
onClickGoBack: action('goBack'),
showPreview: action('previewOpened'),
onClickAdd: action('added'),
openSidebar: action('openedSidebar'),
closeSidebar: action('closedSidebar'),
};
@@ -1,145 +0,0 @@
import { action } from '@storybook/addon-actions';
import PortalListItem from '../PortalListItem';
export default {
title: 'Components/Help Center/Portal List',
component: PortalListItem,
argTypes: {
portals: { control: 'array' },
status: {
control: {
type: 'string',
options: ['archived', 'draft', 'published'],
},
},
selectedLocaleCode: { control: 'string' },
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { PortalListItem },
template:
'<portal-list-item v-bind="$props" @add="addLocale" @open-site="openSite" @open="openSettings" @swap="swapLocale" @delete="deleteLocale" ></portal-list-item>',
});
export const PortalListItemView = Template.bind({});
PortalListItemView.args = {
addLocale: action('addLocale'),
openSite: action('openSite'),
openSettings: action('openSettings'),
swapLocale: action('swapLocale'),
deleteLocale: action('deleteLocale'),
status: 'published',
selectedLocaleCode: 'en-US',
portals: [
{
name: 'Chatwoot Help Center',
id: 1,
color: 'red',
custom_domain: 'help-center.chatwoot.com',
articles_count: 123,
header_text: 'Help center',
homepage_link: null,
page_title: 'English',
slug: 'help-center',
archived: false,
config: {
allowed_locales: [
{
code: 'en-US',
name: 'English',
articles_count: 123,
categories_count: 42,
},
{
code: 'fr-FR',
name: 'Français',
articles_count: 23,
categories_count: 11,
},
{
code: 'de-DE',
name: 'Deutsch',
articles_count: 32,
categories_count: 12,
},
{
code: 'es-ES',
name: 'Español',
articles_count: 12,
categories_count: 4,
},
],
},
locales: [
{
code: 'en-US',
name: 'English',
articles_count: 123,
categories_count: 42,
},
{
code: 'fr-FR',
name: 'Français',
articles_count: 23,
categories_count: 11,
},
{
code: 'de-DE',
name: 'Deutsch',
articles_count: 32,
categories_count: 12,
},
{
code: 'es-ES',
name: 'Español',
articles_count: 12,
categories_count: 4,
},
],
},
{
name: 'Chatwoot Docs',
id: 2,
color: 'green',
custom_domain: 'doc-chatwoot.com',
articles_count: 67,
header_text: 'Docs',
homepage_link: null,
page_title: 'Portal',
slug: 'second_portal',
archived: false,
config: {
allowed_locales: [
{
name: 'English',
code: 'en-EN',
articles_count: 12,
categories_count: 66,
},
{
name: 'Mandarin',
code: 'ch-CH',
articles_count: 6,
categories_count: 23,
},
],
},
locales: [
{
name: 'English',
code: 'en-EN',
articles_count: 12,
categories_count: 66,
},
{
name: 'Mandarin',
code: 'ch-CH',
articles_count: 6,
categories_count: 23,
},
],
},
],
};
@@ -1,154 +0,0 @@
import PortalPopover from '../PortalPopover.vue';
export default {
title: 'Components/Help Center/Portal Popover',
component: PortalPopover,
argTypes: {
active: {
defaultValue: false,
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { PortalPopover },
template:
'<PortalPopover v-bind="$props" @click="onClick">{{label}}</PortalPopover>',
});
export const Primary = Template.bind({});
Primary.args = {
portals: [
{
name: 'Chatwoot Help Center',
id: 1,
color: null,
custom_domain: 'doc',
articles_count: 123,
header_text: null,
homepage_link: null,
page_title: null,
slug: 'first_portal',
archived: false,
config: {
allowed_locales: [
{
code: 'en',
name: 'English',
articles_count: 123,
},
{
code: 'fr',
name: 'Français',
articles_count: 123,
},
{
code: 'de',
name: 'Deutsch',
articles_count: 32,
},
{
code: 'es',
name: 'Español',
articles_count: 12,
},
{
code: 'it',
name: 'Italiano',
articles_count: 8,
},
],
},
locales: [
{
name: 'English',
code: 'en',
articles_count: 12,
},
{
name: 'Español',
code: 'es',
articles_count: 42,
},
{
name: 'French',
code: 'fr',
articles_count: 29,
},
{
name: 'Italian',
code: 'it',
articles_count: 4,
},
{
name: 'German',
code: 'de',
articles_count: 66,
},
],
},
{
name: 'Chatwoot Docs',
id: 2,
color: null,
custom_domain: 'doc',
articles_count: 124,
header_text: null,
homepage_link: null,
page_title: null,
slug: 'second_portal',
archived: false,
config: {
allowed_locales: [
{
code: 'en',
name: 'English',
articles_count: 123,
},
{
code: 'fr',
name: 'Français',
articles_count: 123,
},
{
code: 'de',
name: 'Deutsch',
articles_count: 32,
},
{
code: 'es',
name: 'Español',
articles_count: 12,
},
{
code: 'it',
name: 'Italiano',
articles_count: 8,
},
],
},
locales: [
{
name: 'English',
code: 'en',
articles_count: 12,
},
{
name: 'Japanese',
code: 'jp',
articles_count: 4,
},
{
name: 'Mandarin',
code: 'CH',
articles_count: 6,
},
],
},
],
};
@@ -1,58 +0,0 @@
import PortalSwitch from '../PortalSwitch.vue';
export default {
title: 'Components/Help Center/Portal Switch',
component: PortalSwitch,
argTypes: {
active: {
defaultValue: false,
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { PortalSwitch },
template:
'<PortalSwitch v-bind="$props" @click="onClick">{{label}}</PortalSwitch>',
});
export const Primary = Template.bind({});
Primary.args = {
active: false,
portal: {
name: 'Chatwoot Help Center',
articles_count: 124,
locales: [
{
name: 'English',
code: 'en',
articles_count: 12,
},
{
name: 'Español',
code: 'es',
articles_count: 42,
},
{
name: 'French',
code: 'fr',
articles_count: 29,
},
{
name: 'Italian',
code: 'it',
articles_count: 4,
},
{
name: 'German',
code: 'de',
articles_count: 66,
},
],
},
};
@@ -1,134 +0,0 @@
import { action } from '@storybook/addon-actions';
import Sidebar from '../Sidebar/Sidebar';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
export default {
title: 'Components/Help Center/Sidebar',
component: { Sidebar, Thumbnail },
argTypes: {
thumbnailSrc: {
defaultValue: '',
control: {
type: 'text',
},
},
headerTitle: {
defaultValue: '',
control: {
type: 'text',
},
},
subTitle: {
defaultValue: '',
control: {
type: 'text',
},
},
accessibleMenuItems: [],
additionalSecondaryMenuItems: [],
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Sidebar },
template: '<sidebar v-bind="$props" @input="onSearch"></sidebar>',
});
export const HelpCenterSidebarView = Template.bind({});
HelpCenterSidebarView.args = {
onSearch: action('search'),
thumbnailSrc: '',
headerTitle: 'Help Center',
subTitle: 'English',
accessibleMenuItems: [
{
icon: 'book',
label: 'HELP_CENTER.ALL_ARTICLES',
key: 'helpcenter_all',
count: 199,
toState: 'accounts/1/articles/all',
toolTip: 'All Articles',
toStateName: 'helpcenter_all',
},
{
icon: 'pen',
label: 'HELP_CENTER.MY_ARTICLES',
key: 'helpcenter_mine',
count: 112,
toState: 'accounts/1/articles/mine',
toolTip: 'My articles',
toStateName: 'helpcenter_mine',
},
{
icon: 'draft',
label: 'HELP_CENTER.DRAFT',
key: 'helpcenter_draft',
count: 32,
toState: 'accounts/1/articles/draft',
toolTip: 'Draft',
toStateName: 'helpcenter_draft',
},
{
icon: 'archive',
label: 'HELP_CENTER.ARCHIVED',
key: 'helpcenter_archive',
count: 10,
toState: 'accounts/1/articles/archived',
toolTip: 'Archived',
toStateName: 'helpcenter_archive',
},
],
additionalSecondaryMenuItems: [
{
icon: 'folder',
label: 'HELP_CENTER.CATEGORY',
hasSubMenu: true,
key: 'category',
children: [
{
id: 1,
label: 'Getting started',
count: 12,
truncateLabel: true,
toState: 'accounts/1/articles/categories/new',
},
{
id: 2,
label: 'Channel',
count: 19,
truncateLabel: true,
toState: 'accounts/1/articles/categories/channel',
},
{
id: 3,
label: 'Feature',
count: 24,
truncateLabel: true,
toState: 'accounts/1/articles/categories/feature',
},
{
id: 4,
label: 'Advanced',
count: 8,
truncateLabel: true,
toState: 'accounts/1/articles/categories/advanced',
},
{
id: 5,
label: 'Mobile app',
count: 3,
truncateLabel: true,
toState: 'accounts/1/articles/categories/mobile-app',
},
{
id: 6,
label: 'Others',
count: 39,
truncateLabel: true,
toState: 'accounts/1/articles/categories/others',
},
],
},
],
};
@@ -22,21 +22,17 @@ defineProps({
<template>
<div class="flex flex-col w-full h-full gap-10 font-inter">
<slot name="header" />
<!-- 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>
<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>
</template>
@@ -12,12 +12,6 @@ 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() };
@@ -26,10 +20,7 @@ export default {
return {
displayName: '',
description: '',
// 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,
attributeModel: 0,
attributeType: 0,
attributeKey: '',
regexPattern: null,
@@ -289,16 +280,13 @@ 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 {
@@ -307,16 +295,13 @@ export default {
}
}
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: var(--border-radius-normal);
}
@@ -1,141 +1,195 @@
<script setup>
<script>
import { mapGetters } from 'vuex';
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();
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;
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 = {};
},
},
};
</script>
<template>
<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"
<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"
>
{{ 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>
{{ $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>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
@@ -162,4 +216,17 @@ const openDelete = value => {
.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,112 +1,40 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
<script>
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
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'),
export default {
components: {
AddAttribute,
CustomAttribute,
},
data() {
return {
showAddPopup: false,
};
},
methods: {
openAddPopup() {
this.showAddPopup = true;
},
{
key: 1,
name: t('ATTRIBUTES_MGMT.TABS.CONTACT'),
hideAddPopup() {
this.showAddPopup = false;
},
];
});
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>
<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"
<div class="flex-1 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup()"
>
<AddAttribute
:on-close="hideAddPopup"
:selected-attribute-model-tab="selectedTabIndex"
/>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
</woot-button>
<CustomAttribute />
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddAttribute :on-close="hideAddPopup" />
</woot-modal>
</SettingsLayout>
</div>
</template>
@@ -1,12 +1,17 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const SettingsContent = () => import('../Wrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/custom-attributes'),
component: SettingsWrapper,
component: SettingsContent,
props: {
headerTitle: 'ATTRIBUTES_MGMT.HEADER',
icon: 'code',
showNewButton: false,
},
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 { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
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() {
const { campaignType, isOngoingType, isOneOffType } = useCampaign();
return { v$: useVuelidate(), campaignType, isOngoingType, isOneOffType };
return { v$: useVuelidate() };
},
data() {
return {
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import CampaignsTable from './CampaignsTable.vue';
import EditCampaign from './EditCampaign.vue';
export default {
@@ -9,16 +9,13 @@ 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 { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import CampaignCard from './CampaignCard.vue';
export default {
@@ -10,6 +10,9 @@ export default {
Spinner,
CampaignCard,
},
mixins: [campaignMixin],
props: {
campaigns: {
type: Array,
@@ -24,10 +27,7 @@ export default {
default: false,
},
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
computed: {
currentInboxId() {
return this.$route.params.inboxId;
@@ -4,13 +4,14 @@ 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 { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
},
mixins: [campaignMixin],
props: {
selectedCampaign: {
type: Object,
@@ -18,8 +19,7 @@ export default {
},
},
setup() {
const { isOngoingType } = useCampaign();
return { v$: useVuelidate(), isOngoingType };
return { v$: useVuelidate() };
},
data() {
return {
@@ -1,5 +1,5 @@
<script>
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import Campaign from './Campaign.vue';
import AddCampaign from './AddCampaign.vue';
@@ -8,10 +8,7 @@ export default {
Campaign,
AddCampaign,
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
mixins: [campaignMixin],
data() {
return { showAddPopup: false };
},
@@ -1,177 +1,219 @@
<script setup>
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
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';
import { useAccount } from 'dashboard/composables/useAccount';
import Settings from './Settings.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const { isAdmin } = useAdmin();
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 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;
confirmDeletion() {
this.deleteInbox(this.selectedInbox);
this.closeDelete();
},
openDelete(inbox) {
this.showDeletePopup = true;
this.selectedInbox = inbox;
},
closeDelete() {
this.showDeletePopup = false;
this.selectedInbox = {};
},
},
};
</script>
<template>
<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>
<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') }}
<router-link
v-if="isAdmin"
class="button nice rounded-md"
:to="{ name: 'settings_inbox_new' }"
:to="accountScopedUrl('settings/inboxes/new')"
>
<fluent-icon icon="add-circle" />
{{ $t('SETTINGS.INBOXES.NEW_INBOX') }}
</router-link>
</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"
</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"
/>
<div
<img
v-else
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"
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}`)"
>
<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"
/>
<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"
<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"
/>
</svg>
</div>
<div>
<span class="block font-medium capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
/>
</div>
</div>
</td>
</router-link>
<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')"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="settings"
color-scheme="secondary"
class-names="grey-btn"
:is-loading="loading[item.id]"
icon="dismiss-circle"
@click="openDelete(item)"
/>
</router-link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<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>
<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"
/>
<woot-confirm-delete-modal
v-if="showDeletePopup"
@@ -185,5 +227,5 @@ const openDelete = inbox => {
@onConfirm="confirmDeletion"
@onClose="closeDelete"
/>
</SettingsLayout>
</div>
</template>
@@ -1,55 +0,0 @@
<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,24 +12,6 @@ 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,
@@ -44,6 +26,18 @@ export default {
};
},
children: [
{
path: '',
redirect: 'list',
},
{
path: 'list',
name: 'settings_inbox_list',
component: InboxHome,
meta: {
permissions: ['administrator'],
},
},
{
path: 'new',
component: InboxChannel,
@@ -3,18 +3,16 @@ 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 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: {},
@@ -28,10 +26,8 @@ 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');
@@ -79,59 +75,69 @@ export default {
</script>
<template>
<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"
<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"
>
<WebhookRow
v-for="(webHookItem, index) in records"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
{{ $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>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
@@ -157,5 +163,5 @@ export default {
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
/>
</SettingsLayout>
</div>
</template>
@@ -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,6 +18,7 @@ const SUPPORTED_WEBHOOK_EVENTS = [
];
export default {
mixins: [webhookMixin],
props: {
value: {
type: Object,
@@ -69,7 +70,6 @@ export default {
subscriptions: this.subscriptions,
});
},
getEventNamei18n,
},
};
</script>
@@ -105,10 +105,10 @@ export default {
type="checkbox"
:value="event"
name="subscriptions"
class="mr-2"
class="checkbox"
/>
<label :for="event" class="text-sm">
{{ `${$t(getEventNamei18n(event))} (${event})` }}
{{ `${getEventLabel(event)} (${event})` }}
</label>
</div>
</div>
@@ -129,3 +129,9 @@ export default {
</div>
</form>
</template>
<style lang="scss" scoped>
.checkbox {
@apply mr-2;
}
</style>
@@ -1,58 +1,59 @@
<script setup>
import { computed } from 'vue';
import { getEventNamei18n } from './webhookHelper';
<script>
import webhookMixin from './webhookMixin';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import { useI18n } from 'dashboard/composables/useI18n';
const props = defineProps({
webhook: {
type: Object,
required: true,
export default {
components: { ShowMore },
mixins: [webhookMixin],
props: {
webhook: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
index: {
type: Number,
required: true,
computed: {
subscribedEvents() {
const { subscriptions } = this.webhook;
return subscriptions.map(event => this.getEventLabel(event)).join(', ');
},
},
});
const { t } = useI18n();
const subscribedEvents = computed(() => {
const { subscriptions } = props.webhook;
return subscriptions.map(event => t(getEventNamei18n(event))).join(', ');
});
};
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<tr class="space-x-2">
<td class="max-w-2xl">
<div class="font-medium break-words text-slate-700 dark:text-slate-100">
{{ webhook.url }}
</div>
<div class="text-sm text-slate-500 dark:text-slate-400 block mt-1">
<span class="text-xs text-slate-500 dark:text-slate-400">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
<ShowMore :text="subscribedEvents" :limit="60" />
</div>
</span>
</td>
<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 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>
</tr>
</template>
@@ -1,9 +0,0 @@
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`
);
});
});
@@ -0,0 +1,26 @@
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`
);
});
});
});
@@ -1,4 +0,0 @@
export const getEventNamei18n = event => {
const eventName = event.toUpperCase();
return `INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`;
};
@@ -0,0 +1,10 @@
export default {
methods: {
getEventLabel(event) {
const eventName = event.toUpperCase();
return this.$t(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`
);
},
},
};
@@ -30,14 +30,6 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'webhook',
component: Webhook,
name: 'settings_integrations_webhook',
meta: {
permissions: ['administrator'],
},
},
],
},
{
@@ -57,6 +49,14 @@ export default {
};
},
children: [
{
path: 'webhook',
component: Webhook,
name: 'settings_integrations_webhook',
meta: {
permissions: ['administrator'],
},
},
{
path: 'slack',
name: 'settings_integrations_slack',

Some files were not shown because too many files have changed in this diff Show More