diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.spec.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.spec.js new file mode 100644 index 000000000..21b42e580 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.spec.js @@ -0,0 +1,36 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import Button from 'dashboard/components-next/button/Button.vue'; +import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; +import AssistantAudienceForm from './AssistantAudienceForm.vue'; + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +vi.mock('./audience/useAudienceFilterTypes.js', () => ({ + useAudienceFilterTypes: () => ({ filterTypes: [] }), +})); + +const mountComponent = () => + shallowMount(AssistantAudienceForm, { + props: { assistant: { config: {} } }, + global: { stubs: { RadioCard: false } }, + }); + +describe('AssistantAudienceForm', () => { + it('does not submit a specific audience without conditions', async () => { + const wrapper = mountComponent(); + + wrapper.findAllComponents(RadioCard)[1].vm.$emit('select', 'specific'); + await nextTick(); + wrapper.findComponent(Button).vm.$emit('click'); + await nextTick(); + + expect(wrapper.emitted('submit')).toBeUndefined(); + expect(wrapper.text()).toContain( + 'CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_ERROR' + ); + }); +}); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue new file mode 100644 index 000000000..b07cf7634 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue @@ -0,0 +1,195 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue new file mode 100644 index 000000000..10e304dc2 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue new file mode 100644 index 000000000..fbb499f47 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue new file mode 100644 index 000000000..9b6cf1f28 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue @@ -0,0 +1,167 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js new file mode 100644 index 000000000..07b13d8d8 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js @@ -0,0 +1,131 @@ +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useMapGetter } from 'dashboard/composables/store.js'; +import { useContactFilterContext } from 'dashboard/components-next/filter/contactProvider.js'; +import { useOperators } from 'dashboard/components-next/filter/operators.js'; +import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js'; + +const STANDARD_ICONS = { + name: 'i-lucide-user', + email: 'i-lucide-mail', + phone_number: 'i-lucide-phone', + identifier: 'i-lucide-fingerprint', + country_code: 'i-lucide-flag', + city: 'i-lucide-map-pin', + company_name: 'i-lucide-building-2', + created_at: 'i-lucide-calendar', + last_activity_at: 'i-lucide-activity', + blocked: 'i-lucide-ban', + labels: 'i-lucide-tags', + hmac_verified: 'i-lucide-user-check', + browser_language: 'i-lucide-globe', +}; + +const CUSTOM_TYPE_ICONS = { + text: 'i-lucide-type', + number: 'i-lucide-hash', + currency: 'i-lucide-banknote', + percent: 'i-lucide-percent', + link: 'i-lucide-link', + date: 'i-lucide-calendar', + list: 'i-lucide-list', + checkbox: 'i-lucide-square-check', +}; + +const DEFAULT_ICON = 'i-lucide-tag'; + +const header = (id, label) => ({ + value: `__group_${id}`, + label, + disabled: true, +}); + +export function useAudienceFilterTypes() { + const { t } = useI18n(); + const { filterTypes: contactFilterTypes } = useContactFilterContext(); + const { equalityOperators } = useOperators(); + const contactAttributes = useMapGetter('attributes/getContactAttributes'); + + const customTypeByKey = computed(() => + Object.fromEntries( + (contactAttributes.value || []).map(attr => [ + attr.attributeKey, + attr.attributeDisplayType, + ]) + ) + ); + + const conversationOption = (key, label, options) => ({ + attributeKey: key, + value: key, + attributeName: label, + label, + icon: STANDARD_ICONS[key], + inputType: 'searchSelect', + options, + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'additional', + }); + + const conversationTypes = computed(() => [ + conversationOption( + 'hmac_verified', + t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'), + [ + { + id: 'true', + name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_TRUE'), + }, + { + id: 'false', + name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_FALSE'), + }, + ] + ), + conversationOption( + 'browser_language', + t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.BROWSER_LANGUAGE'), + languages + ), + ]); + + const standardTypes = computed(() => + contactFilterTypes.value + .filter(type => type.attributeModel !== 'customAttributes') + .map(type => ({ + ...type, + icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON, + })) + ); + + const customTypes = computed(() => + contactFilterTypes.value + .filter(type => type.attributeModel === 'customAttributes') + .filter(type => type.attributeKey in customTypeByKey.value) + .map(type => ({ + ...type, + icon: + CUSTOM_TYPE_ICONS[customTypeByKey.value[type.attributeKey]] || + DEFAULT_ICON, + })) + ); + + 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 }; +} diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.spec.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.spec.js new file mode 100644 index 000000000..0818d9264 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.spec.js @@ -0,0 +1,98 @@ +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', + '__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); + }); +}); diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.vue index 30709babd..e28c744e0 100644 --- a/app/javascript/dashboard/components-next/filter/ConditionRow.vue +++ b/app/javascript/dashboard/components-next/filter/ConditionRow.vue @@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js'; const { filterTypes } = defineProps({ showQueryOperator: { type: Boolean, default: false }, filterTypes: { type: Array, required: true }, + searchableAttributes: { type: Boolean, default: false }, }); const emit = defineEmits(['remove']); @@ -199,6 +200,7 @@ defineExpose({ validate, resetValidation }); v-model="attributeKey" variant="faded" :options="filterTypes" + :searchable="searchableAttributes" @update:model-value="resetModelOnAttributeKeyChange" /> import { computed, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; import { useElementBounding, useWindowSize } from '@vueuse/core'; import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue'; import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue'; @@ -7,6 +8,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import Button from 'next/button/Button.vue'; +import Icon from 'next/icon/Icon.vue'; // [{label, icon, value}] const props = defineProps({ @@ -30,6 +32,10 @@ const props = defineProps({ type: String, default: null, }, + searchable: { + type: Boolean, + default: false, + }, }); const selected = defineModel({ @@ -37,9 +43,23 @@ const selected = defineModel({ required: true, }); +const vFocus = { mounted: el => el.focus() }; + +const { t } = useI18n(); + +const searchQuery = ref(''); const triggerRef = ref(null); const dropdownRef = ref(null); +const filteredOptions = computed(() => { + const query = searchQuery.value.trim().toLowerCase(); + if (!props.searchable || !query) return props.options; + return props.options.filter( + option => + !option.disabled && (option.label || '').toLowerCase().includes(query) + ); +}); + const { top } = useElementBounding(triggerRef); const { height } = useWindowSize(); const { height: dropdownHeight } = useElementBounding(dropdownRef); @@ -65,6 +85,12 @@ const dropdownPosition = computed(() => { const updateSelected = newValue => { selected.value = newValue; + searchQuery.value = ''; +}; + +const handleTriggerClick = toggle => { + searchQuery.value = ''; + toggle(); }; @@ -81,7 +107,7 @@ const updateSelected = newValue => { :icon="iconToRender" :trailing-icon="selectedOption.icon ? false : true" :label="label || (hideLabel ? null : selectedOption.label)" - @click="toggle" + @click="handleTriggerClick(toggle)" /> @@ -91,8 +117,17 @@ const updateSelected = newValue => { :class="dropdownPosition" strong > +
+ + +
-