Fix undefined tags crash in features table, redesign tag assignment UX
Guard against missing tags array in FeaturesView so a malformed row doesn't blow up the whole table render. Replace the separate create-tag-then-click-to-assign flow in TagsChipInput with a single type-and-add input: typing >=2 chars suggests existing unassigned tags, picking one assigns it instead of creating a duplicate, and the color picker is now a dialog of 15 preset swatches plus a custom picker trigger.
This commit is contained in:
@@ -1,19 +1,6 @@
|
||||
<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">Tags</span>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="ri-add-line"
|
||||
:loading="isCreating"
|
||||
data-testid="create-tag-btn"
|
||||
@click="showCreateForm = !showCreateForm"
|
||||
>
|
||||
New tag
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
@@ -21,98 +8,124 @@
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-2"
|
||||
class="my-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
|
||||
ref="tagLabelInputRef"
|
||||
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"
|
||||
@blur="focusTagLabel"
|
||||
/>
|
||||
<v-btn
|
||||
icon="ri-check-line"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
type="submit"
|
||||
:loading="isCreating"
|
||||
data-testid="confirm-create-tag-btn"
|
||||
@click.prevent="onCreateTag"
|
||||
/>
|
||||
<v-btn
|
||||
icon="ri-close-line"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="showCreateForm = false"
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
|
||||
<p class="text-caption text-medium-emphasis mb-2">Click a tag to add or remove it from this feature.</p>
|
||||
|
||||
<!-- 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">
|
||||
<template v-else>
|
||||
<div v-if="assignedTags.length > 0" class="d-flex flex-wrap ga-1 my-2" data-testid="tag-chips">
|
||||
<v-chip
|
||||
v-for="tag in tags"
|
||||
v-for="tag in assignedTags"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
variant="flat"
|
||||
closable
|
||||
:variant="isAssigned(tag) ? 'flat' : 'outlined'"
|
||||
:color="tag.color"
|
||||
:prepend-icon="isAssigned(tag) ? 'ri-check-line' : undefined"
|
||||
:loading="togglingTagId === tag.id"
|
||||
:data-testid="`tag-chip-${tag.label}`"
|
||||
@click="onToggleTag(tag)"
|
||||
@click:close="onDeleteTag(tag)"
|
||||
@click:close="onRemoveTag(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 v-else class="text-body-2 text-medium-emphasis my-2" data-testid="tags-empty-message">
|
||||
No tags assigned. Type a name below to add one.
|
||||
</p>
|
||||
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-combobox
|
||||
v-model="newTagLabel"
|
||||
:items="suggestions"
|
||||
label="Tag name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
hide-no-data
|
||||
no-filter
|
||||
data-testid="tag-name-input"
|
||||
@keyup.enter="onAddTag"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style="width:36px;height:36px;border:none;padding:0;cursor:pointer;border-radius:4px"
|
||||
:style="{ background: newTagColor }"
|
||||
title="Tag colour"
|
||||
data-testid="tag-color-swatch"
|
||||
@click="showColorDialog = true"
|
||||
/>
|
||||
<input
|
||||
ref="customColorInputRef"
|
||||
v-model="newTagColor"
|
||||
type="color"
|
||||
style="position:absolute;width:0;height:0;opacity:0;pointer-events:none"
|
||||
data-testid="custom-color-input"
|
||||
@input="showColorDialog = false"
|
||||
/>
|
||||
<v-btn
|
||||
icon="ri-add-line"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="!newTagLabel.trim()"
|
||||
:loading="isSubmitting"
|
||||
data-testid="add-tag-btn"
|
||||
@click="onAddTag"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Colour picker dialog -->
|
||||
<v-dialog v-model="showColorDialog" max-width="260" data-testid="color-dialog">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 pb-2 text-body-1">Tag colour</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<button
|
||||
v-for="color in presetColors"
|
||||
:key="color"
|
||||
type="button"
|
||||
style="width:32px;height:32px;border:none;padding:0;cursor:pointer;border-radius:4px"
|
||||
:style="{ background: color }"
|
||||
:title="color"
|
||||
:data-testid="`preset-color-${color}`"
|
||||
@click="onSelectPresetColor(color)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="d-flex align-center justify-center"
|
||||
style="width:32px;height:32px;border:1px solid rgba(0,0,0,0.2);padding:0;cursor:pointer;border-radius:4px;background:conic-gradient(red,yellow,lime,aqua,blue,magenta,red)"
|
||||
title="Custom colour"
|
||||
data-testid="custom-color-trigger"
|
||||
@click="customColorInputRef?.click()"
|
||||
>
|
||||
<v-icon size="16" color="white">ri-palette-line</v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue';
|
||||
import { listTags, createTag, deleteTag } from '@/api/tags';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { listTags, createTag } from '@/api/tags';
|
||||
import { assignFeatureTag, removeFeatureTag } from '@/api/features';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { TagResponse, FeatureResponse } from '@/types/api';
|
||||
|
||||
const presetColors = [
|
||||
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||
'#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#795548',
|
||||
];
|
||||
|
||||
interface Props {
|
||||
feature: FeatureResponse;
|
||||
}
|
||||
@@ -127,30 +140,33 @@ const contextStore = useContextStore();
|
||||
|
||||
const tags = ref<TagResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const togglingTagId = ref<number | null>(null);
|
||||
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 tagLabelInputRef = ref<{ focus: () => void } | null>(null);
|
||||
const showColorDialog = ref(false);
|
||||
const customColorInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function isAssigned(tag: TagResponse): boolean {
|
||||
return props.feature.tags.some((t) => t.id === tag.id);
|
||||
function onSelectPresetColor(color: string): void {
|
||||
newTagColor.value = color;
|
||||
showColorDialog.value = false;
|
||||
}
|
||||
|
||||
function focusTagLabel(): void {
|
||||
nextTick(() => tagLabelInputRef.value?.focus());
|
||||
}
|
||||
const assignedTags = computed(() => props.feature.tags);
|
||||
|
||||
watch(showCreateForm, (open) => {
|
||||
if (open) focusTagLabel();
|
||||
const suggestions = computed(() => {
|
||||
const query = newTagLabel.value.trim().toLowerCase();
|
||||
if (query.length < 2) return [];
|
||||
const assignedIds = new Set(assignedTags.value.map((t) => t.id));
|
||||
return tags.value
|
||||
.filter((t) => !assignedIds.has(t.id) && t.label.toLowerCase().includes(query))
|
||||
.map((t) => t.label);
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (v: string) => !!v || 'Tag name is required',
|
||||
};
|
||||
function isAssigned(tag: TagResponse): boolean {
|
||||
return assignedTags.value.some((t) => t.id === tag.id);
|
||||
}
|
||||
|
||||
async function fetchTags(): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
@@ -166,61 +182,48 @@ async function fetchTags(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateTag(): Promise<void> {
|
||||
if (!createFormRef.value) return;
|
||||
const { valid } = await createFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
async function onAddTag(): Promise<void> {
|
||||
const label = newTagLabel.value.trim();
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) return;
|
||||
if (!label || !projectId) return;
|
||||
|
||||
isCreating.value = true;
|
||||
errorMessage.value = null;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const created = await createTag(projectId, {
|
||||
label: newTagLabel.value.trim(),
|
||||
color: newTagColor.value,
|
||||
});
|
||||
tags.value.push(created);
|
||||
let tag = tags.value.find((t) => t.label.toLowerCase() === label.toLowerCase());
|
||||
if (!tag) {
|
||||
tag = await createTag(projectId, { label, color: newTagColor.value });
|
||||
tags.value.push(tag);
|
||||
}
|
||||
if (!isAssigned(tag)) {
|
||||
togglingTagId.value = tag.id;
|
||||
const updated = await assignFeatureTag(projectId, props.feature.id, tag.id);
|
||||
emit('updated', updated);
|
||||
}
|
||||
newTagLabel.value = '';
|
||||
newTagColor.value = '#1565C0';
|
||||
showCreateForm.value = false;
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to create tag. Please try again.';
|
||||
errorMessage.value = 'Failed to add tag. Please try again.';
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
isSubmitting.value = false;
|
||||
togglingTagId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleTag(tag: TagResponse): Promise<void> {
|
||||
async function onRemoveTag(tag: TagResponse): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) return;
|
||||
|
||||
togglingTagId.value = tag.id;
|
||||
errorMessage.value = null;
|
||||
togglingTagId.value = tag.id;
|
||||
try {
|
||||
const updated = isAssigned(tag)
|
||||
? await removeFeatureTag(projectId, props.feature.id, tag.id)
|
||||
: await assignFeatureTag(projectId, props.feature.id, tag.id);
|
||||
const updated = await removeFeatureTag(projectId, props.feature.id, tag.id);
|
||||
emit('updated', updated);
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to update tag assignment. Please try again.';
|
||||
errorMessage.value = 'Failed to remove tag. Please try again.';
|
||||
} finally {
|
||||
togglingTagId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-chip
|
||||
v-for="tag in item.tags.slice(0, 5)"
|
||||
v-for="tag in (item.tags ?? []).slice(0, 5)"
|
||||
:key="tag.id"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
@@ -83,9 +83,9 @@
|
||||
{{ tag.label }}
|
||||
</v-chip>
|
||||
<span
|
||||
v-if="item.tags.length > 5"
|
||||
v-if="(item.tags ?? []).length > 5"
|
||||
class="text-caption text-medium-emphasis"
|
||||
:title="`${item.tags.length - 5} more`"
|
||||
:title="`${(item.tags ?? []).length - 5} more`"
|
||||
>…</span>
|
||||
</div>
|
||||
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { mount, flushPromises, DOMWrapper, 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';
|
||||
import type { ProjectResponse, TagResponse, FeatureResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/tags');
|
||||
jest.mock('@/api/features');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
@@ -14,18 +15,25 @@ jest.mock('@/api/client', () => ({
|
||||
}));
|
||||
|
||||
import * as tagsApi from '@/api/tags';
|
||||
import * as featuresApi from '@/api/features';
|
||||
|
||||
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 },
|
||||
];
|
||||
const backendTag: TagResponse = { id: 1, label: 'backend', color: '#ff0000', projectId: 10 };
|
||||
const frontendTag: TagResponse = { id: 2, label: 'frontend', color: '#0000ff', projectId: 10 };
|
||||
const mockTags: TagResponse[] = [backendTag, frontendTag];
|
||||
|
||||
function mountComponent() {
|
||||
function mockFeature(tags: TagResponse[]): FeatureResponse {
|
||||
return {
|
||||
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: null,
|
||||
defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags,
|
||||
};
|
||||
}
|
||||
|
||||
function mountComponent(feature: FeatureResponse) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(TagsChipInput, { global: { plugins: [router] } });
|
||||
return mount(TagsChipInput, { props: { feature }, global: { plugins: [router] } });
|
||||
}
|
||||
|
||||
describe('TagsChipInput', () => {
|
||||
@@ -40,100 +48,173 @@ describe('TagsChipInput', () => {
|
||||
describe('WhenComponentMounts', () => {
|
||||
it('ThenTagsAreFetchedFromApi', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
mountComponent();
|
||||
mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
|
||||
});
|
||||
|
||||
it('ThenExistingTagChipsAreRendered', async () => {
|
||||
it('ThenAssignedTagChipsAreRendered', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
const wrapper = mountComponent();
|
||||
const wrapper = mountComponent(mockFeature([backendTag]));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('ThenEmptyMessageIsShownWhenNoTagsExist', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
|
||||
const wrapper = mountComponent();
|
||||
it('ThenEmptyMessageIsShownWhenNoTagsAssigned', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateTagIsSubmitted', () => {
|
||||
it('ThenCreateTagApiIsCalled', async () => {
|
||||
describe('WhenAddingANewTagName', () => {
|
||||
it('ThenCreateTagAndAssignAreBothCalled', async () => {
|
||||
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
|
||||
const updatedFeature = mockFeature([newTag]);
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
|
||||
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
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');
|
||||
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
|
||||
await input.vm.$emit('update:modelValue', 'api');
|
||||
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(tagsApi.createTag).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ label: 'api' }),
|
||||
);
|
||||
expect(tagsApi.createTag).toHaveBeenCalledWith(mockProject.id, expect.objectContaining({ label: 'api' }));
|
||||
expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, newTag.id);
|
||||
});
|
||||
|
||||
it('ThenNewTagAppearsInTheList', async () => {
|
||||
it('ThenUpdatedEventIsEmitted', 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 () => {
|
||||
const updatedFeature = mockFeature([newTag]);
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
|
||||
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
|
||||
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
|
||||
await input.vm.$emit('update:modelValue', 'api');
|
||||
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenTypedNameMatchesAnExistingTag', () => {
|
||||
it('ThenExistingTagIsAssignedInsteadOfCreatingANewOne', async () => {
|
||||
const updatedFeature = mockFeature([backendTag]);
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
|
||||
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
|
||||
await input.vm.$emit('update:modelValue', 'backend');
|
||||
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(tagsApi.createTag).not.toHaveBeenCalled();
|
||||
expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, backendTag.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenTypingTwoOrMoreCharacters', () => {
|
||||
it('ThenMatchingUnassignedTagsAreSuggested', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
|
||||
await input.vm.$emit('update:modelValue', 'ba');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(input.props('items')).toEqual(['backend']);
|
||||
});
|
||||
|
||||
it('ThenNoSuggestionsAreShownBelowTwoCharacters', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
|
||||
await input.vm.$emit('update:modelValue', 'b');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(input.props('items')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenChoosingATagColour', () => {
|
||||
it('ThenSwatchClickOpensTheColourDialog', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="color-dialog"]').exists()).toBe(false);
|
||||
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'VDialog' }).props('modelValue')).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenFifteenPresetColoursAreOffered', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const presets = new DOMWrapper(document.body).findAll('[data-testid^="preset-color-"]');
|
||||
const distinctColors = new Set(presets.map((p) => p.attributes('data-testid')));
|
||||
expect(distinctColors.size).toBe(15);
|
||||
});
|
||||
|
||||
it('ThenSelectingAPresetUpdatesTheSwatchAndClosesTheDialog', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
const wrapper = mountComponent(mockFeature([]));
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const presets = new DOMWrapper(document.body).findAll('[data-testid="preset-color-#4CAF50"]');
|
||||
await presets[presets.length - 1].trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tag-color-swatch"]').attributes('style')).toContain('background: rgb(76, 175, 80)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenTagChipIsClosed', () => {
|
||||
it('ThenRemoveFeatureTagApiIsCalled', async () => {
|
||||
const updatedFeature = mockFeature([]);
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
jest.mocked(featuresApi.removeFeatureTag).mockResolvedValue(updatedFeature);
|
||||
|
||||
const wrapper = mountComponent(mockFeature([backendTag]));
|
||||
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);
|
||||
expect(featuresApi.removeFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, backendTag.id);
|
||||
expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse, TagResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/features');
|
||||
jest.mock('@/api/featureStates');
|
||||
@@ -25,9 +25,13 @@ 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' },
|
||||
{ id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags: [] },
|
||||
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags: [] },
|
||||
];
|
||||
|
||||
function makeTag(id: number, label: string): TagResponse {
|
||||
return { id, label, color: '#1565C0', 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' },
|
||||
@@ -85,6 +89,43 @@ describe('FeaturesView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenFeatureHasTags', () => {
|
||||
it('ThenUpToFiveTagChipsAreShownAndExtraAreEllipsised', async () => {
|
||||
const sixTags = [1, 2, 3, 4, 5, 6].map((id) => makeTag(id, `tag-${id}`));
|
||||
const taggedFeature: FeatureResponse = { ...mockFeatures[0], tags: sixTags };
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 1, next: null, previous: null, results: [taggedFeature] });
|
||||
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
const chips = sixTags.slice(0, 5).map((tag) => wrapper.find(`[data-testid="feature-tag-chip-${taggedFeature.id}-${tag.id}"]`));
|
||||
chips.forEach((chip) => expect(chip.exists()).toBe(true));
|
||||
expect(wrapper.find(`[data-testid="feature-tag-chip-${taggedFeature.id}-6"]`).exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('…');
|
||||
});
|
||||
|
||||
it('ThenMissingTagsDoesNotThrow', async () => {
|
||||
const untaggedFeature = { ...mockFeatures[0], tags: undefined } as unknown as FeatureResponse;
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 1, next: null, previous: null, results: [untaggedFeature] });
|
||||
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain(untaggedFeature.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenToggleEnabledIsClicked', () => {
|
||||
it('ThenPatchFeatureStateIsCalled', async () => {
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
|
||||
|
||||
Reference in New Issue
Block a user