Adding Feature flag management
This commit is contained in:
44
src/admin/src/api/featureStates.ts
Normal file
44
src/admin/src/api/featureStates.ts
Normal 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;
|
||||
}
|
||||
66
src/admin/src/api/features.ts
Normal file
66
src/admin/src/api/features.ts
Normal 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
16
src/admin/src/api/tags.ts
Normal 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}`);
|
||||
}
|
||||
370
src/admin/src/components/features/FeatureDetail.vue
Normal file
370
src/admin/src/components/features/FeatureDetail.vue
Normal 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>
|
||||
217
src/admin/src/components/features/FeatureDialog.vue
Normal file
217
src/admin/src/components/features/FeatureDialog.vue
Normal 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>
|
||||
86
src/admin/src/components/features/FeatureValueEditor.vue
Normal file
86
src/admin/src/components/features/FeatureValueEditor.vue
Normal 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>
|
||||
175
src/admin/src/components/features/TagsChipInput.vue
Normal file
175
src/admin/src/components/features/TagsChipInput.vue
Normal 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>
|
||||
@@ -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.
|
||||
</v-card-text>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
238
src/admin/tests/unit/components/features/FeatureDetail.spec.ts
Normal file
238
src/admin/tests/unit/components/features/FeatureDetail.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
133
src/admin/tests/unit/components/features/FeatureDialog.spec.ts
Normal file
133
src/admin/tests/unit/components/features/FeatureDialog.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
src/admin/tests/unit/components/features/TagsChipInput.spec.ts
Normal file
139
src/admin/tests/unit/components/features/TagsChipInput.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
src/admin/tests/unit/views/FeaturesView.spec.ts
Normal file
132
src/admin/tests/unit/views/FeaturesView.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user