Merge remote-tracking branch 'origin/develop' into captain-message-generation-metadata
# Conflicts: # config/routes.rb # db/schema.rb # enterprise/app/models/enterprise/concerns/message.rb
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class MessageReports extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/message_reports', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageReports();
|
||||
@@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getDrilldown({
|
||||
metric,
|
||||
bucketTimestamp,
|
||||
from,
|
||||
to,
|
||||
type = 'account',
|
||||
id,
|
||||
groupBy,
|
||||
businessHours,
|
||||
page,
|
||||
perPage,
|
||||
signal,
|
||||
}) {
|
||||
const requestConfig = {
|
||||
params: {
|
||||
metric,
|
||||
bucket_timestamp: bucketTimestamp,
|
||||
since: from,
|
||||
until: to,
|
||||
type,
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
page,
|
||||
per_page: perPage,
|
||||
},
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
requestConfig.signal = signal;
|
||||
}
|
||||
|
||||
return axios.get(`${this.url}/drilldown`, requestConfig);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import reportsAPI from '../reports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
describe('#Reports API', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||
@@ -11,6 +13,7 @@ describe('#Reports API', () => {
|
||||
expect(reportsAPI).toHaveProperty('update');
|
||||
expect(reportsAPI).toHaveProperty('delete');
|
||||
expect(reportsAPI).toHaveProperty('getReports');
|
||||
expect(reportsAPI).toHaveProperty('getDrilldown');
|
||||
expect(reportsAPI).toHaveProperty('getSummary');
|
||||
expect(reportsAPI).toHaveProperty('getAgentReports');
|
||||
expect(reportsAPI).toHaveProperty('getLabelReports');
|
||||
@@ -42,11 +45,14 @@ describe('#Reports API', () => {
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
|
||||
params: {
|
||||
business_hours: undefined,
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
metric: 'conversations_count',
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
timezone_offset: -0,
|
||||
timezone_offset: timezoneOffset(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -59,13 +65,70 @@ describe('#Reports API', () => {
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
since: 1621103400,
|
||||
timezone_offset: -0,
|
||||
timezone_offset: timezoneOffset(),
|
||||
type: 'account',
|
||||
until: 1621621800,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#getDrilldown', () => {
|
||||
reportsAPI.getDrilldown({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
groupBy: 'day',
|
||||
businessHours: false,
|
||||
page: 2,
|
||||
perPage: 25,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||
params: {
|
||||
metric: 'incoming_messages_count',
|
||||
bucket_timestamp: 1621103400,
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
group_by: 'day',
|
||||
business_hours: false,
|
||||
timezone_offset: timezoneOffset(),
|
||||
page: 2,
|
||||
per_page: 25,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#getDrilldown with abort signal', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
reportsAPI.getDrilldown({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||
params: {
|
||||
metric: 'incoming_messages_count',
|
||||
bucket_timestamp: 1621103400,
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
type: 'account',
|
||||
id: undefined,
|
||||
group_by: undefined,
|
||||
business_hours: undefined,
|
||||
timezone_offset: timezoneOffset(),
|
||||
page: undefined,
|
||||
per_page: undefined,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it('#getAgentReports', () => {
|
||||
reportsAPI.getAgentReports({
|
||||
from: 1621103400,
|
||||
|
||||
+7
-1
@@ -16,6 +16,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -49,7 +53,9 @@ const unreadMessagesCount = computed(() => {
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return (
|
||||
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
|
||||
!props.contact?.blocked &&
|
||||
slaCardLabelRef.value?.hasSlaThreshold &&
|
||||
props.conversation?.appliedSla?.id
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ const onCardClick = e => {
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:contact="contact"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
() =>
|
||||
!props.currentContact?.blocked &&
|
||||
(props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold)
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
|
||||
+4
-11
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -19,16 +19,8 @@ const slaStatus = ref({
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const slaEvents = computed(() => props.conversation?.slaEvents);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
@@ -41,8 +33,9 @@ const slaStatusText = computed(() => {
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.conversation,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -20,6 +20,7 @@ const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
@@ -28,13 +29,18 @@ const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
[
|
||||
VOICE_CALL_STATUS.NO_ANSWER,
|
||||
VOICE_CALL_STATUS.FAILED,
|
||||
VOICE_CALL_STATUS.REJECTED,
|
||||
].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
@@ -27,6 +27,7 @@ defineOptions({
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
@@ -34,6 +35,7 @@ const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.chat,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
|
||||
import { useEventListener, onKeyStroke, useRafFn } from '@vueuse/core';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
// Reorderable list with cross-page drag. It must stay mounted during a page
|
||||
// fetch — hide the list spinner while `dragging`, or the held item is lost.
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
itemKey: { type: String, default: 'id' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
currentPage: { type: Number, default: 1 },
|
||||
totalPages: { type: Number, default: 1 },
|
||||
});
|
||||
const emit = defineEmits(['reorder', 'navigatePage', 'dragging']);
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
const EDGE_BAND = 72; // px from a side that arms a page turn
|
||||
const AUTO_PAGE_DELAY = 600; // ms hovering an edge before it flips
|
||||
const SCROLL_BAND = 60; // px from top/bottom that autoscrolls the list
|
||||
const SCROLL_STEP = 10; // px scrolled per frame while parked at an edge
|
||||
const PILL = 36; // px, the page-turn arrow button on each edge
|
||||
|
||||
const root = ref(null);
|
||||
const isDragging = ref(false);
|
||||
const dragged = ref(null);
|
||||
const pointer = ref({ x: 0, y: 0 });
|
||||
const insertBefore = ref(null); // key the gap sits before, null = end of list
|
||||
const activeEdge = ref(null);
|
||||
const bounds = ref({ left: 0, right: 0, top: 0, bottom: 0 }); // visible list rect
|
||||
const grab = ref({ dx: 0, dy: 0 }); // cursor offset inside the grabbed card
|
||||
const dragHeight = ref(0);
|
||||
|
||||
let press = null; // pending press, before it becomes a drag
|
||||
let dwell = null; // timer that flips the page after hovering an edge
|
||||
let paging = false; // waiting for a flipped page to load
|
||||
let scroller = null; // scrollable ancestor, found when a drag begins
|
||||
let scrollDir = 0; // -1 up, +1 down, 0 idle
|
||||
let sourcePage = 1; // page the drag started on, to resolve cross-page end-drops
|
||||
|
||||
const keyOf = item => String(item?.[props.itemKey]);
|
||||
const others = item => props.items.filter(o => keyOf(o) !== keyOf(item));
|
||||
const isRTL = () =>
|
||||
document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl';
|
||||
const canPage = dir =>
|
||||
dir === 'next' ? props.currentPage < props.totalPages : props.currentPage > 1;
|
||||
|
||||
// Key of the item after `key` in `list`, or null when it is the last one.
|
||||
const keyAfter = (list, key) => {
|
||||
const next = list[list.findIndex(o => keyOf(o) === key) + 1];
|
||||
return next ? keyOf(next) : null;
|
||||
};
|
||||
|
||||
const edges = computed(() =>
|
||||
['prev', 'next'].filter(canPage).map(dir => {
|
||||
const onLeft = isRTL() ? dir === 'next' : dir === 'prev';
|
||||
return {
|
||||
dir,
|
||||
onLeft,
|
||||
icon: onLeft ? 'i-lucide-chevrons-left' : 'i-lucide-chevrons-right',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Cursor overlay: an edge glow band, a page-turn arrow per edge, and a ghost of
|
||||
// the dragged row that follows the cursor and shrinks when aimed at an edge.
|
||||
const EDGE_BAND_W = 80; // px width of the glow band on each pageable edge
|
||||
const bandStyle = onLeft => {
|
||||
const x = onLeft ? bounds.value.left : bounds.value.right - EDGE_BAND_W;
|
||||
return {
|
||||
width: `${EDGE_BAND_W}px`,
|
||||
height: `${bounds.value.bottom - bounds.value.top}px`,
|
||||
transform: `translate(${x}px, ${bounds.value.top}px)`,
|
||||
};
|
||||
};
|
||||
const pillStyle = onLeft => {
|
||||
const x = onLeft ? bounds.value.left + 12 : bounds.value.right - PILL - 12;
|
||||
return { transform: `translate(${x}px, ${pointer.value.y - PILL / 2}px)` };
|
||||
};
|
||||
const ghostStyle = computed(() => ({
|
||||
width: `${bounds.value.right - bounds.value.left}px`,
|
||||
transform: `translate(${pointer.value.x - grab.value.dx}px, ${pointer.value.y - grab.value.dy}px)`,
|
||||
}));
|
||||
const scaleStyle = computed(() => ({
|
||||
transformOrigin: `${grab.value.dx}px ${grab.value.dy}px`,
|
||||
}));
|
||||
|
||||
// The list as shown mid-drag: the dragged row slotted into the gap at `insertBefore`.
|
||||
const displayItems = computed(() => {
|
||||
if (!isDragging.value || !dragged.value) return props.items;
|
||||
const rest = others(dragged.value);
|
||||
const at = rest.findIndex(o => keyOf(o) === insertBefore.value);
|
||||
rest.splice(at === -1 ? rest.length : at, 0, dragged.value);
|
||||
return rest;
|
||||
});
|
||||
|
||||
// Move the gap when the cursor crosses the midpoint of the card it is over.
|
||||
const aim = () => {
|
||||
const card = document
|
||||
.elementFromPoint(pointer.value.x, pointer.value.y)
|
||||
?.closest('[data-drag-id]');
|
||||
const key = card?.dataset.dragId;
|
||||
if (!key || key === keyOf(dragged.value)) return;
|
||||
|
||||
const { top, height } = card.getBoundingClientRect();
|
||||
const above = pointer.value.y < top + height / 2;
|
||||
insertBefore.value = above ? key : keyAfter(others(dragged.value), key);
|
||||
};
|
||||
|
||||
const flip = dir => {
|
||||
if (paging || !isDragging.value) return;
|
||||
paging = true;
|
||||
emit('navigatePage', props.currentPage + (dir === 'next' ? 1 : -1));
|
||||
};
|
||||
|
||||
const aimEdge = x => {
|
||||
const rect = root.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Clip the list rect to the scroll viewport so the glow band and its top/bottom
|
||||
// fade always sit on the visible edges, not the far ends of the full content.
|
||||
const view = scroller?.getBoundingClientRect();
|
||||
const viewTop = Math.max(view?.top ?? 0, 0);
|
||||
const viewBottom = Math.min(
|
||||
view?.bottom ?? window.innerHeight,
|
||||
window.innerHeight
|
||||
);
|
||||
bounds.value = {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: Math.max(rect.top, viewTop),
|
||||
bottom: Math.min(rect.bottom, viewBottom),
|
||||
};
|
||||
|
||||
let dir = null;
|
||||
if (x <= rect.left + EDGE_BAND) dir = isRTL() ? 'next' : 'prev';
|
||||
else if (x >= rect.right - EDGE_BAND) dir = isRTL() ? 'prev' : 'next';
|
||||
if (dir && !canPage(dir)) dir = null;
|
||||
if (dir === activeEdge.value) return;
|
||||
|
||||
activeEdge.value = dir;
|
||||
clearTimeout(dwell);
|
||||
if (dir) dwell = setTimeout(() => flip(dir), AUTO_PAGE_DELAY);
|
||||
};
|
||||
|
||||
// Nearest scrollable ancestor, so a drag can reach rows that are off-screen.
|
||||
const scrollParent = () => {
|
||||
let el = root.value?.parentElement;
|
||||
while (el) {
|
||||
const { overflowY } = getComputedStyle(el);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// While parked at the top/bottom edge, keep scrolling and re-aim as rows slide by.
|
||||
const { pause: pauseScroll, resume: resumeScroll } = useRafFn(
|
||||
() => {
|
||||
if (!scroller || !scrollDir) return;
|
||||
scroller.scrollTop += scrollDir * SCROLL_STEP;
|
||||
aim();
|
||||
aimEdge(pointer.value.x);
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const updateAutoScroll = y => {
|
||||
if (!scroller) return;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const atTop = scroller.scrollTop <= 0;
|
||||
const atBottom =
|
||||
scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
|
||||
if (y < rect.top + SCROLL_BAND && !atTop) scrollDir = -1;
|
||||
else if (y > rect.bottom - SCROLL_BAND && !atBottom) scrollDir = 1;
|
||||
else scrollDir = 0;
|
||||
if (scrollDir) resumeScroll();
|
||||
else pauseScroll();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
isDragging.value = false;
|
||||
dragged.value = null;
|
||||
insertBefore.value = null;
|
||||
activeEdge.value = null;
|
||||
clearTimeout(dwell);
|
||||
paging = false;
|
||||
scrollDir = 0;
|
||||
scroller = null;
|
||||
pauseScroll();
|
||||
document.body.classList.remove('select-none');
|
||||
emit('dragging', false);
|
||||
};
|
||||
|
||||
// Positions sit on a gap-of-10 grid; a midpoint (±5 at the ends) slots between two rows.
|
||||
// Cross-page end-drops are the tricky case: moving an item off its source page pulls the
|
||||
// target page's boundary row into the vacated slot. So at the leading edge after moving
|
||||
// DOWN (or the trailing edge after moving UP) we land between the two boundary rows, or
|
||||
// the item sorts onto the adjacent page and vanishes from view.
|
||||
const drop = (item, rawBefore) => {
|
||||
const pos = o => o?.position || 0;
|
||||
const mid = (a, b) => Math.floor((pos(a) + pos(b)) / 2);
|
||||
const rest = others(item);
|
||||
// No other rows to position against — e.g. the lone article on a page dropped
|
||||
// without crossing to another page. Leave the order untouched.
|
||||
if (!rest.length) return;
|
||||
// A page flip can leave the aimed key pointing at a row that is no longer on
|
||||
// this page. Like displayItems, resolve an unknown key to null (end of list),
|
||||
// so what the user sees and what we save agree.
|
||||
const before = rest.some(o => keyOf(o) === rawBefore) ? rawBefore : null;
|
||||
const movedDown = props.currentPage > sourcePage;
|
||||
const movedUp = props.currentPage < sourcePage;
|
||||
|
||||
let position;
|
||||
if (before === null) {
|
||||
position =
|
||||
movedUp && rest.length >= 2
|
||||
? mid(rest.at(-2), rest.at(-1))
|
||||
: pos(rest.at(-1)) + 5;
|
||||
} else {
|
||||
const i = rest.findIndex(o => keyOf(o) === before);
|
||||
if (i > 0) {
|
||||
position = mid(rest[i - 1], rest[i]);
|
||||
} else if (movedDown) {
|
||||
// Top-of-page after moving down: the old first row slid up into the source
|
||||
// page's gap, so land just after it to stay this page's first.
|
||||
position = rest.length >= 2 ? mid(rest[0], rest[1]) : pos(rest[0]) + 5;
|
||||
} else {
|
||||
position = pos(rest[0]) - 5;
|
||||
}
|
||||
}
|
||||
emit('reorder', { [item[props.itemKey]]: position });
|
||||
};
|
||||
|
||||
const startDrag = () => {
|
||||
isDragging.value = true;
|
||||
dragged.value = press.item;
|
||||
grab.value = { dx: press.dx, dy: press.dy };
|
||||
dragHeight.value = press.h;
|
||||
insertBefore.value = keyAfter(props.items, keyOf(press.item));
|
||||
sourcePage = props.currentPage;
|
||||
scroller = scrollParent();
|
||||
document.body.classList.add('select-none');
|
||||
emit('dragging', true);
|
||||
};
|
||||
|
||||
const onPointerDown = (item, e) => {
|
||||
if (e.button !== 0 || props.disabled) return;
|
||||
if (e.target.closest('button, a, input, [role="button"]')) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
press = {
|
||||
item,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
dx: e.clientX - rect.left,
|
||||
dy: e.clientY - rect.top,
|
||||
h: rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
useEventListener(window, 'pointermove', e => {
|
||||
if (!press) return;
|
||||
if (!isDragging.value) {
|
||||
const moved = Math.hypot(e.clientX - press.x, e.clientY - press.y);
|
||||
if (moved < DRAG_THRESHOLD) return;
|
||||
startDrag();
|
||||
}
|
||||
e.preventDefault();
|
||||
pointer.value = { x: e.clientX, y: e.clientY };
|
||||
aim();
|
||||
aimEdge(e.clientX);
|
||||
updateAutoScroll(e.clientY);
|
||||
});
|
||||
|
||||
useEventListener(window, 'pointerup', () => {
|
||||
if (!press) return;
|
||||
press = null;
|
||||
if (!isDragging.value) return; // a press without movement is a click
|
||||
const item = dragged.value;
|
||||
const before = insertBefore.value;
|
||||
const flipping = paging;
|
||||
reset();
|
||||
if (!flipping) drop(item, before);
|
||||
});
|
||||
|
||||
// When the flipped page loads, re-aim under the held cursor so a parked edge keeps flipping.
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (!isDragging.value || !paging) return;
|
||||
paging = false;
|
||||
nextTick(() => {
|
||||
if (!isDragging.value) return;
|
||||
aim();
|
||||
activeEdge.value = null;
|
||||
aimEdge(pointer.value.x);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (!isDragging.value) return;
|
||||
press = null;
|
||||
reset();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(dwell);
|
||||
document.body.classList.remove('select-none');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative w-full h-full">
|
||||
<ul class="w-full h-full space-y-4">
|
||||
<li
|
||||
v-for="(item, index) in displayItems"
|
||||
:key="keyOf(item)"
|
||||
:data-drag-id="keyOf(item)"
|
||||
class="relative list-none"
|
||||
:class="{ 'cursor-grab': !disabled && !isDragging }"
|
||||
@pointerdown="onPointerDown(item, $event)"
|
||||
@dragstart.prevent
|
||||
>
|
||||
<div
|
||||
v-if="isDragging && keyOf(item) === keyOf(dragged)"
|
||||
:style="{ height: `${dragHeight}px` }"
|
||||
class="border-2 border-dashed rounded-2xl border-n-brand/50 bg-n-brand/5"
|
||||
/>
|
||||
<slot v-else name="item" :item="item" :index="index" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Teleport v-if="isDragging" to="body">
|
||||
<div
|
||||
v-for="edge in edges"
|
||||
:key="`band-${edge.dir}`"
|
||||
:style="bandStyle(edge.onLeft)"
|
||||
class="fixed top-0 left-0 z-40 pointer-events-none from-n-brand/15 to-transparent transition-opacity duration-200 [mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)]"
|
||||
:class="[
|
||||
edge.onLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l',
|
||||
activeEdge === edge.dir ? 'opacity-100' : 'opacity-0',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="edge in edges"
|
||||
:key="edge.dir"
|
||||
:style="pillStyle(edge.onLeft)"
|
||||
class="fixed top-0 left-0 z-50 flex items-center justify-center transition-all duration-150 border rounded-full pointer-events-none size-9 backdrop-blur-sm"
|
||||
:class="
|
||||
activeEdge === edge.dir
|
||||
? 'scale-110 border-n-brand bg-n-brand/20 text-n-brand shadow-md'
|
||||
: 'opacity-70 border-n-weak/60 bg-n-solid-1/70 text-n-slate-10'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
:icon="edge.icon"
|
||||
class="size-4"
|
||||
:class="activeEdge === edge.dir && 'animate-pulse'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dragged"
|
||||
:style="ghostStyle"
|
||||
class="fixed top-0 left-0 z-50 pointer-events-none select-none"
|
||||
>
|
||||
<div
|
||||
:style="scaleStyle"
|
||||
class="transition-transform duration-150 shadow-2xl rounded-2xl"
|
||||
:class="{ 'scale-50': activeEdge }"
|
||||
>
|
||||
<slot name="ghost" :item="dragged" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h, nextTick } from 'vue';
|
||||
import DraggableReorderList from '../DraggableReorderList.vue';
|
||||
|
||||
// The component is pointer-driven, so we drive it through real pointer events on
|
||||
// window while mocking the layout APIs jsdom does not implement: elementFromPoint
|
||||
// (which card is under the cursor) and getBoundingClientRect (its geometry).
|
||||
const elementAtPoint = { current: null };
|
||||
|
||||
const move = (clientX, clientY) =>
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY }));
|
||||
const release = () => window.dispatchEvent(new MouseEvent('pointerup'));
|
||||
|
||||
// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list.
|
||||
const stubGeometry = wrapper => {
|
||||
wrapper.element.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 600,
|
||||
});
|
||||
wrapper.findAll('[data-drag-id]').forEach((li, index) => {
|
||||
const top = index * 50;
|
||||
li.element.getBoundingClientRect = () => ({
|
||||
top,
|
||||
height: 40,
|
||||
bottom: top + 40,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const mountList = (props = {}) =>
|
||||
mount(DraggableReorderList, {
|
||||
props: { items: [], ...props },
|
||||
slots: {
|
||||
item: scope => h('div', { class: 'card' }, scope.item.title),
|
||||
ghost: scope => h('div', { class: 'ghost' }, scope.item.title),
|
||||
},
|
||||
global: { stubs: { Icon: true, teleport: true } },
|
||||
});
|
||||
|
||||
describe('DraggableReorderList', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
elementAtPoint.current = null;
|
||||
document.elementFromPoint = vi.fn(() => elementAtPoint.current);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const startDragging = async id => {
|
||||
stubGeometry(wrapper);
|
||||
wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
clientY: 20,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
it('renders each item through the item slot', () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
});
|
||||
|
||||
const cards = wrapper.findAll('.card');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0].text()).toBe('Alpha');
|
||||
expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a grab affordance only when enabled', () => {
|
||||
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] });
|
||||
expect(wrapper.find('[data-drag-id="1"]').classes()).toContain(
|
||||
'cursor-grab'
|
||||
);
|
||||
|
||||
wrapper.unmount();
|
||||
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true });
|
||||
expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain(
|
||||
'cursor-grab'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start a drag when disabled', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
disabled: true,
|
||||
});
|
||||
await startDragging(1);
|
||||
move(250, 200);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits dragging true then false across a drag', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
});
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||
move(250, 60);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')[0]).toEqual([true]);
|
||||
|
||||
release();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('dragging')[1]).toEqual([false]);
|
||||
});
|
||||
|
||||
it('emits the midpoint position when dropped between two rows', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
{ id: 3, title: 'Gamma', position: 30 },
|
||||
],
|
||||
});
|
||||
await startDragging(1);
|
||||
|
||||
// Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap
|
||||
// sits before Gamma; dropping there lands halfway between Beta and Gamma.
|
||||
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||
move(250, 85);
|
||||
await nextTick();
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 });
|
||||
});
|
||||
|
||||
it('does not reorder when the only row on a page is dropped in place', async () => {
|
||||
// P1: dragging the lone article on a later page and releasing without
|
||||
// crossing to another page must be a no-op, not move it to the top.
|
||||
wrapper = mountList({
|
||||
items: [{ id: 5, title: 'Solo', position: 260 }],
|
||||
currentPage: 2,
|
||||
totalPages: 2,
|
||||
});
|
||||
await startDragging(5);
|
||||
move(250, 300);
|
||||
await nextTick();
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')).toEqual([[true], [false]]);
|
||||
expect(wrapper.emitted('reorder')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('turns the page after dwelling on a pageable edge', async () => {
|
||||
vi.useFakeTimers();
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
],
|
||||
currentPage: 1,
|
||||
totalPages: 2,
|
||||
});
|
||||
await startDragging(1);
|
||||
|
||||
// Drag to the right edge over blank space (no card) and hold.
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(wrapper.emitted('navigatePage')[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it('can still turn pages after releasing during a pending flip', async () => {
|
||||
// Releasing while a flip fetch is in flight must clear paging state, or every
|
||||
// later drag would be stuck unable to navigate.
|
||||
vi.useFakeTimers();
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
],
|
||||
currentPage: 1,
|
||||
totalPages: 2,
|
||||
});
|
||||
|
||||
// First drag: park at the edge to start a flip, then release before the new
|
||||
// page arrives (items never change here).
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
// Second drag must be able to flip again.
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]);
|
||||
});
|
||||
});
|
||||
+68
-72
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -10,6 +9,7 @@ import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
@@ -28,9 +28,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
const emit = defineEmits([
|
||||
'translateArticle',
|
||||
'toggleSelect',
|
||||
'navigatePage',
|
||||
'dragging',
|
||||
]);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
@@ -39,14 +52,14 @@ const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
const hoveredArticleId = ref(null);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
const canReorder = props.articles?.length > 1 || props.totalPages > 1;
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
!props.isSearching &&
|
||||
localArticles.value?.length > 1 &&
|
||||
canReorder &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
});
|
||||
@@ -63,6 +76,10 @@ const handleCardHover = (isHovered, id) => {
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const openArticle = id => {
|
||||
const { tab, categorySlug, locale } = route.params;
|
||||
if (props.isCategoryArticles) {
|
||||
@@ -83,38 +100,25 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = async reorderedGroup => {
|
||||
const onReorder = async positionsHash => {
|
||||
const [movedId] = Object.keys(positionsHash);
|
||||
// A same-page reorder updates optimistically in the store, so it needs no
|
||||
// refetch. Only a cross-page drop must refresh, to pull the moved article
|
||||
// onto this page in its new spot.
|
||||
const isCrossPage = !props.articles.some(
|
||||
article => String(article.id) === movedId
|
||||
);
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
reorderedGroup: positionsHash,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
if (isCrossPage) emit('navigatePage', props.currentPage);
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
// Create a map of article IDs to their new positions
|
||||
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
|
||||
obj[key] = sortedArticlePositions[index];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
onReorder(reorderedGroup);
|
||||
};
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const getStatusMessage = (status, isSuccess) => {
|
||||
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
||||
const statusMap = {
|
||||
@@ -184,54 +188,46 @@ const updateArticle = ({ action, value, id }) => {
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
|
||||
// Watch for changes in the articles prop and update the localArticles ref
|
||||
watch(
|
||||
() => props.articles,
|
||||
newArticles => {
|
||||
localArticles.value = newArticles;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Draggable
|
||||
v-model="localArticles"
|
||||
<DraggableReorderList
|
||||
:items="articles"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
ghost-class="article-ghost-class"
|
||||
class="w-full h-full space-y-4"
|
||||
@end="onDragEnd"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@reorder="onReorder"
|
||||
@navigate-page="page => emit('navigatePage', page)"
|
||||
@dragging="value => emit('dragging', value)"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none rounded-2xl">
|
||||
<ArticleCard
|
||||
:id="element.id"
|
||||
:key="element.id"
|
||||
:title="element.title"
|
||||
:status="element.status"
|
||||
:author="element.author"
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(element.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||
/>
|
||||
</li>
|
||||
<template #item="{ item }">
|
||||
<ArticleCard
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:status="item.status"
|
||||
:author="item.author"
|
||||
:category="getCategory(item.category.id)"
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(item.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(item.id)"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<template #ghost="{ item }">
|
||||
<ArticleCard
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:status="item.status"
|
||||
:author="item.author"
|
||||
:category="getCategory(item.category.id)"
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
/>
|
||||
</template>
|
||||
</DraggableReorderList>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-ghost-class {
|
||||
@apply opacity-50 bg-n-solid-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
+8
-1
@@ -70,6 +70,7 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
);
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const isArticleDragging = ref(false);
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
@@ -148,6 +149,8 @@ const articlesCount = computed(() => {
|
||||
return Number(countMap[tab] || countMap['']);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.ceil(articlesCount.value / 25) || 1);
|
||||
|
||||
const showArticleHeaderControls = computed(
|
||||
() => !props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
@@ -343,7 +346,7 @@ watch(
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
v-if="isLoading && !isArticleDragging"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
@@ -453,9 +456,13 @@ watch(
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:is-searching="isSearching"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-pages="totalPages"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@toggle-select="handleToggleSelect"
|
||||
@navigate-page="handlePageChange"
|
||||
@dragging="isArticleDragging = $event"
|
||||
/>
|
||||
</template>
|
||||
<ArticleEmptyState
|
||||
|
||||
-23
@@ -29,7 +29,6 @@ const initialState = {
|
||||
handoffMessage: '',
|
||||
resolutionMessage: '',
|
||||
instructions: '',
|
||||
temperature: 1,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
@@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => {
|
||||
state.handoffMessage = config.handoff_message;
|
||||
state.resolutionMessage = config.resolution_message;
|
||||
state.instructions = config.instructions;
|
||||
state.temperature = config.temperature || 1;
|
||||
};
|
||||
|
||||
const handleSystemMessagesUpdate = async () => {
|
||||
@@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => {
|
||||
...props.assistant.config,
|
||||
handoff_message: state.handoffMessage,
|
||||
resolution_message: state.resolutionMessage,
|
||||
temperature: state.temperature || 1,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,26 +128,6 @@ watch(
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
v-model="state.temperature"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11 italic">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
|
||||
@@ -168,6 +168,7 @@ const selectEmoji = emoji => {
|
||||
<Button
|
||||
v-if="showRemoveButton && value"
|
||||
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
|
||||
@@ -147,8 +147,14 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const isCaptainMessage = computed(() => {
|
||||
const senderType = props.sender?.type ?? props.senderType;
|
||||
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||
@@ -390,6 +396,10 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
report:
|
||||
isOnChatwootCloud.value &&
|
||||
isCaptainMessage.value &&
|
||||
!isMessageDeleted.value,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
||||
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -81,7 +82,11 @@ const isWhatsapp = computed(
|
||||
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
[
|
||||
VOICE_CALL_STATUS.NO_ANSWER,
|
||||
VOICE_CALL_STATUS.FAILED,
|
||||
VOICE_CALL_STATUS.REJECTED,
|
||||
].includes(status.value)
|
||||
);
|
||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const endReason = computed(() => call.value?.endReason);
|
||||
|
||||
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
|
||||
COMPLETED: 'completed',
|
||||
NO_ANSWER: 'no-answer',
|
||||
FAILED: 'failed',
|
||||
REJECTED: 'rejected',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
|
||||
@@ -123,6 +123,10 @@ onMounted(() => {
|
||||
a {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,22 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createErrorMessage(error) {
|
||||
const responseError = error?.response?.data?.error;
|
||||
if (typeof responseError === 'string') return responseError;
|
||||
|
||||
return (
|
||||
responseError?.error?.message ||
|
||||
responseError?.message ||
|
||||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
|
||||
);
|
||||
},
|
||||
async onClick() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
await DyteAPI.createAMeeting(this.conversationId);
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
|
||||
useAlert(this.createErrorMessage(error));
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ const showMetaSection = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
|
||||
);
|
||||
|
||||
const showLabelsSection = computed(() => {
|
||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||
|
||||
@@ -93,7 +93,9 @@ const hasMultipleInboxes = computed(
|
||||
() => store.getters['inboxes/getInboxes'].length > 1
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.applied_sla?.id && !currentContact.value?.blocked
|
||||
);
|
||||
|
||||
const copyConversationId = async () => {
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
@@ -29,13 +30,18 @@ const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
[
|
||||
VOICE_CALL_STATUS.NO_ANSWER,
|
||||
VOICE_CALL_STATUS.FAILED,
|
||||
VOICE_CALL_STATUS.REJECTED,
|
||||
].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -61,6 +61,7 @@ const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ConversationCard from '../ConversationCard.vue';
|
||||
|
||||
const defaultChat = {
|
||||
id: 1,
|
||||
labels: [],
|
||||
messages: [],
|
||||
priority: null,
|
||||
unread_count: 0,
|
||||
timestamp: 1700000000,
|
||||
created_at: 1700000000,
|
||||
};
|
||||
|
||||
const mountComponent = (chat, currentContact = {}) =>
|
||||
shallowMount(ConversationCard, {
|
||||
props: {
|
||||
chat: { ...defaultChat, ...chat },
|
||||
currentContact: {
|
||||
name: 'Jane Doe',
|
||||
thumbnail: '',
|
||||
availability_status: 'offline',
|
||||
...currentContact,
|
||||
},
|
||||
inbox: { id: 1 },
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
'fluent-icon': true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ConversationCard', () => {
|
||||
it('does not reserve the labels row when only a persisted SLA policy id is present', () => {
|
||||
const wrapper = mountComponent({ sla_policy_id: 1, applied_sla: null });
|
||||
|
||||
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the labels row when an active applied SLA is present', () => {
|
||||
const wrapper = mountComponent({
|
||||
sla_policy_id: 1,
|
||||
applied_sla: { id: 1 },
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reserve the labels row when the contact is blocked', () => {
|
||||
const wrapper = mountComponent(
|
||||
{
|
||||
sla_policy_id: 1,
|
||||
applied_sla: { id: 1 },
|
||||
},
|
||||
{ blocked: true }
|
||||
);
|
||||
|
||||
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -161,6 +161,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
// 409 = the call already ended before accept landed (e.g. caller hung up mid-ring).
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
markDismissed(callSid);
|
||||
|
||||
@@ -163,6 +163,11 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Context::NoToolbar': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: ['bulletList', 'orderedList'],
|
||||
menu: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Editor menu options for Full Editor
|
||||
@@ -183,6 +188,16 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'insertTable',
|
||||
];
|
||||
|
||||
// [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer
|
||||
// escapes; the re-parse renders them literally, unescaping would crash it.
|
||||
const flattenLink = (_match, text, url) => {
|
||||
const cleanUrl = url
|
||||
.trim()
|
||||
.replace(/\s+["'(].*$/, '')
|
||||
.replace(/^<|>$/g, '');
|
||||
return text === cleanUrl ? cleanUrl : `${text}: ${cleanUrl}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown formatting patterns for stripping unsupported formatting.
|
||||
*
|
||||
@@ -259,7 +274,12 @@ export const MARKDOWN_PATTERNS = [
|
||||
{
|
||||
type: 'link', // PM: link
|
||||
patterns: [
|
||||
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
|
||||
// Escape-aware label + URL captures so a \] or \) can't cut the match
|
||||
// short and leave link markup that crashes the re-parse.
|
||||
{
|
||||
pattern: /\[((?:\\.|[^\]\\])*)\]\(((?:\\.|[^)\\])*)\)/g,
|
||||
replacement: flattenLink,
|
||||
},
|
||||
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
|
||||
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
|
||||
],
|
||||
|
||||
@@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
|
||||
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
|
||||
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
|
||||
});
|
||||
|
||||
@@ -13,6 +13,24 @@ export const INBOX_TYPES = {
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
};
|
||||
|
||||
// Short channel-type slugs used to identify a channel without leaning on its
|
||||
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
|
||||
export const CHANNEL_TYPES = {
|
||||
WEBSITE: 'website',
|
||||
WHATSAPP: 'whatsapp',
|
||||
FACEBOOK: 'facebook',
|
||||
INSTAGRAM: 'instagram',
|
||||
TIKTOK: 'tiktok',
|
||||
TELEGRAM: 'telegram',
|
||||
LINE: 'line',
|
||||
GMAIL: 'gmail',
|
||||
OUTLOOK: 'outlook',
|
||||
SMS: 'sms',
|
||||
API: 'api',
|
||||
VOICE: 'voice',
|
||||
EMAIL: 'email',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
/**
|
||||
* Formats seconds into a human-readable time string
|
||||
* @param {number} seconds - The time in seconds (can be negative for overdue)
|
||||
* @returns {string} Formatted time string like "2h 30m" or "1d 4h"
|
||||
*/
|
||||
const formatSLATime = seconds => {
|
||||
const absSeconds = Math.abs(seconds);
|
||||
|
||||
const units = {
|
||||
y: 31536000,
|
||||
mo: 2592000,
|
||||
d: 86400,
|
||||
h: 3600,
|
||||
m: 60,
|
||||
};
|
||||
|
||||
if (absSeconds < 60) {
|
||||
return '1m';
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
let remaining = absSeconds;
|
||||
|
||||
Object.entries(units).forEach(([unit, value]) => {
|
||||
if (parts.length >= 2) return;
|
||||
const count = Math.floor(remaining / value);
|
||||
if (count > 0) {
|
||||
parts.push(`${count}${unit}`);
|
||||
remaining -= count * value;
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const toUnixTimestamp = value => {
|
||||
if (!value || typeof value === 'number') return value;
|
||||
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isNaN(numericValue)) return numericValue;
|
||||
|
||||
const parsedTimestamp = Date.parse(value);
|
||||
return Number.isNaN(parsedTimestamp)
|
||||
? value
|
||||
: Math.floor(parsedTimestamp / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates SLA status using backend-computed due times
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {Object} params.appliedSla - The applied SLA with due_at timestamps
|
||||
* @param {Object} params.chat - The conversation object
|
||||
* @param {Array} params.slaEvents - Recorded SLA miss events for this conversation
|
||||
* @returns {Object} SLA status with type, threshold, icon, and isSlaMissed
|
||||
*/
|
||||
export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
const emptyStatus = { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
|
||||
if (!appliedSla || !chat) {
|
||||
return emptyStatus;
|
||||
}
|
||||
|
||||
const sla = useCamelCase(appliedSla);
|
||||
const conversation = useCamelCase(chat);
|
||||
const events = useCamelCase(slaEvents || []);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const slaStatuses = [];
|
||||
|
||||
const dueAtByType = {
|
||||
FRT: sla.slaFrtDueAt,
|
||||
RT: sla.slaRtDueAt,
|
||||
};
|
||||
const slaTypes = ['FRT', 'NRT', 'RT'];
|
||||
|
||||
events.forEach(event => {
|
||||
const type = event.eventType?.toUpperCase();
|
||||
if (!slaTypes.includes(type)) return;
|
||||
|
||||
const missedAt =
|
||||
type === 'NRT' ? event.createdAt : dueAtByType[type] || event.createdAt;
|
||||
if (!missedAt) return;
|
||||
|
||||
slaStatuses.push({
|
||||
type,
|
||||
threshold: missedAt - currentTime,
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
|
||||
const shouldCheckFirstResponse =
|
||||
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
|
||||
|
||||
// Check FRT - until first reply is made on time
|
||||
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
|
||||
const threshold = sla.slaFrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'FRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check NRT - only if first reply made and waiting for response
|
||||
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
|
||||
const threshold = sla.slaNrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'NRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check RT - only if conversation is unresolved
|
||||
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
|
||||
const threshold = sla.slaRtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'RT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (slaStatuses.length === 0) {
|
||||
return emptyStatus;
|
||||
}
|
||||
|
||||
// Show existing breaches before upcoming deadlines, then pick the closest timer.
|
||||
slaStatuses.sort((a, b) => {
|
||||
if (a.isSlaMissed !== b.isSlaMissed) {
|
||||
return a.isSlaMissed ? -1 : 1;
|
||||
}
|
||||
|
||||
return Math.abs(a.threshold) - Math.abs(b.threshold);
|
||||
});
|
||||
const mostUrgent = slaStatuses[0];
|
||||
|
||||
return {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(mostUrgent.threshold),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
EditorState,
|
||||
EditorView,
|
||||
buildMessageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
import {
|
||||
@@ -978,13 +983,77 @@ describe('stripUnsupportedFormatting', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('strips links but keeps text', () => {
|
||||
it('keeps link text and URL when schema does not support links', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [this link](https://example.com)',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check this link');
|
||||
).toBe('Check this link: https://example.com');
|
||||
});
|
||||
|
||||
it('drops the hidden link title when preserving the URL', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [docs](https://example.com "Docs")',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check docs: https://example.com');
|
||||
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [docs](<https://example.com> "Docs")',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check docs: https://example.com');
|
||||
});
|
||||
|
||||
// Output is re-parsed before sending, so assert the final text
|
||||
// (strip + re-parse); the re-parse turns serializer escapes into literals.
|
||||
describe('links round-trip through re-parse without crashing', () => {
|
||||
const smsSchema = buildMessageSchema([], []); // no marks, no nodes
|
||||
const sendAs = md =>
|
||||
new MessageMarkdownTransformer(smsSchema).parse(
|
||||
stripUnsupportedFormatting(md, smsSchema)
|
||||
).textContent;
|
||||
|
||||
it('keeps escaped parens/underscores anywhere in the URL', () => {
|
||||
expect(
|
||||
sendAs('See [wiki](https://en.wikipedia.org/wiki/Foo\\_\\(bar\\))')
|
||||
).toBe('See wiki: https://en.wikipedia.org/wiki/Foo_(bar)');
|
||||
expect(sendAs('See [wiki](https://host/a\\_\\(b\\)c)')).toBe(
|
||||
'See wiki: https://host/a_(b)c'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the label when it equals the URL even when escaped', () => {
|
||||
expect(
|
||||
sendAs(
|
||||
'[www.example.com/Foo\\_\\(bar\\)](www.example.com/Foo\\_\\(bar\\))'
|
||||
)
|
||||
).toBe('www.example.com/Foo_(bar)');
|
||||
});
|
||||
|
||||
it('does not reintroduce emphasis from an escaped label', () => {
|
||||
expect(sendAs('[Use \\_id\\_](https://example.com)')).toBe(
|
||||
'Use _id_: https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('flattens a label containing an escaped closing bracket', () => {
|
||||
expect(sendAs('[FAQ \\[v2\\]](https://example.com)')).toBe(
|
||||
'FAQ [v2]: https://example.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves bare URLs untouched so channels can auto-link them', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('Visit www.example.com now', emptySchema)
|
||||
).toBe('Visit www.example.com now');
|
||||
expect(
|
||||
stripUnsupportedFormatting('Visit <https://example.com>', emptySchema)
|
||||
).toBe('Visit https://example.com');
|
||||
});
|
||||
|
||||
it('converts autolinks to plain URLs when schema does not support links', () => {
|
||||
@@ -1049,7 +1118,7 @@ describe('stripUnsupportedFormatting', () => {
|
||||
it('handles complex content with multiple formatting types', () => {
|
||||
const content =
|
||||
'**Bold** and *italic* with `code` and [link](url)\n- list item';
|
||||
const expected = 'Bold and italic with code and link\nlist item';
|
||||
const expected = 'Bold and italic with code and link: url\nlist item';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { evaluateSLAStatus } from '../slaHelper';
|
||||
|
||||
describe('#SLA Helpers', () => {
|
||||
const currentTimestamp = 1700000000; // Fixed timestamp for testing
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(currentTimestamp * 1000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('evaluateSLAStatus', () => {
|
||||
describe('when inputs are invalid', () => {
|
||||
it('returns empty status when appliedSla is null', () => {
|
||||
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty status when chat is null', () => {
|
||||
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FRT (First Response Time)', () => {
|
||||
it('returns FRT status when first reply not made and within threshold', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns missed FRT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return FRT when first reply was made before due time', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 1000,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('FRT');
|
||||
});
|
||||
|
||||
it('does not return FRT when first reply was made at due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
});
|
||||
|
||||
it('returns missed FRT when first reply was made after due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 300,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('10m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: new Date(
|
||||
(currentTimestamp - 300) * 1000
|
||||
).toISOString(),
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('10m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the due time for a missed FRT event created after the deadline', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NRT (Next Response Time)', () => {
|
||||
it('returns NRT status when waiting for response and within threshold', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns missed NRT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 2700,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('15m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return NRT when not waiting for response', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('NRT');
|
||||
});
|
||||
|
||||
it('returns missed NRT when a recorded NRT miss exists after response', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: null,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('15m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
|
||||
const appliedSla = {
|
||||
sla_nrt_due_at: currentTimestamp + 300,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 60,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return NRT when first reply not made', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('NRT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RT (Resolution Time)', () => {
|
||||
it('returns RT status when conversation is open and within threshold', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['pending', 'snoozed'])(
|
||||
'returns RT status when conversation is %s and within threshold',
|
||||
status => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status,
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns missed RT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the due time for a missed RT event created after the deadline', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return RT when conversation is resolved', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status: 'resolved',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority selection', () => {
|
||||
it('returns most urgent SLA when multiple are active', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
|
||||
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
|
||||
sla_rt_due_at: currentTimestamp + 3600, // 1h
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
// FRT is selected because first_reply_created_at is null
|
||||
// NRT is not checked when first_reply_created_at is null
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
});
|
||||
|
||||
it('returns most urgent missed SLA over upcoming SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
|
||||
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 2100,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an existing missed SLA over a closer upcoming SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
|
||||
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time formatting', () => {
|
||||
it('formats time in days and hours', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1d 1h');
|
||||
});
|
||||
|
||||
it('formats time less than a minute as 1m', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1m');
|
||||
});
|
||||
|
||||
it('formats months correctly', () => {
|
||||
const appliedSla = {
|
||||
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
|
||||
}; // 1 month + 1 day
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1mo 1d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty status scenarios', () => {
|
||||
it('returns empty when no SLA thresholds are set', () => {
|
||||
const appliedSla = {};
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty when all conditions are met', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp + 3600,
|
||||
sla_nrt_due_at: currentTimestamp + 1800,
|
||||
sla_rt_due_at: currentTimestamp + 7200,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
|
||||
waiting_since: null, // Not waiting, so NRT not applicable
|
||||
status: 'resolved', // RT not applicable
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||
import {
|
||||
CONTENT_TYPES,
|
||||
VOICE_CALL_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import types from 'dashboard/store/mutation-types';
|
||||
@@ -7,6 +10,7 @@ export const TERMINAL_STATUSES = [
|
||||
'completed',
|
||||
'busy',
|
||||
'failed',
|
||||
'rejected',
|
||||
'no-answer',
|
||||
'canceled',
|
||||
'missed',
|
||||
@@ -100,6 +104,7 @@ export function handleVoiceCallCreated(
|
||||
callSid,
|
||||
callId,
|
||||
provider,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
inboxId,
|
||||
@@ -107,6 +112,11 @@ export function handleVoiceCallCreated(
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
// A voice_call message can be created already terminal when the caller hangs
|
||||
// up before connect. Only ring while the call is actually ringing; mirrors the
|
||||
// guard in seedCallsFromHydratedMessages.
|
||||
if (status !== VOICE_CALL_STATUS.RINGING) return;
|
||||
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
|
||||
@@ -316,6 +316,25 @@
|
||||
"MESSAGE": "You cannot undo this action",
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"REPORT_MESSAGE": {
|
||||
"LABEL": "Report message",
|
||||
"TITLE": "Report Captain message",
|
||||
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
|
||||
"PROBLEM_TYPE": "Problem type",
|
||||
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
|
||||
"DESCRIPTION_LABEL": "Description",
|
||||
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
|
||||
"SUBMIT": "Report",
|
||||
"SUCCESS": "Thanks for reporting. Our team will take a look.",
|
||||
"ERROR": "Could not report this message. Please try again.",
|
||||
"REASONS": {
|
||||
"incorrect_information": "Incorrect information",
|
||||
"inappropriate_response": "Inappropriate response",
|
||||
"incomplete_response": "Incomplete response",
|
||||
"outdated_information": "Outdated information",
|
||||
"other": "Other"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
|
||||
@@ -494,10 +494,6 @@
|
||||
"PLACEHOLDER": "Enter assistant name",
|
||||
"ERROR": "The name is required"
|
||||
},
|
||||
"TEMPERATURE": {
|
||||
"LABEL": "Response Temperature",
|
||||
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Enter assistant description",
|
||||
|
||||
@@ -30,5 +30,59 @@
|
||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
||||
"SUCCESS": "Details saved successfully",
|
||||
"ERROR": "Could not save details. Please try again."
|
||||
},
|
||||
"ONBOARDING_INBOX_SETUP": {
|
||||
"GREETING": "Let's set up a few things",
|
||||
"SUBTITLE": "This will give you head-start in your workspace",
|
||||
"CONTINUE": "Continue",
|
||||
"SKIP": "Skip",
|
||||
"ERROR": "Something went wrong. Please try again.",
|
||||
"WHATSAPP_CONNECTED": "WhatsApp connected successfully",
|
||||
"FACEBOOK_CONNECTED": "Facebook connected successfully",
|
||||
"CREATED_FOR_YOU": {
|
||||
"TITLE": "We've created the following for you",
|
||||
"LIVE_CHAT": "Live Chat widget",
|
||||
"LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
|
||||
"LIVE_CHAT_STATUS": "Almost done…",
|
||||
"LIVE_CHAT_READY": "Ready",
|
||||
"HELP_CENTER": "Help Center",
|
||||
"HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
|
||||
"HELP_CENTER_GENERATING": "Creating your help center…",
|
||||
"HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
|
||||
"HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
|
||||
"HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
|
||||
"HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
|
||||
"HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
|
||||
"HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"TITLE": "Connect all your conversation channels",
|
||||
"HEADER": "We found a few channels you can connect",
|
||||
"CONNECT": "Connect",
|
||||
"CONNECTED": "Connected",
|
||||
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
|
||||
"MORE_CHANNELS_EMAIL": "Email",
|
||||
"MORE_CHANNELS_VOICE": "Voice",
|
||||
"VIEW_ALL": "View all",
|
||||
"GMAIL": "Gmail",
|
||||
"OUTLOOK": "Outlook",
|
||||
"OTHER_EMAIL": "Other Email Providers"
|
||||
},
|
||||
"CHANNELS_DIALOG": {
|
||||
"TITLE": "Connect all your channels instantly",
|
||||
"SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
|
||||
"NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
|
||||
"CONNECT_TITLE": "Connect your {name} account",
|
||||
"CONNECT_SUBTITLE": "Fill out these quick details",
|
||||
"CONNECT": "Connect",
|
||||
"BACK": "Back",
|
||||
"SETUP_LATER": "Setup later in app",
|
||||
"FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
|
||||
"FACEBOOK_LAUNCH": "Continue with Facebook",
|
||||
"FACEBOOK_SELECT_PAGE": "Select a Page to connect",
|
||||
"FACEBOOK_LOADING": "Loading your Facebook Pages…",
|
||||
"FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
|
||||
"FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,26 @@
|
||||
"CLEAR_FILTER": "Clear filter",
|
||||
"EMPTY_LIST": "No results found"
|
||||
},
|
||||
"DRILLDOWN": {
|
||||
"TITLE": "{metric} details",
|
||||
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations",
|
||||
"RESULT_COUNT_MESSAGE": "{count} message | {count} messages",
|
||||
"EMPTY": "No records found for this bar.",
|
||||
"ERROR": "Could not load records. Please try again.",
|
||||
"ADMIN_ONLY": "Only administrators can drill down into report records.",
|
||||
"LOAD_MORE": "Load more",
|
||||
"CLOSE": "Close details",
|
||||
"PREVIOUS_BUCKET": "Previous bar",
|
||||
"NEXT_BUCKET": "Next bar",
|
||||
"UNKNOWN_CONTACT": "Unknown contact",
|
||||
"UNKNOWN_INBOX": "Unknown inbox",
|
||||
"UNASSIGNED_AGENT": "Unassigned",
|
||||
"NO_MESSAGE_CONTENT": "No message content",
|
||||
"MESSAGE_CREATED_AT": "Message created at {time}",
|
||||
"EVENT_OCCURRED_AT": "Event occurred at {time}",
|
||||
"INCOMING_MESSAGE": "Incoming message",
|
||||
"OUTGOING_MESSAGE": "Outgoing message"
|
||||
},
|
||||
"PAGINATION": {
|
||||
"RESULTS": "Showing {start} to {end} of {total} results",
|
||||
"PER_PAGE_TEMPLATE": "{size} / page"
|
||||
|
||||
@@ -440,7 +440,9 @@
|
||||
"DESCRIPTION": "Enable or disable AI-powered features.",
|
||||
"AUDIO_TRANSCRIPTION": {
|
||||
"TITLE": "Audio Transcription",
|
||||
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
|
||||
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
|
||||
"MODEL_TITLE": "Audio Transcription Model",
|
||||
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
|
||||
},
|
||||
"HELP_CENTER_SEARCH": {
|
||||
"TITLE": "Help Center Search Indexing",
|
||||
@@ -798,10 +800,14 @@
|
||||
},
|
||||
"FAIR_DISTRIBUTION": {
|
||||
"LABEL": "Fair distribution policy",
|
||||
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
|
||||
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
|
||||
"INPUT_MAX": "Assign max",
|
||||
"DURATION": "Conversations per agent in every"
|
||||
},
|
||||
"EXCLUDE_OLDER_THAN": {
|
||||
"LABEL": "Skip stale conversations",
|
||||
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
|
||||
},
|
||||
"INBOXES": {
|
||||
"LABEL": "Added inboxes",
|
||||
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
|
||||
|
||||
@@ -6,6 +6,7 @@ import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
||||
import {
|
||||
ACCOUNT_EVENTS,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -21,6 +23,7 @@ export default {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
NextButton,
|
||||
ReportCaptainMessageDialog,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
@@ -117,16 +120,20 @@ export default {
|
||||
handleClose(e) {
|
||||
this.$emit('close', e);
|
||||
},
|
||||
handleTranslate() {
|
||||
async handleTranslate() {
|
||||
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
|
||||
const agentLocale = this.getUISettings?.locale;
|
||||
const targetLanguage = agentLocale || accountLocale || 'en';
|
||||
this.$store.dispatch('translateMessage', {
|
||||
conversationId: this.conversationId,
|
||||
messageId: this.messageId,
|
||||
targetLanguage,
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
||||
try {
|
||||
await this.$store.dispatch('translateMessage', {
|
||||
conversationId: this.conversationId,
|
||||
messageId: this.messageId,
|
||||
targetLanguage,
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
||||
} catch (error) {
|
||||
useAlert(parseAPIErrorResponse(error));
|
||||
}
|
||||
this.handleClose();
|
||||
},
|
||||
handleReplyTo() {
|
||||
@@ -152,6 +159,10 @@ export default {
|
||||
closeDeleteModal() {
|
||||
this.showDeleteModal = false;
|
||||
},
|
||||
openReportDialog() {
|
||||
this.handleClose();
|
||||
this.$refs.reportDialog?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -243,6 +254,16 @@ export default {
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['report']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['report']"
|
||||
:option="{
|
||||
icon: 'warning',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openReportDialog"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
@@ -255,6 +276,11 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
<ReportCaptainMessageDialog
|
||||
v-if="enabledOptions['report']"
|
||||
ref="reportDialog"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: [Number, String], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'incorrect_information',
|
||||
'inappropriate_response',
|
||||
'incomplete_response',
|
||||
'outdated_information',
|
||||
'other',
|
||||
];
|
||||
|
||||
const reasonOptions = computed(() =>
|
||||
REPORT_REASONS.map(value => ({
|
||||
value,
|
||||
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const form = reactive({ reportReason: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.reportReason);
|
||||
|
||||
const resetForm = () => {
|
||||
form.reportReason = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await MessageReportsAPI.create({
|
||||
message_id: props.messageId,
|
||||
report_reason: form.reportReason,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
|
||||
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
|
||||
:is-loading="isLoading"
|
||||
:disable-confirm-button="isFormInvalid"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.reportReason"
|
||||
class="!w-full [&>select]:w-full"
|
||||
:options="reasonOptions"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
|
||||
</label>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="500"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -252,6 +252,7 @@ export default {
|
||||
<MultiselectDropdown
|
||||
:options="teamsList"
|
||||
:selected-item="assignedTeam"
|
||||
show-emoji-icon
|
||||
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
|
||||
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
|
||||
:no-search-result="
|
||||
|
||||
@@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue';
|
||||
import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
import OnboardingAccountDetails from './onboarding/Index.vue';
|
||||
import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -40,6 +41,14 @@ export default {
|
||||
},
|
||||
component: OnboardingAccountDetails,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
|
||||
name: 'onboarding_inbox_setup',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
},
|
||||
component: OnboardingInboxSetup,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/suspended'),
|
||||
name: 'account_suspended',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import OnboardingLayout from './shared/OnboardingLayout.vue';
|
||||
import OnboardingSection from './shared/OnboardingSection.vue';
|
||||
import InboxChannelsDialog from './inbox-setup/InboxChannelsDialog.vue';
|
||||
import InboxChannelsFooter from './inbox-setup/InboxChannelsFooter.vue';
|
||||
import ChannelRow from './inbox-setup/ChannelRow.vue';
|
||||
import WebWidgetCreationStatus from './inbox-setup/WebWidgetCreationStatus.vue';
|
||||
import HelpCenterCreationStatus from './inbox-setup/HelpCenterCreationStatus.vue';
|
||||
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useChannelConnect } from './inbox-setup/useChannelConnect';
|
||||
import { useDetectedChannels } from './inbox-setup/useDetectedChannels';
|
||||
import { DIALOG_CHANNELS } from './inbox-setup/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
|
||||
|
||||
const helpCenterGenerationId = computed(
|
||||
() => currentAccount.value?.custom_attributes?.help_center_generation_id
|
||||
);
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const {
|
||||
displayedChannels,
|
||||
remainingChannels,
|
||||
connectedInbox,
|
||||
hasDetectedChannels,
|
||||
} = useDetectedChannels();
|
||||
|
||||
const channelsDialogRef = ref(null);
|
||||
|
||||
// The initial inboxes fetch happens in WebWidgetCreationStatus, which polls
|
||||
// `inboxes/get` from its own mount — no need to dispatch it here too.
|
||||
onMounted(() => {
|
||||
useTrack(ONBOARDING_EVENTS.INBOX_SETUP_VISITED);
|
||||
});
|
||||
|
||||
const completeOnboarding = async event => {
|
||||
if (isSubmitting.value) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
// Declare the step we're completing so the controller only clears it when
|
||||
// the stored step still matches (idempotent). setUser then refreshes the
|
||||
// auth store so the router guard sees the cleared step and lets us in.
|
||||
await finishOnboarding({ onboarding_step: 'inbox_setup' });
|
||||
useTrack(event);
|
||||
await store.dispatch('setUser');
|
||||
router.push({ name: 'home', params: { accountId: accountId.value } });
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () =>
|
||||
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_COMPLETED);
|
||||
const handleSkip = () =>
|
||||
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_SKIPPED);
|
||||
const openChannelsDialog = () => channelsDialogRef.value?.open();
|
||||
const refetchInboxes = () => store.dispatch('inboxes/get');
|
||||
|
||||
// WhatsApp connects via Meta's embedded-signup popup; the DIALOG_CHANNELS open
|
||||
// the channels dialog preselected to their in-dialog step; the rest go through
|
||||
// the redirect OAuth flow (Gmail/Outlook keyed by email provider, Instagram by
|
||||
// channel type).
|
||||
const connectChannel = channel => {
|
||||
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
|
||||
connectWhatsapp();
|
||||
return;
|
||||
}
|
||||
if (DIALOG_CHANNELS.includes(channel.type)) {
|
||||
channelsDialogRef.value?.open(channel.type);
|
||||
return;
|
||||
}
|
||||
connectViaOAuth(channel.inbox?.provider || channel.type);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<OnboardingLayout
|
||||
:greeting="t('ONBOARDING_INBOX_SETUP.GREETING')"
|
||||
:subtitle="t('ONBOARDING_INBOX_SETUP.SUBTITLE')"
|
||||
:continue-label="t('ONBOARDING_INBOX_SETUP.CONTINUE')"
|
||||
:skip-label="t('ONBOARDING_INBOX_SETUP.SKIP')"
|
||||
:is-loading="isSubmitting"
|
||||
@continue="handleContinue"
|
||||
@skip="handleSkip"
|
||||
>
|
||||
<template #greeting-icon>
|
||||
<Icon icon="i-lucide-wrench" class="size-4 text-n-slate-7" />
|
||||
</template>
|
||||
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.TITLE')"
|
||||
icon="i-lucide-sparkles"
|
||||
>
|
||||
<div class="divide-y divide-n-weak">
|
||||
<WebWidgetCreationStatus />
|
||||
<HelpCenterCreationStatus
|
||||
v-if="isEnterprise && helpCenterGenerationId"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingSection>
|
||||
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CHANNELS.TITLE')"
|
||||
icon="i-lucide-inbox"
|
||||
>
|
||||
<div
|
||||
v-if="hasDetectedChannels"
|
||||
class="flex items-center gap-2 p-3 border-b border-dashed border-n-strong"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-lightbulb"
|
||||
class="size-4 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 min-w-0 text-body-main text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
|
||||
</span>
|
||||
</div>
|
||||
<ChannelRow
|
||||
v-for="channel in displayedChannels"
|
||||
:key="channel.type"
|
||||
:channel="channel"
|
||||
:connected-inbox="connectedInbox(channel)"
|
||||
@connect="connectChannel"
|
||||
/>
|
||||
<InboxChannelsFooter
|
||||
:remaining-channels="remainingChannels"
|
||||
@view-all="openChannelsDialog"
|
||||
/>
|
||||
</OnboardingSection>
|
||||
</OnboardingLayout>
|
||||
<InboxChannelsDialog
|
||||
ref="channelsDialogRef"
|
||||
:inboxes="inboxes"
|
||||
@connected="refetchInboxes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,22 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, computed, nextTick, onMounted } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import OnboardingLayout from './OnboardingLayout.vue';
|
||||
import OnboardingSection from './OnboardingSection.vue';
|
||||
import OnboardingFormRow from './OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './OnboardingFormSelect.vue';
|
||||
import OnboardingLayout from './shared/OnboardingLayout.vue';
|
||||
import OnboardingSection from './shared/OnboardingSection.vue';
|
||||
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
|
||||
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import {
|
||||
@@ -23,12 +24,16 @@ import {
|
||||
INDUSTRY_OPTIONS,
|
||||
REFERRAL_SOURCE_OPTIONS,
|
||||
USER_ROLE_OPTIONS,
|
||||
} from './constants';
|
||||
} from './shared/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||
|
||||
// Where each onboarding cursor routes. The backend owns which steps run where;
|
||||
// the frontend just follows the cursor it advanced us to (no deployment checks).
|
||||
const STEP_ROUTES = { inbox_setup: 'onboarding_inbox_setup' };
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -46,7 +51,10 @@ const showErrorOnFields = ref(false);
|
||||
|
||||
const validationRules = {
|
||||
userRole: {},
|
||||
website: {},
|
||||
// Website is required: the onboarding web-widget inbox can't be created
|
||||
// without a URL (Channel::WebWidget validates presence), so a blank value
|
||||
// would leave the "Live Chat widget" status polling forever.
|
||||
website: { required },
|
||||
locale: {},
|
||||
timezone: {},
|
||||
companySize: {},
|
||||
@@ -67,12 +75,15 @@ const v$ = useVuelidate(validationRules, {
|
||||
const userName = computed(() => currentUser.value?.name || '');
|
||||
const userEmail = computed(() => currentUser.value?.email || '');
|
||||
const accountName = computed(() => currentAccount.value?.name || '');
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
const { isEnriching, getChangedFields } = useAccountEnrichment({
|
||||
locale,
|
||||
website,
|
||||
timezone,
|
||||
companySize,
|
||||
industry,
|
||||
referralSource,
|
||||
});
|
||||
|
||||
const companyLogo = computed(() => {
|
||||
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
||||
if (!logos?.length) return '';
|
||||
@@ -98,94 +109,10 @@ const timezoneOptions = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales.
|
||||
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
|
||||
// falls back to account locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of auto-populated values to detect user edits at submit time
|
||||
const initialValues = ref({});
|
||||
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const account = currentAccount.value;
|
||||
const attrs = account?.custom_attributes || {};
|
||||
const brandInfo = attrs.brand_info;
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = attrs.website || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
}
|
||||
if (!companySize.value) companySize.value = attrs.company_size || '';
|
||||
if (!industry.value) {
|
||||
industry.value =
|
||||
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
|
||||
}
|
||||
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, newVal => {
|
||||
if (newVal) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
const enableWebsiteEditing = () => {
|
||||
isEditingWebsite.value = true;
|
||||
nextTick(() => websiteInput.value?.focus());
|
||||
@@ -204,8 +131,10 @@ const normalizeWebsiteUrl = raw => {
|
||||
const handleSubmit = async () => {
|
||||
// Block submit while enrichment is still running so users can't bypass
|
||||
// the form with empty values — the controller would otherwise clear
|
||||
// onboarding_step and persist incomplete data.
|
||||
if (isEnriching.value) return;
|
||||
// onboarding_step and persist incomplete data. Also guard against
|
||||
// re-entry while a submit is in flight (double-click/Enter), which would
|
||||
// fire parallel requests that can duplicate the auto-created inbox/portal.
|
||||
if (isEnriching.value || isSubmitting.value) return;
|
||||
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
@@ -214,22 +143,15 @@ const handleSubmit = async () => {
|
||||
setTimeout(() => {
|
||||
showErrorOnFields.value = false;
|
||||
}, 600);
|
||||
// The website field is read-only until edited; open it so a required-but-
|
||||
// empty value is immediately fixable rather than just shaking a locked field.
|
||||
if (v$.value.website.$error) enableWebsiteEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect which enrichable fields the user actually edited *before*
|
||||
// normalizing — otherwise an untouched auto-filled domain
|
||||
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
|
||||
// and gets falsely reported as changed, skewing onboarding telemetry.
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
const fieldsChanged = Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key);
|
||||
// Capture which enrichable fields the user edited *before* normalizing the
|
||||
// website, so an untouched auto-filled domain isn't falsely flagged.
|
||||
const fieldsChanged = getChangedFields();
|
||||
|
||||
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
||||
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
||||
@@ -246,6 +168,7 @@ const handleSubmit = async () => {
|
||||
timezone: timezone.value,
|
||||
referral_source: referralSource.value,
|
||||
user_role: userRole.value,
|
||||
onboarding_step: 'account_details',
|
||||
});
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
@@ -260,8 +183,19 @@ const handleSubmit = async () => {
|
||||
});
|
||||
|
||||
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
||||
store.commit('RESET_ONBOARDING', accountId.value);
|
||||
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
|
||||
// Follow the cursor the backend advanced us to. A next step routes there; no
|
||||
// next step means onboarding is complete, so refresh the user (so the router
|
||||
// guard sees the cleared cursor) and head to the dashboard.
|
||||
const nextStep = currentAccount.value?.custom_attributes?.onboarding_step;
|
||||
if (STEP_ROUTES[nextStep]) {
|
||||
router.push({
|
||||
name: STEP_ROUTES[nextStep],
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
} else {
|
||||
await store.dispatch('setUser');
|
||||
router.push({ name: 'home', params: { accountId: accountId.value } });
|
||||
}
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
||||
} finally {
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
|
||||
const ENRICHMENT_TIMEOUT = 30000;
|
||||
|
||||
// Manages the post-signup enrichment lifecycle for the account-details form.
|
||||
// After signup the account is enriched asynchronously (onboarding_step ===
|
||||
// 'enrichment'); this fills empty form fields from the enriched
|
||||
// custom_attributes/brand_info as it arrives — idempotently, so it never
|
||||
// clobbers a value the user has already typed — waits the step out (with a
|
||||
// timeout fallback), and tracks which enrichable fields the user edited.
|
||||
//
|
||||
// `fields` is the set of form refs to populate, owned by the component so it can
|
||||
// still wire them to validation and the template.
|
||||
export function useAccountEnrichment(fields) {
|
||||
const { currentAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales: exact match
|
||||
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account
|
||||
// locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of the auto-populated values, used to detect user edits at submit.
|
||||
const initialValues = ref({});
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: fields.website.value,
|
||||
company_size: fields.companySize.value,
|
||||
industry: fields.industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const {
|
||||
website,
|
||||
timezone,
|
||||
company_size: companySize,
|
||||
industry,
|
||||
referral_source: referralSource,
|
||||
brand_info: brandInfo,
|
||||
} = currentAccount.value?.custom_attributes || {};
|
||||
|
||||
const fillIfEmpty = (field, value) => {
|
||||
if (!field.value) field.value = value || '';
|
||||
};
|
||||
|
||||
fillIfEmpty(fields.locale, detectBestLocale());
|
||||
fillIfEmpty(fields.website, website || brandInfo?.domain);
|
||||
fillIfEmpty(
|
||||
fields.timezone,
|
||||
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
fillIfEmpty(fields.companySize, companySize);
|
||||
fillIfEmpty(
|
||||
fields.industry,
|
||||
industry || brandInfo?.industries?.[0]?.industry
|
||||
);
|
||||
fillIfEmpty(fields.referralSource, referralSource);
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, ENRICHMENT_TIMEOUT);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, enriching => {
|
||||
if (enriching) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
// Enrichable fields the user actually edited since they were auto-filled.
|
||||
// Compare against the snapshot *before* the caller normalizes any values —
|
||||
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
|
||||
// compares unequal and gets falsely reported as changed.
|
||||
const getChangedFields = () => {
|
||||
const init = initialValues.value;
|
||||
const current = {
|
||||
website: fields.website.value,
|
||||
company_size: fields.companySize.value,
|
||||
industry: fields.industry.value,
|
||||
};
|
||||
return Object.entries(current)
|
||||
.filter(([key, value]) => value !== init[key])
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
return { isEnriching, getChangedFields };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
channel: { type: Object, required: true },
|
||||
connectedInbox: { type: Object, default: null },
|
||||
});
|
||||
|
||||
defineEmits(['connect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const connected = computed(() => Boolean(props.connectedInbox));
|
||||
// Prefer the real connected account's name over the detected handle — the user
|
||||
// may have connected a different account than the one we detected.
|
||||
const connectedName = computed(
|
||||
() =>
|
||||
props.connectedInbox?.name ||
|
||||
props.channel.handle ||
|
||||
t(props.channel.labelKey)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 p-3 border-b border-n-weak">
|
||||
<div class="size-4 rounded overflow-hidden flex-shrink-0">
|
||||
<ChannelIcon
|
||||
:inbox="channel.inbox"
|
||||
use-brand-icon
|
||||
class="size-4"
|
||||
:class="{ grayscale: !connected }"
|
||||
/>
|
||||
</div>
|
||||
<span class="flex-1 min-w-0 truncate text-body-main text-n-slate-12">
|
||||
{{ t(channel.labelKey) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="connected"
|
||||
class="flex items-center gap-2 flex-shrink-0 text-body-main text-n-slate-11"
|
||||
>
|
||||
<span class="whitespace-nowrap">{{ connectedName }}</span>
|
||||
<span class="w-px h-3 bg-n-weak" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex items-center flex-shrink-0 h-7 px-2 rounded-lg outline outline-1 outline-n-container bg-n-button-color text-button text-n-blue-11"
|
||||
@click="$emit('connect', channel)"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
// Presentational shell for the "Created for you" rows. The owning component
|
||||
// drives `ready` from its own lifecycle (widget polling, help-center events);
|
||||
// row separation is handled by the parent's `divide-y`.
|
||||
defineProps({
|
||||
ready: { type: Boolean, default: false },
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
status: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Icon
|
||||
v-if="ready"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 text-n-teal-11 flex-shrink-0"
|
||||
/>
|
||||
<Spinner v-else :size="16" class="text-n-slate-9 flex-shrink-0" />
|
||||
<span class="text-sm font-medium text-n-slate-12 flex-shrink-0">
|
||||
{{ title }}
|
||||
</span>
|
||||
<span class="w-px h-4 bg-n-weak flex-shrink-0" />
|
||||
<span class="text-sm text-n-slate-11 truncate">{{ description }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 flex-shrink-0">{{ status }}</span>
|
||||
</div>
|
||||
</template>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useTimeoutPoll } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import OnboardingAPI from 'dashboard/api/onboarding';
|
||||
import CreationStatusRow from './CreationStatusRow.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
const generation = ref({
|
||||
generation_id: null,
|
||||
state: null,
|
||||
articles_count: 0,
|
||||
categories_count: 0,
|
||||
});
|
||||
|
||||
// Before the first article arrives, advance through phases so the spinner
|
||||
// label doesn't sit on a single line through the whole curation step.
|
||||
const generatingPhases = computed(() => [
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_GENERATING'),
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ANALYZING_WEBSITE'),
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SETTING_UP_CATEGORIES'),
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CURATING_ARTICLES'),
|
||||
]);
|
||||
const PHASE_DELAY_BASE = 4000;
|
||||
const PHASE_DELAY_JITTER = 2000;
|
||||
|
||||
const phaseIndex = ref(0);
|
||||
let phaseTimer = null;
|
||||
|
||||
// Advance one phase after a jittered delay; stop once we reach the last line
|
||||
// (it then holds until the first article arrives).
|
||||
const scheduleNextPhase = () => {
|
||||
if (phaseIndex.value >= generatingPhases.value.length - 1) return;
|
||||
const delay = PHASE_DELAY_BASE + Math.random() * PHASE_DELAY_JITTER;
|
||||
phaseTimer = setTimeout(() => {
|
||||
phaseIndex.value += 1;
|
||||
scheduleNextPhase();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const status = computed(
|
||||
() =>
|
||||
generation.value.state?.status ||
|
||||
(generation.value.generation_id ? 'generating' : 'not_started')
|
||||
);
|
||||
const isCompleted = computed(() => status.value === 'completed');
|
||||
const isSkipped = computed(() => status.value === 'skipped');
|
||||
const isNotStarted = computed(() => status.value === 'not_started');
|
||||
const isTerminal = computed(
|
||||
() => isCompleted.value || isSkipped.value || isNotStarted.value
|
||||
);
|
||||
const articlesCount = computed(() => generation.value.articles_count || 0);
|
||||
const categoriesCount = computed(() => generation.value.categories_count || 0);
|
||||
|
||||
// Poll the generation status until it reaches a terminal state. useTimeoutPoll
|
||||
// waits for each request to settle before scheduling the next (so requests never
|
||||
// overlap), fires immediately on mount, and stops on unmount.
|
||||
const { pause: stopPolling } = useTimeoutPoll(
|
||||
async () => {
|
||||
try {
|
||||
const { data } = await OnboardingAPI.getHelpCenterGeneration();
|
||||
generation.value = data;
|
||||
if (isTerminal.value) stopPolling();
|
||||
} catch {
|
||||
// Keep polling; transient network failures should not strand onboarding.
|
||||
}
|
||||
},
|
||||
POLL_INTERVAL,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(scheduleNextPhase);
|
||||
onBeforeUnmount(() => clearTimeout(phaseTimer));
|
||||
|
||||
const articlesText = computed(() =>
|
||||
t(
|
||||
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ARTICLES',
|
||||
{ count: articlesCount.value },
|
||||
articlesCount.value
|
||||
)
|
||||
);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isCompleted.value) {
|
||||
const categories = t(
|
||||
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CATEGORIES',
|
||||
{ count: categoriesCount.value },
|
||||
categoriesCount.value
|
||||
);
|
||||
return t(
|
||||
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SUMMARY',
|
||||
{ count: articlesCount.value, categories },
|
||||
articlesCount.value
|
||||
);
|
||||
}
|
||||
if (articlesCount.value > 0) return articlesText.value;
|
||||
return generatingPhases.value[phaseIndex.value];
|
||||
});
|
||||
|
||||
const isVisible = computed(() => !isSkipped.value && !isNotStarted.value);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<CreationStatusRow
|
||||
v-if="isVisible"
|
||||
:ready="isCompleted"
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER')"
|
||||
:description="
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_DESCRIPTION')
|
||||
"
|
||||
:status="statusText"
|
||||
/>
|
||||
</template>
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
channel: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back', 'created']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
// Per-channel field list + payload shape, mirroring the standalone settings
|
||||
// forms (channels/Line.vue, channels/Telegram.vue). Labels/placeholders reuse
|
||||
// the existing INBOX_MGMT translations so there's nothing new to localize.
|
||||
const FORMS = {
|
||||
line: {
|
||||
fields: [
|
||||
{
|
||||
key: 'lineChannelId',
|
||||
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL'),
|
||||
placeholder: t(
|
||||
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lineChannelSecret',
|
||||
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL'),
|
||||
placeholder: t(
|
||||
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER'
|
||||
),
|
||||
type: 'password',
|
||||
},
|
||||
{
|
||||
key: 'lineChannelToken',
|
||||
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL'),
|
||||
placeholder: t(
|
||||
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER'
|
||||
),
|
||||
type: 'password',
|
||||
},
|
||||
],
|
||||
errorMessage: t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'),
|
||||
// Inbox name is mandatory for Line; prefill it from the channel label
|
||||
// rather than asking, to keep the form to just the credentials.
|
||||
buildPayload: values => ({
|
||||
name: t(props.channel.labelKey),
|
||||
channel: {
|
||||
type: 'line',
|
||||
line_channel_id: values.lineChannelId,
|
||||
line_channel_secret: values.lineChannelSecret,
|
||||
line_channel_token: values.lineChannelToken,
|
||||
},
|
||||
}),
|
||||
},
|
||||
telegram: {
|
||||
fields: [
|
||||
{
|
||||
key: 'botToken',
|
||||
label: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.LABEL'),
|
||||
placeholder: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.PLACEHOLDER'),
|
||||
type: 'password',
|
||||
},
|
||||
],
|
||||
errorMessage: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE'),
|
||||
buildPayload: values => ({
|
||||
channel: { type: 'telegram', bot_token: values.botToken },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const config = FORMS[props.channel.type];
|
||||
const values = reactive(
|
||||
Object.fromEntries(config.fields.map(field => [field.key, '']))
|
||||
);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const isValid = computed(() =>
|
||||
config.fields.every(field => values[field.key].trim())
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
if (!isValid.value || isCreating.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/createChannel', config.buildPayload(values));
|
||||
emit('created');
|
||||
} catch (error) {
|
||||
useAlert(error?.message || config.errorMessage);
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- This sits inside the dialog's <form>; intercept Enter so it submits the
|
||||
channel here instead of bubbling up and completing onboarding. -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-4" @keydown.enter.prevent="submit">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
|
||||
<template v-for="field in config.fields" :key="field.key">
|
||||
<label :for="field.key" class="text-sm font-medium text-n-slate-12">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<Input
|
||||
:id="field.key"
|
||||
v-model="values[field.key]"
|
||||
:type="field.type || 'text'"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
blue
|
||||
class="flex-1 justify-center"
|
||||
:is-loading="isCreating"
|
||||
:disabled="!isValid"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useChannelConnect } from './useChannelConnect';
|
||||
import { useChannelConfig } from './useChannelConfig';
|
||||
import { CHANNEL_LIST } from './constants';
|
||||
import { isChannelConnected } from './channelMatchers';
|
||||
import InboxChannelForm from './InboxChannelForm.vue';
|
||||
import InboxFacebookForm from './InboxFacebookForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['connected']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
|
||||
const { isConfigured } = useChannelConfig();
|
||||
|
||||
// Maps the dialog's display types to the OAuth client key the flow expects.
|
||||
// Types without an entry (manual-setup channels) are no-ops for now.
|
||||
const OAUTH_PROVIDERS = {
|
||||
[CHANNEL_TYPES.GMAIL]: 'google',
|
||||
[CHANNEL_TYPES.OUTLOOK]: 'microsoft',
|
||||
[CHANNEL_TYPES.INSTAGRAM]: 'instagram',
|
||||
[CHANNEL_TYPES.TIKTOK]: 'tiktok',
|
||||
};
|
||||
|
||||
// A card's availability — what the user can do with it right now:
|
||||
// available — usable now (configured, not deferred)
|
||||
// setupLater — deferred to in-app setup (SMS/API/Voice/Email cards)
|
||||
// Channels whose installation OAuth credentials are missing are hidden entirely
|
||||
// (see channelCards), so they never reach this state.
|
||||
// `connected` (a real inbox already backs it) is orthogonal and tracked
|
||||
// separately, since a connected channel can still be in any of these states.
|
||||
const channelAvailability = channel =>
|
||||
channel.setupLater ? 'setupLater' : 'available';
|
||||
|
||||
const CARD_CLASS = {
|
||||
available: 'bg-n-solid-1 hover:outline-n-slate-6 cursor-pointer',
|
||||
setupLater: 'bg-n-slate-2 cursor-not-allowed',
|
||||
};
|
||||
|
||||
// Decorate the catalog with per-render state so the template reads plain fields
|
||||
// rather than calling predicates for each card. Channels needing an absent
|
||||
// installation credential are dropped so they don't show at all; deferred
|
||||
// (setupLater) channels stay since they aren't a configuration problem.
|
||||
const channelCards = computed(() =>
|
||||
CHANNEL_LIST.filter(
|
||||
channel => channel.setupLater || isConfigured(channel.type)
|
||||
).map(channel => {
|
||||
const connected = isChannelConnected(props.inboxes, channel.inbox);
|
||||
// Website inboxes are only auto-created during onboarding — there is no
|
||||
// manual creation path, so an unconnected Website card defers rather than
|
||||
// offering a click that can't do anything.
|
||||
const availability =
|
||||
channel.type === CHANNEL_TYPES.WEBSITE && !connected
|
||||
? 'setupLater'
|
||||
: channelAvailability(channel);
|
||||
return { ...channel, availability, connected };
|
||||
})
|
||||
);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
// Credential-form channels (Line, Telegram) and Facebook swap the grid for an
|
||||
// inline view; OAuth channels redirect; the rest are no-ops for now.
|
||||
const selectedChannel = ref(null);
|
||||
|
||||
// An inbox was created by an in-dialog form (Line/Telegram credentials or the
|
||||
// Facebook page picker); close the dialog (its @close resets the form view) and
|
||||
// let the parent refetch so the connected state and real channel icons update.
|
||||
const onCreated = () => {
|
||||
dialogRef.value?.close();
|
||||
emit('connected');
|
||||
};
|
||||
|
||||
const onCardClick = channel => {
|
||||
if (channel.availability !== 'available') return;
|
||||
if (channel.form) {
|
||||
selectedChannel.value = channel;
|
||||
return;
|
||||
}
|
||||
// WhatsApp uses Meta's embedded-signup popup, not the redirect OAuth flow.
|
||||
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
|
||||
connectWhatsapp();
|
||||
return;
|
||||
}
|
||||
// Facebook swaps to an in-dialog page picker (FB.login → choose a Page).
|
||||
if (channel.type === CHANNEL_TYPES.FACEBOOK) {
|
||||
selectedChannel.value = channel;
|
||||
return;
|
||||
}
|
||||
connectViaOAuth(OAUTH_PROVIDERS[channel.type]);
|
||||
};
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
selectedChannel.value
|
||||
? t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_TITLE', {
|
||||
name: t(selectedChannel.value.labelKey),
|
||||
})
|
||||
: t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.TITLE')
|
||||
);
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
if (!selectedChannel.value) {
|
||||
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SUBTITLE');
|
||||
}
|
||||
if (selectedChannel.value.type === CHANNEL_TYPES.FACEBOOK) {
|
||||
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SUBTITLE');
|
||||
}
|
||||
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_SUBTITLE');
|
||||
});
|
||||
|
||||
const open = preselectType => {
|
||||
const entry = preselectType
|
||||
? channelCards.value.find(channel => channel.type === preselectType)
|
||||
: null;
|
||||
// Only jump straight into a channel's view when it's actually usable;
|
||||
// otherwise show the grid (with its muted card) rather than launching SDK
|
||||
// auth with a missing credential.
|
||||
selectedChannel.value = entry?.availability === 'available' ? entry : null;
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
width="lg"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="selectedChannel = null"
|
||||
>
|
||||
<InboxFacebookForm
|
||||
v-if="selectedChannel?.type === CHANNEL_TYPES.FACEBOOK"
|
||||
@back="selectedChannel = null"
|
||||
@created="onCreated"
|
||||
/>
|
||||
<InboxChannelForm
|
||||
v-else-if="selectedChannel"
|
||||
:channel="selectedChannel"
|
||||
@back="selectedChannel = null"
|
||||
@created="onCreated"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="channel in channelCards"
|
||||
:key="channel.type"
|
||||
type="button"
|
||||
:disabled="channel.availability !== 'available'"
|
||||
class="flex items-center gap-3 p-3 rounded-xl outline outline-1 outline-n-weak shadow-[0px_1px_2px_0px_rgba(27,28,29,0.036)] transition-colors text-start"
|
||||
:class="CARD_CLASS[channel.availability]"
|
||||
@click="onCardClick(channel)"
|
||||
>
|
||||
<div
|
||||
class="size-9 rounded-[10px] outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<ChannelIcon
|
||||
v-if="channel.inbox"
|
||||
:inbox="channel.inbox"
|
||||
use-brand-icon
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:icon="channel.fallbackIcon"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-sm font-medium text-n-slate-12">
|
||||
{{ t(channel.labelKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="channel.availability === 'setupLater'"
|
||||
class="block text-xs text-n-slate-11"
|
||||
>
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SETUP_LATER') }}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="channel.connected"
|
||||
icon="i-lucide-circle-check"
|
||||
class="size-5 text-n-teal-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="channel.availability === 'available'"
|
||||
icon="i-lucide-chevron-right"
|
||||
class="size-5 text-n-slate-9"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.NOTE') }}
|
||||
</p>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import { FALLBACK_PREVIEW_CHANNELS } from './constants';
|
||||
import { useChannelConfig } from './useChannelConfig';
|
||||
|
||||
const props = defineProps({
|
||||
remainingChannels: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
defineEmits(['viewAll']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isConfigured } = useChannelConfig();
|
||||
|
||||
// Icons shown next to "View all". Defaults to the unconnected socials, but when
|
||||
// everything detected is already connected we still want a hint of what's
|
||||
// behind the dialog — fall back to a representative trio. Either way, drop
|
||||
// channels whose installation credentials are missing so we never preview an
|
||||
// icon the dialog itself hides.
|
||||
const previewChannels = computed(() =>
|
||||
(props.remainingChannels.length
|
||||
? props.remainingChannels
|
||||
: FALLBACK_PREVIEW_CHANNELS
|
||||
).filter(channel => isConfigured(channel.type))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-3 pt-3 pb-4">
|
||||
<Icon icon="i-lucide-info" class="size-4 text-n-slate-9 flex-shrink-0" />
|
||||
<I18nT
|
||||
keypath="ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_NOTE"
|
||||
tag="span"
|
||||
class="flex-1 min-w-0 text-body-main text-n-slate-11"
|
||||
>
|
||||
<template #email>
|
||||
<span class="text-n-slate-12">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #voice>
|
||||
<span class="text-n-slate-12">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
|
||||
</span>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<ChannelIcon
|
||||
v-for="channel in previewChannels"
|
||||
:key="channel.type"
|
||||
:inbox="channel.inbox"
|
||||
use-brand-icon
|
||||
class="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ps-1">
|
||||
<span class="w-px h-3 bg-n-weak" />
|
||||
<button
|
||||
type="button"
|
||||
class="text-button text-n-slate-12 hover:underline"
|
||||
@click="$emit('viewAll')"
|
||||
>
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const emit = defineEmits(['back', 'created']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isAuthenticating, preloadSdk, loginAndFetchPages } =
|
||||
useFacebookPageConnect();
|
||||
|
||||
// null until the login + page fetch completes.
|
||||
const pages = ref(null);
|
||||
const userAccessToken = ref('');
|
||||
const selectedPageId = ref('');
|
||||
const errored = ref(false);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const hasFetched = computed(() => pages.value !== null);
|
||||
// Pages already connected as inboxes come back with `exists: true`.
|
||||
const pageOptions = computed(() =>
|
||||
(pages.value || [])
|
||||
.filter(page => !page.exists)
|
||||
.map(page => ({ value: page.id, label: page.name }))
|
||||
);
|
||||
|
||||
// Warm the SDK on open so the login click opens its popup within the gesture's
|
||||
// activation window (see useFacebookPageConnect).
|
||||
onMounted(preloadSdk);
|
||||
|
||||
const connect = async () => {
|
||||
errored.value = false;
|
||||
try {
|
||||
const result = await loginAndFetchPages();
|
||||
if (!result) return; // cancelled — stay on the connect prompt
|
||||
userAccessToken.value = result.userAccessToken;
|
||||
pages.value = result.pages;
|
||||
} catch {
|
||||
errored.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const createInbox = async () => {
|
||||
const page = (pages.value || []).find(p => p.id === selectedPageId.value);
|
||||
if (!page || isCreating.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/createFBChannel', {
|
||||
user_access_token: userAccessToken.value,
|
||||
page_access_token: page.access_token,
|
||||
page_id: page.id,
|
||||
inbox_name: page.name,
|
||||
});
|
||||
useAlert(t('ONBOARDING_INBOX_SETUP.FACEBOOK_CONNECTED'));
|
||||
emit('created');
|
||||
} catch (error) {
|
||||
useAlert(parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="isAuthenticating"
|
||||
class="flex items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<Spinner :size="16" class="text-n-blue-10" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="hasFetched && pageOptions.length">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="selectedPageId"
|
||||
:options="pageOptions"
|
||||
:placeholder="
|
||||
t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
blue
|
||||
class="flex-1 justify-center"
|
||||
:is-loading="isCreating"
|
||||
:disabled="!selectedPageId"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
|
||||
@click="createInbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasFetched">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
|
||||
</p>
|
||||
<div class="flex">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="errored" class="text-sm text-n-ruby-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
blue
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH')"
|
||||
@click="connect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTimeoutPoll } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import CreationStatusRow from './CreationStatusRow.vue';
|
||||
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
// The web widget inbox is created asynchronously during account setup. We poll
|
||||
// the inboxes endpoint until it shows up, then stop — much simpler than the
|
||||
// event-driven help center flow.
|
||||
const websiteInboxes = useMapGetter('inboxes/getWebsiteInboxes');
|
||||
const isReady = computed(() => websiteInboxes.value.length > 0);
|
||||
|
||||
// useTimeoutPoll waits for each fetch to settle before scheduling the next (so
|
||||
// requests never overlap), fires immediately on mount, and stops on unmount.
|
||||
const { pause } = useTimeoutPoll(
|
||||
async () => {
|
||||
await store.dispatch('inboxes/get');
|
||||
if (isReady.value) pause();
|
||||
},
|
||||
POLL_INTERVAL,
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreationStatusRow
|
||||
:ready="isReady"
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT')"
|
||||
:description="
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_DESCRIPTION')
|
||||
"
|
||||
:status="
|
||||
isReady
|
||||
? t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_READY')
|
||||
: t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_STATUS')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
// A detected channel maps to a real inbox when they share a channel_type. Gmail
|
||||
// and Outlook both use Channel::Email, so for email we also match on provider.
|
||||
// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
|
||||
|
||||
// Returns the matching inbox (not a boolean) so callers can show the connected
|
||||
// account's real name rather than the detected handle.
|
||||
export const findConnectedInbox = (inboxes, stub) =>
|
||||
inboxes.find(
|
||||
inbox =>
|
||||
inbox.channel_type === stub?.channel_type &&
|
||||
(stub?.channel_type !== INBOX_TYPES.EMAIL ||
|
||||
inbox.provider === stub?.provider)
|
||||
);
|
||||
|
||||
export const isChannelConnected = (inboxes, stub) =>
|
||||
Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
|
||||
@@ -0,0 +1,149 @@
|
||||
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
// Channels whose connect flow opens the channels dialog preselected to their
|
||||
// in-dialog step — Facebook (page picker) and the credential-form channels
|
||||
// (Telegram, Line) — rather than redirecting through OAuth.
|
||||
export const DIALOG_CHANNELS = [
|
||||
CHANNEL_TYPES.FACEBOOK,
|
||||
CHANNEL_TYPES.TELEGRAM,
|
||||
CHANNEL_TYPES.LINE,
|
||||
];
|
||||
|
||||
// Suggested channels (in priority order) to offer as rows when nothing is
|
||||
// detected, so the step isn't empty. The mainstream OAuth channels show on
|
||||
// configured installs, while credential-free Telegram/LINE keep the list
|
||||
// non-empty on a bare self-host.
|
||||
export const DEFAULT_CHANNEL_TYPES = [
|
||||
CHANNEL_TYPES.WHATSAPP,
|
||||
CHANNEL_TYPES.FACEBOOK,
|
||||
CHANNEL_TYPES.INSTAGRAM,
|
||||
CHANNEL_TYPES.TELEGRAM,
|
||||
CHANNEL_TYPES.LINE,
|
||||
];
|
||||
|
||||
// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
|
||||
// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
|
||||
// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
|
||||
// exists and falls back to the monochrome glyph otherwise, so no per-channel
|
||||
// style flag is needed. Entries without a channel type (Voice, Other Email
|
||||
// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
|
||||
// inline credential form; `setupLater: true` defers the channel to in-app setup
|
||||
// for this phase. `labelKey` is an i18n key — most reuse the shared channel
|
||||
// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
|
||||
// names translate without duplicating strings; resolve it with `t()` at display.
|
||||
export const CHANNEL_LIST = [
|
||||
{
|
||||
type: 'website',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
|
||||
inbox: { channel_type: 'Channel::WebWidget' },
|
||||
},
|
||||
{
|
||||
type: 'whatsapp',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Instagram' },
|
||||
},
|
||||
{
|
||||
type: 'facebook',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||
},
|
||||
{
|
||||
type: 'tiktok',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::Tiktok' },
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Telegram' },
|
||||
form: true,
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||
inbox: { channel_type: 'Channel::Line' },
|
||||
form: true,
|
||||
},
|
||||
// Email channels (including Gmail/Outlook OAuth) are set up later in-app for
|
||||
// this phase; they will be enabled in a future PR.
|
||||
{
|
||||
type: 'gmail',
|
||||
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
|
||||
inbox: { channel_type: 'Channel::Email', provider: 'google' },
|
||||
setupLater: true,
|
||||
},
|
||||
{
|
||||
type: 'outlook',
|
||||
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
|
||||
inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
|
||||
setupLater: true,
|
||||
},
|
||||
{
|
||||
type: 'sms',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
|
||||
inbox: { channel_type: 'Channel::Sms' },
|
||||
setupLater: true,
|
||||
},
|
||||
{
|
||||
type: 'api',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
|
||||
inbox: { channel_type: 'Channel::Api' },
|
||||
setupLater: true,
|
||||
},
|
||||
{
|
||||
type: 'voice',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
|
||||
fallbackIcon: 'i-woot-voice',
|
||||
setupLater: true,
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
|
||||
fallbackIcon: 'i-woot-mail',
|
||||
setupLater: true,
|
||||
},
|
||||
];
|
||||
|
||||
const channelByType = type =>
|
||||
CHANNEL_LIST.find(channel => channel.type === type);
|
||||
|
||||
// Icons shown next to "View all" when every detected channel is already
|
||||
// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
|
||||
// aren't duplicated.
|
||||
export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
|
||||
channelByType
|
||||
);
|
||||
|
||||
// Social channels that detected brand_info socials map to, keyed by social type
|
||||
// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
|
||||
// identity (label, channel_type) has a single source. Keys mirror
|
||||
// SocialLinkParser::SOCIAL_DOMAIN_MAP.
|
||||
const SOCIAL_PLATFORM_TYPES = [
|
||||
'whatsapp',
|
||||
'facebook',
|
||||
'line',
|
||||
'instagram',
|
||||
'telegram',
|
||||
'tiktok',
|
||||
];
|
||||
|
||||
export const SOCIAL_PLATFORMS = Object.fromEntries(
|
||||
SOCIAL_PLATFORM_TYPES.map(type => {
|
||||
const { labelKey, inbox } = channelByType(type);
|
||||
return [type, { labelKey, channelType: inbox.channel_type }];
|
||||
})
|
||||
);
|
||||
|
||||
// Mailbox providers inferred from the signup domain's MX records, keyed by
|
||||
// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
|
||||
export const EMAIL_PROVIDERS = Object.fromEntries(
|
||||
CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
|
||||
channel.inbox.provider,
|
||||
{ labelKey: channel.labelKey },
|
||||
])
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
// OAuth/SDK channels need installation-level app credentials to be usable. When
|
||||
// the credential is missing the channel is "not configured" and is hidden from
|
||||
// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
|
||||
// need no installation credential and are always considered configured.
|
||||
// Mirrors the availability checks in ChannelItem.vue.
|
||||
export function useChannelConfig() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const installationConfig = window.chatwootConfig || {};
|
||||
|
||||
const CHANNEL_CONFIGURED = {
|
||||
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
|
||||
// app id (not the 'none' sentinel) and the signup configuration id.
|
||||
whatsapp: () =>
|
||||
Boolean(installationConfig.whatsappAppId) &&
|
||||
installationConfig.whatsappAppId !== 'none' &&
|
||||
Boolean(installationConfig.whatsappConfigurationId),
|
||||
facebook: () => Boolean(installationConfig.fbAppId),
|
||||
instagram: () => Boolean(installationConfig.instagramAppId),
|
||||
tiktok: () => Boolean(installationConfig.tiktokAppId),
|
||||
gmail: () => Boolean(installationConfig.googleOAuthClientId),
|
||||
outlook: () => Boolean(globalConfig.value.azureAppId),
|
||||
};
|
||||
|
||||
const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
|
||||
|
||||
return { isConfigured };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import googleClient from 'dashboard/api/channel/googleClient';
|
||||
import microsoftClient from 'dashboard/api/channel/microsoftClient';
|
||||
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||
import tiktokClient from 'dashboard/api/channel/tiktokClient';
|
||||
|
||||
// Channels that complete via an OAuth redirect. Email channels are keyed by their
|
||||
// Channel::Email provider, others by channel type. The request is tagged with a
|
||||
// return hint so the callback brings the user back to onboarding instead of the
|
||||
// inbox settings page.
|
||||
const OAUTH_CLIENTS = {
|
||||
google: googleClient,
|
||||
microsoft: microsoftClient,
|
||||
instagram: instagramClient,
|
||||
tiktok: tiktokClient,
|
||||
};
|
||||
|
||||
export function useChannelConnect() {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
const connectViaOAuth = async provider => {
|
||||
const client = OAUTH_CLIENTS[provider];
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { url },
|
||||
} = await client.generateAuthorization({ return_to: 'onboarding' });
|
||||
window.location.href = url;
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
// WhatsApp connects via Meta's embedded-signup popup instead of the redirect
|
||||
// OAuth flow above. Collect the signup credentials, exchange them for an
|
||||
// inbox, and surface the result inline — then refetch so the connected state
|
||||
// reflects the freshly created inbox (and renders its real channel icon).
|
||||
const connectWhatsapp = async () => {
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await runEmbeddedSignup();
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||
return;
|
||||
}
|
||||
if (!credentials) return; // user dismissed the popup
|
||||
|
||||
try {
|
||||
await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
|
||||
await store.dispatch('inboxes/get');
|
||||
useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return { connectViaOAuth, connectWhatsapp };
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import {
|
||||
SOCIAL_PLATFORMS,
|
||||
EMAIL_PROVIDERS,
|
||||
DEFAULT_CHANNEL_TYPES,
|
||||
} from './constants';
|
||||
import { findConnectedInbox } from './channelMatchers';
|
||||
import { useChannelConfig } from './useChannelConfig';
|
||||
|
||||
// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
|
||||
// is config-gated like everything else, then sliced to this limit.
|
||||
const DISPLAYED_CHANNEL_LIMIT = 3;
|
||||
|
||||
// Pull the handle/username out of a detected social URL, formatted per channel.
|
||||
const extractHandle = ({ type, url }) => {
|
||||
try {
|
||||
const { pathname } = new URL(url);
|
||||
const path = pathname.replace(/^\/+|\/+$/g, '');
|
||||
if (type === 'whatsapp') {
|
||||
const digits = path.replace(/\D/g, '');
|
||||
return digits ? `+${digits}` : '';
|
||||
}
|
||||
if (type === 'line') return path;
|
||||
return path.startsWith('@') ? path : `@${path}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Derives the channel rows for the inbox-setup step from the account's detected
|
||||
// brand_info (socials + mailbox provider) and the real connected inboxes,
|
||||
// keeping InboxSetup.vue focused on layout, connect routing, and completion.
|
||||
export function useDetectedChannels() {
|
||||
const { currentAccount } = useAccount();
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const { isConfigured } = useChannelConfig();
|
||||
|
||||
const brandSocials = computed(
|
||||
() => currentAccount.value?.custom_attributes?.brand_info?.socials || []
|
||||
);
|
||||
|
||||
const connectedChannels = computed(() =>
|
||||
brandSocials.value
|
||||
.filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
|
||||
.map(social => ({
|
||||
type: social.type,
|
||||
handle: extractHandle(social),
|
||||
labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
|
||||
inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
|
||||
}))
|
||||
);
|
||||
|
||||
const detectedEmailChannel = computed(() => {
|
||||
const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
|
||||
const provider = brandInfo?.email_provider;
|
||||
if (!EMAIL_PROVIDERS[provider]) return null;
|
||||
|
||||
return {
|
||||
type: 'email',
|
||||
handle: brandInfo?.email || '',
|
||||
labelKey: EMAIL_PROVIDERS[provider].labelKey,
|
||||
inbox: { channel_type: 'Channel::Email', provider },
|
||||
};
|
||||
});
|
||||
|
||||
// The real inbox backing a channel, if one exists — returned (not just a
|
||||
// boolean) so the row can show the connected account's real name.
|
||||
const connectedInbox = channel =>
|
||||
findConnectedInbox(inboxes.value, channel.inbox);
|
||||
|
||||
// A channel row built from a social type, with no detected handle — used for
|
||||
// the default suggestions when nothing was detected.
|
||||
const toChannelRow = type => ({
|
||||
type,
|
||||
handle: '',
|
||||
labelKey: SOCIAL_PLATFORMS[type].labelKey,
|
||||
inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
|
||||
});
|
||||
|
||||
const detectedChannels = computed(() =>
|
||||
[detectedEmailChannel.value, ...connectedChannels.value]
|
||||
.filter(Boolean)
|
||||
// Email channels (including Gmail/Outlook OAuth) are disabled for this
|
||||
// phase; they will be enabled in a future PR.
|
||||
.filter(channel => channel.type !== 'email')
|
||||
// Hide channels whose installation OAuth credentials are missing — their
|
||||
// connect flow would only error.
|
||||
.filter(channel => isConfigured(channel.type))
|
||||
);
|
||||
|
||||
const defaultChannels = computed(() =>
|
||||
DEFAULT_CHANNEL_TYPES.filter(isConfigured)
|
||||
.slice(0, DISPLAYED_CHANNEL_LIMIT)
|
||||
.map(toChannelRow)
|
||||
);
|
||||
|
||||
// Show the detected channels, or fall back to the default suggestions so the
|
||||
// step is never an empty list.
|
||||
const displayedChannels = computed(() =>
|
||||
detectedChannels.value.length
|
||||
? detectedChannels.value
|
||||
: defaultChannels.value
|
||||
);
|
||||
|
||||
const remainingChannels = computed(() => {
|
||||
// Exclude whatever is already shown as a row (detected or defaulted) so the
|
||||
// footer preview doesn't duplicate it.
|
||||
const shownTypes = new Set(displayedChannels.value.map(c => c.type));
|
||||
return Object.entries(SOCIAL_PLATFORMS)
|
||||
.filter(([type]) => !shownTypes.has(type))
|
||||
.filter(([type]) => isConfigured(type))
|
||||
.slice(0, 3)
|
||||
.map(([type, { labelKey, channelType }]) => ({
|
||||
type,
|
||||
labelKey,
|
||||
inbox: { channel_type: channelType },
|
||||
}));
|
||||
});
|
||||
|
||||
const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
|
||||
|
||||
return {
|
||||
displayedChannels,
|
||||
remainingChannels,
|
||||
connectedInbox,
|
||||
hasDetectedChannels,
|
||||
};
|
||||
}
|
||||
+27
-12
@@ -5,11 +5,12 @@ defineProps({
|
||||
greeting: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
continueLabel: { type: String, default: 'Continue' },
|
||||
skipLabel: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['continue']);
|
||||
defineEmits(['continue', 'skip']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,7 +21,7 @@ defineEmits(['continue']);
|
||||
<div
|
||||
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
|
||||
/>
|
||||
<div class="relative w-full max-w-[580px]">
|
||||
<div class="relative w-full max-w-[40rem]">
|
||||
<div class="relative ps-12">
|
||||
<!-- Timeline dotted line -->
|
||||
<svg
|
||||
@@ -104,16 +105,30 @@ defineEmits(['continue']);
|
||||
>
|
||||
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
|
||||
</svg>
|
||||
<NextButton
|
||||
type="submit"
|
||||
blue
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-center"
|
||||
@click="$emit('continue')"
|
||||
>
|
||||
{{ continueLabel }}
|
||||
</NextButton>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="submit"
|
||||
blue
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
skipLabel ? 'flex-1 justify-center' : 'w-full justify-center'
|
||||
"
|
||||
@click="$emit('continue')"
|
||||
>
|
||||
{{ continueLabel }}
|
||||
</NextButton>
|
||||
<NextButton
|
||||
v-if="skipLabel"
|
||||
type="button"
|
||||
slate
|
||||
outline
|
||||
class="flex-1 justify-center"
|
||||
@click="$emit('skip')"
|
||||
>
|
||||
{{ skipLabel }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+6
-1
@@ -4,6 +4,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true },
|
||||
bare: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -42,8 +43,12 @@ defineProps({
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
|
||||
<div
|
||||
v-if="!bare"
|
||||
class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
|
||||
|
||||
vi.mock('vue-router');
|
||||
|
||||
const ENABLED_LANGUAGES = [
|
||||
{ iso_639_1_code: 'en', name: 'English' },
|
||||
{ iso_639_1_code: 'fr', name: 'French' },
|
||||
];
|
||||
|
||||
// Mounts the composable against a real store and the real useAccount/useConfig
|
||||
// (only useRoute and the underlying account getter / window config are faked),
|
||||
// so a change to how those resolve their data is exercised here too. `presets`
|
||||
// seeds form fields as if the user had already typed them.
|
||||
const mountComposable = ({
|
||||
account = {},
|
||||
enabledLanguages = ENABLED_LANGUAGES,
|
||||
presets = {},
|
||||
} = {}) => {
|
||||
window.chatwootConfig = { enabledLanguages };
|
||||
|
||||
const store = createStore({
|
||||
modules: {
|
||||
accounts: {
|
||||
namespaced: true,
|
||||
getters: { getAccount: () => () => account },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fields = {
|
||||
locale: ref(presets.locale || ''),
|
||||
website: ref(presets.website || ''),
|
||||
timezone: ref(presets.timezone || ''),
|
||||
companySize: ref(presets.companySize || ''),
|
||||
industry: ref(presets.industry || ''),
|
||||
referralSource: ref(presets.referralSource || ''),
|
||||
};
|
||||
|
||||
let api;
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
api = useAccountEnrichment(fields);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
const wrapper = mount(Component, { global: { plugins: [store] } });
|
||||
return { ...api, fields, wrapper };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootConfig;
|
||||
});
|
||||
|
||||
describe('useAccountEnrichment', () => {
|
||||
describe('populateFormFields', () => {
|
||||
it('fills empty fields from the enriched attributes on mount', () => {
|
||||
const { fields } = mountComposable({
|
||||
account: {
|
||||
locale: 'en',
|
||||
custom_attributes: {
|
||||
website: 'https://acme.com',
|
||||
timezone: 'America/New_York',
|
||||
company_size: '11-50',
|
||||
industry: 'Technology',
|
||||
referral_source: 'google',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fields.website.value).toBe('https://acme.com');
|
||||
expect(fields.timezone.value).toBe('America/New_York');
|
||||
expect(fields.companySize.value).toBe('11-50');
|
||||
expect(fields.industry.value).toBe('Technology');
|
||||
expect(fields.referralSource.value).toBe('google');
|
||||
});
|
||||
|
||||
it('falls back to brand_info for website and industry', () => {
|
||||
const { fields } = mountComposable({
|
||||
account: {
|
||||
custom_attributes: {
|
||||
brand_info: {
|
||||
domain: 'acme.com',
|
||||
industries: [{ industry: 'Retail & E-commerce' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fields.website.value).toBe('acme.com');
|
||||
expect(fields.industry.value).toBe('Retail & E-commerce');
|
||||
});
|
||||
|
||||
it('does not clobber fields the user already set', () => {
|
||||
const { fields } = mountComposable({
|
||||
presets: { website: 'mysite.com', industry: 'Finance' },
|
||||
account: {
|
||||
custom_attributes: {
|
||||
website: 'https://enriched.com',
|
||||
industry: 'Technology',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fields.website.value).toBe('mysite.com');
|
||||
expect(fields.industry.value).toBe('Finance');
|
||||
});
|
||||
|
||||
it('detects the locale from the browser, else the account locale', () => {
|
||||
// jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
|
||||
const { fields } = mountComposable({ account: { locale: 'de' } });
|
||||
expect(fields.locale.value).toBe('en');
|
||||
|
||||
// No enabled language matches the browser -> fall back to account locale.
|
||||
const { fields: other } = mountComposable({
|
||||
account: { locale: 'de' },
|
||||
enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
|
||||
});
|
||||
expect(other.locale.value).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnriching', () => {
|
||||
it('is true while the account is on the enrichment step', () => {
|
||||
const { isEnriching } = mountComposable({
|
||||
account: { custom_attributes: { onboarding_step: 'enrichment' } },
|
||||
});
|
||||
expect(isEnriching.value).toBe(true);
|
||||
});
|
||||
|
||||
it('is false on any other step', () => {
|
||||
const { isEnriching } = mountComposable({
|
||||
account: { custom_attributes: { onboarding_step: 'account_details' } },
|
||||
});
|
||||
expect(isEnriching.value).toBe(false);
|
||||
});
|
||||
|
||||
it('times out after 30s, flipping to false and populating', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { isEnriching, fields } = mountComposable({
|
||||
account: {
|
||||
custom_attributes: {
|
||||
onboarding_step: 'enrichment',
|
||||
company_size: '51-200',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(isEnriching.value).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(30000);
|
||||
|
||||
expect(isEnriching.value).toBe(false);
|
||||
expect(fields.companySize.value).toBe('51-200');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChangedFields', () => {
|
||||
it('lists only enrichable fields edited after auto-fill', () => {
|
||||
const { fields, getChangedFields } = mountComposable({
|
||||
account: {
|
||||
custom_attributes: {
|
||||
website: 'https://acme.com',
|
||||
company_size: '11-50',
|
||||
industry: 'Technology',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getChangedFields()).toEqual([]);
|
||||
|
||||
fields.industry.value = 'Finance';
|
||||
expect(getChangedFields()).toEqual(['industry']);
|
||||
});
|
||||
});
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
|
||||
import OnboardingAPI from 'dashboard/api/onboarding';
|
||||
|
||||
vi.mock('dashboard/api/onboarding', () => ({
|
||||
default: {
|
||||
getHelpCenterGeneration: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key.endsWith('HELP_CENTER_CATEGORIES')) {
|
||||
return `${params.count} categories`;
|
||||
}
|
||||
if (key.endsWith('HELP_CENTER_SUMMARY')) {
|
||||
return `${params.count} articles across ${params.categories}`;
|
||||
}
|
||||
if (key.endsWith('HELP_CENTER_ARTICLES')) {
|
||||
return `${params.count} articles`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountStatus = () =>
|
||||
mount(HelpCenterCreationStatus, {
|
||||
global: {
|
||||
stubs: {
|
||||
CreationStatusRow: {
|
||||
props: ['ready', 'title', 'description', 'status'],
|
||||
template:
|
||||
'<div data-test="row" :data-ready="ready">{{ status }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('HelpCenterCreationStatus', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders completed summary from the status endpoint', async () => {
|
||||
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
|
||||
data: {
|
||||
generation_id: 'generation-123',
|
||||
state: { status: 'completed' },
|
||||
articles_count: 3,
|
||||
categories_count: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountStatus();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
|
||||
'true'
|
||||
);
|
||||
expect(wrapper.find('[data-test="row"]').text()).toBe(
|
||||
'3 articles across 2 categories'
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the row when generation is skipped', async () => {
|
||||
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
|
||||
data: {
|
||||
generation_id: 'generation-123',
|
||||
state: { status: 'skipped' },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountStatus();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('polls while generating and stops after completion', async () => {
|
||||
vi.useFakeTimers();
|
||||
OnboardingAPI.getHelpCenterGeneration
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
generation_id: 'generation-123',
|
||||
state: { status: 'generating' },
|
||||
articles_count: 1,
|
||||
categories_count: 0,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
generation_id: 'generation-123',
|
||||
state: { status: 'completed' },
|
||||
articles_count: 2,
|
||||
categories_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountStatus();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
await flushPromises();
|
||||
|
||||
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
|
||||
'true'
|
||||
);
|
||||
expect(wrapper.find('[data-test="row"]').text()).toBe(
|
||||
'2 articles across 1 categories'
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
await flushPromises();
|
||||
|
||||
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ({ value: {} }),
|
||||
}));
|
||||
vi.mock('../../inbox-setup/useChannelConnect', () => ({
|
||||
useChannelConnect: () => ({
|
||||
connectViaOAuth: vi.fn(),
|
||||
connectWhatsapp: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountDialog = () =>
|
||||
mount(InboxChannelsDialog, {
|
||||
props: { inboxes: [] },
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog: {
|
||||
template: '<div><slot /></div>',
|
||||
methods: { open() {}, close() {} },
|
||||
},
|
||||
InboxFacebookForm: { template: '<div data-test="fb-form" />' },
|
||||
InboxChannelForm: { template: '<div data-test="channel-form" />' },
|
||||
ChannelIcon: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('InboxChannelsDialog Facebook gating', () => {
|
||||
afterEach(() => {
|
||||
delete window.chatwootConfig;
|
||||
});
|
||||
|
||||
it('opens the Facebook page picker when fbAppId is configured', async () => {
|
||||
window.chatwootConfig = { fbAppId: 'fb-app' };
|
||||
const wrapper = mountDialog();
|
||||
|
||||
wrapper.vm.open('facebook');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the grid (not the picker) when fbAppId is missing', async () => {
|
||||
window.chatwootConfig = {};
|
||||
const wrapper = mountDialog();
|
||||
|
||||
wrapper.vm.open('facebook');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
|
||||
// The channel grid renders its cards instead.
|
||||
expect(wrapper.find('button').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { ref, nextTick } from 'vue';
|
||||
import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
|
||||
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
parseAPIErrorResponse: vi.fn(),
|
||||
}));
|
||||
vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
|
||||
useFacebookPageConnect: vi.fn(),
|
||||
}));
|
||||
|
||||
const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({ dispatch }),
|
||||
}));
|
||||
|
||||
const NextButtonStub = {
|
||||
props: ['label', 'disabled', 'isLoading'],
|
||||
emits: ['click'],
|
||||
template: `<button :disabled="disabled" @click="$emit('click')">{{ label }}</button>`,
|
||||
};
|
||||
const ComboBoxStub = {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div data-test="combobox" />',
|
||||
};
|
||||
|
||||
const PAGES = [
|
||||
{ id: 'p1', name: 'Page One', access_token: 'pt1' },
|
||||
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
|
||||
];
|
||||
|
||||
const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
|
||||
const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
|
||||
|
||||
let loginAndFetchPages;
|
||||
let preloadSdk;
|
||||
|
||||
const mountForm = () =>
|
||||
mount(InboxFacebookForm, {
|
||||
global: {
|
||||
stubs: {
|
||||
NextButton: NextButtonStub,
|
||||
ComboBox: ComboBoxStub,
|
||||
Spinner: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const clickButton = (wrapper, label) =>
|
||||
wrapper
|
||||
.findAll('button')
|
||||
.find(button => button.text() === label)
|
||||
.trigger('click');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
preloadSdk = vi.fn();
|
||||
loginAndFetchPages = vi.fn();
|
||||
useFacebookPageConnect.mockReturnValue({
|
||||
isAuthenticating: ref(false),
|
||||
preloadSdk,
|
||||
loginAndFetchPages,
|
||||
});
|
||||
dispatch.mockResolvedValue({ id: 1 });
|
||||
});
|
||||
|
||||
describe('InboxFacebookForm', () => {
|
||||
it('preloads the SDK on mount', () => {
|
||||
mountForm();
|
||||
expect(preloadSdk).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists only connectable pages and creates an inbox for the selected one', async () => {
|
||||
loginAndFetchPages.mockResolvedValue({
|
||||
userAccessToken: 'tok',
|
||||
pages: PAGES,
|
||||
});
|
||||
const wrapper = mountForm();
|
||||
|
||||
await clickButton(wrapper, LAUNCH);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
// p2 is already connected (exists), so only p1 is offered.
|
||||
const combobox = wrapper.findComponent(ComboBoxStub);
|
||||
expect(combobox.props('options')).toEqual([
|
||||
{ value: 'p1', label: 'Page One' },
|
||||
]);
|
||||
|
||||
combobox.vm.$emit('update:modelValue', 'p1');
|
||||
await nextTick();
|
||||
|
||||
await clickButton(wrapper, CONNECT);
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
|
||||
user_access_token: 'tok',
|
||||
page_access_token: 'pt1',
|
||||
page_id: 'p1',
|
||||
inbox_name: 'Page One',
|
||||
});
|
||||
expect(wrapper.emitted('created')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the empty state when every page is already connected', async () => {
|
||||
loginAndFetchPages.mockResolvedValue({
|
||||
userAccessToken: 'tok',
|
||||
pages: [
|
||||
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
|
||||
],
|
||||
});
|
||||
const wrapper = mountForm();
|
||||
|
||||
await clickButton(wrapper, LAUNCH);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
|
||||
);
|
||||
expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows an error when the connection fails', async () => {
|
||||
loginAndFetchPages.mockRejectedValue(new Error('boom'));
|
||||
const wrapper = mountForm();
|
||||
|
||||
await clickButton(wrapper, LAUNCH);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays on the connect prompt without an error when cancelled', async () => {
|
||||
loginAndFetchPages.mockResolvedValue(null);
|
||||
const wrapper = mountForm();
|
||||
|
||||
await clickButton(wrapper, LAUNCH);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
|
||||
);
|
||||
// Launch button is still available to retry.
|
||||
expect(
|
||||
wrapper.findAll('button').some(button => button.text() === LAUNCH)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
findConnectedInbox,
|
||||
isChannelConnected,
|
||||
} from '../../inbox-setup/channelMatchers';
|
||||
|
||||
const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
|
||||
const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
|
||||
const OUTLOOK = {
|
||||
id: 3,
|
||||
channel_type: 'Channel::Email',
|
||||
provider: 'microsoft',
|
||||
};
|
||||
|
||||
describe('channelMatchers', () => {
|
||||
describe('findConnectedInbox', () => {
|
||||
it('returns the inbox sharing the channel type', () => {
|
||||
expect(
|
||||
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
|
||||
).toBe(WHATSAPP);
|
||||
});
|
||||
|
||||
it('matches email inboxes on provider', () => {
|
||||
expect(
|
||||
findConnectedInbox([OUTLOOK, GMAIL], {
|
||||
channel_type: 'Channel::Email',
|
||||
provider: 'google',
|
||||
})
|
||||
).toBe(GMAIL);
|
||||
});
|
||||
|
||||
it('does not match a different email provider', () => {
|
||||
expect(
|
||||
findConnectedInbox([OUTLOOK], {
|
||||
channel_type: 'Channel::Email',
|
||||
provider: 'google',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
expect(
|
||||
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChannelConnected', () => {
|
||||
it('is true when a matching inbox exists', () => {
|
||||
expect(
|
||||
isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when no inbox matches', () => {
|
||||
expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a channel without an inbox stub', () => {
|
||||
expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
|
||||
|
||||
vi.mock('vue-router');
|
||||
|
||||
// Mounts the composable against a real store and the real useAccount (only
|
||||
// useRoute and the underlying getters are faked), so a change to how useAccount
|
||||
// resolves the current account is exercised here too. The real ./constants are
|
||||
// used, so assertions validate against the actual channel identity (label keys,
|
||||
// channel_type, social ordering) derived from CHANNEL_LIST.
|
||||
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
|
||||
const store = createStore({
|
||||
modules: {
|
||||
accounts: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getAccount: () => () => ({
|
||||
id: 1,
|
||||
custom_attributes: { brand_info: brandInfo },
|
||||
}),
|
||||
},
|
||||
},
|
||||
inboxes: {
|
||||
namespaced: true,
|
||||
getters: { getInboxes: () => inboxes },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let result;
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useDetectedChannels();
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
mount(Component, { global: { plugins: [store] } });
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||
// Configure the installation OAuth credentials so detected channels aren't
|
||||
// hidden by the config gate; individual tests clear this to assert hiding.
|
||||
window.chatwootConfig = {
|
||||
fbAppId: 'fb',
|
||||
instagramAppId: 'ig',
|
||||
tiktokAppId: 'tt',
|
||||
whatsappAppId: 'wa',
|
||||
whatsappConfigurationId: 'wa-config',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootConfig;
|
||||
});
|
||||
|
||||
describe('useDetectedChannels', () => {
|
||||
describe('displayedChannels', () => {
|
||||
it('maps detected socials with a url to channel rows', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value).toEqual([
|
||||
{
|
||||
type: 'whatsapp',
|
||||
handle: '+14155552671',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
handle: '@acme',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Instagram' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips socials without a url or with an unknown type', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'telegram' }, // no url
|
||||
{ type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
|
||||
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the raw path for line and falls back to empty on a bad url', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'line', url: 'https://line.me/acme' },
|
||||
{ type: 'facebook', url: 'not-a-url' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value).toEqual([
|
||||
{
|
||||
type: 'line',
|
||||
handle: 'acme',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||
inbox: { channel_type: 'Channel::Line' },
|
||||
},
|
||||
{
|
||||
type: 'facebook',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the detected email channel while email is disabled for this phase', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
email_provider: 'google',
|
||||
email: 'support@acme.com',
|
||||
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'whatsapp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the default channel suggestions when nothing is detected', () => {
|
||||
const { displayedChannels } = mountComposable({ brandInfo: undefined });
|
||||
|
||||
// The configured mainstream channels, with no detected handle.
|
||||
expect(displayedChannels.value).toEqual([
|
||||
{
|
||||
type: 'whatsapp',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||
},
|
||||
{
|
||||
type: 'facebook',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Instagram' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('gates the default suggestions by installation config, keeping the list non-empty', () => {
|
||||
window.chatwootConfig = {}; // no OAuth credentials configured
|
||||
const { displayedChannels } = mountComposable({ brandInfo: undefined });
|
||||
|
||||
// Only the credential-free defaults survive (Telegram, LINE).
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'telegram',
|
||||
'line',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides detected channels whose installation OAuth credentials are missing', () => {
|
||||
window.chatwootConfig = {}; // nothing configured
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'facebook', url: 'https://facebook.com/acme' },
|
||||
{ type: 'line', url: 'https://line.me/acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'line',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remainingChannels', () => {
|
||||
it('returns the platforms not already shown as default rows', () => {
|
||||
// Nothing detected → displayed falls back to the defaults (WhatsApp,
|
||||
// Facebook, Instagram), so the footer previews the remaining platforms.
|
||||
const { remainingChannels } = mountComposable({ brandInfo: {} });
|
||||
|
||||
expect(remainingChannels.value).toEqual([
|
||||
{
|
||||
type: 'line',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||
inbox: { channel_type: 'Channel::Line' },
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Telegram' },
|
||||
},
|
||||
{
|
||||
type: 'tiktok',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::Tiktok' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes already-detected socials, preserving order', () => {
|
||||
const { remainingChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
|
||||
'facebook',
|
||||
'line',
|
||||
'instagram',
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes channels whose installation OAuth credentials are missing', () => {
|
||||
window.chatwootConfig = {}; // nothing configured
|
||||
const { remainingChannels } = mountComposable({ brandInfo: {} });
|
||||
|
||||
// The only configured channels (Telegram, LINE) are shown as default rows,
|
||||
// and every other platform is gated out — so nothing remains for the footer.
|
||||
expect(remainingChannels.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectedInbox', () => {
|
||||
it('returns the real inbox sharing the channel type', () => {
|
||||
const inbox = {
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
name: 'WA Biz',
|
||||
};
|
||||
const { connectedInbox } = mountComposable({
|
||||
brandInfo: {},
|
||||
inboxes: [inbox],
|
||||
});
|
||||
|
||||
expect(
|
||||
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
|
||||
).toBe(inbox);
|
||||
});
|
||||
|
||||
it('matches email inboxes on provider', () => {
|
||||
const gmail = {
|
||||
id: 1,
|
||||
channel_type: 'Channel::Email',
|
||||
provider: 'google',
|
||||
};
|
||||
const outlook = {
|
||||
id: 2,
|
||||
channel_type: 'Channel::Email',
|
||||
provider: 'microsoft',
|
||||
};
|
||||
const { connectedInbox } = mountComposable({
|
||||
brandInfo: {},
|
||||
inboxes: [outlook, gmail],
|
||||
});
|
||||
|
||||
expect(
|
||||
connectedInbox({
|
||||
inbox: { channel_type: 'Channel::Email', provider: 'google' },
|
||||
})
|
||||
).toBe(gmail);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
const { connectedInbox } = mountComposable({
|
||||
brandInfo: {},
|
||||
inboxes: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import Editor from 'next/Editor/Editor.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import DurationInput from 'next/input/DurationInput.vue';
|
||||
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
||||
>
|
||||
<TextArea
|
||||
<Editor
|
||||
v-model="message"
|
||||
class="w-full"
|
||||
channel-type="Context::NoToolbar"
|
||||
enable-variables
|
||||
:enable-canned-responses="false"
|
||||
:show-character-count="false"
|
||||
:placeholder="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
|
||||
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
|
||||
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
|
||||
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
|
||||
|
||||
// Default age threshold for excluding stale unassigned conversations (7 days)
|
||||
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
|
||||
|
||||
// Options groupings
|
||||
export const OPTIONS = {
|
||||
ORDER: [ROUND_ROBIN, BALANCED],
|
||||
|
||||
+1
@@ -106,6 +106,7 @@ const formData = computed(() => ({
|
||||
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
||||
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
|
||||
}));
|
||||
|
||||
const handleDeleteInbox = async inboxId => {
|
||||
|
||||
+52
-1
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import {
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
EARLIEST_CREATED,
|
||||
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -28,6 +31,7 @@ const props = defineProps({
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
}),
|
||||
},
|
||||
mode: {
|
||||
@@ -56,7 +60,6 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'submit',
|
||||
'addInbox',
|
||||
@@ -64,6 +67,9 @@ const emit = defineEmits([
|
||||
'navigateToInbox',
|
||||
'validationChange',
|
||||
]);
|
||||
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
|
||||
const MIN_EXCLUSION_MINUTES = 60;
|
||||
const MAX_EXCLUSION_MINUTES = 1438560;
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -83,12 +89,28 @@ const state = reactive({
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
});
|
||||
|
||||
const validationState = ref({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const exclusionUnit = ref(DURATION_UNITS.DAYS);
|
||||
|
||||
// DurationInput works in minutes; the policy stores hours, so bridge the two
|
||||
const excludeOlderThanMinutes = computed({
|
||||
get() {
|
||||
return state.excludeOlderThanHours == null
|
||||
? null
|
||||
: state.excludeOlderThanHours * 60;
|
||||
},
|
||||
set(minutes) {
|
||||
state.excludeOlderThanHours =
|
||||
minutes == null ? null : Math.round(minutes / 60);
|
||||
},
|
||||
});
|
||||
|
||||
const createOption = (
|
||||
type,
|
||||
key,
|
||||
@@ -170,6 +192,7 @@ const resetForm = () => {
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -177,10 +200,17 @@ const handleSubmit = () => {
|
||||
emit('submit', { ...state });
|
||||
};
|
||||
|
||||
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
|
||||
const detectExclusionUnit = hours => {
|
||||
exclusionUnit.value =
|
||||
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
newData => {
|
||||
Object.assign(state, newData);
|
||||
detectExclusionUnit(newData.excludeOlderThanHours);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
@@ -247,6 +277,27 @@ defineExpose({
|
||||
v-model:window-unit="state.windowUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 pb-2 flex-col flex gap-4">
|
||||
<div class="flex flex-col items-start gap-1 py-1">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
|
||||
</label>
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<DurationInput
|
||||
v-model:unit="exclusionUnit"
|
||||
v-model:model-value="excludeOlderThanMinutes"
|
||||
:min="MIN_EXCLUSION_MINUTES"
|
||||
:max="MAX_EXCLUSION_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -149,9 +149,5 @@ export default {
|
||||
|
||||
:deep(.ProseMirror-woot-style) {
|
||||
@apply min-h-[12.5rem];
|
||||
|
||||
p {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -153,9 +153,5 @@ export default {
|
||||
|
||||
:deep(.ProseMirror-woot-style) {
|
||||
@apply min-h-[12.5rem];
|
||||
|
||||
p {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -144,8 +144,10 @@ const showWebhookSection = computed(
|
||||
() => props.healthData?.webhook_configuration !== undefined
|
||||
);
|
||||
|
||||
// Phone-level override takes precedence over WABA-level (application), so prefer it.
|
||||
const webhookUrl = computed(
|
||||
() =>
|
||||
props.healthData?.webhook_configuration?.phone_number ||
|
||||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||
props.healthData?.webhook_configuration?.application
|
||||
);
|
||||
|
||||
@@ -101,6 +101,9 @@ export default {
|
||||
summary-fetching-key="getBotSummaryFetchingStatus"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:business-hours="businessHours"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,6 +121,11 @@ export default {
|
||||
show-group-by
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<ReportContainer :group-by="groupBy" />
|
||||
<ReportContainer
|
||||
:group-by="groupBy"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:business-hours="businessHours"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,16 +5,38 @@ import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ChartStats from './components/ChartElements/ChartStats.vue';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
import ReportDrilldownDrawer from './components/ReportDrilldownDrawer.vue';
|
||||
|
||||
export default {
|
||||
components: { ChartStats, BarChart },
|
||||
components: { ChartStats, BarChart, ReportDrilldownDrawer },
|
||||
props: {
|
||||
groupBy: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
from: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
to: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
reportType: {
|
||||
type: String,
|
||||
default: 'account',
|
||||
},
|
||||
selectedItemId: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
businessHours: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accountSummaryKey: {
|
||||
type: String,
|
||||
default: 'getAccountSummary',
|
||||
@@ -42,10 +64,27 @@ export default {
|
||||
);
|
||||
return { calculateTrend, isAverageMetricType };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
drilldownRequest: null,
|
||||
drilldownMetric: null,
|
||||
drilldownIndex: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountReport: 'getAccountReports',
|
||||
currentRole: 'getCurrentRole',
|
||||
}),
|
||||
isAdmin() {
|
||||
return this.currentRole === 'administrator';
|
||||
},
|
||||
canDrilldownPrev() {
|
||||
return this.findDrillableIndex(this.drilldownIndex - 1, -1) !== null;
|
||||
},
|
||||
canDrilldownNext() {
|
||||
return this.findDrillableIndex(this.drilldownIndex + 1, 1) !== null;
|
||||
},
|
||||
metrics() {
|
||||
const reportKeys = Object.keys(this.reportKeys);
|
||||
const infoText = {
|
||||
@@ -139,6 +178,82 @@ export default {
|
||||
|
||||
return options;
|
||||
},
|
||||
isDrilldownEnabled() {
|
||||
return !!(this.from && this.to);
|
||||
},
|
||||
onChartElementClick(metric, event) {
|
||||
if (!this.isDrilldownEnabled()) return;
|
||||
|
||||
const dataPoint = this.accountReport.data[metric.KEY]?.[event.dataIndex];
|
||||
if (!this.canOpenDrilldown(metric, dataPoint)) return;
|
||||
if (!this.isAdmin) {
|
||||
useAlert(this.$t('REPORT.DRILLDOWN.ADMIN_ONLY'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.openDrilldownAt(metric, event.dataIndex);
|
||||
},
|
||||
openDrilldownAt(metric, dataIndex) {
|
||||
const dataPoint = this.accountReport.data[metric.KEY]?.[dataIndex];
|
||||
if (!this.canOpenDrilldown(metric, dataPoint)) return;
|
||||
|
||||
const labels = this.getCollection(metric).labels || [];
|
||||
|
||||
this.drilldownMetric = metric;
|
||||
this.drilldownIndex = dataIndex;
|
||||
this.drilldownRequest = {
|
||||
metric: metric.KEY,
|
||||
metricName: metric.NAME,
|
||||
bucketLabel: labels[dataIndex],
|
||||
bucketTimestamp: dataPoint.timestamp,
|
||||
bucketValue: dataPoint.value,
|
||||
isAverageMetric: this.isAverageMetricType(metric.KEY),
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
type: this.reportType,
|
||||
id: this.selectedItemId,
|
||||
groupBy: this.groupBy?.period,
|
||||
businessHours: this.businessHours,
|
||||
};
|
||||
},
|
||||
navigateDrilldown(direction) {
|
||||
const nextIndex = this.findDrillableIndex(
|
||||
this.drilldownIndex + direction,
|
||||
direction
|
||||
);
|
||||
if (nextIndex === null) return;
|
||||
|
||||
this.openDrilldownAt(this.drilldownMetric, nextIndex);
|
||||
},
|
||||
findDrillableIndex(startIndex, step) {
|
||||
if (!this.drilldownMetric) return null;
|
||||
|
||||
const data = this.accountReport.data[this.drilldownMetric.KEY] || [];
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < data.length;
|
||||
index += step
|
||||
) {
|
||||
if (this.canOpenDrilldown(this.drilldownMetric, data[index]))
|
||||
return index;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
canOpenDrilldown(metric, dataPoint) {
|
||||
if (!dataPoint) return false;
|
||||
|
||||
if (this.isAverageMetricType(metric.KEY)) {
|
||||
return dataPoint.count > 0;
|
||||
}
|
||||
|
||||
return dataPoint.value > 0;
|
||||
},
|
||||
closeDrilldown() {
|
||||
this.drilldownRequest = null;
|
||||
this.drilldownMetric = null;
|
||||
this.drilldownIndex = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -168,6 +283,8 @@ export default {
|
||||
v-if="accountReport.data[metric.KEY].length"
|
||||
:collection="getCollection(metric)"
|
||||
:chart-options="getChartOptions(metric)"
|
||||
:clickable="isDrilldownEnabled()"
|
||||
@element-click="onChartElementClick(metric, $event)"
|
||||
/>
|
||||
<span v-else class="text-sm text-n-slate-10">
|
||||
{{ $t('REPORT.NO_ENOUGH_DATA') }}
|
||||
@@ -176,4 +293,23 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReportDrilldownDrawer
|
||||
:id="drilldownRequest?.id"
|
||||
:open="!!drilldownRequest"
|
||||
:metric="drilldownRequest?.metric"
|
||||
:metric-name="drilldownRequest?.metricName"
|
||||
:bucket-label="drilldownRequest?.bucketLabel"
|
||||
:bucket-timestamp="drilldownRequest?.bucketTimestamp"
|
||||
:bucket-value="drilldownRequest?.bucketValue"
|
||||
:is-average-metric="drilldownRequest?.isAverageMetric"
|
||||
:from="drilldownRequest?.from"
|
||||
:to="drilldownRequest?.to"
|
||||
:type="drilldownRequest?.type"
|
||||
:group-by="drilldownRequest?.groupBy"
|
||||
:business-hours="drilldownRequest?.businessHours"
|
||||
:can-prev="canDrilldownPrev"
|
||||
:can-next="canDrilldownNext"
|
||||
@navigate="navigateDrilldown"
|
||||
@close="closeDrilldown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import format from 'date-fns/format';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
const props = defineProps({
|
||||
record: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const conversation = computed(() => props.record.conversation || {});
|
||||
const message = computed(() => props.record.message || {});
|
||||
const isMessageRecord = computed(() => props.record.record_type === 'message');
|
||||
const isEventBackedConversationRecord = computed(
|
||||
() => !isMessageRecord.value && !!props.record.event_name
|
||||
);
|
||||
const conversationDisplayId = computed(() => conversation.value.display_id);
|
||||
const conversationNumber = computed(() => `#${conversationDisplayId.value}`);
|
||||
const messageDirection = computed(() => message.value.message_type);
|
||||
|
||||
const formatTimestamp = timestamp => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
return format(fromUnixTime(timestamp), 'dd MMM yyyy, h:mm a');
|
||||
};
|
||||
|
||||
const compactTimestamp = timestamp => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
return shortTimestamp(dynamicTime(timestamp)).trim();
|
||||
};
|
||||
|
||||
const metricValue = computed(() => {
|
||||
const value = props.record.metric_value;
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
return formatTime(value) || `${value}`;
|
||||
});
|
||||
|
||||
const previewText = computed(() => {
|
||||
if (message.value.content) return message.value.content;
|
||||
if (conversation.value.last_message?.content) {
|
||||
return conversation.value.last_message.content;
|
||||
}
|
||||
|
||||
return t('REPORT.DRILLDOWN.NO_MESSAGE_CONTENT');
|
||||
});
|
||||
|
||||
const showPreview = computed(() => {
|
||||
return isMessageRecord.value || conversation.value.last_message;
|
||||
});
|
||||
|
||||
const messageCreatedTooltip = computed(() =>
|
||||
t('REPORT.DRILLDOWN.MESSAGE_CREATED_AT', {
|
||||
time: formatTimestamp(message.value.created_at),
|
||||
})
|
||||
);
|
||||
|
||||
const eventOccurredTooltip = computed(() =>
|
||||
t('REPORT.DRILLDOWN.EVENT_OCCURRED_AT', {
|
||||
time: formatTimestamp(props.record.occurred_at),
|
||||
})
|
||||
);
|
||||
|
||||
const directionDetails = computed(() => {
|
||||
const direction = messageDirection.value;
|
||||
if (!direction) return null;
|
||||
|
||||
const isIncoming = direction === 'incoming';
|
||||
return {
|
||||
icon: isIncoming ? 'i-lucide-arrow-down-left' : 'i-lucide-arrow-up-right',
|
||||
tooltip: isIncoming
|
||||
? t('REPORT.DRILLDOWN.INCOMING_MESSAGE')
|
||||
: t('REPORT.DRILLDOWN.OUTGOING_MESSAGE'),
|
||||
};
|
||||
});
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
if (!conversationDisplayId.value) return '';
|
||||
|
||||
const path = conversationUrl({
|
||||
accountId: route.params.accountId,
|
||||
id: conversationDisplayId.value,
|
||||
});
|
||||
const params =
|
||||
isMessageRecord.value && message.value.id
|
||||
? { messageId: message.value.id }
|
||||
: null;
|
||||
|
||||
return frontendURL(path, params);
|
||||
});
|
||||
|
||||
const contactPath = computed(() => {
|
||||
if (!conversation.value.contact_id) return '';
|
||||
|
||||
return frontendURL(
|
||||
`accounts/${route.params.accountId}/contacts/${conversation.value.contact_id}`
|
||||
);
|
||||
});
|
||||
|
||||
const inboxPath = computed(() => {
|
||||
if (!conversation.value.inbox_id) return '';
|
||||
|
||||
return frontendURL(
|
||||
`accounts/${route.params.accountId}/inbox/${conversation.value.inbox_id}`
|
||||
);
|
||||
});
|
||||
|
||||
const agentPath = computed(() => {
|
||||
if (!conversation.value.assignee_id) return '';
|
||||
|
||||
return frontendURL(
|
||||
`accounts/${route.params.accountId}/reports/agents/${conversation.value.assignee_id}`
|
||||
);
|
||||
});
|
||||
|
||||
const metadataItems = computed(() => [
|
||||
{
|
||||
key: 'contact',
|
||||
icon: 'i-lucide-contact',
|
||||
label:
|
||||
conversation.value.contact_name || t('REPORT.DRILLDOWN.UNKNOWN_CONTACT'),
|
||||
path: contactPath.value,
|
||||
},
|
||||
{
|
||||
key: 'inbox',
|
||||
icon: 'i-lucide-inbox',
|
||||
label: conversation.value.inbox_name || t('REPORT.DRILLDOWN.UNKNOWN_INBOX'),
|
||||
path: inboxPath.value,
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
icon: 'i-lucide-user-round',
|
||||
label:
|
||||
conversation.value.assignee_name ||
|
||||
t('REPORT.DRILLDOWN.UNASSIGNED_AGENT'),
|
||||
path: agentPath.value,
|
||||
},
|
||||
]);
|
||||
|
||||
const metadataAttributes = item => {
|
||||
if (!item.path) return {};
|
||||
|
||||
return {
|
||||
href: item.path,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
};
|
||||
|
||||
const metadataItemClass = item => [
|
||||
'flex min-w-0 items-center gap-1 text-n-slate-10',
|
||||
item.path ? 'group hover:text-n-blue-11 hover:underline' : '',
|
||||
];
|
||||
|
||||
const metadataIconClass = item => [
|
||||
'size-3 shrink-0 text-n-slate-9',
|
||||
item.path ? 'group-hover:text-n-blue-11' : '',
|
||||
];
|
||||
|
||||
const stopMetadataLinkClick = (event, item) => {
|
||||
if (item.path) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const openInNewTab = url => {
|
||||
if (!url) return;
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const openRecord = () => {
|
||||
openInNewTab(conversationPath.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
role="link"
|
||||
tabindex="0"
|
||||
class="cursor-pointer rounded-md border border-n-weak bg-n-solid-2 p-3 hover:bg-n-alpha-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand"
|
||||
@click="openRecord"
|
||||
@keydown.enter.self.prevent="openRecord"
|
||||
@keydown.space.self.prevent="openRecord"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm font-medium leading-5 text-n-slate-12"
|
||||
>
|
||||
<span>{{ conversationNumber }}</span>
|
||||
<span
|
||||
v-if="conversation.status"
|
||||
class="rounded bg-n-alpha-2 px-1.5 py-0.5 text-xs capitalize text-n-slate-11"
|
||||
>
|
||||
{{ conversation.status }}
|
||||
</span>
|
||||
<span
|
||||
v-if="directionDetails"
|
||||
v-tooltip.top="directionDetails.tooltip"
|
||||
:aria-label="directionDetails.tooltip"
|
||||
class="flex size-5 items-center justify-center rounded bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<Icon :icon="directionDetails.icon" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="metricValue"
|
||||
class="rounded bg-n-alpha-2 px-1.5 py-0.5 text-xs text-n-slate-11"
|
||||
>
|
||||
{{ metricValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-2 flex shrink-0 items-center justify-end gap-1 text-end text-xs leading-4 text-n-slate-10"
|
||||
>
|
||||
<span
|
||||
v-if="isMessageRecord"
|
||||
v-tooltip.left="messageCreatedTooltip"
|
||||
:aria-label="messageCreatedTooltip"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ compactTimestamp(message.created_at) }}
|
||||
</span>
|
||||
<TimeAgo
|
||||
v-else
|
||||
:is-auto-refresh-enabled="false"
|
||||
:conversation-id="conversation.id"
|
||||
:last-activity-timestamp="conversation.last_activity_at"
|
||||
:created-at-timestamp="conversation.created_at"
|
||||
class="font-440 !text-xs !text-n-slate-10"
|
||||
/>
|
||||
<span
|
||||
v-if="isEventBackedConversationRecord"
|
||||
v-tooltip.left="eventOccurredTooltip"
|
||||
:aria-label="eventOccurredTooltip"
|
||||
class="whitespace-nowrap rounded bg-n-alpha-2 px-1 py-0.5 text-[11px] leading-4 text-n-slate-10"
|
||||
>
|
||||
{{ compactTimestamp(record.occurred_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="showPreview"
|
||||
class="mt-2 line-clamp-1 text-sm leading-5 text-n-slate-12"
|
||||
>
|
||||
{{ previewText }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||
<component
|
||||
:is="item.path ? 'a' : 'span'"
|
||||
v-for="item in metadataItems"
|
||||
:key="item.key"
|
||||
class="text-body-main"
|
||||
v-bind="metadataAttributes(item)"
|
||||
:class="metadataItemClass(item)"
|
||||
@click="stopMetadataLinkClick($event, item)"
|
||||
>
|
||||
<Icon :icon="item.icon" :class="metadataIconClass(item)" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</component>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import { useReportDrilldown } from '../composables/useReportDrilldown';
|
||||
import ReportDrilldownCard from './ReportDrilldownCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
metric: { type: String, default: '' },
|
||||
metricName: { type: String, default: '' },
|
||||
bucketLabel: { type: String, default: '' },
|
||||
bucketTimestamp: { type: Number, default: null },
|
||||
from: { type: Number, default: null },
|
||||
to: { type: Number, default: null },
|
||||
type: { type: String, default: 'account' },
|
||||
id: { type: [String, Number], default: null },
|
||||
groupBy: { type: String, default: '' },
|
||||
businessHours: { type: Boolean, default: false },
|
||||
bucketValue: { type: Number, default: null },
|
||||
isAverageMetric: { type: Boolean, default: false },
|
||||
canPrev: { type: Boolean, default: false },
|
||||
canNext: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'navigate']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const drawerRef = ref(null);
|
||||
const {
|
||||
records,
|
||||
meta,
|
||||
isFetching,
|
||||
isFetchingMore,
|
||||
hasError,
|
||||
hasRecords,
|
||||
hasMore,
|
||||
open: openDrilldown,
|
||||
close,
|
||||
loadMore,
|
||||
} = useReportDrilldown();
|
||||
|
||||
let previousActiveElement = null;
|
||||
|
||||
const isOpen = computed(() => props.open);
|
||||
|
||||
const title = computed(() => props.metricName || '');
|
||||
|
||||
const bucketValue = computed(() => {
|
||||
if (props.bucketValue === null) return '';
|
||||
|
||||
return props.isAverageMetric
|
||||
? formatTime(props.bucketValue)
|
||||
: `${props.bucketValue}`;
|
||||
});
|
||||
|
||||
// The headline stat already shows the conversation count for conversation-count
|
||||
// metrics (e.g. conversations_count), so the subtitle count would be redundant.
|
||||
const isStatConversationCount = computed(
|
||||
() =>
|
||||
!props.isAverageMetric &&
|
||||
meta.value.record_type === 'conversation' &&
|
||||
props.bucketValue === meta.value.conversation_count
|
||||
);
|
||||
|
||||
const conversationCount = computed(() => {
|
||||
if (!meta.value.conversation_count || isStatConversationCount.value)
|
||||
return '';
|
||||
|
||||
return t('REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
|
||||
count: meta.value.conversation_count,
|
||||
});
|
||||
});
|
||||
|
||||
// Timing metrics (e.g. reply time) show a duration as the stat, so the underlying
|
||||
// message count adds context. Skip it when it just mirrors the conversation count
|
||||
// (e.g. first response time has one response message per conversation).
|
||||
const messageCount = computed(() => {
|
||||
if (
|
||||
!props.isAverageMetric ||
|
||||
meta.value.record_type !== 'message' ||
|
||||
!meta.value.total_count ||
|
||||
meta.value.total_count === meta.value.conversation_count
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return t('REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE', {
|
||||
count: meta.value.total_count,
|
||||
});
|
||||
});
|
||||
|
||||
const subtitle = computed(() =>
|
||||
[props.bucketLabel, conversationCount.value, messageCount.value]
|
||||
.filter(Boolean)
|
||||
.join(' ⋅ ')
|
||||
);
|
||||
|
||||
const restoreFocus = () => {
|
||||
if (previousActiveElement?.isConnected) {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
previousActiveElement = null;
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
close();
|
||||
emit('close');
|
||||
restoreFocus();
|
||||
};
|
||||
|
||||
const recordKey = record =>
|
||||
`${record.record_type}-${record.message?.id || record.conversation?.id}-${
|
||||
record.occurred_at
|
||||
}`;
|
||||
|
||||
const rememberActiveElement = () => {
|
||||
if (previousActiveElement) return;
|
||||
|
||||
previousActiveElement =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
};
|
||||
|
||||
const focusDrawer = () => {
|
||||
nextTick(() => drawerRef.value?.focus());
|
||||
};
|
||||
|
||||
const fetchDrilldown = () => {
|
||||
openDrilldown({
|
||||
metric: props.metric,
|
||||
bucketTimestamp: props.bucketTimestamp,
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
groupBy: props.groupBy,
|
||||
businessHours: props.businessHours,
|
||||
});
|
||||
};
|
||||
|
||||
const navigate = direction => {
|
||||
if (direction < 0 && !props.canPrev) return;
|
||||
if (direction > 0 && !props.canNext) return;
|
||||
|
||||
emit('navigate', direction);
|
||||
};
|
||||
|
||||
const onKeydown = event => {
|
||||
if (!isOpen.value) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeDrawer();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
navigate(-1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
navigate(1);
|
||||
}
|
||||
};
|
||||
|
||||
useEventListener(document, 'keydown', onKeydown);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
isDrawerOpen => {
|
||||
if (!isDrawerOpen) {
|
||||
close();
|
||||
restoreFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
rememberActiveElement();
|
||||
fetchDrilldown();
|
||||
focusDrawer();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.metric, props.bucketTimestamp],
|
||||
() => {
|
||||
if (props.open) fetchDrilldown();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
restoreFocus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection to="body">
|
||||
<Transition name="report-drilldown-fade">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 bg-black/30"
|
||||
role="presentation"
|
||||
@click.self="closeDrawer"
|
||||
>
|
||||
<aside
|
||||
ref="drawerRef"
|
||||
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="bucketValue"
|
||||
class="mt-1 text-xl font-semibold text-n-slate-12"
|
||||
>
|
||||
{{ bucketValue }}
|
||||
</p>
|
||||
<div
|
||||
class="text-sm text-n-slate-11"
|
||||
:class="{
|
||||
'mt-2': bucketValue,
|
||||
'mt-1': !bucketValue,
|
||||
}"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-ph-caret-left"
|
||||
class="rtl:rotate-180"
|
||||
:disabled="!canPrev"
|
||||
:aria-label="$t('REPORT.DRILLDOWN.PREVIOUS_BUCKET')"
|
||||
@click="navigate(-1)"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-ph-caret-right"
|
||||
class="rtl:rotate-180"
|
||||
:disabled="!canNext"
|
||||
:aria-label="$t('REPORT.DRILLDOWN.NEXT_BUCKET')"
|
||||
@click="navigate(1)"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-ph-x"
|
||||
:aria-label="$t('REPORT.DRILLDOWN.CLOSE')"
|
||||
@click="closeDrawer"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex h-40 items-center justify-center"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
|
||||
>
|
||||
{{ $t('REPORT.DRILLDOWN.ERROR') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasRecords"
|
||||
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('REPORT.DRILLDOWN.EMPTY') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<ReportDrilldownCard
|
||||
v-for="record in records"
|
||||
:key="recordKey(record)"
|
||||
:record="record"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="hasMore"
|
||||
faded
|
||||
slate
|
||||
size="sm"
|
||||
class="mx-auto mt-2"
|
||||
:label="$t('REPORT.DRILLDOWN.LOAD_MORE')"
|
||||
:is-loading="isFetchingMore"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
@@ -69,6 +69,9 @@ export default {
|
||||
isAgentType() {
|
||||
return this.type === 'agent';
|
||||
},
|
||||
selectedFilterId() {
|
||||
return this.selectedFilter?.id || null;
|
||||
},
|
||||
reportKeys() {
|
||||
return {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
@@ -181,5 +184,10 @@ export default {
|
||||
v-if="filterItemsList.length"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:report-type="type"
|
||||
:selected-item-id="selectedFilterId"
|
||||
:business-hours="businessHours"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ReportDrilldownCard from '../ReportDrilldownCard.vue';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
params: {
|
||||
accountId: 1,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key === 'REPORT.DRILLDOWN.MESSAGE_CREATED_AT') {
|
||||
return `Message created at ${params.time}`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.EVENT_OCCURRED_AT') {
|
||||
return `Event occurred at ${params.time}`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.INCOMING_MESSAGE') {
|
||||
return 'Incoming message';
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.OUTGOING_MESSAGE') {
|
||||
return 'Outgoing message';
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('shared/helpers/timeHelper', () => ({
|
||||
dynamicTime: timestamp => {
|
||||
const timestamps = {
|
||||
1621103500: '2 minutes ago',
|
||||
1621103400: '4 days ago',
|
||||
1621103700: '4 days ago',
|
||||
};
|
||||
return timestamps[timestamp] || 'less than a minute ago';
|
||||
},
|
||||
shortTimestamp: time => {
|
||||
const timestamps = {
|
||||
'2 minutes ago': '2m',
|
||||
'4 days ago': '4d',
|
||||
};
|
||||
return timestamps[time] || 'now';
|
||||
},
|
||||
dateFormat: timestamp => `date-${timestamp}`,
|
||||
}));
|
||||
|
||||
describe('ReportDrilldownCard.vue', () => {
|
||||
const record = {
|
||||
record_type: 'message',
|
||||
conversation: {
|
||||
id: 10,
|
||||
display_id: 42,
|
||||
contact_id: 11,
|
||||
contact_name: 'Jane',
|
||||
inbox_id: 12,
|
||||
inbox_name: 'Website',
|
||||
assignee_id: 13,
|
||||
assignee_name: 'Alex',
|
||||
status: 'open',
|
||||
created_at: 1621103400,
|
||||
last_activity_at: 1621103700,
|
||||
last_message: {
|
||||
id: 100,
|
||||
content: 'Latest reply',
|
||||
message_type: 'outgoing',
|
||||
created_at: 1621103600,
|
||||
},
|
||||
},
|
||||
message: {
|
||||
id: 99,
|
||||
content: 'Need help',
|
||||
message_type: 'incoming',
|
||||
created_at: 1621103500,
|
||||
},
|
||||
metric_value: null,
|
||||
occurred_at: 1621103500,
|
||||
};
|
||||
|
||||
const mountCard = (props = {}) =>
|
||||
mount(ReportDrilldownCard, {
|
||||
props: {
|
||||
record,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'open').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens the card conversation link in a new tab', async () => {
|
||||
const wrapper = mountCard();
|
||||
|
||||
expect(wrapper.text()).toContain('#42');
|
||||
expect(wrapper.text()).toContain('Need help');
|
||||
expect(wrapper.find('.i-lucide-arrow-down-left').exists()).toBe(true);
|
||||
expect(wrapper.find('[aria-label="Incoming message"]').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('[role="link"]').trigger('click');
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'/app/accounts/1/conversations/42?messageId=99',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders only message created timestamp for message rows', () => {
|
||||
const wrapper = mountCard();
|
||||
const messageCreatedLabel = wrapper
|
||||
.findAll('[aria-label]')
|
||||
.map(timestamp => timestamp.attributes('aria-label'))
|
||||
.find(label => label.includes('Message created at'));
|
||||
|
||||
expect(wrapper.text()).toContain('2m');
|
||||
expect(wrapper.text()).not.toContain('4d • 4d');
|
||||
expect(messageCreatedLabel).toContain('Message created at');
|
||||
});
|
||||
|
||||
it('renders separate contact, inbox, and agent links', async () => {
|
||||
const wrapper = mountCard();
|
||||
const links = wrapper.findAll('a');
|
||||
|
||||
expect(links.map(link => link.attributes('href'))).toEqual([
|
||||
'/app/accounts/1/contacts/11',
|
||||
'/app/accounts/1/inbox/12',
|
||||
'/app/accounts/1/reports/agents/13',
|
||||
]);
|
||||
expect(links.every(link => link.attributes('target') === '_blank')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
links.every(link => link.classes().includes('text-n-slate-10'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
links.every(link => !link.classes().includes('text-n-blue-11'))
|
||||
).toBe(true);
|
||||
expect(wrapper.find('.i-lucide-contact').exists()).toBe(true);
|
||||
expect(wrapper.find('.i-lucide-inbox').exists()).toBe(true);
|
||||
expect(wrapper.find('.i-lucide-user-round').exists()).toBe(true);
|
||||
|
||||
await links[0].trigger('click');
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the last message for conversation rows', () => {
|
||||
const wrapper = mountCard({
|
||||
record: {
|
||||
...record,
|
||||
record_type: 'conversation',
|
||||
message: null,
|
||||
occurred_at: 1621103500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Latest reply');
|
||||
expect(wrapper.text()).toContain('4d • 4d');
|
||||
});
|
||||
|
||||
it('renders event time alongside TimeAgo for event-backed conversation rows', () => {
|
||||
const wrapper = mountCard({
|
||||
record: {
|
||||
...record,
|
||||
record_type: 'conversation',
|
||||
message: null,
|
||||
event_name: 'conversation_bot_handoff',
|
||||
occurred_at: 1621103500,
|
||||
},
|
||||
});
|
||||
const eventOccurredLabel = wrapper
|
||||
.findAll('[aria-label]')
|
||||
.map(timestamp => timestamp.attributes('aria-label'))
|
||||
.find(label => label.includes('Event occurred at'));
|
||||
|
||||
expect(wrapper.text()).toContain('Latest reply');
|
||||
expect(wrapper.text()).toContain('4d • 4d');
|
||||
expect(wrapper.text()).toContain('2m');
|
||||
expect(eventOccurredLabel).toContain('Event occurred at');
|
||||
});
|
||||
});
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
import ReportDrilldownDrawer from '../ReportDrilldownDrawer.vue';
|
||||
|
||||
vi.mock('dashboard/api/reports', () => ({
|
||||
default: {
|
||||
getDrilldown: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key === 'REPORT.DRILLDOWN.TITLE') {
|
||||
return `${params.metric} details`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION') {
|
||||
return `${params.count} conversations`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE') {
|
||||
return `${params.count} messages`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ReportDrilldownDrawer.vue', () => {
|
||||
const request = {
|
||||
metric: 'incoming_messages_count',
|
||||
metricName: 'Messages received',
|
||||
bucketLabel: '20-May',
|
||||
bucketTimestamp: 1621103400,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'account',
|
||||
groupBy: 'day',
|
||||
businessHours: false,
|
||||
};
|
||||
|
||||
const payload = [
|
||||
{
|
||||
record_type: 'message',
|
||||
conversation: {
|
||||
id: 10,
|
||||
display_id: 42,
|
||||
contact_id: 11,
|
||||
contact_name: 'Jane',
|
||||
inbox_id: 12,
|
||||
inbox_name: 'Website',
|
||||
assignee_id: 13,
|
||||
assignee_name: 'Alex',
|
||||
status: 'open',
|
||||
created_at: 1621103400,
|
||||
last_activity_at: 1621103700,
|
||||
last_message: {
|
||||
id: 100,
|
||||
content: 'Latest reply',
|
||||
message_type: 'outgoing',
|
||||
created_at: 1621103600,
|
||||
},
|
||||
},
|
||||
message: {
|
||||
id: 99,
|
||||
content: 'Need help',
|
||||
message_type: 'incoming',
|
||||
created_at: 1621103500,
|
||||
},
|
||||
metric_value: null,
|
||||
occurred_at: 1621103500,
|
||||
},
|
||||
];
|
||||
|
||||
const mountDrawer = options =>
|
||||
mount(ReportDrilldownDrawer, {
|
||||
props: { open: true, ...request, ...options?.props },
|
||||
attachTo: options?.attachTo,
|
||||
global: {
|
||||
stubs: {
|
||||
TeleportWithDirection: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
Transition: false,
|
||||
Spinner: true,
|
||||
Button: {
|
||||
props: ['label'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button @click="$emit(\'click\')">{{ label }}<slot /></button>',
|
||||
},
|
||||
ReportDrilldownCard: {
|
||||
props: ['record'],
|
||||
template:
|
||||
'<div data-testid="drilldown-card">#{{ record.conversation.display_id }}</div>',
|
||||
},
|
||||
},
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
record_type: 'message',
|
||||
conversation_count: 1,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('loads and renders drilldown cards for the request', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
await flushPromises();
|
||||
|
||||
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
page: 1,
|
||||
})
|
||||
);
|
||||
expect(wrapper.text()).toContain('Messages received');
|
||||
expect(wrapper.text()).toContain('1 conversations');
|
||||
expect(wrapper.find('[data-testid="drilldown-card"]').text()).toBe('#42');
|
||||
});
|
||||
|
||||
it('shows the bucket aggregate value for average metrics', async () => {
|
||||
const wrapper = mountDrawer({
|
||||
props: {
|
||||
metric: 'avg_first_response_time',
|
||||
metricName: 'First response time',
|
||||
isAverageMetric: true,
|
||||
bucketValue: 2580,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain(formatTime(2580));
|
||||
});
|
||||
|
||||
it('shows both conversation and message counts when they differ (reply time)', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 8,
|
||||
current_page: 1,
|
||||
record_type: 'message',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: {
|
||||
metric: 'reply_time',
|
||||
isAverageMetric: true,
|
||||
bucketValue: 2580,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5 conversations');
|
||||
expect(wrapper.text()).toContain('8 messages');
|
||||
});
|
||||
|
||||
it('hides the message count when it matches the conversation count (first response time)', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 5,
|
||||
current_page: 1,
|
||||
record_type: 'message',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: {
|
||||
metric: 'avg_first_response_time',
|
||||
isAverageMetric: true,
|
||||
bucketValue: 2580,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5 conversations');
|
||||
expect(wrapper.text()).not.toContain('messages');
|
||||
});
|
||||
|
||||
it('shows the plain count as the bucket value for count metrics', async () => {
|
||||
const wrapper = mountDrawer({ props: { bucketValue: 128 } });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('128');
|
||||
expect(wrapper.text()).not.toContain(formatTime(128));
|
||||
});
|
||||
|
||||
it('hides the redundant subtitle count for conversation-count metrics', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 5,
|
||||
current_page: 1,
|
||||
record_type: 'conversation',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: { metric: 'conversations_count', bucketValue: 5 },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5');
|
||||
expect(wrapper.text()).not.toContain('conversations');
|
||||
});
|
||||
|
||||
it('keeps the subtitle count when it differs from the stat value', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 8,
|
||||
current_page: 1,
|
||||
record_type: 'conversation',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: { metric: 'resolutions_count', bucketValue: 8 },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5 conversations');
|
||||
});
|
||||
|
||||
it('anchors the drawer to the inline-end edge so it flips in RTL', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
await flushPromises();
|
||||
|
||||
const drawer = wrapper.get('[role="dialog"]');
|
||||
expect(drawer.classes()).toContain('end-0');
|
||||
expect(drawer.classes()).not.toContain('right-0');
|
||||
});
|
||||
|
||||
it('flips the navigation caret icons in RTL', async () => {
|
||||
const wrapper = mountDrawer({ props: { canPrev: true, canNext: true } });
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.get('[aria-label="REPORT.DRILLDOWN.PREVIOUS_BUCKET"]').classes()
|
||||
).toContain('rtl:rotate-180');
|
||||
expect(
|
||||
wrapper.get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]').classes()
|
||||
).toContain('rtl:rotate-180');
|
||||
});
|
||||
|
||||
it('emits close when the drawer close button is clicked', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits navigate when the next button is clicked', async () => {
|
||||
const wrapper = mountDrawer({ props: { canNext: true } });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper
|
||||
.get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]')
|
||||
.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('navigate')).toStrictEqual([[1]]);
|
||||
});
|
||||
|
||||
it('does not emit navigate past the available range', async () => {
|
||||
const wrapper = mountDrawer({ props: { canPrev: false } });
|
||||
await flushPromises();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
||||
|
||||
expect(wrapper.emitted('navigate')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('moves focus into the drawer when opened', async () => {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
wrapper.find('[role="dialog"]').element
|
||||
);
|
||||
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
});
|
||||
|
||||
it('closes on Escape even when focus is outside the drawer', async () => {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
|
||||
document.body.focus();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy();
|
||||
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
});
|
||||
|
||||
it('restores focus to the previously focused element when closed', async () => {
|
||||
const opener = document.createElement('button');
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(opener);
|
||||
document.body.appendChild(target);
|
||||
opener.focus();
|
||||
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
opener.remove();
|
||||
});
|
||||
});
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
import { useReportDrilldown } from '../useReportDrilldown';
|
||||
|
||||
vi.mock('dashboard/api/reports', () => ({
|
||||
default: {
|
||||
getDrilldown: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const deferredPromise = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const drilldownRequest = overrides => ({
|
||||
metric: 'conversations_count',
|
||||
bucketTimestamp: 1,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'account',
|
||||
groupBy: 'day',
|
||||
businessHours: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useReportDrilldown', () => {
|
||||
const mountComposable = () =>
|
||||
mount({
|
||||
setup() {
|
||||
return useReportDrilldown();
|
||||
},
|
||||
template: '<div />',
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not request drilldown again for an identical active request', async () => {
|
||||
const request = deferredPromise();
|
||||
ReportsAPI.getDrilldown.mockReturnValue(request.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest());
|
||||
wrapper.vm.open(drilldownRequest());
|
||||
|
||||
expect(ReportsAPI.getDrilldown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aborts an in-flight request when a newer request is opened', async () => {
|
||||
const firstRequest = deferredPromise();
|
||||
const secondRequest = deferredPromise();
|
||||
let firstSignal;
|
||||
|
||||
ReportsAPI.getDrilldown
|
||||
.mockImplementationOnce(({ signal }) => {
|
||||
firstSignal = signal;
|
||||
return firstRequest.promise;
|
||||
})
|
||||
.mockReturnValueOnce(secondRequest.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 }));
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 }));
|
||||
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('passes an abort signal to drilldown requests', async () => {
|
||||
const request = deferredPromise();
|
||||
ReportsAPI.getDrilldown.mockReturnValue(request.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest());
|
||||
|
||||
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale responses when a newer request is opened first', async () => {
|
||||
const firstRequest = deferredPromise();
|
||||
const secondRequest = deferredPromise();
|
||||
ReportsAPI.getDrilldown
|
||||
.mockReturnValueOnce(firstRequest.promise)
|
||||
.mockReturnValueOnce(secondRequest.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 }));
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 }));
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
meta: { current_page: 1, total_count: 1 },
|
||||
payload: [{ id: 'second' }],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.records).toEqual([{ id: 'second' }]);
|
||||
expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 });
|
||||
|
||||
firstRequest.resolve({
|
||||
data: {
|
||||
meta: { current_page: 1, total_count: 1 },
|
||||
payload: [{ id: 'first' }],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.records).toEqual([{ id: 'second' }]);
|
||||
expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 });
|
||||
});
|
||||
});
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
|
||||
export function useReportDrilldown() {
|
||||
const activeRequest = ref(null);
|
||||
const records = ref([]);
|
||||
const meta = ref({});
|
||||
const isFetching = ref(false);
|
||||
const isFetchingMore = ref(false);
|
||||
const hasError = ref(false);
|
||||
let requestToken = 0;
|
||||
let activeRequestController = null;
|
||||
let activeRequestFingerprint = null;
|
||||
|
||||
const hasRecords = computed(() => records.value.length > 0);
|
||||
const hasMore = computed(() => {
|
||||
return records.value.length < (meta.value.total_count || 0);
|
||||
});
|
||||
|
||||
const isCurrentRequest = token =>
|
||||
token === requestToken && !!activeRequest.value;
|
||||
|
||||
const requestFingerprint = request =>
|
||||
JSON.stringify({
|
||||
metric: request.metric,
|
||||
bucketTimestamp: request.bucketTimestamp,
|
||||
from: request.from,
|
||||
to: request.to,
|
||||
type: request.type,
|
||||
id: request.id,
|
||||
groupBy: request.groupBy,
|
||||
businessHours: request.businessHours,
|
||||
});
|
||||
|
||||
const abortActiveRequest = () => {
|
||||
if (!activeRequestController) return;
|
||||
|
||||
activeRequestController.abort();
|
||||
activeRequestController = null;
|
||||
};
|
||||
|
||||
const isAbortError = error =>
|
||||
error?.name === 'AbortError' ||
|
||||
error?.name === 'CanceledError' ||
|
||||
error?.code === 'ERR_CANCELED';
|
||||
|
||||
const fetchPage = async (page, token = requestToken) => {
|
||||
if (!activeRequest.value) return;
|
||||
|
||||
const request = activeRequest.value;
|
||||
const controller = new AbortController();
|
||||
const loadingState = page === 1 ? isFetching : isFetchingMore;
|
||||
activeRequestController = controller;
|
||||
loadingState.value = true;
|
||||
hasError.value = false;
|
||||
|
||||
try {
|
||||
const response = await ReportsAPI.getDrilldown({
|
||||
...request,
|
||||
page,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!isCurrentRequest(token)) return;
|
||||
|
||||
meta.value = response.data.meta || {};
|
||||
records.value =
|
||||
page === 1
|
||||
? response.data.payload || []
|
||||
: [...records.value, ...(response.data.payload || [])];
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest(token) || isAbortError(error)) return;
|
||||
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
if (activeRequestController === controller) {
|
||||
activeRequestController = null;
|
||||
}
|
||||
|
||||
if (isCurrentRequest(token)) {
|
||||
loadingState.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const open = async request => {
|
||||
const fingerprint = requestFingerprint(request);
|
||||
if (activeRequestFingerprint === fingerprint) return;
|
||||
|
||||
abortActiveRequest();
|
||||
requestToken += 1;
|
||||
activeRequestFingerprint = fingerprint;
|
||||
activeRequest.value = request;
|
||||
records.value = [];
|
||||
meta.value = {};
|
||||
hasError.value = false;
|
||||
isFetchingMore.value = false;
|
||||
await fetchPage(1, requestToken);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
abortActiveRequest();
|
||||
requestToken += 1;
|
||||
activeRequestFingerprint = null;
|
||||
activeRequest.value = null;
|
||||
records.value = [];
|
||||
meta.value = {};
|
||||
hasError.value = false;
|
||||
isFetching.value = false;
|
||||
isFetchingMore.value = false;
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (
|
||||
!activeRequest.value ||
|
||||
!hasMore.value ||
|
||||
isFetching.value ||
|
||||
isFetchingMore.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchPage((meta.value.current_page || 1) + 1, requestToken);
|
||||
};
|
||||
|
||||
return {
|
||||
activeRequest,
|
||||
records,
|
||||
meta,
|
||||
isFetching,
|
||||
isFetchingMore,
|
||||
hasError,
|
||||
hasRecords,
|
||||
hasMore,
|
||||
open,
|
||||
close,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ReportContainer from '../ReportContainer.vue';
|
||||
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
useAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useReportMetrics', () => ({
|
||||
useReportMetrics: () => ({
|
||||
calculateTrend: () => 0,
|
||||
isAverageMetricType: key =>
|
||||
['avg_first_response_time', 'avg_resolution_time', 'reply_time'].includes(
|
||||
key
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ReportContainer.vue', () => {
|
||||
const mountComponent = ({
|
||||
dataPoint = { value: 2, timestamp: 1621103400 },
|
||||
data,
|
||||
reportKey = 'conversations_count',
|
||||
role = 'administrator',
|
||||
} = {}) =>
|
||||
shallowMount(ReportContainer, {
|
||||
props: {
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
groupBy: { period: 'day' },
|
||||
reportType: 'inbox',
|
||||
selectedItemId: 1,
|
||||
businessHours: true,
|
||||
reportKeys: {
|
||||
CONVERSATIONS: reportKey,
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
$store: {
|
||||
getters: {
|
||||
getAccountReports: {
|
||||
isFetching: {
|
||||
[reportKey]: false,
|
||||
},
|
||||
data: {
|
||||
[reportKey]: data || [dataPoint],
|
||||
},
|
||||
},
|
||||
getCurrentRole: role,
|
||||
},
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
ChartStats: true,
|
||||
ReportDrilldownDrawer: {
|
||||
name: 'ReportDrilldownDrawer',
|
||||
props: [
|
||||
'open',
|
||||
'metric',
|
||||
'metricName',
|
||||
'bucketLabel',
|
||||
'bucketTimestamp',
|
||||
'bucketValue',
|
||||
'isAverageMetric',
|
||||
'from',
|
||||
'to',
|
||||
'type',
|
||||
'id',
|
||||
'groupBy',
|
||||
'businessHours',
|
||||
'canPrev',
|
||||
'canNext',
|
||||
],
|
||||
emits: ['navigate', 'close'],
|
||||
template: '<div />',
|
||||
},
|
||||
BarChart: {
|
||||
name: 'BarChart',
|
||||
props: ['collection', 'chartOptions', 'clickable'],
|
||||
emits: ['elementClick'],
|
||||
template:
|
||||
'<button data-test-id="bar-chart" @click="$emit(\'elementClick\', { dataIndex: 0, label: \'20-May\', value: 2 })" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens a drilldown request with report context when a non-zero bar is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
|
||||
expect(drawer.props('open')).toBe(true);
|
||||
expect(drawer.props()).toMatchObject({
|
||||
metric: 'conversations_count',
|
||||
metricName: 'REPORT.METRICS.CONVERSATIONS.NAME',
|
||||
bucketLabel: '15-May',
|
||||
bucketTimestamp: 1621103400,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
groupBy: 'day',
|
||||
businessHours: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an alert and does not open drilldown for non-admin users', async () => {
|
||||
const wrapper = mountComponent({ role: 'agent' });
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
expect(useAlert).toHaveBeenCalledWith('REPORT.DRILLDOWN.ADMIN_ONLY');
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'ReportDrilldownDrawer' }).props('open')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not open drilldown for zero-value count bars', async () => {
|
||||
const wrapper = mountComponent({
|
||||
dataPoint: { value: 0, timestamp: 1621103400 },
|
||||
});
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'ReportDrilldownDrawer' }).props('open')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('opens average metric drilldown when the bucket has contributing records', async () => {
|
||||
const wrapper = mountComponent({
|
||||
reportKey: 'avg_first_response_time',
|
||||
dataPoint: { value: 90, count: 2, timestamp: 1621103400 },
|
||||
});
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
|
||||
expect(drawer.props('open')).toBe(true);
|
||||
expect(drawer.props()).toMatchObject({
|
||||
metric: 'avg_first_response_time',
|
||||
bucketTimestamp: 1621103400,
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to adjacent drillable buckets within the report range', async () => {
|
||||
const wrapper = mountComponent({
|
||||
data: [
|
||||
{ value: 2, timestamp: 1621103400 },
|
||||
{ value: 0, timestamp: 1621189800 },
|
||||
{ value: 5, timestamp: 1621276200 },
|
||||
],
|
||||
});
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
|
||||
// Opened on the first bucket: no previous, but a later drillable bucket exists.
|
||||
expect(drawer.props('bucketTimestamp')).toBe(1621103400);
|
||||
expect(drawer.props('canPrev')).toBe(false);
|
||||
expect(drawer.props('canNext')).toBe(true);
|
||||
|
||||
// Skips the zero-value middle bucket and lands on the last drillable one.
|
||||
drawer.vm.$emit('navigate', 1);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(drawer.props('bucketTimestamp')).toBe(1621276200);
|
||||
expect(drawer.props('canPrev')).toBe(true);
|
||||
expect(drawer.props('canNext')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
@@ -123,9 +124,16 @@ const confirmPlaceHolderText = computed(() =>
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1 text-lg"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="team.icon"
|
||||
:value="team.icon"
|
||||
:color="team.icon_color"
|
||||
class="size-5"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-users-round"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
<script>
|
||||
import validations from './helpers/validations';
|
||||
import FormInput from 'v3/components/Form/Input.vue';
|
||||
import { reactive } from 'vue';
|
||||
import { reactive, ref, defineAsyncComponent } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
FormInput,
|
||||
OnClickOutside,
|
||||
EmojiIcon,
|
||||
Icon,
|
||||
EmojiIconPicker,
|
||||
},
|
||||
props: {
|
||||
onSubmit: {
|
||||
@@ -35,19 +47,38 @@ export default {
|
||||
description = '',
|
||||
name: title = '',
|
||||
allow_auto_assign: allowAutoAssign = true,
|
||||
icon = '',
|
||||
icon_color: iconColor = '',
|
||||
} = formData;
|
||||
|
||||
const state = reactive({
|
||||
description,
|
||||
title,
|
||||
allowAutoAssign,
|
||||
icon,
|
||||
iconColor,
|
||||
});
|
||||
|
||||
const isIconPickerOpen = ref(false);
|
||||
|
||||
const rules = validations;
|
||||
const v$ = useVuelidate(rules, state);
|
||||
return { state, v$ };
|
||||
return { state, v$, isIconPickerOpen };
|
||||
},
|
||||
methods: {
|
||||
onSelectIcon({ type, value, color }) {
|
||||
this.state.icon = value;
|
||||
this.state.iconColor = type === 'icon' ? color : '';
|
||||
this.isIconPickerOpen = false;
|
||||
},
|
||||
onColorChange(color) {
|
||||
this.state.iconColor = color;
|
||||
},
|
||||
onRemoveIcon() {
|
||||
this.state.icon = '';
|
||||
this.state.iconColor = '';
|
||||
this.isIconPickerOpen = false;
|
||||
},
|
||||
handleSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
@@ -57,6 +88,8 @@ export default {
|
||||
description: this.state.description,
|
||||
name: this.state.title,
|
||||
allow_auto_assign: this.state.allowAutoAssign,
|
||||
icon: this.state.icon,
|
||||
icon_color: this.state.iconColor,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -66,16 +99,49 @@ export default {
|
||||
<template>
|
||||
<div class="flex-shrink-0 w-full">
|
||||
<form class="mx-0 grid gap-4" @submit.prevent="handleSubmit">
|
||||
<FormInput
|
||||
v-model="state.title"
|
||||
name="title"
|
||||
spacing="compact"
|
||||
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
||||
:has-error="v$.title.$error"
|
||||
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
|
||||
@blur="v$.title.$touch"
|
||||
/>
|
||||
<div class="relative">
|
||||
<FormInput
|
||||
v-model="state.title"
|
||||
class="!ps-12"
|
||||
name="title"
|
||||
spacing="compact"
|
||||
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
||||
:has-error="v$.title.$error"
|
||||
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
|
||||
@blur="v$.title.$touch"
|
||||
/>
|
||||
<OnClickOutside
|
||||
class="absolute top-[1.75rem] start-0"
|
||||
@trigger="isIconPickerOpen = false"
|
||||
>
|
||||
<NextButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="text-lg !size-[2.5rem] !p-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isIconPickerOpen = !isIconPickerOpen"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="state.icon"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
class="size-5 text-xl !leading-5"
|
||||
/>
|
||||
<Icon v-else icon="i-lucide-smile-plus " class="size-4" />
|
||||
</NextButton>
|
||||
<EmojiIconPicker
|
||||
v-if="isIconPickerOpen"
|
||||
class="start-0 top-10"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
show-remove-button
|
||||
@select="onSelectIcon"
|
||||
@color-change="onColorChange"
|
||||
@remove="onRemoveIcon"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<FormInput
|
||||
v-model="state.description"
|
||||
name="description"
|
||||
|
||||
@@ -7,9 +7,12 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import AnalyticsHelper from '../helper/AnalyticsHelper';
|
||||
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup'];
|
||||
const routes = [...dashboard.routes];
|
||||
|
||||
const onboardingPath = step =>
|
||||
step === 'inbox_setup' ? 'onboarding/inbox-setup' : 'onboarding';
|
||||
|
||||
export const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
@@ -39,12 +42,18 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
isActive;
|
||||
|
||||
if (to.name === 'no_accounts' || !to.name) {
|
||||
const target = needsOnboarding ? 'onboarding' : 'dashboard';
|
||||
const target = needsOnboarding
|
||||
? onboardingPath(userAccount?.onboarding_step)
|
||||
: 'dashboard';
|
||||
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
|
||||
}
|
||||
|
||||
if (needsOnboarding && !isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
|
||||
return next(
|
||||
frontendURL(
|
||||
`accounts/${routeAccountId}/${onboardingPath(userAccount?.onboarding_step)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!needsOnboarding && isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
|
||||
|
||||
+5
-9
@@ -2,14 +2,10 @@ import MessageApi from '../../../../api/inbox/message';
|
||||
|
||||
export default {
|
||||
async translateMessage(_, { conversationId, messageId, targetLanguage }) {
|
||||
try {
|
||||
await MessageApi.translateMessage(
|
||||
conversationId,
|
||||
messageId,
|
||||
targetLanguage
|
||||
);
|
||||
} catch (error) {
|
||||
// ignore error
|
||||
}
|
||||
await MessageApi.translateMessage(
|
||||
conversationId,
|
||||
messageId,
|
||||
targetLanguage
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -157,11 +157,13 @@ export const actions = {
|
||||
// Update positions in the store immediately so subsequent mutations preserve correct positions
|
||||
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
|
||||
try {
|
||||
await articlesAPI.reorderArticles({
|
||||
const { data } = await articlesAPI.reorderArticles({
|
||||
portalSlug,
|
||||
reorderedGroup,
|
||||
categorySlug,
|
||||
});
|
||||
// Adopt the backend's re-spaced positions so the next reorder isn't computed from stale local values.
|
||||
if (data?.positions) commit(types.SET_ARTICLE_POSITIONS, data.positions);
|
||||
} catch (error) {
|
||||
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
|
||||
throw error;
|
||||
|
||||
@@ -314,6 +314,25 @@ describe('#actions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('adopts the backend re-spaced positions when the response returns them', async () => {
|
||||
const serverPositions = { 1: 10, 2: 30, 3: 20 };
|
||||
axios.post.mockResolvedValue({ data: { positions: serverPositions } });
|
||||
|
||||
await actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'test-portal',
|
||||
categorySlug: 'test-category',
|
||||
reorderedGroup: { 3: 25 },
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_ARTICLE_POSITIONS,
|
||||
serverPositions
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back positions and throws when API call fails', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Network error' });
|
||||
const reorderedGroup = { 1: 1, 2: 2 };
|
||||
|
||||
@@ -19,8 +19,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['elementClick']);
|
||||
|
||||
ChartJS.register(Title, Tooltip, BarElement, CategoryScale, LinearScale);
|
||||
|
||||
const fontFamily =
|
||||
@@ -67,8 +73,39 @@ const defaultChartOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
const handleClick = (event, elements, chart) => {
|
||||
props.chartOptions.onClick?.(event, elements, chart);
|
||||
|
||||
if (!props.clickable || !elements.length) return;
|
||||
|
||||
const { datasetIndex, index } = elements[0];
|
||||
const dataset = props.collection.datasets?.[datasetIndex] || {};
|
||||
|
||||
emit('elementClick', {
|
||||
datasetIndex,
|
||||
dataIndex: index,
|
||||
dataset,
|
||||
label: props.collection.labels?.[index],
|
||||
value: dataset.data?.[index],
|
||||
});
|
||||
};
|
||||
|
||||
const handleHover = (event, elements, chart) => {
|
||||
props.chartOptions.onHover?.(event, elements, chart);
|
||||
|
||||
if (!event?.native?.target) return;
|
||||
|
||||
event.native.target.style.cursor =
|
||||
props.clickable && elements.length ? 'pointer' : 'default';
|
||||
};
|
||||
|
||||
const options = computed(() => {
|
||||
return { ...defaultChartOptions, ...props.chartOptions };
|
||||
return {
|
||||
...defaultChartOptions,
|
||||
...props.chartOptions,
|
||||
onClick: handleClick,
|
||||
onHover: handleHover,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import BarChart from '../charts/BarChart.vue';
|
||||
|
||||
vi.mock('vue-chartjs', () => ({
|
||||
Bar: {
|
||||
name: 'Bar',
|
||||
props: ['data', 'options'],
|
||||
template: '<canvas />',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BarChart.vue', () => {
|
||||
it('emits the clicked chart element when clickable', () => {
|
||||
const wrapper = shallowMount(BarChart, {
|
||||
props: {
|
||||
clickable: true,
|
||||
collection: {
|
||||
labels: ['20-May'],
|
||||
datasets: [{ type: 'bar', data: [3] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const options = wrapper.findComponent({ name: 'Bar' }).props('options');
|
||||
options.onClick({}, [{ datasetIndex: 0, index: 0 }], {});
|
||||
|
||||
expect(wrapper.emitted('elementClick')[0][0]).toEqual({
|
||||
datasetIndex: 0,
|
||||
dataIndex: 0,
|
||||
dataset: { type: 'bar', data: [3] },
|
||||
label: '20-May',
|
||||
value: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit when chart is not clickable', () => {
|
||||
const wrapper = shallowMount(BarChart, {
|
||||
props: {
|
||||
clickable: false,
|
||||
collection: {
|
||||
labels: ['20-May'],
|
||||
datasets: [{ type: 'bar', data: [3] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const options = wrapper.findComponent({ name: 'Bar' }).props('options');
|
||||
options.onClick({}, [{ datasetIndex: 0, index: 0 }], {});
|
||||
|
||||
expect(wrapper.emitted('elementClick')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -37,6 +38,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'Search',
|
||||
},
|
||||
showEmojiIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
@@ -96,8 +101,18 @@ const hasIcon = computed(() => {
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="hasValue && hasIcon && showEmojiIcon"
|
||||
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
|
||||
>
|
||||
<EmojiIcon
|
||||
:value="selectedItem.icon"
|
||||
:color="selectedItem.icon_color"
|
||||
class="size-3.5 !text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="hasValue && hasIcon"
|
||||
v-else-if="hasValue && hasIcon"
|
||||
:icon="selectedItem.icon"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
@@ -124,6 +139,7 @@ const hasIcon = computed(() => {
|
||||
:has-thumbnail="hasThumbnail"
|
||||
:input-placeholder="inputPlaceholder"
|
||||
:no-search-result="noSearchResult"
|
||||
:show-emoji-icon="showEmojiIcon"
|
||||
@select="onClickSelectItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
@@ -11,6 +12,7 @@ export default {
|
||||
WootDropdownMenu,
|
||||
Avatar,
|
||||
Icon,
|
||||
EmojiIcon,
|
||||
NextButton,
|
||||
},
|
||||
|
||||
@@ -35,6 +37,10 @@ export default {
|
||||
type: String,
|
||||
default: 'No results found',
|
||||
},
|
||||
showEmojiIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['select'],
|
||||
|
||||
@@ -116,8 +122,18 @@ export default {
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="option.icon && showEmojiIcon"
|
||||
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
|
||||
>
|
||||
<EmojiIcon
|
||||
:value="option.icon"
|
||||
:color="option.icon_color"
|
||||
class="size-3.5 !text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="option.icon"
|
||||
v-else-if="option.icon"
|
||||
:icon="option.icon"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
|
||||
const DYTE_MEETING_LINK = 'https://examples.realtime.cloudflare.com/meeting/';
|
||||
|
||||
export const buildDyteURL = dyteAuthToken => {
|
||||
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
|
||||
const params = new URLSearchParams({
|
||||
authToken: dyteAuthToken,
|
||||
showSetupScreen: true,
|
||||
disableVideoBackground: true,
|
||||
});
|
||||
|
||||
return `${DYTE_MEETING_LINK}?${params.toString()}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user