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>
|
<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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user