diff --git a/ui/src/app/flags/rollouts/Rollouts.tsx b/ui/src/app/flags/rollouts/Rollouts.tsx index 222212058b..62b3087525 100644 --- a/ui/src/app/flags/rollouts/Rollouts.tsx +++ b/ui/src/app/flags/rollouts/Rollouts.tsx @@ -18,6 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; +import { useListSegmentsQuery } from '~/app/segments/segmentsApi'; import Button from '~/components/forms/buttons/Button'; import Modal from '~/components/Modal'; import DeletePanel from '~/components/panels/DeletePanel'; @@ -26,17 +27,12 @@ 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, - listSegments, - orderRollouts -} from '~/data/api'; +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 { ISegment, ISegmentList, SegmentOperatorType } from '~/types/Segment'; +import { SegmentOperatorType } from '~/types/Segment'; type RolloutsProps = { flag: IFlag; @@ -45,7 +41,6 @@ type RolloutsProps = { export default function Rollouts(props: RolloutsProps) { const { flag } = props; - const [segments, setSegments] = useState([]); const [rollouts, setRollouts] = useState([]); const [activeRollout, setActiveRollout] = useState(null); @@ -68,13 +63,10 @@ export default function Rollouts(props: RolloutsProps) { const namespace = useSelector(selectCurrentNamespace); const readOnly = useSelector(selectReadonly); + const segments = useListSegmentsQuery(namespace.key)?.data?.segments || []; const loadData = useCallback(async () => { // TODO: move to redux - const segmentList = (await listSegments(namespace.key)) as ISegmentList; - const { segments } = segmentList; - setSegments(segments); - const rolloutList = (await listRollouts( namespace.key, flag.key @@ -284,7 +276,7 @@ export default function Rollouts(props: RolloutsProps) {

{rollouts && rollouts.length > 0 && ( diff --git a/ui/src/app/flags/rules/Rules.tsx b/ui/src/app/flags/rules/Rules.tsx index ba76fce268..11ebf10f8a 100644 --- a/ui/src/app/flags/rules/Rules.tsx +++ b/ui/src/app/flags/rules/Rules.tsx @@ -14,12 +14,13 @@ import { verticalListSortingStrategy } from '@dnd-kit/sortable'; import { PlusIcon } from '@heroicons/react/24/outline'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { useNavigate, useOutletContext } from 'react-router-dom'; import { FlagProps } from '~/app/flags/FlagProps'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; +import { useListSegmentsQuery } from '~/app/segments/segmentsApi'; import EmptyState from '~/components/EmptyState'; import Button from '~/components/forms/buttons/Button'; import Modal from '~/components/Modal'; @@ -28,20 +29,19 @@ import RuleForm from '~/components/rules/forms/RuleForm'; import Rule from '~/components/rules/Rule'; import SortableRule from '~/components/rules/SortableRule'; import Slideover from '~/components/Slideover'; -import { deleteRule, listRules, listSegments, orderRules } from '~/data/api'; +import { deleteRule, listRules, orderRules } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { IDistribution } from '~/types/Distribution'; import { IEvaluatable } from '~/types/Evaluatable'; import { FlagType } from '~/types/Flag'; import { IRule, IRuleList } from '~/types/Rule'; -import { ISegment, ISegmentList, SegmentOperatorType } from '~/types/Segment'; +import { ISegment, SegmentOperatorType } from '~/types/Segment'; import { IVariant } from '~/types/Variant'; export default function Rules() { const { flag } = useOutletContext(); - const [segments, setSegments] = useState([]); const [rules, setRules] = useState([]); const [activeRule, setActiveRule] = useState(null); @@ -60,13 +60,14 @@ export default function Rules() { const namespace = useSelector(selectCurrentNamespace); const readOnly = useSelector(selectReadonly); + const listSegments = useListSegmentsQuery(namespace.key); + const segments = useMemo( + () => listSegments.data?.segments || [], + [listSegments] + ); const loadData = useCallback(async () => { // TODO: move to redux - const segmentList = (await listSegments(namespace.key)) as ISegmentList; - const { segments } = segmentList; - setSegments(segments); - const ruleList = (await listRules(namespace.key, flag.key)) as IRuleList; const rules = ruleList.rules.flatMap((rule: IRule) => { @@ -133,7 +134,7 @@ export default function Rules() { }); setRules(rules); - }, [namespace.key, flag]); + }, [namespace.key, flag, segments]); const incrementRulesVersion = () => { setRulesVersion(rulesVersion + 1); @@ -283,7 +284,7 @@ export default function Rules() {

{ @@ -75,9 +74,6 @@ export default function Segment() { let { segmentKey } = useParams(); const { inTimezone } = useTimezone(); - const [segment, setSegment] = useState(null); - const [segmentVersion, setSegmentVersion] = useState(0); - const [showConstraintForm, setShowConstraintForm] = useState(false); const [editingConstraint, setEditingConstraint] = useState(null); @@ -99,26 +95,27 @@ export default function Segment() { const namespace = useSelector(selectCurrentNamespace); const readOnly = useSelector(selectReadonly); - const incrementSegmentVersion = () => { - setSegmentVersion(segmentVersion + 1); - }; - - useEffect(() => { - if (!segmentKey) return; - - getSegment(namespace.key, segmentKey) - .then((segment: ISegment) => { - setSegment(segment); - clearError(); - }) - .catch((err) => { - setError(err); - }); - }, [segmentVersion, namespace.key, segmentKey, clearError, setError]); - + const { + data: segment, + error, + isLoading, + isError + } = useGetSegmentQuery({ + namespaceKey: namespace.key, + segmentKey: segmentKey || '' + }); + const [deleteSegment] = useDeleteSegmentMutation(); + const [deleteSegmentConstraint] = useDeleteConstraintMutation(); + const [copySegment] = useCopySegmentMutation(); const constraintFormRef = useRef(null); - if (!segment) return ; + if (isError) { + setError(error); + } + + if (isLoading || !segment) { + return ; + } return ( <> @@ -134,7 +131,7 @@ export default function Segment() { constraint={editingConstraint || undefined} setOpen={setShowConstraintForm} onSuccess={() => { - incrementSegmentVersion(); + clearError(); setShowConstraintForm(false); }} /> @@ -157,17 +154,15 @@ export default function Segment() { } panelType="Constraint" setOpen={setShowDeleteConstraintModal} - handleDelete={ - () => - deleteConstraint( - namespace.key, - segment.key, - deletingConstraint?.id ?? '' - ) // TODO: Determine impact of blank ID param + handleDelete={() => + deleteSegmentConstraint({ + namespaceKey: namespace.key, + segmentKey: segment.key, + constraintId: deletingConstraint?.id ?? '' + // TODO: Determine impact of blank ID param + }).unwrap() } - onSuccess={() => { - incrementSegmentVersion(); - }} + onSuccess={() => {}} /> @@ -183,7 +178,12 @@ export default function Segment() { } panelType="Segment" setOpen={setShowDeleteSegmentModal} - handleDelete={() => deleteSegment(namespace.key, segment.key)} + handleDelete={() => + deleteSegment({ + namespaceKey: namespace.key, + segmentKey: segment.key + }).unwrap() + } onSuccess={() => { navigate(`/namespaces/${namespace.key}/segments`); }} @@ -203,10 +203,10 @@ export default function Segment() { panelType="Segment" setOpen={setShowCopySegmentModal} handleCopy={(namespaceKey: string) => - copySegment( - { namespaceKey: namespace.key, key: segment.key }, - { namespaceKey: namespaceKey, key: segment.key } - ) + copySegment({ + from: { namespaceKey: namespace.key, segmentKey: segment.key }, + to: { namespaceKey: namespaceKey, segmentKey: segment.key } + }).unwrap() } onSuccess={() => { clearError(); @@ -279,10 +279,7 @@ export default function Segment() {
- +
diff --git a/ui/src/app/segments/Segments.tsx b/ui/src/app/segments/Segments.tsx index c9ffff2afc..09420a57f3 100644 --- a/ui/src/app/segments/Segments.tsx +++ b/ui/src/app/segments/Segments.tsx @@ -2,24 +2,21 @@ import { PlusIcon } from '@heroicons/react/24/outline'; import { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { Link, useNavigate } from 'react-router-dom'; -import useSWR from 'swr'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; +import { useListSegmentsQuery } from '~/app/segments/segmentsApi'; import EmptyState from '~/components/EmptyState'; import Button from '~/components/forms/buttons/Button'; import SegmentTable from '~/components/segments/SegmentTable'; import { useError } from '~/data/hooks/error'; -import { ISegmentList } from '~/types/Segment'; export default function Segments() { const namespace = useSelector(selectCurrentNamespace); const path = `/namespaces/${namespace.key}/segments`; - const { data, error } = useSWR(path); - - const segments = data?.segments; - + const { data, error } = useListSegmentsQuery(namespace.key); + const segments = data?.segments || []; const navigate = useNavigate(); const { setError, clearError } = useError(); diff --git a/ui/src/app/segments/segmentsApi.ts b/ui/src/app/segments/segmentsApi.ts new file mode 100644 index 0000000000..b0d1d2bff6 --- /dev/null +++ b/ui/src/app/segments/segmentsApi.ts @@ -0,0 +1,200 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; +import { IConstraintBase } from '~/types/Constraint'; +import { ISegment, ISegmentBase, ISegmentList } from '~/types/Segment'; +import { baseQuery } from '~/utils/redux-rtk'; + +export const segmentsApi = createApi({ + reducerPath: 'segments', + baseQuery, + tagTypes: ['Segment'], + endpoints: (builder) => ({ + // get list of segments in this namespace + listSegments: builder.query({ + query: (namespaceKey) => `/namespaces/${namespaceKey}/segments`, + providesTags: (result, _error, namespaceKey) => + result + ? [ + ...result.segments.map(({ key }) => ({ + type: 'Segment' as const, + id: namespaceKey + '/' + key + })), + { type: 'Segment', id: namespaceKey } + ] + : [{ type: 'Segment', id: namespaceKey }] + }), + // get segment in this namespace + getSegment: builder.query< + ISegment, + { namespaceKey: string; segmentKey: string } + >({ + query: ({ namespaceKey, segmentKey }) => + `/namespaces/${namespaceKey}/segments/${segmentKey}`, + providesTags: (_result, _error, { namespaceKey, segmentKey }) => [ + { type: 'Segment', id: namespaceKey + '/' + segmentKey } + ] + }), + // create a new segment in the namespace + createSegment: builder.mutation< + ISegment, + { namespaceKey: string; values: ISegmentBase } + >({ + query({ namespaceKey, values }) { + return { + url: `/namespaces/${namespaceKey}/segments`, + method: 'POST', + body: values + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, values }) => [ + { type: 'Segment', id: namespaceKey }, + { type: 'Segment', id: namespaceKey + '/' + values.key } + ] + }), + // delete the segment from the namespace + deleteSegment: builder.mutation< + void, + { namespaceKey: string; segmentKey: string } + >({ + query({ namespaceKey, segmentKey }) { + return { + url: `/namespaces/${namespaceKey}/segments/${segmentKey}`, + method: 'DELETE' + }; + }, + invalidatesTags: (_result, _error, { namespaceKey }) => [ + { type: 'Segment', id: namespaceKey } + ] + }), + // update the segment in the namespace + updateSegment: builder.mutation< + ISegment, + { namespaceKey: string; segmentKey: string; values: ISegmentBase } + >({ + query({ namespaceKey, segmentKey, values }) { + return { + url: `/namespaces/${namespaceKey}/segments/${segmentKey}`, + method: 'PUT', + body: values + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, segmentKey }) => [ + { type: 'Segment', id: namespaceKey }, + { type: 'Segment', id: namespaceKey + '/' + segmentKey } + ] + }), + // copy the segment from one namespace to another one + copySegment: builder.mutation< + void, + { + from: { namespaceKey: string; segmentKey: string }; + to: { namespaceKey: string; segmentKey: string }; + } + >({ + queryFn: async ({ from, to }, _api, _extraOptions, baseQuery) => { + let resp = await baseQuery({ + url: `/namespaces/${from.namespaceKey}/segments/${from.segmentKey}`, + method: 'get' + }); + if (resp.error) { + return { error: resp.error }; + } + let data = resp.data as ISegment; + + if (to.segmentKey) { + data.key = to.segmentKey; + } + // first create the segment + resp = await baseQuery({ + url: `/namespaces/${to.namespaceKey}/segments`, + method: 'POST', + body: data + }); + if (resp.error) { + return { error: resp.error }; + } + // then copy the constraints + const constraints = data.constraints || []; + for (let constraint of constraints) { + resp = await baseQuery({ + url: `/namespaces/${to.namespaceKey}/segments/${to.segmentKey}/constraints`, + method: 'POST', + body: constraint + }); + if (resp.error) { + return { error: resp.error }; + } + } + + return { data: undefined }; + }, + invalidatesTags: (_result, _error, { to }) => [ + { type: 'Segment', id: to.namespaceKey } + ] + }), + + // create the segment constraint in the namespace + createConstraint: builder.mutation< + void, + { namespaceKey: string; segmentKey: string; values: IConstraintBase } + >({ + query({ namespaceKey, segmentKey, values }) { + return { + url: `/namespaces/${namespaceKey}/segments/${segmentKey}/constraints`, + method: 'POST', + body: values + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, segmentKey }) => [ + { type: 'Segment', id: namespaceKey + '/' + segmentKey } + ] + }), + // update the segment constraint in the namespace + updateConstraint: builder.mutation< + void, + { + namespaceKey: string; + segmentKey: string; + constraintId: string; + values: IConstraintBase; + } + >({ + query({ namespaceKey, segmentKey, constraintId, values }) { + return { + url: `/namespaces/${namespaceKey}/segments/${segmentKey}/constraints/${constraintId}`, + method: 'PUT', + body: values + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, segmentKey }) => [ + { type: 'Segment', id: namespaceKey + '/' + segmentKey } + ] + }), + // delete the segment constraint in the namespace + deleteConstraint: builder.mutation< + void, + { namespaceKey: string; segmentKey: string; constraintId: string } + >({ + query({ namespaceKey, segmentKey, constraintId }) { + return { + url: `/namespaces/${namespaceKey}/segments/${segmentKey}/constraints/${constraintId}`, + method: 'DELETE' + }; + }, + invalidatesTags: (_result, _error, { namespaceKey, segmentKey }) => [ + { type: 'Segment', id: namespaceKey + '/' + segmentKey } + ] + }) + }) +}); + +export const { + useListSegmentsQuery, + useGetSegmentQuery, + useCreateSegmentMutation, + useDeleteSegmentMutation, + useUpdateSegmentMutation, + useCopySegmentMutation, + useCreateConstraintMutation, + useUpdateConstraintMutation, + useDeleteConstraintMutation +} = segmentsApi; diff --git a/ui/src/components/segments/ConstraintForm.tsx b/ui/src/components/segments/ConstraintForm.tsx index 11bc73f275..59d6e04a0f 100644 --- a/ui/src/components/segments/ConstraintForm.tsx +++ b/ui/src/components/segments/ConstraintForm.tsx @@ -9,12 +9,15 @@ import { Link } from 'react-router-dom'; import * as Yup from 'yup'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import { selectTimezone } from '~/app/preferences/preferencesSlice'; +import { + useCreateConstraintMutation, + useUpdateConstraintMutation +} from '~/app/segments/segmentsApi'; import Button from '~/components/forms/buttons/Button'; import Input from '~/components/forms/Input'; import Select from '~/components/forms/Select'; import Loading from '~/components/Loading'; import MoreInfo from '~/components/MoreInfo'; -import { createConstraint, updateConstraint } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { @@ -266,11 +269,23 @@ const ConstraintForm = forwardRef((props: ConstraintFormProps, ref: any) => { description: constraint?.description || '' }; + const [createConstraint] = useCreateConstraintMutation(); + const [updateConstraint] = useUpdateConstraintMutation(); + const handleSubmit = (values: IConstraintBase) => { if (isNew) { - return createConstraint(namespace.key, segmentKey, values); + return createConstraint({ + namespaceKey: namespace.key, + segmentKey, + values + }).unwrap(); } - return updateConstraint(namespace.key, segmentKey, constraint?.id, values); + return updateConstraint({ + namespaceKey: namespace.key, + segmentKey, + constraintId: constraint?.id, + values + }).unwrap(); }; const getValuePlaceholder = (type: ConstraintType, operator: string) => { diff --git a/ui/src/components/segments/SegmentForm.tsx b/ui/src/components/segments/SegmentForm.tsx index 6eefe5f8cf..ce7d21bcd9 100644 --- a/ui/src/components/segments/SegmentForm.tsx +++ b/ui/src/components/segments/SegmentForm.tsx @@ -6,10 +6,13 @@ import { useNavigate } from 'react-router-dom'; import * as Yup from 'yup'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; +import { + useCreateSegmentMutation, + useUpdateSegmentMutation +} from '~/app/segments/segmentsApi'; import Button from '~/components/forms/buttons/Button'; import Input from '~/components/forms/Input'; import Loading from '~/components/Loading'; -import { createSegment, updateSegment } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { keyValidation, requiredValidation } from '~/data/validations'; @@ -47,11 +50,18 @@ export default function SegmentForm(props: SegmentFormProps) { const namespace = useSelector(selectCurrentNamespace); const readOnly = useSelector(selectReadonly); + const [createSegment] = useCreateSegmentMutation(); + const [updateSegment] = useUpdateSegmentMutation(); + const handleSubmit = (values: ISegmentBase) => { if (isNew) { - return createSegment(namespace.key, values); + return createSegment({ namespaceKey: namespace.key, values }).unwrap(); } - return updateSegment(namespace.key, segment?.key, values); + return updateSegment({ + namespaceKey: namespace.key, + segmentKey: segment?.key, + values + }).unwrap(); }; const initialValues: ISegmentBase = { diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index aef1818945..9c1f12e0e1 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -1,14 +1,12 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { IAuthTokenBase } from '~/types/auth/Token'; -import { IConstraintBase } from '~/types/Constraint'; import { IDistributionBase } from '~/types/Distribution'; import { FlagType, IFlagBase } from '~/types/Flag'; import { IRolloutBase } from '~/types/Rollout'; import { IRuleBase } from '~/types/Rule'; -import { ISegmentBase } from '~/types/Segment'; import { IVariantBase } from '~/types/Variant'; -const apiURL = '/api/v1'; +export const apiURL = '/api/v1'; const authURL = '/auth/v1'; const evaluateURL = '/evaluate/v1'; const metaURL = '/meta'; @@ -26,34 +24,46 @@ export class APIError extends Error { // // base methods -function setCsrf(req: any) { +function headerCsrf(): Record { const csrfToken = window.localStorage.getItem(csrfTokenHeaderKey); if (csrfToken !== null) { - req.headers[csrfTokenHeaderKey] = csrfToken; + return { 'x-csrf-token': csrfToken }; } + return {}; +} +// TODO: find a better name for this +export function checkResponse(response: Response) { + if (!response.ok) { + if (response.status === 401) { + window.localStorage.removeItem(csrfTokenHeaderKey); + window.localStorage.removeItem(sessionKey); + window.location.reload(); + } + } +} - return req; +export function defaultHeaders(): Record { + const headers = { + ...headerCsrf(), + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Cache-Control': 'no-store' + }; + + return headers; } export async function request(method: string, uri: string, body?: any) { - const req = setCsrf({ + const req = { method, - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'Cache-Control': 'no-store' - }, + headers: defaultHeaders(), body: JSON.stringify(body) - }); + }; const res = await fetch(uri, req); - if (!res.ok) { - if (res.status === 401) { - window.localStorage.removeItem(csrfTokenHeaderKey); - window.localStorage.removeItem(sessionKey); - window.location.reload(); - } + if (!res.ok) { + checkResponse(res); const contentType = res.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { @@ -64,7 +74,6 @@ export async function request(method: string, uri: string, body?: any) { let err = await res.json(); throw new APIError(err.message, res.status); } - return res.json(); } @@ -216,15 +225,16 @@ export async function orderRollouts( flagKey: string, rolloutIds: string[] ) { - const req = setCsrf({ + 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`, @@ -276,15 +286,16 @@ export async function orderRules( flagKey: string, ruleIds: string[] ) { - const req = setCsrf({ + const req = { method: 'PUT', headers: { + ...headerCsrf(), 'Content-Type': 'application/json' }, body: JSON.stringify({ ruleIds }) - }); + }; const res = await fetch( `${apiURL}/namespaces/${namespaceKey}/flags/${flagKey}/rules/order`, @@ -354,90 +365,6 @@ export async function deleteVariant( ); } -// -// segments -export async function listSegments(namespaceKey: string) { - return get(`/namespaces/${namespaceKey}/segments`); -} - -export async function getSegment(namespaceKey: string, key: string) { - return get(`/namespaces/${namespaceKey}/segments/${key}`); -} - -export async function createSegment( - namespaceKey: string, - values: ISegmentBase -) { - return post(`/namespaces/${namespaceKey}/segments`, values); -} - -export async function updateSegment( - namespaceKey: string, - key: string, - values: ISegmentBase -) { - return put(`/namespaces/${namespaceKey}/segments/${key}`, values); -} - -export async function deleteSegment(namespaceKey: string, key: string) { - return del(`/namespaces/${namespaceKey}/segments/${key}`); -} - -export async function copySegment( - from: { namespaceKey: string; key: string }, - to: { namespaceKey: string; key?: string } -) { - let segment = await get( - `/namespaces/${from.namespaceKey}/segments/${from.key}` - ); - if (to.key) { - segment.key = to.key; - } - - // first create the segment - await post(`/namespaces/${to.namespaceKey}/segments`, segment); - - // then copy the constraints - for (let constraint of segment.constraints) { - await createConstraint(to.namespaceKey, segment.key, constraint); - } -} - -// -// constraints -export async function createConstraint( - namespaceKey: string, - segmentKey: string, - values: IConstraintBase -) { - return post( - `/namespaces/${namespaceKey}/segments/${segmentKey}/constraints`, - values - ); -} - -export async function updateConstraint( - namespaceKey: string, - segmentKey: string, - constraintId: string, - values: IConstraintBase -) { - return put( - `/namespaces/${namespaceKey}/segments/${segmentKey}/constraints/${constraintId}`, - values - ); -} - -export async function deleteConstraint( - namespaceKey: string, - segmentKey: string, - constraintId: string -) { - return del( - `/namespaces/${namespaceKey}/segments/${segmentKey}/constraints/${constraintId}` - ); -} - // // evaluate export async function evaluate( diff --git a/ui/src/store.ts b/ui/src/store.ts index c69f4650ba..909cb4a728 100644 --- a/ui/src/store.ts +++ b/ui/src/store.ts @@ -11,6 +11,7 @@ import { preferencesKey, preferencesSlice } from './app/preferences/preferencesSlice'; +import { segmentsApi } from './app/segments/segmentsApi'; import { LoadingStatus } from './types/Meta'; const listenerMiddleware = createListenerMiddleware(); @@ -67,10 +68,13 @@ export const store = configureStore({ namespaces: namespacesSlice.reducer, preferences: preferencesSlice.reducer, flags: flagsSlice.reducer, - meta: metaSlice.reducer + meta: metaSlice.reducer, + [segmentsApi.reducerPath]: segmentsApi.reducer }, middleware: (getDefaultMiddleware) => - getDefaultMiddleware().prepend(listenerMiddleware.middleware) + getDefaultMiddleware() + .prepend(listenerMiddleware.middleware) + .concat(segmentsApi.middleware) }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/ui/src/utils/helpers.ts b/ui/src/utils/helpers.ts index 74b843383e..0ac59a8aba 100644 --- a/ui/src/utils/helpers.ts +++ b/ui/src/utils/helpers.ts @@ -58,9 +58,23 @@ function isErrorWithMessage(error: unknown): error is ErrorWithMessage { ); } +function isFetchBaseQueryError(error: unknown): error is ErrorWithMessage { + return ( + typeof error === 'object' && + error !== null && + 'data' in error && + error.data !== null && + typeof (error.data as Record).message === 'string' + ); +} + function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { if (isErrorWithMessage(maybeError)) return maybeError; - + // handle Redux FetchBaseQueryError + if (isFetchBaseQueryError(maybeError)) { + // @ts-ignore + return maybeError.data; + } try { return new Error(JSON.stringify(maybeError)); } catch { diff --git a/ui/src/utils/redux-rtk.ts b/ui/src/utils/redux-rtk.ts new file mode 100644 index 0000000000..d02e3c8501 --- /dev/null +++ b/ui/src/utils/redux-rtk.ts @@ -0,0 +1,23 @@ +import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { apiURL, checkResponse, defaultHeaders } from '~/data/api'; + +type CustomFetchFn = ( + url: RequestInfo, + options: RequestInit | undefined +) => Promise; + +const customFetchFn: CustomFetchFn = async (url, options) => { + const headers = defaultHeaders(); + + const response = await fetch(url, { + ...options, + headers + }); + checkResponse(response); + return response; +}; + +export const baseQuery = fetchBaseQuery({ + baseUrl: apiURL, + fetchFn: customFetchFn +});