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:
2026-06-30 15:57:13 -07:00
parent 2347063ae2
commit 6a5f6a7765
4 changed files with 317 additions and 192 deletions

View File

@@ -1,19 +1,6 @@
<template> <template>
<div data-testid="tags-chip-input"> <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>
<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-alert
v-if="errorMessage" v-if="errorMessage"
@@ -21,98 +8,124 @@
variant="tonal" variant="tonal"
density="compact" density="compact"
closable closable
class="mb-2" class="my-2"
@click:close="errorMessage = null" @click:close="errorMessage = null"
> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </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"> <div v-if="isLoading" class="d-flex justify-center py-3">
<v-progress-circular indeterminate size="20" width="2" color="primary" /> <v-progress-circular indeterminate size="20" width="2" color="primary" />
</div> </div>
<div v-else-if="tags.length > 0" class="d-flex flex-wrap ga-1" data-testid="tag-chips"> <template v-else>
<v-chip <div v-if="assignedTags.length > 0" class="d-flex flex-wrap ga-1 my-2" data-testid="tag-chips">
v-for="tag in tags" <v-chip
:key="tag.id" v-for="tag in assignedTags"
size="small" :key="tag.id"
closable size="small"
:variant="isAssigned(tag) ? 'flat' : 'outlined'" variant="flat"
:color="tag.color" closable
:prepend-icon="isAssigned(tag) ? 'ri-check-line' : undefined" :color="tag.color"
:loading="togglingTagId === tag.id" :loading="togglingTagId === tag.id"
:data-testid="`tag-chip-${tag.label}`" :data-testid="`tag-chip-${tag.label}`"
@click="onToggleTag(tag)" @click:close="onRemoveTag(tag)"
@click:close="onDeleteTag(tag)" >
> {{ tag.label }}
{{ tag.label }} </v-chip>
</v-chip> </div>
</div> <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>
<p <div class="d-flex align-center ga-2">
v-else <v-combobox
class="text-body-2 text-medium-emphasis" v-model="newTagLabel"
data-testid="tags-empty-message" :items="suggestions"
> label="Tag name"
No tags yet. Create one to organise your features. variant="outlined"
</p> 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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { listTags, createTag, deleteTag } from '@/api/tags'; import { listTags, createTag } from '@/api/tags';
import { assignFeatureTag, removeFeatureTag } from '@/api/features'; import { assignFeatureTag, removeFeatureTag } from '@/api/features';
import { useContextStore } from '@/stores/context'; import { useContextStore } from '@/stores/context';
import type { TagResponse, FeatureResponse } from '@/types/api'; 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 { interface Props {
feature: FeatureResponse; feature: FeatureResponse;
} }
@@ -127,30 +140,33 @@ const contextStore = useContextStore();
const tags = ref<TagResponse[]>([]); const tags = ref<TagResponse[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const isCreating = ref(false); const isSubmitting = ref(false);
const togglingTagId = ref<number | null>(null); const togglingTagId = ref<number | null>(null);
const showCreateForm = ref(false);
const newTagLabel = ref(''); const newTagLabel = ref('');
const newTagColor = ref('#1565C0'); const newTagColor = ref('#1565C0');
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null); const showColorDialog = ref(false);
const tagLabelInputRef = ref<{ focus: () => void } | null>(null); const customColorInputRef = ref<HTMLInputElement | null>(null);
function isAssigned(tag: TagResponse): boolean { function onSelectPresetColor(color: string): void {
return props.feature.tags.some((t) => t.id === tag.id); newTagColor.value = color;
showColorDialog.value = false;
} }
function focusTagLabel(): void { const assignedTags = computed(() => props.feature.tags);
nextTick(() => tagLabelInputRef.value?.focus());
}
watch(showCreateForm, (open) => { const suggestions = computed(() => {
if (open) focusTagLabel(); 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 = { function isAssigned(tag: TagResponse): boolean {
required: (v: string) => !!v || 'Tag name is required', return assignedTags.value.some((t) => t.id === tag.id);
}; }
async function fetchTags(): Promise<void> { async function fetchTags(): Promise<void> {
const projectId = contextStore.currentProject?.id; const projectId = contextStore.currentProject?.id;
@@ -166,61 +182,48 @@ async function fetchTags(): Promise<void> {
} }
} }
async function onCreateTag(): Promise<void> { async function onAddTag(): Promise<void> {
if (!createFormRef.value) return; const label = newTagLabel.value.trim();
const { valid } = await createFormRef.value.validate();
if (!valid) return;
const projectId = contextStore.currentProject?.id; const projectId = contextStore.currentProject?.id;
if (!projectId) return; if (!label || !projectId) return;
isCreating.value = true;
errorMessage.value = null; errorMessage.value = null;
isSubmitting.value = true;
try { try {
const created = await createTag(projectId, { let tag = tags.value.find((t) => t.label.toLowerCase() === label.toLowerCase());
label: newTagLabel.value.trim(), if (!tag) {
color: newTagColor.value, tag = await createTag(projectId, { label, color: newTagColor.value });
}); tags.value.push(tag);
tags.value.push(created); }
if (!isAssigned(tag)) {
togglingTagId.value = tag.id;
const updated = await assignFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated);
}
newTagLabel.value = ''; newTagLabel.value = '';
newTagColor.value = '#1565C0'; newTagColor.value = '#1565C0';
showCreateForm.value = false;
} catch { } catch {
errorMessage.value = 'Failed to create tag. Please try again.'; errorMessage.value = 'Failed to add tag. Please try again.';
} finally { } 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; const projectId = contextStore.currentProject?.id;
if (!projectId) return; if (!projectId) return;
togglingTagId.value = tag.id;
errorMessage.value = null; errorMessage.value = null;
togglingTagId.value = tag.id;
try { try {
const updated = isAssigned(tag) const updated = await removeFeatureTag(projectId, props.feature.id, tag.id);
? await removeFeatureTag(projectId, props.feature.id, tag.id)
: await assignFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated); emit('updated', updated);
} catch { } catch {
errorMessage.value = 'Failed to update tag assignment. Please try again.'; errorMessage.value = 'Failed to remove tag. Please try again.';
} finally { } finally {
togglingTagId.value = null; 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); onMounted(fetchTags);
</script> </script>

View File

@@ -73,7 +73,7 @@
</template> </template>
</v-tooltip> </v-tooltip>
<v-chip <v-chip
v-for="tag in item.tags.slice(0, 5)" v-for="tag in (item.tags ?? []).slice(0, 5)"
:key="tag.id" :key="tag.id"
size="x-small" size="x-small"
variant="flat" variant="flat"
@@ -83,9 +83,9 @@
{{ tag.label }} {{ tag.label }}
</v-chip> </v-chip>
<span <span
v-if="item.tags.length > 5" v-if="(item.tags ?? []).length > 5"
class="text-caption text-medium-emphasis" class="text-caption text-medium-emphasis"
:title="`${item.tags.length - 5} more`" :title="`${(item.tags ?? []).length - 5} more`"
></span> ></span>
</div> </div>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0"> <p v-if="item.description" class="text-caption text-medium-emphasis mb-0">

View File

@@ -1,12 +1,13 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 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 { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import TagsChipInput from '@/components/features/TagsChipInput.vue'; import TagsChipInput from '@/components/features/TagsChipInput.vue';
import { useContextStore } from '@/stores/context'; 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/tags');
jest.mock('@/api/features');
jest.mock('@/api/client', () => ({ jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(), setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(), clearAuthTokens: jest.fn(),
@@ -14,18 +15,25 @@ jest.mock('@/api/client', () => ({
})); }));
import * as tagsApi from '@/api/tags'; import * as tagsApi from '@/api/tags';
import * as featuresApi from '@/api/features';
const mockProject: ProjectResponse = { const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z', id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
}; };
const mockTags: TagResponse[] = [ const backendTag: TagResponse = { id: 1, label: 'backend', color: '#ff0000', projectId: 10 };
{ id: 1, label: 'backend', color: '#ff0000', projectId: 10 }, const frontendTag: TagResponse = { id: 2, label: 'frontend', color: '#0000ff', projectId: 10 };
{ 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 />' } }] }); 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', () => { describe('TagsChipInput', () => {
@@ -40,100 +48,173 @@ describe('TagsChipInput', () => {
describe('WhenComponentMounts', () => { describe('WhenComponentMounts', () => {
it('ThenTagsAreFetchedFromApi', async () => { it('ThenTagsAreFetchedFromApi', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
mountComponent(); mountComponent(mockFeature([]));
await flushPromises(); await flushPromises();
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id); expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
}); });
it('ThenExistingTagChipsAreRendered', async () => { it('ThenAssignedTagChipsAreRendered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(); const wrapper = mountComponent(mockFeature([backendTag]));
await flushPromises(); await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true); 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 () => { it('ThenEmptyMessageIsShownWhenNoTagsAssigned', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue([]); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(); const wrapper = mountComponent(mockFeature([]));
await flushPromises(); await flushPromises();
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true); expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
}); });
}); });
describe('WhenCreateTagIsSubmitted', () => { describe('WhenAddingANewTagName', () => {
it('ThenCreateTagApiIsCalled', async () => { it('ThenCreateTagAndAssignAreBothCalled', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 }; const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
const updatedFeature = mockFeature([newTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag); jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(); const wrapper = mountComponent(mockFeature([]));
await flushPromises(); await flushPromises();
// Open the create form const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click'); await input.vm.$emit('update:modelValue', 'api');
await wrapper.vm.$nextTick(); await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
// 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(); await flushPromises();
expect(tagsApi.createTag).toHaveBeenCalledWith( expect(tagsApi.createTag).toHaveBeenCalledWith(mockProject.id, expect.objectContaining({ label: 'api' }));
mockProject.id, expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, newTag.id);
expect.objectContaining({ label: 'api' }),
);
}); });
it('ThenNewTagAppearsInTheList', async () => { it('ThenUpdatedEventIsEmitted', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 }; const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
jest.mocked(tagsApi.listTags).mockResolvedValue([]); const updatedFeature = mockFeature([newTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag); jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(); const wrapper = mountComponent(mockFeature([]));
await flushPromises(); await flushPromises();
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click'); const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await wrapper.vm.$nextTick(); await input.vm.$emit('update:modelValue', 'api');
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api'); await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-api"]').exists()).toBe(true); expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]);
}); });
}); });
describe('WhenTagChipIsDeleted', () => { describe('WhenTypedNameMatchesAnExistingTag', () => {
it('ThenDeleteTagApiIsCalled', async () => { it('ThenExistingTagIsAssignedInsteadOfCreatingANewOne', async () => {
const updatedFeature = mockFeature([backendTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined); jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(); const wrapper = mountComponent(mockFeature([]));
await flushPromises(); await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>; const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await chip.vm.$emit('click:close'); await input.vm.$emit('update:modelValue', 'backend');
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(tagsApi.deleteTag).toHaveBeenCalledWith(mockProject.id, mockTags[0].id); 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('ThenDeletedTagIsRemovedFromTheList', async () => { it('ThenNoSuggestionsAreShownBelowTwoCharacters', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
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', '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(); await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>; const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close'); await chip.vm.$emit('click:close');
await flushPromises(); 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]);
}); });
}); });
}); });

View File

@@ -4,7 +4,7 @@ import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import FeaturesView from '@/views/FeaturesView.vue'; import FeaturesView from '@/views/FeaturesView.vue';
import { useContextStore } from '@/stores/context'; 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/features');
jest.mock('@/api/featureStates'); 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', id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
}; };
const mockFeatures: FeatureResponse[] = [ 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: 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' }, { 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[] = [ 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: 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' }, { 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', () => { describe('WhenToggleEnabledIsClicked', () => {
it('ThenPatchFeatureStateIsCalled', async () => { it('ThenPatchFeatureStateIsCalled', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures }); jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });