Adding Feature flag management

This commit is contained in:
2026-04-13 16:04:23 -07:00
parent f2816b6900
commit 8dfcb107d0
13 changed files with 1946 additions and 12 deletions

View File

@@ -0,0 +1,44 @@
import apiClient from './client';
import type {
FeatureStateResponse,
UpdateFeatureStateRequest,
PatchFeatureStateRequest,
} from '@/types/api';
export async function listFeatureStates(envApiKey: string): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environments/${envApiKey}/featurestates`,
);
return data;
}
export async function getFeatureState(envApiKey: string, id: number): Promise<FeatureStateResponse> {
const { data } = await apiClient.get<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
);
return data;
}
export async function updateFeatureState(
envApiKey: string,
id: number,
request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
request,
);
return data;
}
export async function patchFeatureState(
envApiKey: string,
id: number,
request: PatchFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.patch<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
request,
);
return data;
}

View File

@@ -0,0 +1,66 @@
import apiClient from './client';
import type {
FeatureResponse,
CreateFeatureRequest,
UpdateFeatureRequest,
PatchFeatureRequest,
PaginatedResponse,
} from '@/types/api';
export async function listFeatures(
projectId: number,
page = 1,
pageSize = 100,
): Promise<PaginatedResponse<FeatureResponse>> {
const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>(
`/v1/projects/${projectId}/features`,
{ params: { page, pageSize } },
);
return data;
}
export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> {
const { data } = await apiClient.get<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
);
return data;
}
export async function createFeature(
projectId: number,
request: CreateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.post<FeatureResponse>(
`/v1/projects/${projectId}/features`,
request,
);
return data;
}
export async function updateFeature(
projectId: number,
id: number,
request: UpdateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
request,
);
return data;
}
export async function patchFeature(
projectId: number,
id: number,
request: PatchFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.patch<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
request,
);
return data;
}
export async function deleteFeature(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/features/${id}`);
}

16
src/admin/src/api/tags.ts Normal file
View File

@@ -0,0 +1,16 @@
import apiClient from './client';
import type { TagResponse, CreateTagRequest } from '@/types/api';
export async function listTags(projectId: number): Promise<TagResponse[]> {
const { data } = await apiClient.get<TagResponse[]>(`/v1/projects/${projectId}/tags`);
return data;
}
export async function createTag(projectId: number, request: CreateTagRequest): Promise<TagResponse> {
const { data } = await apiClient.post<TagResponse>(`/v1/projects/${projectId}/tags`, request);
return data;
}
export async function deleteTag(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/tags/${id}`);
}

View File

@@ -0,0 +1,370 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="620"
scrollable
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card v-if="feature" rounded="lg" data-testid="feature-detail">
<!-- Header -->
<v-card-title class="d-flex align-center justify-space-between pa-6 pb-3">
<div class="d-flex align-center ga-2">
<v-chip
:color="feature.type === 'MULTIVARIATE' ? 'secondary' : 'primary'"
size="small"
variant="tonal"
>
{{ feature.type }}
</v-chip>
<span class="text-h6">{{ feature.name }}</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="$emit('update:modelValue', false)" />
</v-card-title>
<v-tabs v-model="activeTab" color="primary" class="px-4">
<v-tab value="value" data-testid="tab-value">Value</v-tab>
<v-tab value="settings" data-testid="tab-settings">Settings</v-tab>
</v-tabs>
<v-divider />
<v-card-text class="pa-0">
<v-tabs-window v-model="activeTab">
<!-- Value tab -->
<v-tabs-window-item value="value" class="pa-6">
<v-alert
v-if="valueErrorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="valueErrorMessage = null"
>
{{ valueErrorMessage }}
</v-alert>
<div v-if="!featureState" class="text-center py-6 text-medium-emphasis">
<v-icon size="32" class="mb-2">mdi-server-outline</v-icon>
<p>Select an environment to manage feature state.</p>
</div>
<template v-else>
<!-- Enabled toggle -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<p class="text-body-1 font-weight-medium mb-1">Enabled</p>
<p class="text-body-2 text-medium-emphasis">
{{ featureState.enabled ? 'On in this environment' : 'Off in this environment' }}
</p>
</div>
<v-switch
:model-value="featureState.enabled"
color="primary"
hide-details
:loading="isTogglingEnabled"
data-testid="feature-enabled-toggle"
@update:model-value="onToggleEnabled"
/>
</div>
<v-divider class="mb-5" />
<!-- Value editor -->
<p class="text-body-2 font-weight-medium mb-2">Value</p>
<FeatureValueEditor
v-model="editedValue"
class="mb-4"
/>
<div class="d-flex justify-end">
<v-btn
color="primary"
variant="flat"
size="small"
:loading="isSavingValue"
:disabled="editedValue === featureState.value"
data-testid="save-value-btn"
@click="onSaveValue"
>
Save value
</v-btn>
</div>
<v-divider class="my-5" />
<!-- Tags -->
<TagsChipInput />
</template>
</v-tabs-window-item>
<!-- Settings tab -->
<v-tabs-window-item value="settings" class="pa-6">
<v-alert
v-if="settingsErrorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="settingsErrorMessage = null"
>
{{ settingsErrorMessage }}
</v-alert>
<v-form ref="settingsFormRef" data-testid="settings-form">
<v-text-field
v-model="settingsForm.name"
label="Name"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.nameFormat]"
:counter="150"
data-testid="settings-name-input"
/>
<v-textarea
v-model="settingsForm.description"
label="Description"
variant="outlined"
density="comfortable"
rows="2"
class="mb-3"
data-testid="settings-description-input"
/>
<div class="d-flex align-center justify-space-between mb-4">
<div>
<p class="text-body-2 font-weight-medium">Default enabled</p>
<p class="text-caption text-medium-emphasis">Applies to new environments</p>
</div>
<v-switch
v-model="settingsForm.defaultEnabled"
color="primary"
hide-details
data-testid="settings-default-enabled-toggle"
/>
</div>
<div class="d-flex justify-end mb-6">
<v-btn
color="primary"
variant="flat"
size="small"
:loading="isSavingSettings"
data-testid="save-settings-btn"
@click="onSaveSettings"
>
Save settings
</v-btn>
</div>
</v-form>
<!-- Danger zone -->
<v-card variant="outlined" color="error" rounded="lg">
<v-card-title class="text-body-1 text-error pa-4 pb-2">Danger Zone</v-card-title>
<v-card-text class="pa-4 pt-0">
<p class="text-body-2 mb-3">
Permanently delete this feature flag. This cannot be undone and will remove
the flag from all environments.
</p>
<p class="text-body-2 mb-2">
Type <strong>{{ feature.name }}</strong> to confirm:
</p>
<v-text-field
v-model="deleteConfirmName"
variant="outlined"
density="compact"
hide-details
:placeholder="feature.name"
class="mb-3"
data-testid="delete-confirm-input"
/>
<v-btn
color="error"
variant="flat"
size="small"
:disabled="deleteConfirmName !== feature.name"
:loading="isDeleting"
data-testid="delete-feature-btn"
@click="onDelete"
>
Delete feature
</v-btn>
</v-card-text>
</v-card>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { patchFeature, deleteFeature } from '@/api/features';
import { patchFeatureState } from '@/api/featureStates';
import { useContextStore } from '@/stores/context';
import FeatureValueEditor from './FeatureValueEditor.vue';
import TagsChipInput from './TagsChipInput.vue';
import type { FeatureResponse, FeatureStateResponse } from '@/types/api';
interface Props {
modelValue: boolean;
feature: FeatureResponse | null;
featureState: FeatureStateResponse | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
updated: [feature: FeatureResponse];
deleted: [featureId: number];
stateUpdated: [state: FeatureStateResponse];
}>();
const contextStore = useContextStore();
const activeTab = ref('value');
const isTogglingEnabled = ref(false);
const isSavingValue = ref(false);
const isSavingSettings = ref(false);
const isDeleting = ref(false);
const deleteConfirmName = ref('');
const editedValue = ref<string | null>(null);
const settingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const valueErrorMessage = ref<string | null>(null);
const settingsErrorMessage = ref<string | null>(null);
const settingsForm = ref({
name: '',
description: '',
defaultEnabled: false,
});
const rules = {
required: (v: string) => !!v || 'Required',
nameFormat: (v: string) =>
/^[a-zA-Z0-9_-]+$/.test(v) || 'Only letters, numbers, hyphens and underscores',
};
function syncSettingsForm(f: FeatureResponse | null): void {
if (f) {
settingsForm.value = {
name: f.name,
description: f.description ?? '',
defaultEnabled: f.defaultEnabled,
};
deleteConfirmName.value = '';
}
}
// Sync form when feature changes
watch(() => props.feature, syncSettingsForm, { immediate: true });
// Sync edited value when feature state changes
watch(
() => props.featureState,
(s) => {
editedValue.value = s?.value ?? null;
},
{ immediate: true },
);
// Reset UI state each time the dialog opens
watch(
() => props.modelValue,
(open) => {
if (open) {
activeTab.value = 'value';
valueErrorMessage.value = null;
settingsErrorMessage.value = null;
syncSettingsForm(props.feature);
editedValue.value = props.featureState?.value ?? null;
}
},
);
async function onToggleEnabled(enabled: boolean | null): Promise<void> {
if (!props.featureState || enabled === null) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
isTogglingEnabled.value = true;
valueErrorMessage.value = null;
try {
const updated = await patchFeatureState(envApiKey, props.featureState.id, { enabled });
emit('stateUpdated', updated);
} catch {
valueErrorMessage.value = 'Failed to update enabled state. Please try again.';
} finally {
isTogglingEnabled.value = false;
}
}
async function onSaveValue(): Promise<void> {
if (!props.featureState) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
if (editedValue.value) {
const trimmed = editedValue.value.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
JSON.parse(trimmed);
} catch {
valueErrorMessage.value = 'Value contains invalid JSON.';
return;
}
}
}
isSavingValue.value = true;
valueErrorMessage.value = null;
try {
const updated = await patchFeatureState(envApiKey, props.featureState.id, {
value: editedValue.value,
});
emit('stateUpdated', updated);
} catch {
valueErrorMessage.value = 'Failed to save value. Please try again.';
} finally {
isSavingValue.value = false;
}
}
async function onSaveSettings(): Promise<void> {
if (!settingsFormRef.value) return;
const { valid } = await settingsFormRef.value.validate();
if (!valid || !props.feature) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isSavingSettings.value = true;
settingsErrorMessage.value = null;
try {
const updated = await patchFeature(projectId, props.feature.id, {
name: settingsForm.value.name,
description: settingsForm.value.description || null,
defaultEnabled: settingsForm.value.defaultEnabled,
});
emit('updated', updated);
} catch {
settingsErrorMessage.value = 'Failed to save settings. Please try again.';
} finally {
isSavingSettings.value = false;
}
}
async function onDelete(): Promise<void> {
if (!props.feature || deleteConfirmName.value !== props.feature.name) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isDeleting.value = true;
settingsErrorMessage.value = null;
try {
await deleteFeature(projectId, props.feature.id);
emit('deleted', props.feature.id);
emit('update:modelValue', false);
} catch {
settingsErrorMessage.value = 'Failed to delete feature. Please try again.';
} finally {
isDeleting.value = false;
}
}
</script>

View File

@@ -0,0 +1,217 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="560"
persistent
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card rounded="lg">
<v-card-title class="pa-6 pb-2 text-h6">
{{ isEditing ? 'Edit Feature' : 'Create Feature' }}
</v-card-title>
<v-card-text class="pa-6 pt-2">
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
closable
class="mb-4"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<v-form ref="formRef" @submit.prevent="onSubmit" data-testid="feature-form">
<!-- Name -->
<v-text-field
v-model="form.name"
label="Name"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.nameFormat]"
:counter="150"
:maxlength="150"
:disabled="isEditing"
hint="Letters, numbers, hyphens and underscores only"
persistent-hint
data-testid="feature-name-input"
/>
<!-- Type -->
<v-select
v-model="form.type"
label="Type"
:items="typeOptions"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required]"
:disabled="isEditing"
data-testid="feature-type-select"
/>
<!-- Initial Value (MULTIVARIATE only, create mode only) -->
<v-textarea
v-if="form.type === 'MULTIVARIATE' && !isEditing"
v-model="form.initialValue"
label="Initial Value"
variant="outlined"
density="comfortable"
class="mb-3"
rows="3"
:counter="20000"
:maxlength="20000"
hint="Default value delivered to all environments"
persistent-hint
data-testid="feature-initial-value-input"
/>
<!-- Description -->
<v-textarea
v-model="form.description"
label="Description"
variant="outlined"
density="comfortable"
rows="2"
data-testid="feature-description-input"
/>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
:disabled="isSaving"
data-testid="feature-dialog-cancel"
@click="onCancel"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="isSaving"
data-testid="feature-dialog-save"
@click="onSubmit"
>
{{ isEditing ? 'Save' : 'Create' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { createFeature, updateFeature } from '@/api/features';
import { useContextStore } from '@/stores/context';
import type { FeatureResponse, FeatureType } from '@/types/api';
interface Props {
modelValue: boolean;
feature?: FeatureResponse | null;
}
const props = withDefaults(defineProps<Props>(), {
feature: null,
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [feature: FeatureResponse];
}>();
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: '',
type: 'STANDARD' as FeatureType,
initialValue: '',
description: '',
});
const isEditing = computed(() => props.feature !== null);
const typeOptions = [
{ title: 'Standard (boolean/string)', value: 'STANDARD' },
{ title: 'Multivariate (A/B variants)', value: 'MULTIVARIATE' },
];
const rules = {
required: (v: string) => !!v || 'This field is required',
nameFormat: (v: string) =>
/^[a-zA-Z0-9_-]+$/.test(v) || 'Only letters, numbers, hyphens and underscores allowed',
};
function resetForm(): void {
if (props.feature) {
form.value = {
name: props.feature.name,
type: props.feature.type,
initialValue: props.feature.initialValue ?? '',
description: props.feature.description ?? '',
};
} else {
form.value = { name: '', type: 'STANDARD', initialValue: '', description: '' };
}
}
// Populate form when editing an existing feature
watch(() => props.feature, resetForm, { immediate: true });
// Reset form and clear errors each time the dialog opens
watch(
() => props.modelValue,
(open) => {
if (open) {
errorMessage.value = null;
resetForm();
}
},
);
async function onSubmit(): 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 {
let saved: FeatureResponse;
if (isEditing.value && props.feature) {
saved = await updateFeature(projectId, props.feature.id, {
name: form.value.name,
description: form.value.description || null,
});
} else {
saved = await createFeature(projectId, {
name: form.value.name,
type: form.value.type,
initialValue: form.value.initialValue || null,
description: form.value.description || null,
});
}
emit('saved', saved);
emit('update:modelValue', false);
} catch {
errorMessage.value = 'Failed to save feature. Please try again.';
} finally {
isSaving.value = false;
}
}
function onCancel(): void {
emit('update:modelValue', false);
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div data-testid="feature-value-editor">
<!-- Type selector -->
<v-btn-toggle
v-model="detectedMode"
density="compact"
variant="outlined"
class="mb-3"
data-testid="value-mode-toggle"
>
<v-btn value="text" size="small">Text</v-btn>
<v-btn value="json" size="small">JSON</v-btn>
</v-btn-toggle>
<!-- JSON mode -->
<v-textarea
v-if="detectedMode === 'json'"
:model-value="modelValue ?? ''"
label="Value (JSON)"
variant="outlined"
density="comfortable"
rows="5"
style="font-family: monospace; font-size: 0.85rem"
:rules="[rules.validJson]"
data-testid="json-value-input"
@update:model-value="$emit('update:modelValue', $event || null)"
/>
<!-- Text mode -->
<v-text-field
v-else
:model-value="modelValue ?? ''"
label="Value"
variant="outlined"
density="comfortable"
clearable
data-testid="text-value-input"
@update:model-value="$emit('update:modelValue', $event || null)"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
interface Props {
modelValue: string | null;
}
const props = defineProps<Props>();
defineEmits<{
'update:modelValue': [value: string | null];
}>();
type ValueMode = 'text' | 'json';
function detectMode(value: string | null): ValueMode {
if (!value) return 'text';
const trimmed = value.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json';
return 'text';
}
const detectedMode = ref<ValueMode>(detectMode(props.modelValue));
// Re-detect when value changes externally (e.g. a different feature is opened)
watch(
() => props.modelValue,
(v) => {
detectedMode.value = detectMode(v);
},
);
const rules = {
validJson: (v: string) => {
if (!v) return true;
try {
JSON.parse(v);
return true;
} catch {
return 'Invalid JSON';
}
},
};
</script>

View File

@@ -0,0 +1,175 @@
<template>
<div data-testid="tags-chip-input">
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2 font-weight-medium">Project Tags</span>
<v-btn
size="x-small"
variant="tonal"
color="primary"
prepend-icon="mdi-plus"
:loading="isCreating"
data-testid="create-tag-btn"
@click="showCreateForm = !showCreateForm"
>
New tag
</v-btn>
</div>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-2"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<!-- Create form -->
<v-expand-transition>
<v-card v-if="showCreateForm" variant="outlined" rounded="lg" class="mb-3 pa-3">
<v-form ref="createFormRef" @submit.prevent="onCreateTag">
<div class="d-flex align-center ga-2">
<v-text-field
v-model="newTagLabel"
label="Tag name"
variant="outlined"
density="compact"
hide-details
:rules="[rules.required]"
data-testid="new-tag-label-input"
/>
<input
v-model="newTagColor"
type="color"
style="width:36px;height:36px;border:none;padding:2px;cursor:pointer;border-radius:4px"
title="Tag colour"
data-testid="new-tag-color-input"
/>
<v-btn
icon="mdi-check"
size="small"
color="primary"
variant="flat"
type="submit"
:loading="isCreating"
data-testid="confirm-create-tag-btn"
@click.prevent="onCreateTag"
/>
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="showCreateForm = false"
/>
</div>
</v-form>
</v-card>
</v-expand-transition>
<!-- Tag chips -->
<div v-if="isLoading" class="d-flex justify-center py-3">
<v-progress-circular indeterminate size="20" width="2" color="primary" />
</div>
<div v-else-if="tags.length > 0" class="d-flex flex-wrap ga-1" data-testid="tag-chips">
<v-chip
v-for="tag in tags"
:key="tag.id"
size="small"
closable
:color="tag.color"
:data-testid="`tag-chip-${tag.label}`"
@click:close="onDeleteTag(tag)"
>
{{ tag.label }}
</v-chip>
</div>
<p
v-else
class="text-body-2 text-medium-emphasis"
data-testid="tags-empty-message"
>
No tags yet. Create one to organise your features.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { listTags, createTag, deleteTag } from '@/api/tags';
import { useContextStore } from '@/stores/context';
import type { TagResponse } from '@/types/api';
const contextStore = useContextStore();
const tags = ref<TagResponse[]>([]);
const isLoading = ref(false);
const isCreating = ref(false);
const showCreateForm = ref(false);
const newTagLabel = ref('');
const newTagColor = ref('#1565C0');
const errorMessage = ref<string | null>(null);
const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const rules = {
required: (v: string) => !!v || 'Tag name is required',
};
async function fetchTags(): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isLoading.value = true;
try {
tags.value = await listTags(projectId);
} catch {
tags.value = [];
errorMessage.value = 'Failed to load tags.';
} finally {
isLoading.value = false;
}
}
async function onCreateTag(): Promise<void> {
if (!createFormRef.value) return;
const { valid } = await createFormRef.value.validate();
if (!valid) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isCreating.value = true;
errorMessage.value = null;
try {
const created = await createTag(projectId, {
label: newTagLabel.value.trim(),
color: newTagColor.value,
});
tags.value.push(created);
newTagLabel.value = '';
newTagColor.value = '#1565C0';
showCreateForm.value = false;
} catch {
errorMessage.value = 'Failed to create tag. Please try again.';
} finally {
isCreating.value = false;
}
}
async function onDeleteTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
errorMessage.value = null;
try {
await deleteTag(projectId, tag.id);
tags.value = tags.value.filter((t) => t.id !== tag.id);
} catch {
errorMessage.value = 'Failed to delete tag. Please try again.';
}
}
onMounted(fetchTags);
</script>

View File

@@ -1,17 +1,317 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Features</h1>
<v-card>
<v-card-text>
Manage feature flags across projects and environments.
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Features</h1>
<v-btn
v-if="contextStore.currentProject"
color="primary"
prepend-icon="mdi-plus"
data-testid="create-feature-btn"
@click="openCreateDialog"
>
Create Feature
</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-flag-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 feature flags.
</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
<template v-else>
<!-- Search bar -->
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search features"
variant="outlined"
density="comfortable"
clearable
hide-details
class="mb-4"
data-testid="feature-search"
/>
<!-- Features table -->
<v-card rounded="lg">
<v-data-table
:headers="headers"
:items="filteredFeatures"
:loading="isLoading"
:items-per-page="20"
:search="search"
hover
data-testid="features-table"
@click:row="onRowClick"
>
<!-- Name + description -->
<template #item.name="{ item }: { item: FeatureResponse }">
<div>
<span class="text-body-2 font-weight-medium">{{ item.name }}</span>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
{{ item.description }}
</p>
</div>
</template>
<!-- Type chip -->
<template #item.type="{ item }: { item: FeatureResponse }">
<v-chip
:color="item.type === 'MULTIVARIATE' ? 'secondary' : 'primary'"
size="x-small"
variant="tonal"
>
{{ item.type }}
</v-chip>
</template>
<!-- Enabled toggle for current environment -->
<template #item.enabled="{ item }: { item: FeatureResponse }">
<div v-if="!contextStore.currentEnvironment" class="text-caption text-medium-emphasis">
</div>
<v-switch
v-else
:model-value="featureStateMap.get(item.id)?.enabled ?? false"
color="primary"
hide-details
density="compact"
:loading="togglingFeatureId === item.id"
:data-testid="`toggle-${item.id}`"
@update:model-value="onToggleEnabled(item.id, $event)"
@click.stop
/>
</template>
<!-- Created at -->
<template #item.createdAt="{ item }: { item: FeatureResponse }">
<span class="text-caption text-medium-emphasis">
{{ formatDate(item.createdAt) }}
</span>
</template>
<!-- Row actions -->
<template #item.actions="{ item }: { item: FeatureResponse }">
<v-btn
icon="mdi-pencil-outline"
size="small"
variant="text"
:data-testid="`edit-feature-${item.id}`"
@click.stop="openEditDialog(item)"
/>
</template>
<!-- Empty state -->
<template #no-data>
<div class="text-center py-8">
<v-icon size="40" color="medium-emphasis" class="mb-2">mdi-flag-outline</v-icon>
<p class="text-body-2 text-medium-emphasis">No features yet. Create your first flag.</p>
</div>
</template>
</v-data-table>
</v-card>
</template>
<!-- Create / Edit dialog -->
<FeatureDialog
v-model="showDialog"
:feature="editingFeature"
@saved="onFeatureSaved"
/>
<!-- Feature detail drawer -->
<FeatureDetail
v-model="showDetail"
:feature="selectedFeature"
:feature-state="selectedFeatureState"
@updated="onFeatureUpdated"
@deleted="onFeatureDeleted"
@state-updated="onStateUpdated"
/>
<!-- 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 lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'FeaturesView' });
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { listFeatures } from '@/api/features';
import { listFeatureStates, patchFeatureState } from '@/api/featureStates';
import { useContextStore } from '@/stores/context';
import FeatureDialog from '@/components/features/FeatureDialog.vue';
import FeatureDetail from '@/components/features/FeatureDetail.vue';
import type { FeatureResponse, FeatureStateResponse } from '@/types/api';
const contextStore = useContextStore();
// ─── State ────────────────────────────────────────────────────────────────────
const features = ref<FeatureResponse[]>([]);
const featureStateMap = ref<Map<number, FeatureStateResponse>>(new Map());
const isLoading = ref(false);
const search = ref('');
const togglingFeatureId = ref<number | null>(null);
// Error snackbar
const showErrorSnackbar = ref(false);
const snackbarMessage = ref('');
function showError(message: string): void {
snackbarMessage.value = message;
showErrorSnackbar.value = true;
}
// Dialog / detail state
const showDialog = ref(false);
const editingFeature = ref<FeatureResponse | null>(null);
const showDetail = ref(false);
const selectedFeature = ref<FeatureResponse | null>(null);
const selectedFeatureState = computed(() =>
selectedFeature.value ? (featureStateMap.value.get(selectedFeature.value.id) ?? null) : null,
);
// ─── Table config ─────────────────────────────────────────────────────────────
const headers = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Type', key: 'type', sortable: true, width: '140' },
{ title: 'Enabled', key: 'enabled', sortable: false, width: '100' },
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
{ title: '', key: 'actions', sortable: false, width: '50', align: 'end' as const },
];
// ─── Computed ─────────────────────────────────────────────────────────────────
const filteredFeatures = computed(() => {
if (!search.value) return features.value;
const q = search.value.toLowerCase();
return features.value.filter(
(f) => f.name.toLowerCase().includes(q) || (f.description ?? '').toLowerCase().includes(q),
);
});
// ─── Data loading ─────────────────────────────────────────────────────────────
async function loadFeatures(): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) {
features.value = [];
return;
}
isLoading.value = true;
try {
const result = await listFeatures(projectId, 1, 100);
features.value = result.results;
} catch {
features.value = [];
showError('Failed to load features. Please refresh the page.');
} finally {
isLoading.value = false;
}
}
async function loadFeatureStates(): Promise<void> {
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) {
featureStateMap.value = new Map();
return;
}
try {
const states = await listFeatureStates(envApiKey);
featureStateMap.value = new Map(states.map((s) => [s.featureId, s]));
} catch {
featureStateMap.value = new Map();
showError('Failed to load feature states. Please refresh the page.');
}
}
async function loadData(): Promise<void> {
await Promise.all([loadFeatures(), loadFeatureStates()]);
}
// Reload when project or environment changes
watch(() => contextStore.currentProject, loadData, { immediate: true });
watch(() => contextStore.currentEnvironment, loadFeatureStates);
// ─── Actions ──────────────────────────────────────────────────────────────────
function openCreateDialog(): void {
editingFeature.value = null;
showDialog.value = true;
}
function openEditDialog(feature: FeatureResponse): void {
editingFeature.value = feature;
showDialog.value = true;
}
function onRowClick(_event: Event, { item }: { item: FeatureResponse }): void {
selectedFeature.value = item;
showDetail.value = true;
}
async function onFeatureSaved(saved: FeatureResponse): Promise<void> {
const idx = features.value.findIndex((f) => f.id === saved.id);
if (idx >= 0) {
features.value[idx] = saved;
} else {
features.value.unshift(saved);
// New feature: load its state for the current environment
await loadFeatureStates();
}
}
function onFeatureUpdated(updated: FeatureResponse): void {
const idx = features.value.findIndex((f) => f.id === updated.id);
if (idx >= 0) features.value[idx] = updated;
if (selectedFeature.value?.id === updated.id) selectedFeature.value = updated;
}
function onFeatureDeleted(featureId: number): void {
features.value = features.value.filter((f) => f.id !== featureId);
featureStateMap.value.delete(featureId);
featureStateMap.value = new Map(featureStateMap.value);
showDetail.value = false;
}
function onStateUpdated(state: FeatureStateResponse): void {
featureStateMap.value.set(state.featureId, state);
// Trigger reactivity on the Map
featureStateMap.value = new Map(featureStateMap.value);
}
async function onToggleEnabled(featureId: number, enabled: boolean | null): Promise<void> {
if (enabled === null) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
const state = featureStateMap.value.get(featureId);
if (!envApiKey || !state) return;
togglingFeatureId.value = featureId;
try {
const updated = await patchFeatureState(envApiKey, state.id, { enabled });
onStateUpdated(updated);
} catch {
showError('Failed to update feature state. Please try again.');
} finally {
togglingFeatureId.value = null;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>

View File

@@ -10,6 +10,24 @@ global.ResizeObserver = class ResizeObserver {
disconnect() {}
};
// Stub visualViewport which jsdom doesn't implement (required by Vuetify VOverlay)
if (!window.visualViewport) {
Object.defineProperty(window, 'visualViewport', {
value: {
width: 1024,
height: 768,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: () => {},
removeEventListener: () => {},
},
writable: true,
});
}
// Stub CSS.supports
Object.defineProperty(window, 'CSS', {
value: { supports: () => false },

View File

@@ -0,0 +1,238 @@
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 FeatureDetail from '@/components/features/FeatureDetail.vue';
import { useContextStore } from '@/stores/context';
import type { FeatureResponse, FeatureStateResponse, ProjectResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/featureStates');
jest.mock('@/api/tags');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as featuresApi from '@/api/features';
import * as featureStatesApi from '@/api/featureStates';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeature: FeatureResponse = {
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null,
description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeatureState: FeatureStateResponse = {
id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null,
enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
};
const dialogStub = { template: '<div><slot /></div>' };
const tagsStub = { template: '<div data-testid="tags-chip-input" />' };
function mountDetail(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(FeatureDetail, {
props: { modelValue: true, feature: mockFeature, featureState: null, ...props },
global: { plugins: [router], stubs: { 'v-dialog': dialogStub, TagsChipInput: tagsStub } },
});
}
describe('FeatureDetail', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
contextStore.setEnvironment({ id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '' });
});
describe('WhenOpenedWithFeature', () => {
it('ThenFeatureDetailCardIsRendered', () => {
const wrapper = mountDetail();
expect(wrapper.find('[data-testid="feature-detail"]').exists()).toBe(true);
});
it('ThenFeatureNameIsDisplayed', () => {
const wrapper = mountDetail();
expect(wrapper.text()).toContain('dark_mode');
});
it('ThenValueAndSettingsTabsArePresent', () => {
const wrapper = mountDetail();
expect(wrapper.find('[data-testid="tab-value"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tab-settings"]').exists()).toBe(true);
});
});
describe('WhenNoEnvironmentIsSelected', () => {
it('ThenSelectEnvironmentMessageIsShown', () => {
const wrapper = mountDetail({ featureState: null });
expect(wrapper.text()).toContain('Select an environment');
});
});
describe('WhenFeatureStateIsProvided', () => {
it('ThenEnabledToggleIsRendered', () => {
const wrapper = mountDetail({ featureState: mockFeatureState });
expect(wrapper.find('[data-testid="feature-enabled-toggle"]').exists()).toBe(true);
});
it('ThenPatchFeatureStateIsCalledOnToggle', async () => {
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue({ ...mockFeatureState, enabled: true });
const wrapper = mountDetail({ featureState: mockFeatureState });
await (wrapper.find('[data-testid="feature-enabled-toggle"]').findComponent({ name: 'VSwitch' }) as VueWrapper<any>).vm.$emit('update:modelValue', true);
await flushPromises();
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith('env-dev', mockFeatureState.id, { enabled: true });
});
it('ThenStateUpdatedEventIsEmittedAfterToggle', async () => {
const updatedState = { ...mockFeatureState, enabled: true };
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updatedState);
const wrapper = mountDetail({ featureState: mockFeatureState });
await (wrapper.find('[data-testid="feature-enabled-toggle"]').findComponent({ name: 'VSwitch' }) as VueWrapper<any>).vm.$emit('update:modelValue', true);
await flushPromises();
expect(wrapper.emitted('stateUpdated')).toBeTruthy();
expect(wrapper.emitted('stateUpdated')![0]).toEqual([updatedState]);
});
});
describe('WhenSaveValueIsClicked', () => {
it('ThenPatchFeatureStateIsCalledWithEditedValue', async () => {
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: 'old_value' };
const updated = { ...stateWithValue, value: 'new_value' };
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updated);
const wrapper = mountDetail({ featureState: stateWithValue });
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', 'new_value');
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
await flushPromises();
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith('env-dev', stateWithValue.id, { value: 'new_value' });
});
it('ThenStateUpdatedEventIsEmittedAfterValueSave', async () => {
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: 'old_value' };
const updated = { ...stateWithValue, value: 'new_value' };
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updated);
const wrapper = mountDetail({ featureState: stateWithValue });
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', 'new_value');
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('stateUpdated')).toBeTruthy();
expect(wrapper.emitted('stateUpdated')![0]).toEqual([updated]);
});
it('ThenSaveIsBlockedWhenValueIsInvalidJson', async () => {
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: '{"key": "val"}' };
const wrapper = mountDetail({ featureState: stateWithValue });
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', '{bad json}');
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
await flushPromises();
expect(featureStatesApi.patchFeatureState).not.toHaveBeenCalled();
});
});
describe('WhenSettingsFormIsSubmitted', () => {
it('ThenPatchFeatureIsCalledWithFormValues', async () => {
const updated = { ...mockFeature, description: 'Updated desc' };
jest.mocked(featuresApi.patchFeature).mockResolvedValue(updated);
const wrapper = mountDetail();
// Switch to settings tab
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="settings-description-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Updated desc');
await wrapper.find('[data-testid="save-settings-btn"]').trigger('click');
await flushPromises();
expect(featuresApi.patchFeature).toHaveBeenCalledWith(
mockProject.id,
mockFeature.id,
expect.objectContaining({ name: mockFeature.name }),
);
});
it('ThenUpdatedEventIsEmittedAfterSave', async () => {
const updated = { ...mockFeature, description: 'Updated desc' };
jest.mocked(featuresApi.patchFeature).mockResolvedValue(updated);
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="save-settings-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('updated')).toBeTruthy();
});
});
describe('WhenDeleteConfirmationIsEntered', () => {
it('ThenDeleteButtonIsDisabledWhenNameDoesNotMatch', async () => {
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
const deleteBtn = wrapper.findComponent('[data-testid="delete-feature-btn"]') as VueWrapper<any>;
expect(deleteBtn.props('disabled')).toBe(true);
});
it('ThenDeleteFeatureIsCalledWhenNameMatches', async () => {
jest.mocked(featuresApi.deleteFeature).mockResolvedValue(undefined);
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
// Type the feature name into the confirm input
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', mockFeature.name);
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="delete-feature-btn"]').trigger('click');
await flushPromises();
expect(featuresApi.deleteFeature).toHaveBeenCalledWith(mockProject.id, mockFeature.id);
});
it('ThenDeletedEventIsEmittedAfterDeletion', async () => {
jest.mocked(featuresApi.deleteFeature).mockResolvedValue(undefined);
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', mockFeature.name);
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="delete-feature-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('deleted')).toBeTruthy();
expect(wrapper.emitted('deleted')![0]).toEqual([mockFeature.id]);
});
});
});

View File

@@ -0,0 +1,133 @@
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 FeatureDialog from '@/components/features/FeatureDialog.vue';
import { useContextStore } from '@/stores/context';
import type { FeatureResponse, ProjectResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as featuresApi from '@/api/features';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeature: FeatureResponse = {
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null,
description: 'Enables dark mode', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z',
};
const savedFeature: FeatureResponse = { ...mockFeature, id: 99, name: 'new_flag' };
const dialogStub = { template: '<div><slot /></div>' };
function mountDialog(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(FeatureDialog, {
props: { modelValue: true, ...props },
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
});
}
describe('FeatureDialog', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
});
describe('WhenOpenedInCreateMode', () => {
it('ThenNameTypeAndDescriptionFieldsArePresent', () => {
const wrapper = mountDialog();
expect(wrapper.find('[data-testid="feature-name-input"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="feature-type-select"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="feature-description-input"]').exists()).toBe(true);
});
it('ThenNameAndTypeFieldsAreEnabled', () => {
const wrapper = mountDialog();
const nameInput = wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>;
expect(nameInput.props('disabled')).toBeFalsy();
});
});
describe('WhenOpenedInEditMode', () => {
it('ThenNameAndTypeFieldsAreDisabled', () => {
const wrapper = mountDialog({ feature: mockFeature });
const nameInput = wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>;
expect(nameInput.props('disabled')).toBe(true);
});
it('ThenFormIsPopulatedWithFeatureData', () => {
const wrapper = mountDialog({ feature: mockFeature });
const descInput = wrapper.findComponent('[data-testid="feature-description-input"]') as VueWrapper<any>;
expect(descInput.props('modelValue')).toBe(mockFeature.description);
});
});
describe('WhenFormIsSubmittedInCreateMode', () => {
it('ThenCreateFeatureApiIsCalled', async () => {
jest.mocked(featuresApi.createFeature).mockResolvedValue(savedFeature);
const wrapper = mountDialog();
// Fill in name
await (wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_flag');
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
await flushPromises();
expect(featuresApi.createFeature).toHaveBeenCalledWith(
mockProject.id,
expect.objectContaining({ name: 'new_flag', type: 'STANDARD' }),
);
});
it('ThenSavedEventIsEmitted', async () => {
jest.mocked(featuresApi.createFeature).mockResolvedValue(savedFeature);
const wrapper = mountDialog();
await (wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_flag');
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('saved')).toBeTruthy();
expect(wrapper.emitted('saved')![0]).toEqual([savedFeature]);
});
});
describe('WhenFormIsSubmittedInEditMode', () => {
it('ThenUpdateFeatureApiIsCalled', async () => {
jest.mocked(featuresApi.updateFeature).mockResolvedValue({ ...mockFeature, description: 'Updated' });
const wrapper = mountDialog({ feature: mockFeature });
await (wrapper.findComponent('[data-testid="feature-description-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Updated');
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
await flushPromises();
expect(featuresApi.updateFeature).toHaveBeenCalledWith(
mockProject.id,
mockFeature.id,
expect.objectContaining({ name: mockFeature.name }),
);
});
});
describe('WhenCancelIsClicked', () => {
it('ThenDialogIsClosedWithoutApiCall', async () => {
const wrapper = mountDialog();
await wrapper.find('[data-testid="feature-dialog-cancel"]').trigger('click');
expect(featuresApi.createFeature).not.toHaveBeenCalled();
expect(wrapper.emitted('update:modelValue')).toBeTruthy();
expect(wrapper.emitted('update:modelValue')![0]).toEqual([false]);
});
});
});

View File

@@ -0,0 +1,139 @@
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 TagsChipInput from '@/components/features/TagsChipInput.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, TagResponse } from '@/types/api';
jest.mock('@/api/tags');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as tagsApi from '@/api/tags';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockTags: TagResponse[] = [
{ id: 1, label: 'backend', color: '#ff0000', projectId: 10 },
{ id: 2, label: 'frontend', color: '#0000ff', projectId: 10 },
];
function mountComponent() {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(TagsChipInput, { global: { plugins: [router] } });
}
describe('TagsChipInput', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
});
describe('WhenComponentMounts', () => {
it('ThenTagsAreFetchedFromApi', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
mountComponent();
await flushPromises();
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
});
it('ThenExistingTagChipsAreRendered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(true);
});
it('ThenEmptyMessageIsShownWhenNoTagsExist', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
});
});
describe('WhenCreateTagIsSubmitted', () => {
it('ThenCreateTagApiIsCalled', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
const wrapper = mountComponent();
await flushPromises();
// Open the create form
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await wrapper.vm.$nextTick();
// Fill in the label
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
// Submit
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
await flushPromises();
expect(tagsApi.createTag).toHaveBeenCalledWith(
mockProject.id,
expect.objectContaining({ label: 'api' }),
);
});
it('ThenNewTagAppearsInTheList', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
const wrapper = mountComponent();
await flushPromises();
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-api"]').exists()).toBe(true);
});
});
describe('WhenTagChipIsDeleted', () => {
it('ThenDeleteTagApiIsCalled', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
const wrapper = mountComponent();
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
await flushPromises();
expect(tagsApi.deleteTag).toHaveBeenCalledWith(mockProject.id, mockTags[0].id);
});
it('ThenDeletedTagIsRemovedFromTheList', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
const wrapper = mountComponent();
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(false);
});
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import FeaturesView from '@/views/FeaturesView.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/featureStates');
jest.mock('@/api/tags');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as featuresApi from '@/api/features';
import * as featureStatesApi from '@/api/featureStates';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockEnv: EnvironmentResponse = {
id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeatures: FeatureResponse[] = [
{ id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
];
const mockStates: FeatureStateResponse[] = [
{ id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null, enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
{ id: 102, featureId: 2, environmentId: 100, identityId: null, featureSegmentId: null, enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
];
function mountView() {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(FeaturesView, { global: { plugins: [router] } });
}
describe('FeaturesView', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenNoProjectIsSelected', () => {
it('ThenEmptyStateIsShown', () => {
const wrapper = mountView();
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(false);
expect(wrapper.text()).toContain('Select a project');
});
});
describe('WhenProjectIsSelected', () => {
it('ThenFeaturesAndStatesAreLoaded', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
mountView();
await flushPromises();
expect(featuresApi.listFeatures).toHaveBeenCalledWith(mockProject.id, 1, 100);
expect(featureStatesApi.listFeatureStates).toHaveBeenCalledWith(mockEnv.apiKey);
});
it('ThenFeaturesTableIsRendered', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="create-feature-btn"]').exists()).toBe(true);
});
});
describe('WhenToggleEnabledIsClicked', () => {
it('ThenPatchFeatureStateIsCalled', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue({ ...mockStates[0], enabled: true });
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
const wrapper = mountView();
await flushPromises();
// Find the toggle for feature id=1 and simulate a change
const toggle = wrapper.find('[data-testid="toggle-1"]');
expect(toggle.exists()).toBe(true);
await toggle.findComponent({ name: 'VSwitch' }).vm.$emit('update:modelValue', true);
await flushPromises();
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith(mockEnv.apiKey, 101, { enabled: true });
});
});
describe('WhenCreateButtonIsClicked', () => {
it('ThenFeatureDialogIsOpened', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 0, next: null, previous: null, results: [] });
jest.mocked(featureStatesApi.listFeatureStates).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-feature-btn"]').trigger('click');
await wrapper.vm.$nextTick();
// FeatureDialog should now be open (v-dialog with model-value=true)
const dialog = wrapper.findComponent({ name: 'FeatureDialog' });
expect(dialog.exists()).toBe(true);
});
});
});