diff --git a/ui/src/app/flags/rollouts/Rollouts.tsx b/ui/src/app/flags/rollouts/Rollouts.tsx index 62b3087525..1b6680900c 100644 --- a/ui/src/app/flags/rollouts/Rollouts.tsx +++ b/ui/src/app/flags/rollouts/Rollouts.tsx @@ -14,8 +14,13 @@ import { verticalListSortingStrategy } from '@dnd-kit/sortable'; import { PlusIcon, StarIcon } from '@heroicons/react/24/outline'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; +import { + useDeleteRolloutMutation, + useListRolloutsQuery, + useOrderRolloutsMutation +} from '~/app/flags/rolloutsApi'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import { useListSegmentsQuery } from '~/app/segments/segmentsApi'; @@ -27,11 +32,10 @@ import RolloutForm from '~/components/rollouts/forms/RolloutForm'; import Rollout from '~/components/rollouts/Rollout'; import SortableRollout from '~/components/rollouts/SortableRollout'; import Slideover from '~/components/Slideover'; -import { deleteRollout, listRollouts, orderRollouts } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { IFlag } from '~/types/Flag'; -import { IRollout, IRolloutList } from '~/types/Rollout'; +import { IRollout } from '~/types/Rollout'; import { SegmentOperatorType } from '~/types/Segment'; type RolloutsProps = { @@ -41,11 +45,8 @@ type RolloutsProps = { export default function Rollouts(props: RolloutsProps) { const { flag } = props; - const [rollouts, setRollouts] = useState([]); - const [activeRollout, setActiveRollout] = useState(null); - const [rolloutsVersion, setRolloutsVersion] = useState(0); const [showRolloutForm, setShowRolloutForm] = useState(false); const [showEditRolloutForm, setShowEditRolloutForm] = @@ -63,18 +64,27 @@ export default function Rollouts(props: RolloutsProps) { const namespace = useSelector(selectCurrentNamespace); const readOnly = useSelector(selectReadonly); - const segments = useListSegmentsQuery(namespace.key)?.data?.segments || []; + const segmentsList = useListSegmentsQuery(namespace.key); + const segments = useMemo( + () => segmentsList.data?.segments || [], + [segmentsList] + ); - const loadData = useCallback(async () => { - // TODO: move to redux - const rolloutList = (await listRollouts( - namespace.key, - flag.key - )) as IRolloutList; + const [deleteRollout] = useDeleteRolloutMutation(); + const rolloutsList = useListRolloutsQuery({ + namespaceKey: namespace.key, + flagKey: flag.key + }); + const rolloutsRules = useMemo( + () => rolloutsList.data?.rules || [], + [rolloutsList] + ); + + const rollouts = useMemo(() => { // Combine both segmentKey and segmentKeys for legacy purposes. // TODO(yquansah): Should be removed once there are no more references to `segmentKey`. - const rolloutRules = rolloutList.rules.map((rollout) => { + return rolloutsRules.map((rollout) => { if (rollout.segment) { let segmentKeys: string[] = []; if ( @@ -101,13 +111,7 @@ export default function Rollouts(props: RolloutsProps) { ...rollout }; }); - - setRollouts(rolloutRules); - }, [namespace.key, flag.key]); - - const incrementRolloutsVersion = () => { - setRolloutsVersion(rolloutsVersion + 1); - }; + }, [rolloutsRules]); const sensors = useSensors( useSensor(PointerSensor), @@ -116,14 +120,15 @@ export default function Rollouts(props: RolloutsProps) { }) ); + const [orderRollouts] = useOrderRolloutsMutation(); + const reorderRollouts = (rollouts: IRollout[]) => { - orderRollouts( - namespace.key, - flag.key, - rollouts.map((rollout) => rollout.id) - ) + orderRollouts({ + namespaceKey: namespace.key, + flagKey: flag.key, + rolloutIds: rollouts.map((rollout) => rollout.id) + }) .then(() => { - incrementRolloutsVersion(); clearError(); setSuccess('Successfully reordered rollouts'); }) @@ -150,7 +155,6 @@ export default function Rollouts(props: RolloutsProps) { })(rollouts); reorderRollouts(reordered); - setRollouts(reordered); } setActiveRollout(null); @@ -166,10 +170,6 @@ export default function Rollouts(props: RolloutsProps) { } }; - useEffect(() => { - loadData(); - }, [loadData, rolloutsVersion]); - return ( <> {/* rollout delete modal */} @@ -187,11 +187,15 @@ export default function Rollouts(props: RolloutsProps) { } panelType="Rollout" setOpen={setShowDeleteRolloutModal} - handleDelete={ - () => - deleteRollout(namespace.key, flag.key, deletingRollout?.id ?? '') // TODO: Determine impact of blank ID param + handleDelete={() => + deleteRollout({ + namespaceKey: namespace.key, + flagKey: flag.key, + rolloutId: deletingRollout?.id ?? '' + // TODO: Determine impact of blank ID param + }).unwrap() } - onSuccess={incrementRolloutsVersion} + onSuccess={() => {}} /> @@ -207,7 +211,6 @@ export default function Rollouts(props: RolloutsProps) { segments={segments} setOpen={setShowRolloutForm} onSuccess={() => { - incrementRolloutsVersion(); setShowRolloutForm(false); }} /> @@ -227,7 +230,6 @@ export default function Rollouts(props: RolloutsProps) { setOpen={setShowEditRolloutForm} onSuccess={() => { setShowEditRolloutForm(false); - incrementRolloutsVersion(); }} /> @@ -297,7 +299,7 @@ export default function Rollouts(props: RolloutsProps) { flag={flag} rollout={rollout} segments={segments} - onSuccess={incrementRolloutsVersion} + onSuccess={() => {}} onEdit={() => { setEditingRollout(rollout); setShowEditRolloutForm(true); diff --git a/ui/src/app/flags/rolloutsApi.ts b/ui/src/app/flags/rolloutsApi.ts new file mode 100644 index 0000000000..f88c59b38d --- /dev/null +++ b/ui/src/app/flags/rolloutsApi.ts @@ -0,0 +1,141 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; +import { IRollout, IRolloutBase, IRolloutList } from '~/types/Rollout'; + +import { baseQuery } from '~/utils/redux-rtk'; + +export const rolloutsApi = createApi({ + reducerPath: 'rollouts', + baseQuery, + tagTypes: ['Rollout'], + endpoints: (builder) => ({ + // get list of rollouts + listRollouts: builder.query< + IRolloutList, + { namespaceKey: string; flagKey: string } + >({ + query: ({ namespaceKey, flagKey }) => + `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts`, + providesTags: (_result, _error, { namespaceKey, flagKey }) => [ + { type: 'Rollout', id: namespaceKey + '/' + flagKey } + ] + }), + // delete the rollout + deleteRollout: builder.mutation< + void, + { namespaceKey: string; flagKey: string; rolloutId: string } + >({ + query({ namespaceKey, flagKey, rolloutId }) { + return { + url: `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/${rolloutId}`, + method: 'DELETE' + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [ + { type: 'Rollout', id: namespaceKey + '/' + flagKey } + ] + }), + // create the rollout + createRollout: builder.mutation< + void, + { + namespaceKey: string; + flagKey: string; + values: IRolloutBase; + } + >({ + query({ namespaceKey, flagKey, values }) { + return { + url: `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts`, + method: 'POST', + body: values + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [ + { type: 'Rollout', id: namespaceKey + '/' + flagKey } + ] + }), + // update the rollout + updateRollout: builder.mutation< + void, + { + namespaceKey: string; + flagKey: string; + rolloutId: string; + values: IRolloutBase; + } + >({ + query({ namespaceKey, flagKey, rolloutId, values }) { + return { + url: `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/${rolloutId}`, + method: 'PUT', + body: values + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [ + { type: 'Rollout', id: namespaceKey + '/' + flagKey } + ] + }), + // reorder the rollouts + orderRollouts: builder.mutation< + IRollout, + { + namespaceKey: string; + flagKey: string; + rolloutIds: string[]; + } + >({ + query({ namespaceKey, flagKey, rolloutIds }) { + return { + url: `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/order`, + method: 'PUT', + body: { rolloutIds: rolloutIds } + }; + }, + async onQueryStarted( + { namespaceKey, flagKey, rolloutIds }, + { dispatch, queryFulfilled } + ) { + // this is manual optimistic cache update of the listRollouts + // to set a desire order of rules of the listRollouts while server is updating the state. + // If we don't do this we will have very strange UI state with rules in old order + // until the result will be get from the server. It's very visible on slow connections. + const patchResult = dispatch( + rolloutsApi.util.updateQueryData( + 'listRollouts', + { namespaceKey, flagKey }, + (draft: IRolloutList) => { + const rules = draft.rules; + const resortedRules = rules.sort((a, b) => { + const ida = rolloutIds.indexOf(a.id); + const idb = rolloutIds.indexOf(b.id); + if (ida < idb) { + return -1; + } else if (ida > idb) { + return 1; + } + return 0; + }); + return Object.assign(draft, { rules: resortedRules }); + } + ) + ); + try { + await queryFulfilled; + } catch { + patchResult.undo(); + } + }, + invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [ + { type: 'Rollout', id: namespaceKey + '/' + flagKey } + ] + }) + }) +}); + +export const { + useListRolloutsQuery, + useCreateRolloutMutation, + useUpdateRolloutMutation, + useDeleteRolloutMutation, + useOrderRolloutsMutation +} = rolloutsApi; diff --git a/ui/src/components/rollouts/forms/EditRolloutForm.tsx b/ui/src/components/rollouts/forms/EditRolloutForm.tsx index 904eabeee5..150943ea71 100644 --- a/ui/src/components/rollouts/forms/EditRolloutForm.tsx +++ b/ui/src/components/rollouts/forms/EditRolloutForm.tsx @@ -3,6 +3,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline'; import { FieldArray, Form, Formik } from 'formik'; import { useSelector } from 'react-redux'; import { twMerge } from 'tailwind-merge'; +import { useUpdateRolloutMutation } from '~/app/flags/rolloutsApi'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import Button from '~/components/forms/buttons/Button'; @@ -11,7 +12,6 @@ import SegmentsPicker from '~/components/forms/SegmentsPicker'; import Select from '~/components/forms/Select'; import Loading from '~/components/Loading'; import MoreInfo from '~/components/MoreInfo'; -import { updateRollout } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { IRollout, RolloutType } from '~/types/Rollout'; @@ -65,34 +65,44 @@ export default function EditRolloutForm(props: EditRolloutFormProps) { : SegmentOperatorType.OR; const readOnly = useSelector(selectReadonly); - + const [updateRollout] = useUpdateRolloutMutation(); const handleSegmentSubmit = (values: RolloutFormValues) => { let rolloutSegment = rollout; rolloutSegment.threshold = undefined; - return updateRollout(namespace.key, flagKey, rollout.id, { - ...rolloutSegment, - description: values.description, - segment: { - segmentKeys: values.segmentKeys?.map((s) => s.key), - segmentOperator: values.operator, - value: values.value === 'true' + return updateRollout({ + namespaceKey: namespace.key, + flagKey, + rolloutId: rollout.id, + values: { + ...rolloutSegment, + description: values.description, + segment: { + segmentKeys: values.segmentKeys?.map((s) => s.key), + segmentOperator: values.operator, + value: values.value === 'true' + } } - }); + }).unwrap(); }; const handleThresholdSubmit = (values: RolloutFormValues) => { let rolloutThreshold = rollout; rolloutThreshold.segment = undefined; - return updateRollout(namespace.key, flagKey, rollout.id, { - ...rolloutThreshold, - description: values.description, - threshold: { - percentage: values.percentage || 0, - value: values.value === 'true' + return updateRollout({ + namespaceKey: namespace.key, + flagKey, + rolloutId: rollout.id, + values: { + ...rolloutThreshold, + description: values.description, + threshold: { + percentage: values.percentage || 0, + value: values.value === 'true' + } } - }); + }).unwrap(); }; const initialValue = diff --git a/ui/src/components/rollouts/forms/QuickEditRolloutForm.tsx b/ui/src/components/rollouts/forms/QuickEditRolloutForm.tsx index b077c625ea..cfe68d1c96 100644 --- a/ui/src/components/rollouts/forms/QuickEditRolloutForm.tsx +++ b/ui/src/components/rollouts/forms/QuickEditRolloutForm.tsx @@ -1,6 +1,7 @@ import { FieldArray, Form, Formik } from 'formik'; import { useSelector } from 'react-redux'; import { twMerge } from 'tailwind-merge'; +import { useUpdateRolloutMutation } from '~/app/flags/rolloutsApi'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import TextButton from '~/components/forms/buttons/TextButton'; @@ -8,7 +9,6 @@ import Input from '~/components/forms/Input'; import SegmentsPicker from '~/components/forms/SegmentsPicker'; import Select from '~/components/forms/Select'; import Loading from '~/components/Loading'; -import { updateRollout } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { IFlag } from '~/types/Flag'; @@ -48,32 +48,42 @@ export default function QuickEditRolloutForm(props: QuickEditRolloutFormProps) { : SegmentOperatorType.OR; const readOnly = useSelector(selectReadonly); - + const [updateRollout] = useUpdateRolloutMutation(); const handleSegmentSubmit = (values: RolloutFormValues) => { let rolloutSegment = rollout; rolloutSegment.threshold = undefined; - return updateRollout(namespace.key, flag.key, rollout.id, { - ...rolloutSegment, - segment: { - segmentKeys: values.segmentKeys?.map((s) => s.key), - segmentOperator: values.operator, - value: values.value === 'true' + return updateRollout({ + namespaceKey: namespace.key, + flagKey: flag.key, + rolloutId: rollout.id, + values: { + ...rolloutSegment, + segment: { + segmentKeys: values.segmentKeys?.map((s) => s.key), + segmentOperator: values.operator, + value: values.value === 'true' + } } - }); + }).unwrap(); }; const handleThresholdSubmit = (values: RolloutFormValues) => { let rolloutThreshold = rollout; rolloutThreshold.segment = undefined; - return updateRollout(namespace.key, flag.key, rollout.id, { - ...rolloutThreshold, - threshold: { - percentage: values.percentage || 0, - value: values.value === 'true' + return updateRollout({ + namespaceKey: namespace.key, + flagKey: flag.key, + rolloutId: rollout.id, + values: { + ...rolloutThreshold, + threshold: { + percentage: values.percentage || 0, + value: values.value === 'true' + } } - }); + }).unwrap(); }; const initialValue = diff --git a/ui/src/components/rollouts/forms/RolloutForm.tsx b/ui/src/components/rollouts/forms/RolloutForm.tsx index d059551b25..35de9f210e 100644 --- a/ui/src/components/rollouts/forms/RolloutForm.tsx +++ b/ui/src/components/rollouts/forms/RolloutForm.tsx @@ -3,6 +3,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline'; import { FieldArray, Form, Formik } from 'formik'; import { useState } from 'react'; import { useSelector } from 'react-redux'; +import { useCreateRolloutMutation } from '~/app/flags/rolloutsApi'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import Button from '~/components/forms/buttons/Button'; import Input from '~/components/forms/Input'; @@ -10,7 +11,6 @@ import SegmentsPicker from '~/components/forms/SegmentsPicker'; import Select from '~/components/forms/Select'; import Loading from '~/components/Loading'; import MoreInfo from '~/components/MoreInfo'; -import { createRollout } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { RolloutType } from '~/types/Rollout'; @@ -60,30 +60,38 @@ export default function RolloutForm(props: RolloutFormProps) { const namespace = useSelector(selectCurrentNamespace); const [rolloutRuleType, setRolloutRuleType] = useState(RolloutType.THRESHOLD); - + const [createRollout] = useCreateRolloutMutation(); const handleSegmentSubmit = (values: RolloutFormValues) => { - return createRollout(namespace.key, flagKey, { - rank, - type: rolloutRuleType, - description: values.description, - segment: { - segmentKeys: values.segmentKeys?.map((s) => s.key), - segmentOperator: values.operator, - value: values.value === 'true' + return createRollout({ + namespaceKey: namespace.key, + flagKey, + values: { + rank, + type: rolloutRuleType, + description: values.description, + segment: { + segmentKeys: values.segmentKeys?.map((s) => s.key), + segmentOperator: values.operator, + value: values.value === 'true' + } } - }); + }).unwrap(); }; const handleThresholdSubmit = (values: RolloutFormValues) => { - return createRollout(namespace.key, flagKey, { - rank, - type: rolloutRuleType, - description: values.description, - threshold: { - percentage: values.percentage || 0, - value: values.value === 'true' + return createRollout({ + namespaceKey: namespace.key, + flagKey, + values: { + rank, + type: rolloutRuleType, + description: values.description, + threshold: { + percentage: values.percentage || 0, + value: values.value === 'true' + } } - }); + }).unwrap(); }; return ( diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index fbca87a06c..0ec2456dae 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { IAuthTokenBase } from '~/types/auth/Token'; import { FlagType, IFlagBase } from '~/types/Flag'; -import { IRolloutBase } from '~/types/Rollout'; import { IVariantBase } from '~/types/Variant'; export const apiURL = '/api/v1'; @@ -182,69 +181,6 @@ export async function copyFlag( await createVariant(to.namespaceKey, flag.key, variant); } } - -// rollouts -export async function listRollouts(namespaceKey: string, flagKey: string) { - return get(`/namespaces/${namespaceKey}/flags/${flagKey}/rollouts`); -} - -export async function createRollout( - namespaceKey: string, - flagKey: string, - values: IRolloutBase -) { - return post(`/namespaces/${namespaceKey}/flags/${flagKey}/rollouts`, values); -} - -export async function updateRollout( - namespaceKey: string, - flagKey: string, - rolloutId: string, - values: IRolloutBase -) { - return put( - `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/${rolloutId}`, - values - ); -} - -export async function deleteRollout( - namespaceKey: string, - flagKey: string, - rolloutId: string -) { - return del( - `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/${rolloutId}` - ); -} - -export async function orderRollouts( - namespaceKey: string, - flagKey: string, - rolloutIds: string[] -) { - const req = { - method: 'PUT', - headers: { - ...headerCsrf(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - rolloutIds: rolloutIds - }) - }; - - const res = await fetch( - `${apiURL}/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/order`, - req - ); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.message); - } - return res.ok; -} - // // variants export async function createVariant( diff --git a/ui/src/store.ts b/ui/src/store.ts index ed0e7a80c1..01b04106c1 100644 --- a/ui/src/store.ts +++ b/ui/src/store.ts @@ -5,6 +5,7 @@ import { } from '@reduxjs/toolkit'; import { flagsSlice } from './app/flags/flagsSlice'; +import { rolloutsApi } from './app/flags/rolloutsApi'; import { rulesApi } from './app/flags/rulesApi'; import { metaSlice } from './app/meta/metaSlice'; import { namespacesSlice } from './app/namespaces/namespacesSlice'; @@ -71,12 +72,17 @@ export const store = configureStore({ flags: flagsSlice.reducer, meta: metaSlice.reducer, [segmentsApi.reducerPath]: segmentsApi.reducer, - [rulesApi.reducerPath]: rulesApi.reducer + [rulesApi.reducerPath]: rulesApi.reducer, + [rolloutsApi.reducerPath]: rolloutsApi.reducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware() .prepend(listenerMiddleware.middleware) - .concat(segmentsApi.middleware, rulesApi.middleware) + .concat( + segmentsApi.middleware, + rulesApi.middleware, + rolloutsApi.middleware + ) }); // Infer the `RootState` and `AppDispatch` types from the store itself