Merge branch 'develop' into feature/10945-search-results-page

This commit is contained in:
Sojan Jose
2025-06-04 22:39:33 -05:00
committed by GitHub
44 changed files with 470 additions and 418 deletions
+7 -1
View File
@@ -44,6 +44,12 @@ force_run:
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug:
overmind connect backend
@@ -53,4 +59,4 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
+4
View File
@@ -0,0 +1,4 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = contacts.page(@current_page)
@contacts = fetch_contacts(contacts)
end
def show; end
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label)
end
def check_signup_enabled
@@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create
resource = resource_class.new(resource_params)
authorize_resource(resource)
+5
View File
@@ -61,6 +61,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -17,6 +17,7 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
});
@@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
@@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
buttonLabel: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
isSegmentsView: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -85,7 +59,7 @@ const emit = defineEmits([
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
@@ -105,7 +79,12 @@ const emit = defineEmits([
<slot name="filter" />
</div>
<Button
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
@@ -113,7 +92,7 @@ const emit = defineEmits([
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView"
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
@@ -36,6 +36,7 @@ const props = defineProps({
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -277,6 +278,7 @@ defineExpose({
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
const props = defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
@@ -37,10 +37,23 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index'
);
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
@@ -57,7 +70,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
ref="contactListHeaderWrapper"
:show-search="isNotSegmentView"
:show-search="isNotSegmentView && !isActiveView"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@@ -66,6 +79,7 @@ const openFilter = () => {
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@@ -74,11 +88,7 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
@@ -71,6 +71,7 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@@ -46,8 +46,6 @@ const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard';
};
@@ -42,6 +42,7 @@ const showCopilotLauncher = computed(() => {
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
@@ -74,6 +74,7 @@ const updateSelected = newValue => {
<slot name="trigger" :toggle="toggle">
<Button
ref="triggerRef"
type="button"
sm
slate
:variant
@@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({
const {
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: {
type: Array,
required: true,
@@ -18,6 +25,22 @@ const { options } = defineProps({
type: Boolean,
default: false,
},
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
});
const { t } = useI18n();
@@ -69,15 +92,26 @@ const toggleSelected = option => {
sm
slate
faded
type="button"
:icon="selectedItem.icon"
:label="selectedItem.name"
@click="toggle"
/>
<Button v-else sm slate faded @click="toggle">
<Button
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
<Icon :icon="placeholderIcon" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
<span class="text-n-slate-11">{{
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -87,7 +121,7 @@ const toggleSelected = option => {
v-model="searchTerm"
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
@@ -1,7 +1,8 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, watch } from 'vue';
import Input from './Input.vue';
import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({
min: { type: Number, default: 0 },
@@ -11,36 +12,50 @@ const props = defineProps({
const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const UNIT_TYPES = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
const convertToMinutes = newValue => {
if (unit.value === DURATION_UNITS.MINUTES) {
return Math.floor(newValue);
}
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
};
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({
get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
if (unit.value === UNIT_TYPES.DAYS)
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS)
return Math.floor(duration.value / 60);
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60);
return 0;
},
set(newValue) {
let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
let minuteValue = convertToMinutes(newValue);
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
},
});
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script>
<template>
@@ -57,10 +72,12 @@ const transformedValue = computed({
:disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
>
<option :value="UNIT_TYPES.MINUTES">
<option :value="DURATION_UNITS.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }}
</option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
<option :value="DURATION_UNITS.HOURS">
{{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select>
</template>
@@ -0,0 +1,5 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -235,6 +235,12 @@ const menuItems = computed(() => {
),
activeOn: ['contacts_dashboard_index', 'contacts_edit'],
},
{
name: 'Active',
label: t('SIDEBAR.ACTIVE'),
to: accountScopedRoute('contacts_dashboard_active'),
activeOn: ['contacts_dashboard_active'],
},
{
name: 'Segments',
icon: 'i-lucide-group',
@@ -9,6 +9,7 @@ const contacts = accountId => ({
'contacts_edit',
'contacts_edit_segment',
'contacts_edit_label',
'contacts_dashboard_active',
],
menuItems: [
{
@@ -18,6 +19,13 @@ const contacts = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
},
{
icon: 'visitor-contacts',
label: 'ACTIVE',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts/active`),
toStateName: 'contacts_dashboard_active',
},
],
});
@@ -45,9 +45,10 @@ export function useAccount() {
};
};
const updateAccount = async data => {
const updateAccount = async (data, options) => {
await store.dispatch('accounts/update', {
...data,
options,
});
};
@@ -286,6 +286,7 @@
"HEADER": {
"TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
@@ -560,7 +561,8 @@
"SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
@@ -46,7 +46,31 @@
},
"AUTO_RESOLVE": {
"TITLE": "Auto-resolve conversations",
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below."
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.",
"DURATION": {
"LABEL": "Inactivity duration",
"HELP": "Time period of inactivity after which conversation is auto-resolved",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
}
},
"MESSAGE": {
"LABEL": "Custom auto-resolution message",
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"HELP": "Message sent to the customer after conversation is auto-resolved"
},
"PREFERENCES": "Preferences",
"LABEL": {
"LABEL": "Add label after auto-resolution",
"PLACEHOLDER": "Select a label"
},
"IGNORE_WAITING": {
"LABEL": "Skip conversations waiting for agents reply"
},
"UPDATE_BUTTON": "Save Changes"
},
"NAME": {
"LABEL": "Account name",
@@ -68,24 +92,6 @@
"PLACEHOLDER": "Your company's support email",
"ERROR": ""
},
"AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Exclude unattended conversations",
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agents reply."
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution",
"HELP": "Duration after a conversation should auto resolve if there is no activity",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
},
"UPDATE_BUTTON": "Update",
"MESSAGE_LABEL": "Custom resolution message",
"MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
},
"FEATURES": {
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
@@ -286,6 +286,7 @@
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
"ACTIVE": "Active",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
@@ -67,9 +67,11 @@ const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
);
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0;
});
const showEmptyStateLayout = computed(() => {
return (
!searchQuery.value &&
@@ -89,11 +91,20 @@ const showEmptyText = computed(() => {
const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE');
});
const emptyStateMessage = computed(() => {
if (isActiveView.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
if (!searchQuery.value || hasAppliedFilters.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const updatePageParam = (page, search = '') => {
const query = {
...route.query,
@@ -132,6 +143,15 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
updatePageParam(page);
};
const fetchActiveContacts = async (page = 1) => {
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
sortAttr: buildSortAttr(),
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
@@ -158,6 +178,11 @@ const fetchContactsBasedOnContext = async page => {
}
// Reset the search value when we change the view
searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
return;
}
// If there are applied filters or active segment with query
if (
(hasAppliedFilters.value || activeSegment.value?.query) &&
@@ -184,6 +209,11 @@ const handleSort = async ({ sort, order }) => {
return;
}
if (isActiveView.value) {
await fetchActiveContacts();
return;
}
await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value
@@ -210,7 +240,7 @@ watch(
);
watch(
[activeLabel, activeSegment],
[activeLabel, activeSegment, isActiveView],
() => {
fetchContactsBasedOnContext(pageNumber.value);
},
@@ -222,6 +252,13 @@ watch(searchQuery, value => {
searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group
if (value === undefined) {
if (
isActiveView.value ||
activeLabel.value ||
activeSegment.value ||
hasAppliedFilters.value
)
return;
fetchContacts();
}
});
@@ -232,6 +269,10 @@ onMounted(async () => {
await searchContacts(searchQuery.value, pageNumber.value);
return;
}
if (isActiveView.value) {
await fetchActiveContacts(pageNumber.value);
return;
}
await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact(
@@ -286,11 +327,7 @@ onMounted(async () => {
class="flex items-center justify-center py-10"
>
<span class="text-base text-n-slate-11">
{{
searchQuery || !hasAppliedFilters
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
}}
{{ emptyStateMessage }}
</span>
</div>
@@ -32,6 +32,12 @@ export const routes = [
component: ContactsIndex,
meta: commonMeta,
},
{
path: 'active',
name: 'contacts_dashboard_active',
component: ContactsIndex,
meta: commonMeta,
},
],
},
{
@@ -1,35 +1,78 @@
<script setup>
import { ref, watch } from 'vue';
import { h, ref, watch, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import DurationInput from 'next/input/DurationInput.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n();
const duration = ref(0);
const unit = ref(DURATION_UNITS.MINUTES);
const message = ref('');
const labelToApply = ref({});
const ignoreWaiting = ref(false);
const isEnabled = ref(false);
const isSubmitting = ref(false);
const { currentAccount, updateAccount } = useAccount();
const labels = useMapGetter('labels/getLabels');
const labelOptions = computed(() =>
labels.value?.length
? labels.value.map(label => ({
id: label.title,
name: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
}))
: []
);
const selectedLabelName = computed(() => {
return labelToApply.value?.name ?? null;
});
watch(
currentAccount,
[currentAccount, labelOptions],
() => {
const {
auto_resolve_after,
auto_resolve_message,
auto_resolve_ignore_waiting,
auto_resolve_label,
} = currentAccount.value?.settings || {};
duration.value = auto_resolve_after;
message.value = auto_resolve_message;
ignoreWaiting.value = auto_resolve_ignore_waiting;
// find the correct label option from the list
// the single select component expects the full label object
// in our case, the label id and name are both the same
labelToApply.value = labelOptions.value.find(
option => option.name === auto_resolve_label
);
// Set unit based on duration and its divisibility
if (duration.value) {
if (duration.value % (24 * 60) === 0) {
unit.value = DURATION_UNITS.DAYS;
} else if (duration.value % 60 === 0) {
unit.value = DURATION_UNITS.HOURS;
} else {
unit.value = DURATION_UNITS.MINUTES;
}
}
if (duration.value) {
isEnabled.value = true;
@@ -40,16 +83,19 @@ watch(
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS'));
isSubmitting.value = true;
await updateAccount(settings, { silent: true });
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR'));
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
} finally {
isSubmitting.value = false;
}
};
const handleSubmit = async () => {
if (duration.value < 10) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR'));
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
return Promise.resolve();
}
@@ -57,6 +103,7 @@ const handleSubmit = async () => {
auto_resolve_after: duration.value,
auto_resolve_message: message.value,
auto_resolve_ignore_waiting: ignoreWaiting.value,
auto_resolve_label: selectedLabelName.value,
});
};
@@ -68,6 +115,7 @@ const handleDisable = async () => {
auto_resolve_after: null,
auto_resolve_message: '',
auto_resolve_ignore_waiting: false,
auto_resolve_label: null,
});
};
@@ -80,6 +128,7 @@ const toggleAutoResolve = async () => {
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:hide-content="!isEnabled"
with-border
>
<template #headerActions>
@@ -90,50 +139,65 @@ const toggleAutoResolve = async () => {
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
>
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model="duration"
v-model:unit="unit"
min="0"
max="1439856"
max="1438560"
class="w-full"
/>
</div>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')"
:help-message="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
v-model="message"
class="w-full"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER')
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
/>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')"
>
<template #rightOfLabel>
<Switch v-model="ignoreWaiting" />
</template>
<p class="text-sm ml-px text-n-slate-10 max-w-lg">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }}
</p>
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
<div
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
</span>
<Switch v-model="ignoreWaiting" />
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
/>
</div>
</div>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:label="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON')
"
:is-loading="isSubmitting"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
/>
</div>
</form>
@@ -1,24 +1,19 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
withBorder: {
type: Boolean,
default: false,
},
title: { type: String, required: true },
description: { type: String, required: true },
withBorder: { type: Boolean, default: false },
hideContent: { type: Boolean, default: false },
});
</script>
<template>
<section
class="grid grid-cols-1 py-8 gap-8"
:class="{ 'border-t border-n-weak': withBorder }"
class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]"
:class="{
'border-t border-n-weak': withBorder,
'pb-8': !hideContent,
}"
>
<header class="grid grid-cols-4">
<div class="col-span-3">
@@ -33,7 +28,10 @@ defineProps({
<slot name="headerActions" />
</div>
</header>
<div class="text-n-slate-12">
<div
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
>
<slot />
</div>
</section>
@@ -63,8 +63,11 @@ export const actions = {
});
}
},
update: async ({ commit }, updateObj) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
update: async ({ commit }, { options, ...updateObj }) => {
if (options?.silent !== true) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
}
try {
const response = await AccountAPI.update('', updateObj);
commit(types.default.EDIT_ACCOUNT, response.data);
@@ -75,6 +75,21 @@ export const actions = {
}
},
active: async ({ commit }, { page = 1, sortAttr } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.active(page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { id }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
try {
@@ -70,6 +70,30 @@ describe('#actions', () => {
});
});
describe('#active', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: contactList, meta: { count: 100, current_page: 1 } },
});
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.CLEAR_CONTACTS],
[types.SET_CONTACTS, contactList],
[types.SET_CONTACT_META, { count: 100, current_page: 1 }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct mutations if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#update', () => {
it('sends correct mutations if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
+1
View File
@@ -8,6 +8,7 @@ class Conversations::ResolutionJob < ApplicationJob
# send message from bot that conversation has been resolved
# do this is account.auto_resolve_message is set
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
conversation.add_labels(account.auto_resolve_label) if account.auto_resolve_label.present?
conversation.toggle_status
end
end
+3 -2
View File
@@ -35,7 +35,8 @@ class Account < ApplicationRecord
{
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }
},
'required': [],
'additionalProperties': true
@@ -51,7 +52,7 @@ class Account < ApplicationRecord
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
+1 -49
View File
@@ -91,55 +91,7 @@ as well as a link to its edit page.
</div>
</section>
<section class="main-content__body">
<% account_user_page =
Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
<% if account_user_page.resource.errors.any? %>
<div id="error_explanation">
<h2>
<%= t(
"administrate.form.errors",
pluralized_errors:
pluralize(
account_user_page.resource.errors.count,
t("administrate.form.error"),
),
resource_name: display_resource_name(account_user_page.resource_name),
) %>
</h2>
<ul>
<% account_user_page.resource.errors.full_messages.each do |message| %>
<li class="flash-error"><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<% account_user_page.attributes.each do |title, attributes| -%>
<% attributes.each do |attribute| %>
<% if attribute.name == "account" %>
<%= f.hidden_field("account_id", value: page.resource.id) %>
<% else %>
<div
class="
field-unit field-unit--<%= attribute.html_class %>
field-unit--<%= requireness(attribute) %>
"
>
<%= render_field attribute, f: f %>
</div>
<% end %>
<% end %>
<% end -%>
<div class="form-actions">
<%= f.submit %>
</div>
<% end %>
</section>
<%= render 'super_admin/shared/account_user_form', page: page, namespace: namespace, resource_type: 'account' %>
<%= render partial: "seed_data", locals: { page: page } %>
@@ -0,0 +1,54 @@
<%#
# Account User Form Partial
This partial renders the account user creation form.
Used by both account and user show pages.
## Local variables:
- `page`: The Administrate page object (Account or User)
- `namespace`: The current namespace (usually :super_admin)
- `resource_type`: Either 'account' or 'user' to determine hidden field
%>
<section class="main-content__body">
<% account_user_page = Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
<% if account_user_page.resource.errors.any? %>
<div id="error_explanation">
<h2>
<%= t(
"administrate.form.errors",
pluralized_errors: pluralize(
account_user_page.resource.errors.count,
t("administrate.form.error")
),
resource_name: display_resource_name(account_user_page.resource_name)
) %>
</h2>
<ul>
<% account_user_page.resource.errors.full_messages.each do |message| %>
<li class="flash-error"><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<% account_user_page.attributes.each do |title, attributes| -%>
<% attributes.each do |attribute| %>
<% if attribute.name == resource_type %>
<%= f.hidden_field("#{resource_type}_id", value: page.resource.id) %>
<% else %>
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
<%= render_field attribute, f: f %>
</div>
<% end %>
<% end %>
<% end -%>
<div class="form-actions">
<%= f.submit %>
</div>
<% end %>
</section>
+1 -39
View File
@@ -56,44 +56,6 @@ as well as a link to its edit page.
</dl>
</section>
<section class="main-content__body">
<% account_user_page = Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
<% if account_user_page.resource.errors.any? %>
<div id="error_explanation">
<h2>
<%= t(
"administrate.form.errors",
pluralized_errors: pluralize(account_user_page.resource.errors.count, t("administrate.form.error")),
resource_name: display_resource_name(account_user_page.resource_name)
) %>
</h2>
<ul>
<% account_user_page.resource.errors.full_messages.each do |message| %>
<li class="flash-error"><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<% account_user_page.attributes.each do |title, attributes| -%>
<% attributes.each do |attribute| %>
<% if attribute.name == "user" %>
<%= f.hidden_field('user_id', value: page.resource.id) %>
<% else %>
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
<%= render_field attribute, f: f %>
</div>
<% end %>
<% end %>
<% end -%>
<div class="form-actions">
<%= f.submit %>
</div>
<% end %>
</section>
<%= render 'super_admin/shared/account_user_form', page: page, namespace: namespace, resource_type: 'user' %>
<%= render partial: "impersonate", locals: {page: page} %>
+1 -1
View File
@@ -1,6 +1,6 @@
# frozen_string_literal: true
if Rails.env.development?
if Rails.env.development? && ENV['DISABLE_MINI_PROFILER'].blank?
require 'rack-mini-profiler'
# initialization is skipped so trigger it
+1 -1
View File
@@ -534,7 +534,7 @@ Rails.application.routes.draw do
end
# resources that doesn't appear in primary navigation in super admin
resources :account_users, only: [:new, :create, :destroy]
resources :account_users, only: [:new, :create, :show, :destroy]
end
authenticated :super_admin do
mount Sidekiq::Web => '/monitoring/sidekiq'
-28
View File
@@ -1,28 +0,0 @@
## Chatwoot Developer Documentation
Welcome to the official Chatwoot developer documentation. This guide contains everything you need to know about Chatwoot APIs and build custom flows on top of Chatwoot APIs.
### 👩‍💻 Development
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
```
npm i -g mintlify
```
Run the following command at the root of your documentation (where mint.json is)
```
mintlify dev
```
### 😎 Publishing Changes
Changes will be deployed to production automatically after pushing to the default branch.
You can also preview changes using PRs, which generates a preview link of the docs.
#### Troubleshooting
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
-100
View File
@@ -1,100 +0,0 @@
{
"$schema": "https://mintlify.com/docs.json",
"name": "Chatwoot Developer Docs",
"description": "Official developer documentation for Chatwoot - the open-source customer support platform. Learn about our APIs, integrations, and development guidelines.",
"logo": {
"dark": "/logo/dark.png",
"light": "/logo/light.png"
},
"favicon": "/favicon.png",
"colors": {
"primary": "#0069ED",
"light": "#4D9CFF",
"dark": "#0050B4"
},
"fonts": {
"heading": {
"family": "Haskoy",
"weight": 500,
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Medium.woff2",
"format": "woff2"
},
"body": {
"family": "Haskoy",
"weight": 400,
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Regular.woff2",
"format": "woff2"
}
},
"theme": "maple",
"navigation": {
"groups": [
{
"group": "API Reference",
"pages": [
"introduction"
]
},
{
"group": "Application API",
"description": "APIs for managing application aspects of Chatwoot",
"includeTags": [
"Agents",
"Automation Rule",
"Account AgentBots",
"Canned Responses",
"Contact Labels",
"Contacts",
"Conversation Assignments",
"Conversation Labels",
"Conversations",
"Custom Attributes",
"Custom Filters",
"Help Center",
"Inboxes",
"Integrations",
"Messages",
"Profile",
"Reports",
"Teams",
"Webhooks"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_swagger.json"
},
{
"group": "Platform API",
"description": "APIs for managing platform aspects of Chatwoot",
"includeTags": [
"Accounts",
"Account Users",
"AgentBots",
"Users"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/platform_swagger.json"
},
{
"group": "Client API",
"description": "APIs for client applications",
"includeTags": [
"Contacts API",
"Conversations API",
"Messages API"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/client_swagger.json"
},
{
"group": "Other APIs",
"description": "Other Chatwoot APIs",
"includeTags": [
"CSAT Survey Page"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/other_swagger.json"
}
]
},
"footerSocials": {
"twitter": "https://twitter.com/chatwootapp",
"github": "https://github.com/chatwoot",
"linkedin": "https://www.linkedin.com/company/chatwoot"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-49
View File
@@ -1,49 +0,0 @@
---
title: Introduction to Chatwoot APIs
description: Learn how to use Chatwoot APIs to build integrations, customize chat experiences, and manage your installation.
sidebarTitle: Introduction
---
Welcome to the Chatwoot API documentation. Whether you're building custom workflows for your support team, integrating Chatwoot into your product, or managing users across installations, our APIs provide the flexibility and power to help you do more with Chatwoot.
Chatwoot provides three categories of APIs, each designed with a specific use case in mind:
- **Application APIs** For account-level automation and agent-facing integrations.
- **Client APIs** For building custom chat interfaces for end-users
- **Platform APIs** For managing and administering installations at scale
---
## Application APIs
Application APIs are designed for interacting with a Chatwoot account from an agent/admin perspective. Use them to build internal tools, automate workflows, or perform bulk operations like data import/export.
- **Authentication**: Requires a user `access_token`, which can be generated from **Profile Settings** after logging into your Chatwoot account.
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
- **Example**: [Google Cloud Functions Demo](https://github.com/chatwoot/google-cloud-functions-demo)
---
## Client APIs
Client APIs are intended for building custom messaging experiences over Chatwoot. If you're not using the native website widget or want to embed chat in your mobile app, these APIs are the way to go.
- **Authentication**: Uses `inbox_identifier` (from **Settings → Configuration** in API inboxes) and `contact_identifier` (returned when creating a contact).
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
- **Examples**:
- [Client API Demo](https://github.com/chatwoot/client-api-demo)
- [Flutter SDK](https://github.com/chatwoot/chatwoot-flutter-sdk)
---
## Platform APIs
Platform APIs are used to manage Chatwoot installations at the admin level. These APIs allow you to control users, roles, and accounts, or sync data from external authentication systems.
- **Authentication**: Requires an `access_token` generated by a **Platform App**, which can be created in the **Super Admin Console**.
- **Availability**: Available on **Self-hosted** / **Managed Hosting** Chatwoot installations only.
---
Use the right API for your use case, and you'll be able to extend, customize, and integrate Chatwoot into your stack with ease.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

@@ -4,6 +4,7 @@ RSpec.describe Conversations::ResolutionJob do
subject(:job) { described_class.perform_later(account: account) }
let!(:account) { create(:account) }
let(:label) { create(:label, title: 'auto-resolved', account: account) }
let!(:conversation) { create(:conversation, account: account) }
it 'enqueues the job' do
@@ -47,6 +48,16 @@ RSpec.describe Conversations::ResolutionJob do
end
end
it 'adds a label after resolution' do
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
described_class.perform_now(account: account)
expect(conversation.reload.status).to eq('resolved')
expect(conversation.reload.label_list).to include('auto-resolved')
end
it 'resolves only a limited number of conversations in a single execution' do
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes