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>
|
<template>
|
||||||
<v-row>
|
<div>
|
||||||
<v-col cols="12">
|
<div class="d-flex align-center justify-space-between mb-4">
|
||||||
<h1 class="text-h4 mb-4">Features</h1>
|
<h1 class="text-h5 font-weight-bold">Features</h1>
|
||||||
<v-card>
|
<v-btn
|
||||||
<v-card-text>
|
v-if="contextStore.currentProject"
|
||||||
Manage feature flags across projects and environments.
|
color="primary"
|
||||||
</v-card-text>
|
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-card>
|
||||||
</v-col>
|
</template>
|
||||||
</v-row>
|
|
||||||
|
<!-- 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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
export default defineComponent({ name: 'FeaturesView' });
|
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>
|
</script>
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ global.ResizeObserver = class ResizeObserver {
|
|||||||
disconnect() {}
|
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
|
// Stub CSS.supports
|
||||||
Object.defineProperty(window, 'CSS', {
|
Object.defineProperty(window, 'CSS', {
|
||||||
value: { supports: () => false },
|
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