diff --git a/ui/src/app/environments/environmentsApi.ts b/ui/src/app/environments/environmentsApi.ts index 9bbe3d243a..e7f1b96307 100644 --- a/ui/src/app/environments/environmentsApi.ts +++ b/ui/src/app/environments/environmentsApi.ts @@ -68,6 +68,9 @@ export const environmentsSlice = createSlice({ if (state.currentEnvironment === action.payload.environmentKey) { state.revision = action.payload.revision; } + }, + environmentAdded: (state, action) => { + state.environments[action.payload.key] = action.payload; } } }); @@ -77,6 +80,7 @@ export const selectRevision = (state: RootState) => state.environments.revision; export const { currentEnvironmentChanged, environmentsChanged, + environmentAdded, revisionChanged } = environmentsSlice.actions; @@ -152,7 +156,28 @@ export const environmentsApi = createApi({ invalidatesTags: () => [ { type: 'Environment' }, { type: 'BranchEnvironment' } - ] + ], + async onQueryStarted( + { environmentKey, key }, + { dispatch, queryFulfilled } + ) { + try { + await queryFulfilled; + dispatch( + environmentsSlice.actions.environmentAdded({ + key, + configuration: { + base: environmentKey, + remote: '', + branch: key, + directory: '' + } + }) + ); + } catch { + // Mutation failed, no cache updates needed + } + } }), deleteBranchEnvironment: builder.mutation< void, diff --git a/ui/src/app/flags/flagsApi.ts b/ui/src/app/flags/flagsApi.ts index c61b5eee40..e2403d7065 100644 --- a/ui/src/app/flags/flagsApi.ts +++ b/ui/src/app/flags/flagsApi.ts @@ -2,6 +2,8 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { createApi } from '@reduxjs/toolkit/query/react'; import { SortingState } from '@tanstack/react-table'; +import { revisionChanged } from '~/app/environments/environmentsApi'; + import { IFlag, IFlagList } from '~/types/Flag'; import { INamespaceList } from '~/types/Namespace'; import { IResourceListResponse, IResourceResponse } from '~/types/Resource'; @@ -32,6 +34,22 @@ export const flagsTableSlice = createSlice({ export const selectSorting = (state: RootState) => state.flagsTable.sorting; export const { setSorting } = flagsTableSlice.actions; +function enrichFlag(flag: IFlag): IFlag { + return { + ...flag, + rollouts: flag.rollouts?.map((r: IRollout, i: number) => ({ + ...r, + id: uuid(), + rank: i + })), + rules: flag.rules?.map((r: IRule, i: number) => ({ + ...r, + id: uuid(), + rank: i + })) + }; +} + export const flagsApi = createApi({ reducerPath: 'flags', baseQuery, @@ -81,28 +99,12 @@ export const flagsApi = createApi({ { type: 'Flag', id: environmentKey + '/' + namespaceKey } ], transformResponse: (response: IResourceResponse): IFlag => { - return { - ...response.resource.payload, - rollouts: response.resource.payload.rollouts?.map( - (r: IRollout, i: number) => ({ - ...r, - id: uuid(), - rank: i - }) - ), - rules: response.resource.payload.rules?.map( - (r: IRule, i: number) => ({ - ...r, - id: uuid(), - rank: i - }) - ) - }; + return enrichFlag(response.resource.payload); } }), // create a new flag in the namespace createFlag: builder.mutation< - IFlag, + IResourceResponse, { environmentKey: string; namespaceKey: string; @@ -124,17 +126,42 @@ export const flagsApi = createApi({ } }; }, - invalidatesTags: ( - _result, - _error, - { environmentKey, namespaceKey, values } - ) => [ - { type: 'Flag', id: environmentKey + '/' + namespaceKey }, - { - type: 'Flag', - id: environmentKey + '/' + namespaceKey + '/' + values.key + async onQueryStarted( + { environmentKey, namespaceKey }, + { dispatch, queryFulfilled } + ) { + try { + const { data, meta } = await queryFulfilled; + if (meta?.revision) { + dispatch( + revisionChanged({ environmentKey, revision: meta.revision }) + ); + } + const payload = data.resource.payload; + dispatch( + flagsApi.util.upsertQueryData( + 'getFlag', + { + environmentKey, + namespaceKey, + flagKey: data.resource.key + }, + enrichFlag(payload) + ) + ); + dispatch( + flagsApi.util.updateQueryData( + 'listFlags', + { environmentKey, namespaceKey }, + (draft) => { + draft.flags.push(payload); + } + ) + ); + } catch { + // Mutation failed, no cache updates needed } - ] + } }), // delete the flag from the namespace deleteFlag: builder.mutation< @@ -152,21 +179,34 @@ export const flagsApi = createApi({ method: 'DELETE' }; }, - invalidatesTags: ( - _result, - _error, - { environmentKey, namespaceKey, flagKey } - ) => [ - { type: 'Flag', id: environmentKey + '/' + namespaceKey }, - { - type: 'Flag', - id: environmentKey + '/' + namespaceKey + '/' + flagKey + async onQueryStarted( + { environmentKey, namespaceKey, flagKey }, + { dispatch, queryFulfilled } + ) { + try { + const { meta } = await queryFulfilled; + if (meta?.revision) { + dispatch( + revisionChanged({ environmentKey, revision: meta.revision }) + ); + } + dispatch( + flagsApi.util.updateQueryData( + 'listFlags', + { environmentKey, namespaceKey }, + (draft) => { + draft.flags = draft.flags.filter((f) => f.key !== flagKey); + } + ) + ); + } catch { + // Mutation failed, no cache updates needed } - ] + } }), // update the flag in the namespace updateFlag: builder.mutation< - IFlag, + IResourceResponse, { environmentKey: string; namespaceKey: string; @@ -190,17 +230,41 @@ export const flagsApi = createApi({ } }; }, - invalidatesTags: ( - _result, - _error, - { environmentKey, namespaceKey, flagKey } - ) => [ - { type: 'Flag', id: environmentKey + '/' + namespaceKey }, - { - type: 'Flag', - id: environmentKey + '/' + namespaceKey + '/' + flagKey + async onQueryStarted( + { environmentKey, namespaceKey, flagKey }, + { dispatch, queryFulfilled } + ) { + try { + const { data, meta } = await queryFulfilled; + if (meta?.revision) { + dispatch( + revisionChanged({ environmentKey, revision: meta.revision }) + ); + } + const payload = data.resource.payload; + dispatch( + flagsApi.util.upsertQueryData( + 'getFlag', + { environmentKey, namespaceKey, flagKey }, + enrichFlag(payload) + ) + ); + dispatch( + flagsApi.util.updateQueryData( + 'listFlags', + { environmentKey, namespaceKey }, + (draft) => { + const idx = draft.flags.findIndex((f) => f.key === flagKey); + if (idx >= 0) { + draft.flags[idx] = payload; + } + } + ) + ); + } catch { + // Mutation failed, no cache updates needed } - ] + } }), // copy the flag from one namespace to another one copyFlag: builder.mutation< diff --git a/ui/src/app/segments/segmentsApi.ts b/ui/src/app/segments/segmentsApi.ts index 6bf1323ec0..a88503fc78 100644 --- a/ui/src/app/segments/segmentsApi.ts +++ b/ui/src/app/segments/segmentsApi.ts @@ -2,6 +2,8 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { createApi } from '@reduxjs/toolkit/query/react'; import { SortingState } from '@tanstack/react-table'; +import { revisionChanged } from '~/app/environments/environmentsApi'; + import { IConstraint } from '~/types/Constraint'; import { IResourceListResponse, IResourceResponse } from '~/types/Resource'; import { ISegment, ISegmentList } from '~/types/Segment'; @@ -30,6 +32,16 @@ export const segmentsTableSlice = createSlice({ export const selectSorting = (state: RootState) => state.segmentsTable.sorting; export const { setSorting } = segmentsTableSlice.actions; +function enrichSegment(segment: ISegment): ISegment { + return { + ...segment, + constraints: segment.constraints?.map((c: IConstraint) => ({ + ...c, + id: uuid() + })) + }; +} + export const segmentsApi = createApi({ reducerPath: 'segments', baseQuery, @@ -79,20 +91,12 @@ export const segmentsApi = createApi({ { type: 'Segment', id: environmentKey + '/' + namespaceKey } ], transformResponse: (response: IResourceResponse): ISegment => { - return { - ...response.resource.payload, - constraints: response.resource.payload.constraints?.map( - (c: IConstraint) => ({ - ...c, - id: uuid() - }) - ) - }; + return enrichSegment(response.resource.payload); } }), // create a new segment in the namespace createSegment: builder.mutation< - ISegment, + IResourceResponse, { environmentKey: string; namespaceKey: string; @@ -114,17 +118,42 @@ export const segmentsApi = createApi({ } }; }, - invalidatesTags: ( - _result, - _error, - { environmentKey, namespaceKey, values } - ) => [ - { type: 'Segment', id: environmentKey + '/' + namespaceKey }, - { - type: 'Segment', - id: environmentKey + '/' + namespaceKey + '/' + values.key + async onQueryStarted( + { environmentKey, namespaceKey }, + { dispatch, queryFulfilled } + ) { + try { + const { data, meta } = await queryFulfilled; + if (meta?.revision) { + dispatch( + revisionChanged({ environmentKey, revision: meta.revision }) + ); + } + const payload = data.resource.payload; + dispatch( + segmentsApi.util.upsertQueryData( + 'getSegment', + { + environmentKey, + namespaceKey, + segmentKey: data.resource.key + }, + enrichSegment(payload) + ) + ); + dispatch( + segmentsApi.util.updateQueryData( + 'listSegments', + { environmentKey, namespaceKey }, + (draft) => { + draft.segments.push(payload); + } + ) + ); + } catch { + // Mutation failed, no cache updates needed } - ] + } }), // delete the segment from the namespace deleteSegment: builder.mutation< @@ -142,21 +171,36 @@ export const segmentsApi = createApi({ method: 'DELETE' }; }, - invalidatesTags: ( - _result, - _error, - { environmentKey, namespaceKey, segmentKey } - ) => [ - { type: 'Segment', id: environmentKey + '/' + namespaceKey }, - { - type: 'Segment', - id: environmentKey + '/' + namespaceKey + '/' + segmentKey + async onQueryStarted( + { environmentKey, namespaceKey, segmentKey }, + { dispatch, queryFulfilled } + ) { + try { + const { meta } = await queryFulfilled; + if (meta?.revision) { + dispatch( + revisionChanged({ environmentKey, revision: meta.revision }) + ); + } + dispatch( + segmentsApi.util.updateQueryData( + 'listSegments', + { environmentKey, namespaceKey }, + (draft) => { + draft.segments = draft.segments.filter( + (s) => s.key !== segmentKey + ); + } + ) + ); + } catch { + // Mutation failed, no cache updates needed } - ] + } }), // update the segment in the namespace updateSegment: builder.mutation< - ISegment, + IResourceResponse, { environmentKey: string; namespaceKey: string; @@ -180,17 +224,43 @@ export const segmentsApi = createApi({ } }; }, - invalidatesTags: ( - _result, - _error, - { environmentKey, namespaceKey, segmentKey } - ) => [ - { type: 'Segment', id: environmentKey + '/' + namespaceKey }, - { - type: 'Segment', - id: environmentKey + '/' + namespaceKey + '/' + segmentKey + async onQueryStarted( + { environmentKey, namespaceKey, segmentKey }, + { dispatch, queryFulfilled } + ) { + try { + const { data, meta } = await queryFulfilled; + if (meta?.revision) { + dispatch( + revisionChanged({ environmentKey, revision: meta.revision }) + ); + } + const payload = data.resource.payload; + dispatch( + segmentsApi.util.upsertQueryData( + 'getSegment', + { environmentKey, namespaceKey, segmentKey }, + enrichSegment(payload) + ) + ); + dispatch( + segmentsApi.util.updateQueryData( + 'listSegments', + { environmentKey, namespaceKey }, + (draft) => { + const idx = draft.segments.findIndex( + (s) => s.key === segmentKey + ); + if (idx >= 0) { + draft.segments[idx] = payload; + } + } + ) + ); + } catch { + // Mutation failed, no cache updates needed } - ] + } }), // copy the segment from one namespace to another one copySegment: builder.mutation< diff --git a/ui/src/utils/redux-rtk.ts b/ui/src/utils/redux-rtk.ts index 28fc9beb9c..d2505495fc 100644 --- a/ui/src/utils/redux-rtk.ts +++ b/ui/src/utils/redux-rtk.ts @@ -28,10 +28,16 @@ export const internalQuery = fetchBaseQuery({ fetchFn: customFetchFn }); +export interface BaseQueryMeta { + revision?: string; +} + export const baseQuery: BaseQueryFn< string | FetchArgs, unknown, - FetchBaseQueryError + FetchBaseQueryError, + {}, + BaseQueryMeta > = async (args, api, extraOptions) => { const result = await fetchBaseQuery({ baseUrl: apiURL, diff --git a/ui/tests/flags.spec.ts b/ui/tests/flags.spec.ts index 7654e06e7c..43bc21b254 100644 --- a/ui/tests/flags.spec.ts +++ b/ui/tests/flags.spec.ts @@ -16,6 +16,9 @@ test.describe('Flags', () => { await page.getByLabel('Description').click(); await page.getByRole('button', { name: 'Create' }).click(); await expect(page.getByText('Successfully created flag')).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Test Flag' }) + ).toBeVisible(); }); test('can update flag', async ({ page }) => { @@ -24,6 +27,9 @@ test.describe('Flags', () => { await page.getByLabel('Description').fill('Test flag description'); await page.getByRole('button', { name: 'Update' }).click(); await expect(page.getByText('Successfully updated flag')).toBeVisible(); + await expect(page.getByLabel('Description')).toHaveValue( + 'Test flag description' + ); }); test('can add variants to flag', async ({ page }) => { diff --git a/ui/tests/segments.spec.ts b/ui/tests/segments.spec.ts index 626934bd5b..50deb65c26 100644 --- a/ui/tests/segments.spec.ts +++ b/ui/tests/segments.spec.ts @@ -12,6 +12,9 @@ test.describe('Segments', () => { await page.getByLabel('Description').click(); await page.getByRole('button', { name: 'Create' }).click(); await expect(page.getByText('Successfully created segment')).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Test Segment' }) + ).toBeVisible(); }); test('can update segment', async ({ page }) => { @@ -20,6 +23,7 @@ test.describe('Segments', () => { await page.getByLabel('Description').fill("i'm a test"); await page.getByRole('button', { name: 'Update' }).click(); await expect(page.getByText('Successfully updated segment')).toBeVisible(); + await expect(page.getByLabel('Description')).toHaveValue("i'm a test"); }); test('can add constraints to segment', async ({ page }) => {