refactor(captain): split useAudienceFilterTypes into smaller computeds

Extracts standard/custom/conversation sections into named computeds with
a shared option factory, and adds specs for the composable.
This commit is contained in:
Pranav
2026-07-14 18:13:34 -07:00
parent aae21dfbb8
commit 3cd3d653a5
2 changed files with 129 additions and 36 deletions
@@ -35,10 +35,12 @@ const CUSTOM_TYPE_ICONS = {
const DEFAULT_ICON = 'i-lucide-tag';
/**
* Filter types for a Captain assistant audience: contact attributes (reused from the contact
* segment builder) plus conversation-scoped fields, grouped via disabled header options.
*/
const header = (id, label) => ({
value: `__group_${id}`,
label,
disabled: true,
});
export function useAudienceFilterTypes() {
const { t } = useI18n();
const { filterTypes: contactFilterTypes } = useContactFilterContext();
@@ -67,7 +69,7 @@ export function useAudienceFilterTypes() {
attributeModel: 'additional',
});
const conversationFilterTypes = computed(() => [
const conversationTypes = computed(() => [
conversationOption(
'hmac_verified',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
@@ -94,22 +96,17 @@ export function useAudienceFilterTypes() {
),
]);
const header = (id, label) => ({
value: `__group_${id}`,
label,
disabled: true,
});
const filterTypes = computed(() => {
const standard = contactFilterTypes.value
const standardTypes = computed(() =>
contactFilterTypes.value
.filter(type => type.attributeModel !== 'customAttributes')
.map(type => ({
...type,
icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON,
}));
}))
);
// Only contact-model custom attributes belong here; never conversation/company ones.
const custom = contactFilterTypes.value
const customTypes = computed(() =>
contactFilterTypes.value
.filter(type => type.attributeModel === 'customAttributes')
.filter(type => type.attributeKey in customTypeByKey.value)
.map(type => ({
@@ -117,27 +114,24 @@ export function useAudienceFilterTypes() {
icon:
CUSTOM_TYPE_ICONS[customTypeByKey.value[type.attributeKey]] ||
DEFAULT_ICON,
}));
}))
);
return [
header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')),
...standard,
header(
'conversation',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION')
),
...conversationFilterTypes.value,
...(custom.length
? [
header(
'custom',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')
),
...custom,
]
: []),
];
});
const filterTypes = computed(() => [
header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')),
...standardTypes.value,
header(
'conversation',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION')
),
...conversationTypes.value,
...(customTypes.value.length
? [
header('custom', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')),
...customTypes.value,
]
: []),
]);
return { filterTypes };
}
@@ -0,0 +1,99 @@
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
import { useAudienceFilterTypes } from './useAudienceFilterTypes';
const state = vi.hoisted(() => ({
contactFilterTypes: [],
contactAttributes: [],
equalityOperators: [{ value: 'equal_to' }, { value: 'not_equal_to' }],
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: () => ({ value: state.contactAttributes }),
}));
vi.mock('dashboard/components-next/filter/contactProvider.js', () => ({
useContactFilterContext: () => ({
filterTypes: { value: state.contactFilterTypes },
}),
}));
vi.mock('dashboard/components-next/filter/operators.js', () => ({
useOperators: () => ({
equalityOperators: { value: state.equalityOperators },
}),
}));
describe('useAudienceFilterTypes', () => {
beforeEach(() => {
state.contactFilterTypes = [
{ attributeKey: 'email', attributeModel: 'standard' },
{ attributeKey: 'mystery', attributeModel: 'standard' },
{ attributeKey: 'signed_up_on', attributeModel: 'customAttributes' },
{ attributeKey: 'company_size', attributeModel: 'customAttributes' },
];
state.contactAttributes = [
{ attributeKey: 'signed_up_on', attributeDisplayType: 'date' },
];
});
const build = () => useAudienceFilterTypes().filterTypes.value;
it('assembles grouped sections in order', () => {
const keys = build().map(item => item.attributeKey ?? item.value);
expect(keys).toEqual([
'__group_contact',
'email',
'mystery',
'__group_conversation',
'hmac_verified',
'browser_language',
'conversation_language',
'__group_custom',
'signed_up_on',
]);
});
it('marks headers as disabled options', () => {
const headers = build().filter(item => item.value?.startsWith('__group_'));
expect(headers).toHaveLength(3);
expect(headers.every(header => header.disabled)).toBe(true);
});
it('excludes custom attributes that are not contact-model attributes', () => {
const keys = build().map(item => item.attributeKey);
expect(keys).not.toContain('company_size');
});
it('omits the custom section when there are no contact custom attributes', () => {
state.contactAttributes = [];
const values = build().map(item => item.attributeKey ?? item.value);
expect(values).not.toContain('__group_custom');
expect(values).not.toContain('signed_up_on');
});
it('assigns icons by attribute key with a default fallback', () => {
const byKey = Object.fromEntries(
build().map(item => [item.attributeKey, item.icon])
);
expect(byKey.email).toBe('i-lucide-mail');
expect(byKey.mystery).toBe('i-lucide-tag');
expect(byKey.signed_up_on).toBe('i-lucide-calendar');
});
it('builds conversation options as searchSelects with equality operators', () => {
const types = build();
const hmac = types.find(item => item.attributeKey === 'hmac_verified');
expect(hmac.inputType).toBe('searchSelect');
expect(hmac.filterOperators).toEqual(state.equalityOperators);
expect(hmac.options.map(option => option.id)).toEqual(['true', 'false']);
const browserLanguage = types.find(
item => item.attributeKey === 'browser_language'
);
expect(browserLanguage.options).toEqual(languages);
});
});