Merge branch 'develop' into codex/fix-inbox-finish-setup-guidance

This commit is contained in:
Muhsin Keloth
2026-04-21 13:51:36 +04:00
committed by GitHub
1089 changed files with 50709 additions and 11977 deletions
@@ -8,6 +8,7 @@ import {
agents,
teams,
labels,
booleanFilterOptions,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
@@ -73,6 +74,8 @@ describe('useAutomation', () => {
return countries;
case 'message_type':
return messageTypeOptions;
case 'private_note':
return booleanFilterOptions;
case 'priority':
return priorityOptions;
default:
@@ -89,7 +92,9 @@ describe('useAutomation', () => {
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
return options.addNoneToListFn
? options.addNoneToListFn(options.agents)
: options.agents;
case 'send_email_to_team':
return teams;
case 'send_message':
@@ -226,6 +231,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('message_type')).toEqual(
messageTypeOptions
);
expect(getConditionDropdownValues('private_note')).toEqual(
booleanFilterOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
@@ -234,7 +242,11 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'last_responding_agent', name: 'AUTOMATION.LAST_RESPONDING_AGENT' },
...agents,
]);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
@@ -0,0 +1,239 @@
import { ref } from 'vue';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
// Mock @vueuse/core — return reactive refs we can control per test
const mockBounding = () => ({
top: ref(0),
bottom: ref(0),
left: ref(0),
right: ref(0),
width: ref(0),
height: ref(0),
update: vi.fn(),
});
const triggerBounds = mockBounding();
const dropdownBounds = mockBounding();
const winWidth = ref(1024);
const winHeight = ref(768);
vi.mock('@vueuse/core', () => {
let callCount = 0;
return {
// First call = trigger, second = dropdown, third = container (if any)
useElementBounding: () => {
callCount += 1;
return callCount % 3 === 1 ? triggerBounds : dropdownBounds;
},
useWindowSize: () => ({ width: winWidth, height: winHeight }),
};
});
const setTrigger = ({ top, bottom, left, right }) => {
triggerBounds.top.value = top;
triggerBounds.bottom.value = bottom;
triggerBounds.left.value = left ?? 100;
triggerBounds.right.value = right ?? 200;
};
const setDropdown = ({ width, height }) => {
dropdownBounds.width.value = width ?? 200;
dropdownBounds.height.value = height;
};
describe('useDropdownPosition', () => {
beforeEach(() => {
winWidth.value = 1024;
winHeight.value = 768;
document.body.innerHTML = '<div id="app" dir="ltr"></div>';
});
describe('verticalClass (relative mode)', () => {
it('places below when enough space', () => {
// Trigger at y=100, dropdown 200px tall
// Space below = 768 - 140 = 628 → fits (628 > 216)
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('places above when not enough space below but enough above', () => {
// Trigger near bottom at y=600
// Space below = 128 → doesn't fit. Space above = 600 → fits
setTrigger({ top: 600, bottom: 640 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('picks the side with more space when dropdown fits neither', () => {
// Dropdown 500px tall, won't fit above (300) or below (428)
// Below has more room → stays below
setTrigger({ top: 300, bottom: 340 });
setDropdown({ height: 500 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('picks above when above has more space and neither fits', () => {
// Dropdown 600px tall, won't fit above (500) or below (228)
// Above has more room → flips above
setTrigger({ top: 500, bottom: 540 });
setDropdown({ height: 600 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('returns default when disabled', () => {
setTrigger({ top: 700, bottom: 740 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(position.value.class).toBe('top-full mt-2');
expect(position.value.style).toEqual({});
});
});
describe('fixedPosition', () => {
it('places below with correct top and maxHeight', () => {
// Trigger at y=140, space below = 628
// top = 140 + 8(gap) = 148
// maxHeight = 628 - 8(gap) - 16(margin) = 604
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.top).toBe('148px');
expect(fixedPosition.value.style.bottom).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('604px');
});
it('flips above with correct bottom and maxHeight', () => {
// Trigger near bottom at y=650, space below = 78 → doesn't fit
// Flips above: bottom = 768 - 650 + 8 = 126
// maxHeight = 650 - 8 - 16 = 626
setTrigger({ top: 650, bottom: 690 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBe('126px');
expect(fixedPosition.value.style.top).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('626px');
});
it('constrains maxHeight to available space on short viewports', () => {
// Short viewport (400px), trigger in the middle, dropdown 500px tall
// Neither side fits → above (200) > below (160) → places above
// maxHeight capped to 200 - 8 - 16 = 176
winHeight.value = 400;
setTrigger({ top: 200, bottom: 240 });
setDropdown({ height: 500, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBeDefined();
expect(fixedPosition.value.style.maxHeight).toBe('176px');
});
it('returns defaults when disabled', () => {
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(fixedPosition.value.class).toBe('fixed z-[9999]');
expect(fixedPosition.value.style).toEqual({});
});
});
describe('horizontal positioning (fixedPosition)', () => {
it('anchors to the right edge by default (align=end, LTR)', () => {
// align=end + LTR → anchorLeft=false → uses style.right
// right = 1024 - 900 = 124
setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.right).toBe('124px');
});
it('anchors to the left edge when align=start (LTR)', () => {
// align=start + LTR → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.left).toBe('100px');
});
it('shifts left when dropdown overflows right edge', () => {
// Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
// Falls back to right: 16px (margin)
setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
setDropdown({ height: 100, width: 300 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.right).toBe('16px');
});
});
describe('RTL', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app" dir="rtl"></div>';
});
it('flips anchor direction in RTL (align=end anchors left)', () => {
// align=end + RTL → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.left).toBe('100px');
});
});
});
@@ -0,0 +1,98 @@
import { useEditableAutomation } from '../useEditableAutomation';
import useAutomationValues from '../useAutomationValues';
vi.mock('../useAutomationValues');
describe('useEditableAutomation', () => {
beforeEach(() => {
useAutomationValues.mockReturnValue({
getConditionDropdownValues: vi.fn(attributeKey => {
if (attributeKey === 'private_note') {
return [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
}
return [];
}),
getActionDropdownValues: vi.fn(actionName => {
if (actionName === 'assign_agent') {
return [
{ id: 'nil', name: 'None' },
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
{ id: 1, name: 'Agent 1' },
];
}
return [];
}),
});
});
it('rehydrates boolean conditions as a single selected option', () => {
const automation = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: [false],
query_operator: null,
},
],
actions: [],
};
const automationTypes = {
message_created: {
conditions: [{ key: 'private_note', inputType: 'search_select' }],
},
};
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], automationTypes, []);
expect(result.conditions).toEqual([
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: { id: false, name: 'False' },
query_operator: 'and',
},
]);
});
it('rehydrates last responding agent as a selected action option', () => {
const automation = {
event_name: 'conversation_created',
conditions: [],
actions: [
{
action_name: 'assign_agent',
action_params: ['last_responding_agent'],
},
],
};
const automationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], {}, automationActionTypes);
expect(result.actions).toEqual([
{
action_name: 'assign_agent',
action_params: [
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
],
},
]);
});
});
@@ -119,24 +119,30 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
mockTeams.length + 1
); // +1 for "None"
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
mockAgents.length + 2
); // +2 for "None" and "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
const assignTeamResult = getMacroDropdownValues('assign_team');
expect(assignTeamResult[0]).toEqual({
id: 'nil',
name: 'AUTOMATION.NONE_OPTION',
});
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
it('returns agents with "None" and "Self" options for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
expect(result[0]).toEqual({ id: 'nil', name: 'AUTOMATION.NONE_OPTION' });
expect(result[1]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(2)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
@@ -172,8 +178,11 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'self', name: 'Self' },
]);
});
@@ -121,8 +121,19 @@ export default function useAutomationValues() {
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
let agentsList = agents.value;
if (type === 'assign_agent') {
agentsList = [
{
id: 'last_responding_agent',
name: t('AUTOMATION.LAST_RESPONDING_AGENT'),
},
...agentsList,
];
}
return getActionOptions({
agents: agents.value,
agents: agentsList,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
@@ -0,0 +1,128 @@
import { computed, unref, watch } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
const FALLBACK_SIZE = 200;
const SAFE_MARGIN = 16;
const GAP = 8;
/**
* Auto-position a floating element based on available viewport space.
*
* @param {Ref} triggerRef - Trigger element ref
* @param {Ref} dropdownRef - Dropdown/popover element ref
* @param {Ref} enabled - Whether to calculate position
* @param {Object} options
* @param {Ref} [options.container] - Constraining container ref
* @param {number} [options.margin=16] - Min distance from viewport/container edges
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
*/
export function useDropdownPosition(
triggerRef,
dropdownRef,
enabled,
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
) {
const trigger = useElementBounding(triggerRef);
const dropdown = useElementBounding(dropdownRef);
const bounds = useElementBounding(container);
const { width: winWidth, height: winHeight } = useWindowSize();
const isRTL = computed(
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
);
// Whether to anchor to the left edge of the trigger
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
const verticalClass = computed(() => {
if (!unref(enabled)) return 'top-full mt-2';
const dh = dropdown.height.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const spaceAbove = trigger.top.value;
// Only flip above if it fits there; otherwise stay below (more room or equal)
if (spaceBelow >= dh + margin) return 'top-full mt-2';
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
});
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
const position = computed(() => {
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
const dw = dropdown.width.value || FALLBACK_SIZE;
const leftBound = container ? bounds.left.value : 0;
const rightBound = container ? bounds.right.value : winWidth.value;
const style = {};
if (anchorLeft.value) {
const available = rightBound - trigger.left.value;
const overflow = dw - available;
style.left = overflow > 0 ? `-${overflow}px` : '0px';
} else {
const available = trigger.right.value - leftBound;
const overflow = dw - available;
style.right = overflow > 0 ? `-${overflow}px` : '0px';
}
return { class: verticalClass.value, style };
});
// Fixed mode: styles for teleported popovers
const fixedPosition = computed(() => {
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
const dh = dropdown.height.value || FALLBACK_SIZE;
const dw = dropdown.width.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const style = {};
// Vertical: prefer below, flip above only if it fits, else pick the larger side
const spaceAbove = trigger.top.value;
const placeAbove =
spaceBelow < dh + margin &&
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
if (placeAbove) {
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
style.maxHeight = `${spaceAbove - GAP - margin}px`;
} else {
style.top = `${trigger.bottom.value + GAP}px`;
style.maxHeight = `${spaceBelow - GAP - margin}px`;
}
// Horizontal
if (anchorLeft.value) {
const left = trigger.left.value;
if (left + dw > winWidth.value - margin) {
style.right = `${margin}px`;
} else {
style.left = `${Math.max(margin, left)}px`;
}
} else {
const right = winWidth.value - trigger.right.value;
if (trigger.right.value - dw < margin) {
style.left = `${margin}px`;
} else {
style.right = `${right}px`;
}
}
return { class: 'fixed z-[9999]', style };
});
const updatePosition = () => {
trigger.update();
dropdown.update();
if (container) bounds.update();
};
// Update position when dropdown opens to ensure RTL state is current
watch(
() => unref(enabled),
isEnabled => {
if (isEnabled) updatePosition();
}
);
return { position, fixedPosition, updatePosition };
}
@@ -46,11 +46,26 @@ export function useEditableAutomation() {
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
const dropdownValues = getConditionDropdownValues(
condition.attribute_key
);
const hasBooleanOptions =
inputType === 'search_select' &&
dropdownValues.length &&
dropdownValues.every(item => typeof item.id === 'boolean');
if (hasBooleanOptions) {
return {
...condition,
query_operator: condition.query_operator || 'and',
values: dropdownValues.find(item => item.id === condition.values[0]),
};
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
values: [...dropdownValues].filter(item =>
[...condition.values].includes(item.id)
),
};
});
@@ -12,11 +12,14 @@ import { useKeyboardEvents } from './useKeyboardEvents';
/**
* Wrap the action in a function that calls the action and prevents the default event behavior.
* Only prevents default when items are available to navigate.
* @param {Function} action - The action to be called.
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
*/
const createAction = action => ({
const createAction = (action, items) => ({
action: e => {
if (!items.value?.length) return;
action();
e.preventDefault();
},
@@ -38,15 +41,14 @@ const createKeyboardEvents = (
items
) => {
const events = {
ArrowUp: createAction(moveSelectionUp),
'Control+KeyP': createAction(moveSelectionUp),
ArrowDown: createAction(moveSelectionDown),
'Control+KeyN': createAction(moveSelectionDown),
ArrowUp: createAction(moveSelectionUp, items),
'Control+KeyP': createAction(moveSelectionUp, items),
ArrowDown: createAction(moveSelectionDown, items),
'Control+KeyN': createAction(moveSelectionDown, items),
};
// Adds an event handler for the Enter key if the onSelect function is provided.
if (typeof onSelect === 'function') {
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
events.Enter = createAction(onSelect, items);
}
return events;
@@ -15,6 +15,11 @@ export const useMacros = () => {
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const withNoneOption = options => [
{ id: 'nil', name: t('AUTOMATION.NONE_OPTION') },
...(options || []),
];
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
@@ -23,10 +28,15 @@ export const useMacros = () => {
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
return withNoneOption(teams.value);
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
return [
...withNoneOption(),
{ id: 'self', name: 'Self' },
...agents.value,
];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({