Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(ui): move Rules to use Redux for storage #2461

Merged
merged 2 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 39 additions & 45 deletions ui/src/app/flags/rules/Rules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,39 +14,40 @@ import {
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { PlusIcon } from '@heroicons/react/24/outline';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { useOutletContext } from 'react-router-dom';
import { FlagProps } from '~/app/flags/FlagProps';
import {
useDeleteRuleMutation,
useListRulesQuery,
useOrderRulesMutation
} from '~/app/flags/rulesApi';
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 Loading from '~/components/Loading';
import Modal from '~/components/Modal';
import DeletePanel from '~/components/panels/DeletePanel';
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, 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 { IRule } from '~/types/Rule';
import { ISegment, SegmentOperatorType } from '~/types/Segment';
import { IVariant } from '~/types/Variant';

export default function Rules() {
const { flag } = useOutletContext<FlagProps>();

const [rules, setRules] = useState<IEvaluatable[]>([]);

const [activeRule, setActiveRule] = useState<IEvaluatable | null>(null);

const [rulesVersion, setRulesVersion] = useState(0);
const [showRuleForm, setShowRuleForm] = useState<boolean>(false);

const [showDeleteRuleModal, setShowDeleteRuleModal] =
Expand All @@ -56,21 +57,26 @@ export default function Rules() {
const { setError, clearError } = useError();
const { setSuccess } = useSuccess();

const navigate = useNavigate();

const namespace = useSelector(selectCurrentNamespace);
const readOnly = useSelector(selectReadonly);
const listSegments = useListSegmentsQuery(namespace.key);
const segmentsList = useListSegmentsQuery(namespace.key);
const segments = useMemo(
() => listSegments.data?.segments || [],
[listSegments]
() => segmentsList.data?.segments || [],
[segmentsList]
);

const loadData = useCallback(async () => {
// TODO: move to redux
const ruleList = (await listRules(namespace.key, flag.key)) as IRuleList;
const [deleteRule] = useDeleteRuleMutation();
const [orderRules] = useOrderRulesMutation();

const rules = ruleList.rules.flatMap((rule: IRule) => {
const rulesList = useListRulesQuery({
namespaceKey: namespace.key,
flagKey: flag.key
});

const ruleList = useMemo(() => rulesList.data?.rules || [], [rulesList]);

const rules = useMemo(() => {
return ruleList.flatMap((rule: IRule) => {
const rollouts = rule.distributions.flatMap(
(distribution: IDistribution) => {
const variant = flag?.variants?.find(
Expand Down Expand Up @@ -132,13 +138,7 @@ export default function Rules() {
updatedAt: rule.updatedAt
};
});

setRules(rules);
}, [namespace.key, flag, segments]);

const incrementRulesVersion = () => {
setRulesVersion(rulesVersion + 1);
};
}, [flag, segments, ruleList]);

const sensors = useSensors(
useSensor(PointerSensor),
Expand All @@ -148,13 +148,13 @@ export default function Rules() {
);

const reorderRules = (rules: IEvaluatable[]) => {
orderRules(
namespace.key,
flag.key,
rules.map((rule) => rule.id)
)
orderRules({
namespaceKey: namespace.key,
flagKey: flag.key,
ruleIds: rules.map((rule) => rule.id)
})
.unwrap()
.then(() => {
incrementRulesVersion();
clearError();
setSuccess('Successfully reordered rules');
})
Expand All @@ -177,7 +177,6 @@ export default function Rules() {
})(rules);

reorderRules(reordered);
setRules(reordered);
}

setActiveRule(null);
Expand All @@ -193,16 +192,9 @@ export default function Rules() {
}
};

useEffect(() => {
loadData();
}, [loadData, rulesVersion]);

useEffect(() => {
if (flag.type === FlagType.BOOLEAN) {
setError('Boolean flags do not support evaluation rules');
navigate(`/namespaces/${namespace.key}/flags/${flag.key}`);
}
}, [flag.type, navigate, setError, namespace.key, flag.key]);
if (segmentsList.isLoading || rulesList.isLoading) {
return <Loading />;
}

return (
<>
Expand All @@ -222,9 +214,12 @@ export default function Rules() {
panelType="Rule"
setOpen={setShowDeleteRuleModal}
handleDelete={() =>
deleteRule(namespace.key, flag.key, deletingRule?.id ?? '')
deleteRule({
namespaceKey: namespace.key,
flagKey: flag.key,
ruleId: deletingRule?.id ?? ''
}).unwrap()
}
onSuccess={incrementRulesVersion}
/>
</Modal>

Expand All @@ -236,7 +231,6 @@ export default function Rules() {
segments={segments}
setOpen={setShowRuleForm}
onSuccess={() => {
incrementRulesVersion();
setShowRuleForm(false);
}}
/>
Expand Down Expand Up @@ -306,11 +300,11 @@ export default function Rules() {
flag={flag}
rule={rule}
segments={segments}
onSuccess={incrementRulesVersion}
onDelete={() => {
setDeletingRule(rule);
setShowDeleteRuleModal(true);
}}
onSuccess={clearError}
readOnly={readOnly}
/>
))}
Expand Down
187 changes: 187 additions & 0 deletions ui/src/app/flags/rulesApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { IDistributionBase } from '~/types/Distribution';
import { IRule, IRuleBase, IRuleList } from '~/types/Rule';

import { baseQuery } from '~/utils/redux-rtk';

export const rulesApi = createApi({
reducerPath: 'rules',
baseQuery,
tagTypes: ['Rule'],
endpoints: (builder) => ({
// get list of rules
listRules: builder.query<
IRuleList,
{ namespaceKey: string; flagKey: string }
>({
query: ({ namespaceKey, flagKey }) =>
`/namespaces/${namespaceKey}/flags/${flagKey}/rules`,
providesTags: (_result, _error, { namespaceKey, flagKey }) => [
{ type: 'Rule', id: namespaceKey + '/' + flagKey }
]
}),
// create a new rule
createRule: builder.mutation<
IRule,
{
namespaceKey: string;
flagKey: string;
values: IRuleBase;
distributions: IDistributionBase[];
}
>({
queryFn: async (
{ namespaceKey, flagKey, values, distributions },
_api,
_extraOptions,
baseQuery
) => {
const respRule = await baseQuery({
url: `/namespaces/${namespaceKey}/flags/${flagKey}/rules`,
method: 'POST',
body: values
});
if (respRule.error) {
return { error: respRule.error };
}
const rule = respRule.data as IRule;
const ruleId = rule.id;
// then create the distributions
for (let distribution of distributions) {
const resp = await baseQuery({
url: `/namespaces/${namespaceKey}/flags/${flagKey}/rules/${ruleId}/distributions`,
method: 'POST',
body: distribution
});
if (resp.error) {
return { error: resp.error };
}
}
return { data: rule };
},
invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [
{ type: 'Rule', id: namespaceKey + '/' + flagKey }
]
}),
// delete the rule
deleteRule: builder.mutation<
void,
{ namespaceKey: string; flagKey: string; ruleId: string }
>({
query({ namespaceKey, flagKey, ruleId }) {
return {
url: `/namespaces/${namespaceKey}/flags/${flagKey}/rules/${ruleId}`,
method: 'DELETE'
};
},
invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [
{ type: 'Rule', id: namespaceKey + '/' + flagKey }
]
}),
// update the rule
updateRule: builder.mutation<
IRule,
{
namespaceKey: string;
flagKey: string;
ruleId: string;
values: IRuleBase;
}
>({
query({ namespaceKey, flagKey, ruleId, values }) {
return {
url: `/namespaces/${namespaceKey}/flags/${flagKey}/rules/${ruleId}`,
method: 'PUT',
body: values
};
},
invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [
{ type: 'Rule', id: namespaceKey + '/' + flagKey }
]
}),
// reorder the rules
orderRules: builder.mutation<
IRule,
{
namespaceKey: string;
flagKey: string;
ruleIds: string[];
}
>({
query({ namespaceKey, flagKey, ruleIds }) {
return {
url: `/namespaces/${namespaceKey}/flags/${flagKey}/rules/order`,
method: 'PUT',
body: { ruleIds: ruleIds }
};
},
async onQueryStarted(
{ namespaceKey, flagKey, ruleIds },
{ dispatch, queryFulfilled }
) {
// this is manual optimistic cache update of the listRules
// to set a desire order of rules of the listRules 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(
rulesApi.util.updateQueryData(
'listRules',
{ namespaceKey, flagKey },
(draft: IRuleList) => {
const rules = draft.rules;
const resortedRules = rules.sort((a, b) => {
const ida = ruleIds.indexOf(a.id);
const idb = ruleIds.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: 'Rule', id: namespaceKey + '/' + flagKey }
]
}),
// update the dustribution
updateDistribution: builder.mutation<
IRule,
{
namespaceKey: string;
flagKey: string;
ruleId: string;
distributionId: string;
values: IDistributionBase;
}
>({
query({ namespaceKey, flagKey, ruleId, distributionId, values }) {
return {
url: `/namespaces/${namespaceKey}/flags/${flagKey}/rules/${ruleId}/distributions/${distributionId}`,
method: 'PUT',
body: values
};
},
invalidatesTags: (_result, _error, { namespaceKey, flagKey }) => [
{ type: 'Rule', id: namespaceKey + '/' + flagKey }
]
})
})
});
export const {
useListRulesQuery,
useCreateRuleMutation,
useDeleteRuleMutation,
useUpdateRuleMutation,
useOrderRulesMutation,
useUpdateDistributionMutation
} = rulesApi;
Loading