Adding Segments

This commit is contained in:
2026-04-13 19:32:20 -07:00
parent 8dfcb107d0
commit 3fff7e3158
7 changed files with 1096 additions and 12 deletions

View File

@@ -0,0 +1,45 @@
import apiClient from './client';
import type {
SegmentResponse,
CreateSegmentRequest,
UpdateSegmentRequest,
} from '@/types/api';
export async function listSegments(projectId: number): Promise<SegmentResponse[]> {
const { data } = await apiClient.get<SegmentResponse[]>(`/v1/projects/${projectId}/segments`);
return data;
}
export async function getSegment(projectId: number, id: number): Promise<SegmentResponse> {
const { data } = await apiClient.get<SegmentResponse>(
`/v1/projects/${projectId}/segments/${id}`,
);
return data;
}
export async function createSegment(
projectId: number,
request: CreateSegmentRequest,
): Promise<SegmentResponse> {
const { data } = await apiClient.post<SegmentResponse>(
`/v1/projects/${projectId}/segments`,
request,
);
return data;
}
export async function updateSegment(
projectId: number,
id: number,
request: UpdateSegmentRequest,
): Promise<SegmentResponse> {
const { data } = await apiClient.put<SegmentResponse>(
`/v1/projects/${projectId}/segments/${id}`,
request,
);
return data;
}
export async function deleteSegment(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/segments/${id}`);
}

View File

@@ -0,0 +1,120 @@
<template>
<div class="d-flex align-center ga-2 flex-wrap" data-testid="condition-editor">
<!-- Property -->
<v-text-field
:model-value="condition.property"
label="Property"
variant="outlined"
density="compact"
hide-details
style="min-width: 140px; flex: 1"
data-testid="condition-property"
@update:model-value="update('property', $event)"
/>
<!-- Operator -->
<v-select
:model-value="condition.operator"
:items="operatorItems"
label="Operator"
variant="outlined"
density="compact"
hide-details
style="min-width: 160px; flex: 1"
data-testid="condition-operator"
@update:model-value="update('operator', $event)"
/>
<!-- Value hidden for no-value operators -->
<template v-if="needsValue">
<v-slider
v-if="condition.operator === 'PercentageSplit'"
:model-value="percentageValue"
:min="0"
:max="100"
:step="1"
color="primary"
hide-details
thumb-label
style="min-width: 160px; flex: 2"
data-testid="condition-percentage-slider"
@update:model-value="update('value', String($event))"
/>
<v-text-field
v-else
:model-value="condition.value"
label="Value"
variant="outlined"
density="compact"
hide-details
style="min-width: 140px; flex: 1"
data-testid="condition-value"
@update:model-value="update('value', $event)"
/>
</template>
<!-- Remove button -->
<v-btn
icon="mdi-close"
size="x-small"
variant="text"
color="error"
data-testid="remove-condition-btn"
@click="$emit('remove')"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { SegmentCondition, SegmentConditionOperator } from '@/types/api';
interface Props {
condition: SegmentCondition;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:condition': [condition: SegmentCondition];
remove: [];
}>();
const operatorItems: { title: string; value: SegmentConditionOperator }[] = [
{ title: 'Equal', value: 'Equal' },
{ title: 'Not Equal', value: 'NotEqual' },
{ title: 'Contains', value: 'Contains' },
{ title: 'Not Contains', value: 'NotContains' },
{ title: 'Regex', value: 'Regex' },
{ title: 'Greater Than', value: 'GreaterThan' },
{ title: 'Greater Than or Equal', value: 'GreaterThanOrEqual' },
{ title: 'Less Than', value: 'LessThan' },
{ title: 'Less Than or Equal', value: 'LessThanOrEqual' },
{ title: 'Is True', value: 'IsTrue' },
{ title: 'Is False', value: 'IsFalse' },
{ title: 'In', value: 'In' },
{ title: 'Not In', value: 'NotIn' },
{ title: 'Is Set', value: 'IsSet' },
{ title: 'Is Not Set', value: 'IsNotSet' },
{ title: 'Percentage Split', value: 'PercentageSplit' },
{ title: 'Modulo', value: 'ModuloValueDivisorRemainder' },
];
const noValueOperators: SegmentConditionOperator[] = [
'IsTrue',
'IsFalse',
'IsSet',
'IsNotSet',
];
const needsValue = computed(() => !noValueOperators.includes(props.condition.operator));
const percentageValue = computed(() => {
const n = Number(props.condition.value);
return isNaN(n) ? 0 : n;
});
function update(field: keyof SegmentCondition, value: string): void {
emit('update:condition', { ...props.condition, [field]: value });
}
</script>

View File

@@ -0,0 +1,131 @@
<template>
<v-card variant="outlined" rounded="lg" class="pa-3" data-testid="rule-group-editor">
<!-- AND / OR toggle -->
<div class="d-flex align-center ga-2 mb-3">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Match</span>
<v-btn-toggle
:model-value="rule.type"
density="compact"
mandatory
color="primary"
variant="outlined"
data-testid="rule-type-toggle"
@update:model-value="onTypeChange"
>
<v-btn value="AND" size="small" data-testid="rule-type-and">ALL (AND)</v-btn>
<v-btn value="OR" size="small" data-testid="rule-type-or">ANY (OR)</v-btn>
</v-btn-toggle>
<v-spacer />
<v-btn
v-if="removable"
icon="mdi-trash-can-outline"
size="x-small"
variant="text"
color="error"
data-testid="remove-rule-group-btn"
@click="$emit('remove')"
/>
</div>
<!-- Conditions -->
<div class="d-flex flex-column ga-2 mb-3">
<ConditionEditor
v-for="(condition, ci) in rule.conditions"
:key="ci"
:condition="condition"
@update:condition="onUpdateCondition(ci, $event)"
@remove="onRemoveCondition(ci)"
/>
</div>
<!-- Child rule groups -->
<div v-if="rule.childRules && rule.childRules.length > 0" class="d-flex flex-column ga-2 mb-3">
<RuleGroupEditor
v-for="(child, ri) in rule.childRules"
:key="ri"
:rule="child"
:removable="true"
@update:rule="onUpdateChildRule(ri, $event)"
@remove="onRemoveChildRule(ri)"
/>
</div>
<!-- Add buttons -->
<div class="d-flex ga-2">
<v-btn
size="x-small"
variant="tonal"
color="primary"
prepend-icon="mdi-plus"
data-testid="add-condition-btn"
@click="onAddCondition"
>
Add condition
</v-btn>
<v-btn
size="x-small"
variant="tonal"
color="secondary"
prepend-icon="mdi-plus"
data-testid="add-group-btn"
@click="onAddChildGroup"
>
Add group
</v-btn>
</div>
</v-card>
</template>
<script setup lang="ts">
import type { SegmentRule, SegmentCondition, SegmentRuleType } from '@/types/api';
import ConditionEditor from './ConditionEditor.vue';
interface Props {
rule: SegmentRule;
removable?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:rule': [rule: SegmentRule];
remove: [];
}>();
function onTypeChange(type: string): void {
emit('update:rule', { ...props.rule, type: type as SegmentRuleType });
}
function onUpdateCondition(index: number, condition: SegmentCondition): void {
const conditions = [...props.rule.conditions];
conditions[index] = condition;
emit('update:rule', { ...props.rule, conditions });
}
function onRemoveCondition(index: number): void {
const conditions = props.rule.conditions.filter((_, i) => i !== index);
emit('update:rule', { ...props.rule, conditions });
}
function onAddCondition(): void {
const newCondition: SegmentCondition = { property: '', operator: 'Equal', value: '' };
emit('update:rule', { ...props.rule, conditions: [...props.rule.conditions, newCondition] });
}
function onAddChildGroup(): void {
const newGroup: SegmentRule = { type: 'AND', conditions: [] };
const childRules = [...(props.rule.childRules ?? []), newGroup];
emit('update:rule', { ...props.rule, childRules });
}
function onUpdateChildRule(index: number, child: SegmentRule): void {
const childRules = [...(props.rule.childRules ?? [])];
childRules[index] = child;
emit('update:rule', { ...props.rule, childRules });
}
function onRemoveChildRule(index: number): void {
const childRules = (props.rule.childRules ?? []).filter((_, i) => i !== index);
emit('update:rule', { ...props.rule, childRules });
}
</script>

View File

@@ -0,0 +1,179 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="680"
scrollable
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card rounded="lg" data-testid="segment-editor">
<v-card-title class="d-flex align-center justify-space-between pa-6 pb-3">
<span class="text-h6">{{ segment ? 'Edit Segment' : 'Create Segment' }}</span>
<v-btn icon="mdi-close" variant="text" size="small" @click="$emit('update:modelValue', false)" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<!-- Name -->
<v-form ref="formRef" data-testid="segment-form">
<v-text-field
v-model="form.name"
label="Segment name"
variant="outlined"
density="comfortable"
class="mb-4"
:rules="[rules.required]"
:counter="150"
data-testid="segment-name-input"
/>
</v-form>
<!-- Rules -->
<p class="text-body-2 font-weight-medium mb-2">Rules</p>
<div v-if="form.rules.length === 0" class="text-center py-4 text-medium-emphasis text-body-2" data-testid="no-rules-message">
No rules yet. Add a rule group to start targeting users.
</div>
<div class="d-flex flex-column ga-3 mb-3">
<RuleGroupEditor
v-for="(rule, ri) in form.rules"
:key="ri"
:rule="rule"
:removable="true"
@update:rule="onUpdateRule(ri, $event)"
@remove="onRemoveRule(ri)"
/>
</div>
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-plus"
data-testid="add-rule-group-btn"
@click="onAddRuleGroup"
>
Add rule group
</v-btn>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="isSaving"
data-testid="save-segment-btn"
@click="onSave"
>
{{ segment ? 'Save' : 'Create' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { createSegment, updateSegment } from '@/api/segments';
import { useContextStore } from '@/stores/context';
import RuleGroupEditor from './RuleGroupEditor.vue';
import type { SegmentResponse, SegmentRule } from '@/types/api';
interface Props {
modelValue: boolean;
segment?: SegmentResponse | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [segment: SegmentResponse];
}>();
const contextStore = useContextStore();
const formRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const isSaving = ref(false);
const errorMessage = ref<string | null>(null);
const form = ref<{ name: string; rules: SegmentRule[] }>({
name: '',
rules: [],
});
const rules = {
required: (v: string) => !!v || 'Required',
};
function resetForm(): void {
if (props.segment) {
form.value = {
name: props.segment.name,
rules: JSON.parse(JSON.stringify(props.segment.rules)),
};
} else {
form.value = { name: '', rules: [] };
}
errorMessage.value = null;
}
watch(() => props.modelValue, (open) => {
if (open) resetForm();
});
watch(() => props.segment, resetForm, { immediate: true });
function onAddRuleGroup(): void {
form.value.rules = [...form.value.rules, { type: 'AND', conditions: [] }];
}
function onUpdateRule(index: number, rule: SegmentRule): void {
const updated = [...form.value.rules];
updated[index] = rule;
form.value.rules = updated;
}
function onRemoveRule(index: number): void {
form.value.rules = form.value.rules.filter((_, i) => i !== index);
}
async function onSave(): Promise<void> {
if (!formRef.value) return;
const { valid } = await formRef.value.validate();
if (!valid) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isSaving.value = true;
errorMessage.value = null;
try {
const payload = { name: form.value.name, rules: form.value.rules };
const saved = props.segment
? await updateSegment(projectId, props.segment.id, payload)
: await createSegment(projectId, payload);
emit('saved', saved);
emit('update:modelValue', false);
} catch {
errorMessage.value = 'Failed to save segment. Please try again.';
} finally {
isSaving.value = false;
}
}
</script>

View File

@@ -1,18 +1,247 @@
<template>
<v-row>
<v-col cols="12">
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Segments</h1>
</div>
<v-card>
<v-card-text class="text-medium-emphasis">
Segments let you target specific groups of users based on their traits.
Select a project to manage segments.
</v-card-text>
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Segments</h1>
<v-btn
v-if="contextStore.currentProject"
color="primary"
prepend-icon="mdi-plus"
data-testid="create-segment-btn"
@click="openCreateDialog"
>
Create Segment
</v-btn>
</div>
<!-- No project selected -->
<v-card v-if="!contextStore.currentProject" variant="outlined" rounded="lg">
<v-card-text class="text-center py-10">
<v-icon size="48" color="medium-emphasis" class="mb-3">mdi-account-group-outline</v-icon>
<p class="text-h6 mb-2">Select a project</p>
<p class="text-body-2 text-medium-emphasis">
Choose a project from the sidebar to manage its segments.
</p>
</v-card-text>
</v-card>
<template v-else>
<v-card rounded="lg">
<v-data-table
:headers="headers"
:items="segments"
:loading="isLoading"
:items-per-page="20"
hover
data-testid="segments-table"
>
<!-- Name -->
<template #item.name="{ item }: { item: SegmentResponse }">
<span class="text-body-2 font-weight-medium">{{ item.name }}</span>
</template>
<!-- Rule count -->
<template #item.rules="{ item }: { item: SegmentResponse }">
<span class="text-body-2 text-medium-emphasis">{{ item.rules.length }}</span>
</template>
<!-- Created at -->
<template #item.createdAt="{ item }: { item: SegmentResponse }">
<span class="text-caption text-medium-emphasis">{{ formatDate(item.createdAt) }}</span>
</template>
<!-- Actions -->
<template #item.actions="{ item }: { item: SegmentResponse }">
<v-btn
icon="mdi-pencil-outline"
size="small"
variant="text"
:data-testid="`edit-segment-${item.id}`"
@click="openEditDialog(item)"
/>
<v-btn
icon="mdi-trash-can-outline"
size="small"
variant="text"
color="error"
:data-testid="`delete-segment-${item.id}`"
@click="openDeleteConfirm(item)"
/>
</template>
<!-- Empty state -->
<template #no-data>
<div class="text-center py-8">
<v-icon size="40" color="medium-emphasis" class="mb-2">mdi-account-group-outline</v-icon>
<p class="text-body-2 text-medium-emphasis">No segments yet. Create your first segment.</p>
</div>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
</template>
<!-- Create / Edit dialog -->
<SegmentEditor
v-model="showEditor"
:segment="editingSegment"
@saved="onSegmentSaved"
/>
<!-- Delete confirmation dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card rounded="lg" data-testid="delete-confirm-dialog">
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Delete Segment</v-card-title>
<v-card-text class="pa-4 pt-0">
<p class="text-body-2 mb-3">
Are you sure you want to delete
<strong>{{ deletingSegment?.name }}</strong>?
This cannot be undone.
</p>
<p class="text-body-2 mb-2">
Type <strong>{{ deletingSegment?.name }}</strong> to confirm:
</p>
<v-text-field
v-model="deleteConfirmName"
variant="outlined"
density="compact"
hide-details
:placeholder="deletingSegment?.name"
data-testid="delete-confirm-input"
/>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer />
<v-btn variant="text" @click="closeDeleteConfirm">Cancel</v-btn>
<v-btn
color="error"
variant="flat"
:disabled="deleteConfirmName !== deletingSegment?.name"
:loading="isDeleting"
data-testid="confirm-delete-btn"
@click="onDeleteSegment"
>
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Error snackbar -->
<v-snackbar
v-model="showErrorSnackbar"
color="error"
:timeout="4000"
location="bottom"
>
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="showErrorSnackbar = false">Dismiss</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { listSegments, deleteSegment } from '@/api/segments';
import { useContextStore } from '@/stores/context';
import SegmentEditor from '@/components/segments/SegmentEditor.vue';
import type { SegmentResponse } from '@/types/api';
const contextStore = useContextStore();
const segments = ref<SegmentResponse[]>([]);
const isLoading = ref(false);
const showErrorSnackbar = ref(false);
const snackbarMessage = ref('');
const showEditor = ref(false);
const editingSegment = ref<SegmentResponse | null>(null);
const showDeleteDialog = ref(false);
const deletingSegment = ref<SegmentResponse | null>(null);
const deleteConfirmName = ref('');
const isDeleting = ref(false);
const headers = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Rules', key: 'rules', sortable: false, width: '80' },
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
{ title: '', key: 'actions', sortable: false, width: '80', align: 'end' as const },
];
function showError(message: string): void {
snackbarMessage.value = message;
showErrorSnackbar.value = true;
}
async function loadSegments(): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) {
segments.value = [];
return;
}
isLoading.value = true;
try {
segments.value = await listSegments(projectId);
} catch {
segments.value = [];
showError('Failed to load segments. Please refresh the page.');
} finally {
isLoading.value = false;
}
}
watch(() => contextStore.currentProject, loadSegments, { immediate: true });
function openCreateDialog(): void {
editingSegment.value = null;
showEditor.value = true;
}
function openEditDialog(segment: SegmentResponse): void {
editingSegment.value = segment;
showEditor.value = true;
}
function openDeleteConfirm(segment: SegmentResponse): void {
deletingSegment.value = segment;
deleteConfirmName.value = '';
showDeleteDialog.value = true;
}
function closeDeleteConfirm(): void {
showDeleteDialog.value = false;
deletingSegment.value = null;
deleteConfirmName.value = '';
}
function onSegmentSaved(saved: SegmentResponse): void {
const idx = segments.value.findIndex((s) => s.id === saved.id);
if (idx >= 0) {
segments.value[idx] = saved;
} else {
segments.value.unshift(saved);
}
}
async function onDeleteSegment(): Promise<void> {
if (!deletingSegment.value || deleteConfirmName.value !== deletingSegment.value.name) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isDeleting.value = true;
try {
await deleteSegment(projectId, deletingSegment.value.id);
segments.value = segments.value.filter((s) => s.id !== deletingSegment.value!.id);
closeDeleteConfirm();
} catch {
showError('Failed to delete segment. Please try again.');
} finally {
isDeleting.value = false;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import SegmentEditor from '@/components/segments/SegmentEditor.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, SegmentResponse } from '@/types/api';
jest.mock('@/api/segments');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as segmentsApi from '@/api/segments';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockSegment: SegmentResponse = {
id: 1,
name: 'beta_users',
projectId: 10,
createdAt: '2026-01-01T00:00:00Z',
rules: [{ type: 'AND', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] }],
};
const dialogStub = { template: '<div><slot /></div>' };
function mountEditor(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(SegmentEditor, {
props: { modelValue: true, segment: null, ...props },
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
});
}
describe('SegmentEditor', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
});
describe('WhenOpenedForCreate', () => {
it('ThenEditorIsRendered', () => {
const wrapper = mountEditor();
expect(wrapper.find('[data-testid="segment-editor"]').exists()).toBe(true);
});
it('ThenTitleShowsCreateSegment', () => {
const wrapper = mountEditor();
expect(wrapper.text()).toContain('Create Segment');
});
it('ThenNameInputIsEmpty', () => {
const wrapper = mountEditor();
const nameInput = wrapper.find('[data-testid="segment-name-input"]');
expect(nameInput.exists()).toBe(true);
});
it('ThenNoRulesMessageIsShown', () => {
const wrapper = mountEditor();
expect(wrapper.find('[data-testid="no-rules-message"]').exists()).toBe(true);
});
});
describe('WhenOpenedForEdit', () => {
it('ThenTitleShowsEditSegment', () => {
const wrapper = mountEditor({ segment: mockSegment });
expect(wrapper.text()).toContain('Edit Segment');
});
it('ThenFormIsPrePopulatedWithSegmentData', () => {
const wrapper = mountEditor({ segment: mockSegment });
const nameInput = wrapper.findComponent('[data-testid="segment-name-input"]') as VueWrapper<any>;
expect(nameInput.props('modelValue')).toBe(mockSegment.name);
});
it('ThenExistingRulesAreRendered', () => {
const wrapper = mountEditor({ segment: mockSegment });
expect(wrapper.findComponent({ name: 'RuleGroupEditor' }).exists()).toBe(true);
});
});
describe('WhenAddRuleGroupButtonIsClicked', () => {
it('ThenARuleGroupEditorIsAdded', async () => {
const wrapper = mountEditor();
await wrapper.find('[data-testid="add-rule-group-btn"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'RuleGroupEditor' }).exists()).toBe(true);
});
});
describe('WhenSaveIsClickedWithValidData', () => {
it('ThenCreateSegmentIsCalledForNewSegment', async () => {
const created: SegmentResponse = { id: 99, name: 'new_seg', projectId: 10, createdAt: '2026-01-01T00:00:00Z', rules: [] };
jest.mocked(segmentsApi.createSegment).mockResolvedValue(created);
const wrapper = mountEditor();
await (wrapper.findComponent('[data-testid="segment-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_seg');
await wrapper.find('[data-testid="save-segment-btn"]').trigger('click');
await flushPromises();
expect(segmentsApi.createSegment).toHaveBeenCalledWith(
mockProject.id,
expect.objectContaining({ name: 'new_seg' }),
);
});
it('ThenUpdateSegmentIsCalledForExistingSegment', async () => {
const updated = { ...mockSegment, name: 'updated_name' };
jest.mocked(segmentsApi.updateSegment).mockResolvedValue(updated);
const wrapper = mountEditor({ segment: mockSegment });
await wrapper.find('[data-testid="save-segment-btn"]').trigger('click');
await flushPromises();
expect(segmentsApi.updateSegment).toHaveBeenCalledWith(
mockProject.id,
mockSegment.id,
expect.objectContaining({ name: mockSegment.name }),
);
});
it('ThenSavedEventIsEmitted', async () => {
const created: SegmentResponse = { id: 99, name: 'new_seg', projectId: 10, createdAt: '2026-01-01T00:00:00Z', rules: [] };
jest.mocked(segmentsApi.createSegment).mockResolvedValue(created);
const wrapper = mountEditor();
await (wrapper.findComponent('[data-testid="segment-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_seg');
await wrapper.find('[data-testid="save-segment-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('saved')).toBeTruthy();
expect(wrapper.emitted('saved')![0]).toEqual([created]);
});
});
});

View File

@@ -0,0 +1,232 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import SegmentsView from '@/views/SegmentsView.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, SegmentResponse } from '@/types/api';
jest.mock('@/api/segments');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as segmentsApi from '@/api/segments';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const dialogStub = { template: '<div><slot /></div>' };
const mockSegments: SegmentResponse[] = [
{
id: 1, name: 'beta_users', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
rules: [{ type: 'AND', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] }],
},
{
id: 2, name: 'power_users', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
rules: [],
},
];
function mountView() {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(SegmentsView, { global: { plugins: [router], stubs: { 'v-dialog': dialogStub } } });
}
describe('SegmentsView', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenNoProjectIsSelected', () => {
it('ThenEmptyStateIsShown', () => {
const wrapper = mountView();
expect(wrapper.find('[data-testid="segments-table"]').exists()).toBe(false);
expect(wrapper.text()).toContain('Select a project');
});
it('ThenCreateButtonIsNotVisible', () => {
const wrapper = mountView();
expect(wrapper.find('[data-testid="create-segment-btn"]').exists()).toBe(false);
});
});
describe('WhenProjectIsSelected', () => {
it('ThenSegmentsAreLoaded', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
mountView();
await flushPromises();
expect(segmentsApi.listSegments).toHaveBeenCalledWith(mockProject.id);
});
it('ThenSegmentsTableIsRendered', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="segments-table"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="create-segment-btn"]').exists()).toBe(true);
});
it('ThenSegmentNamesAreDisplayed', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
expect(wrapper.text()).toContain('beta_users');
expect(wrapper.text()).toContain('power_users');
});
});
describe('WhenCreateButtonIsClicked', () => {
it('ThenSegmentEditorIsOpened', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="create-segment-btn"]').trigger('click');
await wrapper.vm.$nextTick();
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
expect(editor.exists()).toBe(true);
});
});
describe('WhenEditButtonIsClicked', () => {
it('ThenSegmentEditorIsOpenedWithSegment', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="edit-segment-1"]').trigger('click');
await wrapper.vm.$nextTick();
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
expect(editor.props('segment')).toEqual(mockSegments[0]);
});
});
describe('WhenDeleteConfirmationIsCompleted', () => {
it('ThenDeleteSegmentIsCalledWhenNameMatches', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([...mockSegments]);
jest.mocked(segmentsApi.deleteSegment).mockResolvedValue(undefined);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true);
// Confirm button should be disabled before typing the name
expect(wrapper.find('[data-testid="confirm-delete-btn"]').attributes('disabled')).toBeDefined();
// Type segment name to enable button
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
expect(segmentsApi.deleteSegment).toHaveBeenCalledWith(mockProject.id, 1);
});
it('ThenSegmentIsRemovedFromListAfterDeletion', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([...mockSegments]);
jest.mocked(segmentsApi.deleteSegment).mockResolvedValue(undefined);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
expect(wrapper.text()).not.toContain('beta_users');
});
});
describe('WhenSegmentIsSaved', () => {
it('ThenNewSegmentIsAddedToList', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
const newSegment: SegmentResponse = { id: 99, name: 'new_segment', projectId: 10, createdAt: '2026-01-01T00:00:00Z', rules: [] };
await wrapper.findComponent({ name: 'SegmentEditor' }).vm.$emit('saved', newSegment);
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('new_segment');
});
it('ThenExistingSegmentIsUpdatedInList', async () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([...mockSegments]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
const updated: SegmentResponse = { ...mockSegments[0], name: 'updated_segment' };
await wrapper.findComponent({ name: 'SegmentEditor' }).vm.$emit('saved', updated);
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('updated_segment');
expect(wrapper.text()).not.toContain('beta_users');
});
});
});