diff --git a/src/admin/src/api/segments.ts b/src/admin/src/api/segments.ts new file mode 100644 index 0000000..16ac5d1 --- /dev/null +++ b/src/admin/src/api/segments.ts @@ -0,0 +1,45 @@ +import apiClient from './client'; +import type { + SegmentResponse, + CreateSegmentRequest, + UpdateSegmentRequest, +} from '@/types/api'; + +export async function listSegments(projectId: number): Promise { + const { data } = await apiClient.get(`/v1/projects/${projectId}/segments`); + return data; +} + +export async function getSegment(projectId: number, id: number): Promise { + const { data } = await apiClient.get( + `/v1/projects/${projectId}/segments/${id}`, + ); + return data; +} + +export async function createSegment( + projectId: number, + request: CreateSegmentRequest, +): Promise { + const { data } = await apiClient.post( + `/v1/projects/${projectId}/segments`, + request, + ); + return data; +} + +export async function updateSegment( + projectId: number, + id: number, + request: UpdateSegmentRequest, +): Promise { + const { data } = await apiClient.put( + `/v1/projects/${projectId}/segments/${id}`, + request, + ); + return data; +} + +export async function deleteSegment(projectId: number, id: number): Promise { + await apiClient.delete(`/v1/projects/${projectId}/segments/${id}`); +} diff --git a/src/admin/src/components/segments/ConditionEditor.vue b/src/admin/src/components/segments/ConditionEditor.vue new file mode 100644 index 0000000..70dbf9f --- /dev/null +++ b/src/admin/src/components/segments/ConditionEditor.vue @@ -0,0 +1,120 @@ + + + diff --git a/src/admin/src/components/segments/RuleGroupEditor.vue b/src/admin/src/components/segments/RuleGroupEditor.vue new file mode 100644 index 0000000..4a19f36 --- /dev/null +++ b/src/admin/src/components/segments/RuleGroupEditor.vue @@ -0,0 +1,131 @@ + + + diff --git a/src/admin/src/components/segments/SegmentEditor.vue b/src/admin/src/components/segments/SegmentEditor.vue new file mode 100644 index 0000000..55c5c70 --- /dev/null +++ b/src/admin/src/components/segments/SegmentEditor.vue @@ -0,0 +1,179 @@ + + + diff --git a/src/admin/src/views/SegmentsView.vue b/src/admin/src/views/SegmentsView.vue index 25ace16..07f8edb 100644 --- a/src/admin/src/views/SegmentsView.vue +++ b/src/admin/src/views/SegmentsView.vue @@ -1,18 +1,247 @@ diff --git a/src/admin/tests/unit/components/segments/SegmentEditor.spec.ts b/src/admin/tests/unit/components/segments/SegmentEditor.spec.ts new file mode 100644 index 0000000..ed673e9 --- /dev/null +++ b/src/admin/tests/unit/components/segments/SegmentEditor.spec.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import SegmentEditor from '@/components/segments/SegmentEditor.vue'; +import { useContextStore } from '@/stores/context'; +import type { ProjectResponse, SegmentResponse } from '@/types/api'; + +jest.mock('@/api/segments'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as segmentsApi from '@/api/segments'; + +const mockProject: ProjectResponse = { + id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z', +}; + +const mockSegment: SegmentResponse = { + id: 1, + name: 'beta_users', + projectId: 10, + createdAt: '2026-01-01T00:00:00Z', + rules: [{ type: 'AND', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] }], +}; + +const dialogStub = { template: '
' }; + +function mountEditor(props: Record = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(SegmentEditor, { + props: { modelValue: true, segment: null, ...props }, + global: { plugins: [router], stubs: { 'v-dialog': dialogStub } }, + }); +} + +describe('SegmentEditor', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + }); + + describe('WhenOpenedForCreate', () => { + it('ThenEditorIsRendered', () => { + const wrapper = mountEditor(); + expect(wrapper.find('[data-testid="segment-editor"]').exists()).toBe(true); + }); + + it('ThenTitleShowsCreateSegment', () => { + const wrapper = mountEditor(); + expect(wrapper.text()).toContain('Create Segment'); + }); + + it('ThenNameInputIsEmpty', () => { + const wrapper = mountEditor(); + const nameInput = wrapper.find('[data-testid="segment-name-input"]'); + expect(nameInput.exists()).toBe(true); + }); + + it('ThenNoRulesMessageIsShown', () => { + const wrapper = mountEditor(); + expect(wrapper.find('[data-testid="no-rules-message"]').exists()).toBe(true); + }); + }); + + describe('WhenOpenedForEdit', () => { + it('ThenTitleShowsEditSegment', () => { + const wrapper = mountEditor({ segment: mockSegment }); + expect(wrapper.text()).toContain('Edit Segment'); + }); + + it('ThenFormIsPrePopulatedWithSegmentData', () => { + const wrapper = mountEditor({ segment: mockSegment }); + const nameInput = wrapper.findComponent('[data-testid="segment-name-input"]') as VueWrapper; + expect(nameInput.props('modelValue')).toBe(mockSegment.name); + }); + + it('ThenExistingRulesAreRendered', () => { + const wrapper = mountEditor({ segment: mockSegment }); + expect(wrapper.findComponent({ name: 'RuleGroupEditor' }).exists()).toBe(true); + }); + }); + + describe('WhenAddRuleGroupButtonIsClicked', () => { + it('ThenARuleGroupEditorIsAdded', async () => { + const wrapper = mountEditor(); + + await wrapper.find('[data-testid="add-rule-group-btn"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.findComponent({ name: 'RuleGroupEditor' }).exists()).toBe(true); + }); + }); + + describe('WhenSaveIsClickedWithValidData', () => { + it('ThenCreateSegmentIsCalledForNewSegment', async () => { + const created: SegmentResponse = { id: 99, name: 'new_seg', projectId: 10, createdAt: '2026-01-01T00:00:00Z', rules: [] }; + jest.mocked(segmentsApi.createSegment).mockResolvedValue(created); + + const wrapper = mountEditor(); + + await (wrapper.findComponent('[data-testid="segment-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'new_seg'); + await wrapper.find('[data-testid="save-segment-btn"]').trigger('click'); + await flushPromises(); + + expect(segmentsApi.createSegment).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ name: 'new_seg' }), + ); + }); + + it('ThenUpdateSegmentIsCalledForExistingSegment', async () => { + const updated = { ...mockSegment, name: 'updated_name' }; + jest.mocked(segmentsApi.updateSegment).mockResolvedValue(updated); + + const wrapper = mountEditor({ segment: mockSegment }); + + await wrapper.find('[data-testid="save-segment-btn"]').trigger('click'); + await flushPromises(); + + expect(segmentsApi.updateSegment).toHaveBeenCalledWith( + mockProject.id, + mockSegment.id, + expect.objectContaining({ name: mockSegment.name }), + ); + }); + + it('ThenSavedEventIsEmitted', async () => { + const created: SegmentResponse = { id: 99, name: 'new_seg', projectId: 10, createdAt: '2026-01-01T00:00:00Z', rules: [] }; + jest.mocked(segmentsApi.createSegment).mockResolvedValue(created); + + const wrapper = mountEditor(); + + await (wrapper.findComponent('[data-testid="segment-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'new_seg'); + await wrapper.find('[data-testid="save-segment-btn"]').trigger('click'); + await flushPromises(); + + expect(wrapper.emitted('saved')).toBeTruthy(); + expect(wrapper.emitted('saved')![0]).toEqual([created]); + }); + }); +}); diff --git a/src/admin/tests/unit/views/SegmentsView.spec.ts b/src/admin/tests/unit/views/SegmentsView.spec.ts new file mode 100644 index 0000000..17bd1da --- /dev/null +++ b/src/admin/tests/unit/views/SegmentsView.spec.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import SegmentsView from '@/views/SegmentsView.vue'; +import { useContextStore } from '@/stores/context'; +import type { ProjectResponse, SegmentResponse } from '@/types/api'; + +jest.mock('@/api/segments'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as segmentsApi from '@/api/segments'; + +const mockProject: ProjectResponse = { + id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z', +}; + +const dialogStub = { template: '
' }; + +const mockSegments: SegmentResponse[] = [ + { + id: 1, name: 'beta_users', projectId: 10, createdAt: '2026-01-01T00:00:00Z', + rules: [{ type: 'AND', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] }], + }, + { + id: 2, name: 'power_users', projectId: 10, createdAt: '2026-01-01T00:00:00Z', + rules: [], + }, +]; + +function mountView() { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(SegmentsView, { global: { plugins: [router], stubs: { 'v-dialog': dialogStub } } }); +} + +describe('SegmentsView', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + }); + + describe('WhenNoProjectIsSelected', () => { + it('ThenEmptyStateIsShown', () => { + const wrapper = mountView(); + expect(wrapper.find('[data-testid="segments-table"]').exists()).toBe(false); + expect(wrapper.text()).toContain('Select a project'); + }); + + it('ThenCreateButtonIsNotVisible', () => { + const wrapper = mountView(); + expect(wrapper.find('[data-testid="create-segment-btn"]').exists()).toBe(false); + }); + }); + + describe('WhenProjectIsSelected', () => { + it('ThenSegmentsAreLoaded', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + mountView(); + await flushPromises(); + + expect(segmentsApi.listSegments).toHaveBeenCalledWith(mockProject.id); + }); + + it('ThenSegmentsTableIsRendered', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="segments-table"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="create-segment-btn"]').exists()).toBe(true); + }); + + it('ThenSegmentNamesAreDisplayed', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.text()).toContain('beta_users'); + expect(wrapper.text()).toContain('power_users'); + }); + }); + + describe('WhenCreateButtonIsClicked', () => { + it('ThenSegmentEditorIsOpened', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="create-segment-btn"]').trigger('click'); + await wrapper.vm.$nextTick(); + + const editor = wrapper.findComponent({ name: 'SegmentEditor' }); + expect(editor.exists()).toBe(true); + }); + }); + + describe('WhenEditButtonIsClicked', () => { + it('ThenSegmentEditorIsOpenedWithSegment', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="edit-segment-1"]').trigger('click'); + await wrapper.vm.$nextTick(); + + const editor = wrapper.findComponent({ name: 'SegmentEditor' }); + expect(editor.props('segment')).toEqual(mockSegments[0]); + }); + }); + + describe('WhenDeleteConfirmationIsCompleted', () => { + it('ThenDeleteSegmentIsCalledWhenNameMatches', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue([...mockSegments]); + jest.mocked(segmentsApi.deleteSegment).mockResolvedValue(undefined); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="delete-segment-1"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true); + + // Confirm button should be disabled before typing the name + expect(wrapper.find('[data-testid="confirm-delete-btn"]').attributes('disabled')).toBeDefined(); + + // Type segment name to enable button + await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper).vm.$emit('update:modelValue', 'beta_users'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click'); + await flushPromises(); + + expect(segmentsApi.deleteSegment).toHaveBeenCalledWith(mockProject.id, 1); + }); + + it('ThenSegmentIsRemovedFromListAfterDeletion', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue([...mockSegments]); + jest.mocked(segmentsApi.deleteSegment).mockResolvedValue(undefined); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="delete-segment-1"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper).vm.$emit('update:modelValue', 'beta_users'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click'); + await flushPromises(); + + expect(wrapper.text()).not.toContain('beta_users'); + }); + }); + + describe('WhenSegmentIsSaved', () => { + it('ThenNewSegmentIsAddedToList', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + const newSegment: SegmentResponse = { id: 99, name: 'new_segment', projectId: 10, createdAt: '2026-01-01T00:00:00Z', rules: [] }; + + await wrapper.findComponent({ name: 'SegmentEditor' }).vm.$emit('saved', newSegment); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('new_segment'); + }); + + it('ThenExistingSegmentIsUpdatedInList', async () => { + jest.mocked(segmentsApi.listSegments).mockResolvedValue([...mockSegments]); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + const updated: SegmentResponse = { ...mockSegments[0], name: 'updated_segment' }; + + await wrapper.findComponent({ name: 'SegmentEditor' }).vm.$emit('saved', updated); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('updated_segment'); + expect(wrapper.text()).not.toContain('beta_users'); + }); + }); +});