diff --git a/.env.example b/.env.example
index 8a4f0bb5d..69b1b9cde 100644
--- a/.env.example
+++ b/.env.example
@@ -234,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
+## SafeFetch private network access
+## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
+# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
+
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
index b45c16828..7ea9cab5d 100644
--- a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
+++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
- before_action :set_articles, only: [:update_status, :delete_articles]
+ before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
def translate
head :not_implemented
@@ -19,6 +19,18 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
render_could_not_create_error(e.message)
end
+ def update_category
+ return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
+ return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
+
+ ActiveRecord::Base.transaction do
+ @articles.find_each { |article| article.update!(category_id: params[:category_id]) }
+ end
+ head :ok
+ rescue ActiveRecord::RecordInvalid => e
+ render_could_not_create_error(e.message)
+ end
+
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@@ -39,5 +51,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
+
+ def category_valid?
+ @portal.categories.exists?(id: params[:category_id])
+ end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js
index c79aa5da7..bab45bcb5 100644
--- a/app/javascript/dashboard/api/helpCenter/articles.js
+++ b/app/javascript/dashboard/api/helpCenter/articles.js
@@ -87,6 +87,13 @@ class ArticlesAPI extends PortalsAPI {
);
}
+ bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
+ return axios.patch(
+ `${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
+ { ids: articleIds, category_id: categoryId }
+ );
+ }
+
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js
index 71128682c..b40613739 100644
--- a/app/javascript/dashboard/api/specs/article.spec.js
+++ b/app/javascript/dashboard/api/specs/article.spec.js
@@ -153,4 +153,33 @@ describe('#PortalAPI', () => {
);
});
});
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const axiosMock = {
+ post: vi.fn(() => Promise.resolve()),
+ get: vi.fn(() => Promise.resolve()),
+ patch: vi.fn(() => Promise.resolve()),
+ delete: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ });
+
+ it('#bulkUpdateCategory', () => {
+ articlesAPI.bulkUpdateCategory({
+ portalSlug: 'room-rental',
+ articleIds: [1, 2, 3],
+ categoryId: 7,
+ });
+ expect(axiosMock.patch).toHaveBeenCalledWith(
+ '/api/v1/portals/room-rental/articles/bulk_actions/update_category',
+ { ids: [1, 2, 3], category_id: 7 }
+ );
+ });
+ });
});
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
index fde215824..c15578533 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
@@ -2,6 +2,7 @@
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
+import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
@@ -18,6 +19,7 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
+import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
@@ -62,6 +64,7 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
+const isCategoryMenuOpen = ref(false);
const { isEnterprise } = useConfig();
@@ -221,6 +224,34 @@ const bulkUpdateStatus = async status => {
}
};
+const categoryMenuItems = computed(() =>
+ props.categories.map(category => ({
+ label: category.name,
+ value: category.id,
+ action: 'move',
+ emoji: category.icon,
+ }))
+);
+
+const handleBulkUpdateCategory = async ({ value }) => {
+ isCategoryMenuOpen.value = false;
+ try {
+ await articlesAPI.bulkUpdateCategory({
+ portalSlug: route.params.portalSlug,
+ articleIds: [...selectedArticleIds.value],
+ categoryId: value,
+ });
+ onBulkActionSuccess(
+ t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
+ );
+ } catch (error) {
+ useAlert(
+ error?.message ||
+ t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
+ );
+ }
+};
+
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
@@ -340,6 +371,30 @@ watch(
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
+
+
+
+
+
+
+import ChannelIcon from './ChannelIcon.vue';
+import { useChannelBrandIcon } from './provider';
+
+const inboxes = [
+ { name: 'API', channel_type: 'Channel::Api' },
+ { name: 'Email', channel_type: 'Channel::Email' },
+ { name: 'Gmail', channel_type: 'Channel::Email', provider: 'google' },
+ { name: 'Outlook', channel_type: 'Channel::Email', provider: 'microsoft' },
+ { name: 'Messenger', channel_type: 'Channel::FacebookPage' },
+ { name: 'Instagram', channel_type: 'Channel::Instagram' },
+ { name: 'Line', channel_type: 'Channel::Line' },
+ { name: 'Telegram', channel_type: 'Channel::Telegram' },
+ { name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
+ { name: 'TikTok', channel_type: 'Channel::Tiktok' },
+ { name: 'SMS', channel_type: 'Channel::Sms' },
+ { name: 'Twilio SMS', channel_type: 'Channel::TwilioSms' },
+ {
+ name: 'Twilio WhatsApp',
+ channel_type: 'Channel::TwilioSms',
+ medium: 'whatsapp',
+ },
+ {
+ name: 'Voice',
+ channel_type: 'Channel::TwilioSms',
+ voice_enabled: true,
+ },
+ { name: 'Website', channel_type: 'Channel::WebWidget' },
+];
+
+const brandInboxes = inboxes.filter(inbox => useChannelBrandIcon(inbox).value);
+
+
+
+
+
+
+
+
+ {{ inbox.name }}
+
+
+
+
+
+
+
+ {{ inbox.name }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
index aef6a57ec..f680d3f03 100644
--- a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
+++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
@@ -1,7 +1,7 @@
-
+
inbox?.value ?? inbox;
+
export function useChannelIcon(inbox) {
- const channelTypeIconMap = {
- 'Channel::Api': 'i-woot-api',
- 'Channel::Email': 'i-woot-mail',
- 'Channel::FacebookPage': 'i-woot-messenger',
- 'Channel::Line': 'i-woot-line',
- 'Channel::Sms': 'i-woot-sms',
- 'Channel::Telegram': 'i-woot-telegram',
- 'Channel::TwilioSms': 'i-woot-sms',
- 'Channel::TwitterProfile': 'i-woot-x',
- 'Channel::WebWidget': 'i-woot-website',
- 'Channel::Whatsapp': 'i-woot-whatsapp',
- 'Channel::Instagram': 'i-woot-instagram',
- 'Channel::Tiktok': 'i-woot-tiktok',
- };
-
- const providerIconMap = {
- microsoft: 'i-woot-outlook',
- google: 'i-woot-gmail',
- };
-
const channelIcon = computed(() => {
- const inboxDetails = inbox.value || inbox;
+ const inboxDetails = resolveInbox(inbox);
const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[type];
@@ -58,3 +78,26 @@ export function useChannelIcon(inbox) {
return channelIcon;
}
+
+export function useChannelBrandIcon(inbox) {
+ return computed(() => {
+ const inboxDetails = resolveInbox(inbox);
+ const type = inboxDetails.channel_type;
+ let icon = channelTypeBrandIconMap[type];
+
+ if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
+ if (Object.keys(providerBrandIconMap).includes(inboxDetails.provider)) {
+ icon = providerBrandIconMap[inboxDetails.provider];
+ }
+ }
+
+ if (
+ type === INBOX_TYPES.TWILIO &&
+ inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
+ ) {
+ icon = channelTypeBrandIconMap['Channel::Whatsapp'];
+ }
+
+ return icon ?? null;
+ });
+}
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue
index be67f85ca..5f6544738 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue
@@ -6,9 +6,12 @@ import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
-const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
- inboxId.value
-);
+const {
+ isAFacebookInbox,
+ isAnInstagramChannel,
+ isATiktokChannel,
+ isAWhatsAppChannel,
+} = useInbox(inboxId.value);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
@@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => {
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
+ if (isAWhatsAppChannel.value)
+ return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
index bbbf30090..e46f45da5 100644
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
@@ -14,6 +14,11 @@ const props = defineProps({
type: String,
default: 'conversation',
},
+ action: {
+ type: String,
+ default: 'assign',
+ validator: value => ['assign', 'remove'].includes(value),
+ },
isLoading: {
type: Boolean,
default: false,
@@ -22,9 +27,13 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ appliedLabels: {
+ type: Array,
+ default: null,
+ },
});
-const emit = defineEmits(['assign']);
+const emit = defineEmits(['assign', 'remove']);
const { t } = useI18n();
@@ -35,17 +44,43 @@ const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
+const isRemoveAction = computed(() => props.action === 'remove');
-const buttonLabel = computed(() =>
- props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
+const buttonLabel = computed(() => {
+ if (!isTypeContact.value) return '';
+
+ return isRemoveAction.value
+ ? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
+ : t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
+});
+
+const tooltipLabel = computed(() =>
+ isRemoveAction.value
+ ? t('BULK_ACTION.LABELS.REMOVE_LABELS')
+ : t('BULK_ACTION.LABELS.ASSIGN_LABELS')
+);
+
+const confirmLabel = computed(() =>
+ isRemoveAction.value
+ ? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
+ : t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
+const visibleLabels = computed(() => {
+ if (!isRemoveAction.value || props.appliedLabels === null) {
+ return labels.value;
+ }
+
+ const applied = new Set(props.appliedLabels);
+ return labels.value.filter(label => applied.has(label.title));
+});
+
const labelMenuItems = computed(() => {
- return labels.value.map(label => ({
+ return visibleLabels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
@@ -64,9 +99,13 @@ const toggleLabelSelection = labelTitle => {
}
};
-const handleAssign = () => {
+const handleApply = () => {
if (selectedLabels.value.length > 0) {
- emit('assign', selectedLabels.value);
+ if (isRemoveAction.value) {
+ emit('remove', selectedLabels.value);
+ } else {
+ emit('assign', selectedLabels.value);
+ }
toggleDropdown(false);
selectedLabels.value = [];
}
@@ -81,9 +120,9 @@ const handleDismiss = () => {
{
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue
index 145e89bde..eef70cf02 100644
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue
@@ -1,5 +1,6 @@
@@ -103,6 +108,13 @@ const handleAssignLabels = labels => {
:disabled="!selectedCount"
@assign="handleAssignLabels"
/>
+