Adding Segments
This commit is contained in:
45
src/admin/src/api/segments.ts
Normal file
45
src/admin/src/api/segments.ts
Normal 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}`);
|
||||||
|
}
|
||||||
120
src/admin/src/components/segments/ConditionEditor.vue
Normal file
120
src/admin/src/components/segments/ConditionEditor.vue
Normal 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>
|
||||||
131
src/admin/src/components/segments/RuleGroupEditor.vue
Normal file
131
src/admin/src/components/segments/RuleGroupEditor.vue
Normal 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>
|
||||||
179
src/admin/src/components/segments/SegmentEditor.vue
Normal file
179
src/admin/src/components/segments/SegmentEditor.vue
Normal 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>
|
||||||
@@ -1,18 +1,247 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-row>
|
<div>
|
||||||
<v-col cols="12">
|
<div class="d-flex align-center justify-space-between mb-4">
|
||||||
<div class="d-flex align-center justify-space-between mb-4">
|
<h1 class="text-h5 font-weight-bold">Segments</h1>
|
||||||
<h1 class="text-h5 font-weight-bold">Segments</h1>
|
<v-btn
|
||||||
</div>
|
v-if="contextStore.currentProject"
|
||||||
<v-card>
|
color="primary"
|
||||||
<v-card-text class="text-medium-emphasis">
|
prepend-icon="mdi-plus"
|
||||||
Segments let you target specific groups of users based on their traits.
|
data-testid="create-segment-btn"
|
||||||
Select a project to manage segments.
|
@click="openCreateDialog"
|
||||||
</v-card-text>
|
>
|
||||||
|
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-card>
|
||||||
</v-col>
|
</template>
|
||||||
</v-row>
|
|
||||||
|
<!-- 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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|||||||
148
src/admin/tests/unit/components/segments/SegmentEditor.spec.ts
Normal file
148
src/admin/tests/unit/components/segments/SegmentEditor.spec.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
232
src/admin/tests/unit/views/SegmentsView.spec.ts
Normal file
232
src/admin/tests/unit/views/SegmentsView.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user