Skip to content

Commit

Permalink
feat: eval rollout segment ui (#1839)
Browse files Browse the repository at this point in the history
* feat(wip): start

* feat(ui): finish segment rollout creation form
  • Loading branch information
markphelps authored Jul 10, 2023
1 parent 9339254 commit 64dbe40
Show file tree
Hide file tree
Showing 17 changed files with 145 additions and 76 deletions.
6 changes: 2 additions & 4 deletions ui/src/app/console/Console.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as Yup from 'yup';
import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice';
import EmptyState from '~/components/EmptyState';
import Button from '~/components/forms/buttons/Button';
import Combobox, { IFilterable } from '~/components/forms/Combobox';
import Combobox from '~/components/forms/Combobox';
import Input from '~/components/forms/Input';
import TextArea from '~/components/forms/TextArea';
import { evaluate, listFlags } from '~/data/api';
Expand All @@ -21,14 +21,12 @@ import {
requiredValidation
} from '~/data/validations';
import { IConsole } from '~/types/Console';
import { IFlag, IFlagList } from '~/types/Flag';
import { FilterableFlag, IFlagList } from '~/types/Flag';
import { INamespace } from '~/types/Namespace';
import { classNames } from '~/utils/helpers';

hljs.registerLanguage('json', javascript);

type FilterableFlag = IFlag & IFilterable;

function ResetOnNamespaceChange({ namespace }: { namespace: INamespace }) {
const { resetForm } = useFormikContext();

Expand Down
2 changes: 1 addition & 1 deletion ui/src/app/flags/EditFlag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function EditFlag() {
<Variants flag={flag} flagChanged={onFlagChange} />
)}
{flagTypeToLabel(flag.type) === FlagType.BOOLEAN_FLAG_TYPE && (
<Rollouts flag={flag} flagChanged={onFlagChange} />
<Rollouts flag={flag} />
)}
</div>
</>
Expand Down
20 changes: 12 additions & 8 deletions ui/src/app/flags/rollouts/Rollouts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ import Modal from '~/components/Modal';
import DeletePanel from '~/components/panels/DeletePanel';
import RolloutForm from '~/components/rollouts/RolloutForm';
import Slideover from '~/components/Slideover';
import { deleteRollout, listRollouts } from '~/data/api';
import { deleteRollout, listRollouts, listSegments } from '~/data/api';
import { IFlag } from '~/types/Flag';
import { IRollout, IRolloutList } from '~/types/Rollout';
import { ISegment, ISegmentList } from '~/types/Segment';

type RolloutsProps = {
flag: IFlag;
flagChanged: () => void;
};

export default function Rollouts(props: RolloutsProps) {
const { flag, flagChanged } = props;
const { flag } = props;

const [segments, setSegments] = useState<ISegment[]>([]);
const [rollouts, setRollouts] = useState<IRollout[]>([]);

const [rolloutsVersion, setRolloutsVersion] = useState(0);
Expand All @@ -37,7 +38,10 @@ export default function Rollouts(props: RolloutsProps) {
const readOnly = useSelector(selectReadonly);

const loadData = useCallback(async () => {
// TODO: load segments
// TODO: move to redux
const segmentList = (await listSegments(namespace.key)) as ISegmentList;
const { segments } = segmentList;
setSegments(segments);

const rolloutList = (await listRollouts(
namespace.key,
Expand All @@ -64,14 +68,14 @@ export default function Rollouts(props: RolloutsProps) {
ref={rolloutFormRef}
>
<RolloutForm
flag={flag}
rank={(rollouts?.length || 0) + 1}
flagKey={flag.key}
segments={segments}
rollout={editingRollout || undefined}
rank={(rollouts?.length || 0) + 1}
setOpen={setShowRolloutForm}
onSuccess={() => {
setShowRolloutForm(false);
incrementRolloutsVersion();
flagChanged();
}}
/>
</Slideover>
Expand All @@ -95,7 +99,7 @@ export default function Rollouts(props: RolloutsProps) {
() =>
deleteRollout(namespace.key, flag.key, deletingRollout?.id ?? '') // TODO: Determine impact of blank ID param
}
onSuccess={flagChanged}
onSuccess={incrementRolloutsVersion}
/>
</Modal>

Expand Down
7 changes: 1 addition & 6 deletions ui/src/components/forms/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Combobox as C } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline';
import { useField } from 'formik';
import { useState } from 'react';
import { IFilterable } from '~/types/Selectable';
import { classNames } from '~/utils/helpers';
import { ISelectable } from './Listbox';

type ComboboxProps<T extends IFilterable> = {
id: string;
Expand All @@ -16,11 +16,6 @@ type ComboboxProps<T extends IFilterable> = {
className?: string;
};

export interface IFilterable extends ISelectable {
status?: 'active' | 'inactive';
filterValue: string;
}

export default function Combobox<T extends IFilterable>(
props: ComboboxProps<T>
) {
Expand Down
6 changes: 1 addition & 5 deletions ui/src/components/forms/Listbox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Listbox as L, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { Fragment } from 'react';
import { ISelectable } from '~/types/Selectable';
import { classNames } from '~/utils/helpers';

type ListBoxProps<T extends ISelectable> = {
Expand All @@ -13,11 +14,6 @@ type ListBoxProps<T extends ISelectable> = {
className?: string;
};

export interface ISelectable {
key: string;
displayValue: string;
}

export default function Listbox<T extends ISelectable>(props: ListBoxProps<T>) {
const { id, name, values, selected, setSelected, disabled, className } =
props;
Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/namespaces/NamespaceListbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import {
selectCurrentNamespace,
selectNamespaces
} from '~/app/namespaces/namespacesSlice';
import Listbox, { ISelectable } from '~/components/forms/Listbox';
import Listbox from '~/components/forms/Listbox';
import { useAppDispatch } from '~/data/hooks/store';
import { INamespace } from '~/types/Namespace';
import { ISelectable } from '~/types/Selectable';
import { addNamespaceToPath } from '~/utils/helpers';

export type SelectableNamespace = Pick<INamespace, 'key' | 'name'> &
Expand Down
57 changes: 44 additions & 13 deletions ui/src/components/rollouts/RolloutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import MoreInfo from '~/components/MoreInfo';
import { createRollout } from '~/data/api';
import { useError } from '~/data/hooks/error';
import { useSuccess } from '~/data/hooks/success';
import { IFlag } from '~/types/Flag';
import { IRollout, RolloutType } from '~/types/Rollout';
import { FilterableSegment, ISegment } from '~/types/Segment';
import SegmentRuleFormInputs from './rules/SegmentRuleForm';
import ThresholdRuleFormInputs from './rules/ThresholdRuleForm';

const rolloutRuleTypeSegment = 'SEGMENT_ROLLOUT_TYPE';
Expand All @@ -35,20 +36,22 @@ const rolloutRuleTypes = [
type RolloutFormProps = {
setOpen: (open: boolean) => void;
onSuccess: () => void;
flag: IFlag;
flagKey: string;
rollout?: IRollout;
segments: ISegment[];
rank: number;
};

interface RolloutFormValues {
type: string;
description?: string;
segmentKey?: string;
percentage?: number;
value: string;
}

export default function RolloutForm(props: RolloutFormProps) {
const { setOpen, onSuccess, flag, rank } = props;
const { setOpen, onSuccess, flagKey, segments, rank } = props;

const { setError, clearError } = useError();
const { setSuccess } = useSuccess();
Expand All @@ -58,9 +61,23 @@ export default function RolloutForm(props: RolloutFormProps) {
const [rolloutRuleType, setRolloutRuleType] = useState(
rolloutRuleTypeThreshold
);
const [selectedSegment, setSelectedSegment] =
useState<FilterableSegment | null>(null);

const handleSegmentSubmit = (values: RolloutFormValues) => {
return createRollout(namespace.key, flagKey, {
rank,
type: rolloutRuleType as RolloutType,
description: values.description,
segment: {
segmentKey: values.segmentKey || '',
value: values.value === 'true'
}
});
};

const handleThresholdSubmit = (values: RolloutFormValues) => {
return createRollout(namespace.key, flag.key, {
return createRollout(namespace.key, flagKey, {
rank,
type: rolloutRuleType as RolloutType,
description: values.description,
Expand All @@ -71,19 +88,26 @@ export default function RolloutForm(props: RolloutFormProps) {
});
};

const initialValues: RolloutFormValues = {
type: rolloutRuleType,
description: '',
percentage: 50, // TODO: make this 0?
value: 'true'
};

return (
<Formik
enableReinitialize
initialValues={initialValues}
initialValues={{
type: rolloutRuleType,
description: '',
segmentKey: '',
percentage: 50, // TODO: make this 0?
value: 'true'
}}
onSubmit={(values, { setSubmitting }) => {
handleThresholdSubmit(values)
let handleSubmit = async (_values: RolloutFormValues) => {};

if (rolloutRuleType === rolloutRuleTypeSegment) {
handleSubmit = handleSegmentSubmit;
} else if (rolloutRuleType === rolloutRuleTypeThreshold) {
handleSubmit = handleThresholdSubmit;
}

handleSubmit(values)
.then(() => {
onSuccess();
clearError();
Expand Down Expand Up @@ -179,6 +203,13 @@ export default function RolloutForm(props: RolloutFormProps) {
{rolloutRuleType === rolloutRuleTypeThreshold && (
<ThresholdRuleFormInputs />
)}
{rolloutRuleType === rolloutRuleTypeSegment && (
<SegmentRuleFormInputs
segments={segments}
selectedSegment={selectedSegment}
setSelectedSegment={setSelectedSegment}
/>
)}
<div className="space-y-1 px-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:space-y-0 sm:px-6 sm:py-5">
<label
htmlFor="value"
Expand Down
44 changes: 44 additions & 0 deletions ui/src/components/rollouts/rules/SegmentRuleForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Combobox from '~/components/forms/Combobox';
import { FilterableSegment, ISegment } from '~/types/Segment';
import { truncateKey } from '~/utils/helpers';

type SegmentRuleFormInputsProps = {
segments: ISegment[];
selectedSegment: FilterableSegment | null;
setSelectedSegment: (v: FilterableSegment | null) => void;
};

export default function SegmentRuleFormInputs(
props: SegmentRuleFormInputsProps
) {
const { segments, selectedSegment, setSelectedSegment } = props;

return (
<>
<div className="space-y-1 px-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:space-y-0 sm:px-6 sm:py-5">
<div>
<label
htmlFor="segmentKey"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Segment
</label>
</div>
<div className="sm:col-span-2">
<Combobox<FilterableSegment>
id="segmentKey"
name="segmentKey"
placeholder="Select or search for a segment"
values={segments.map((s) => ({
...s,
filterValue: truncateKey(s.key),
displayValue: s.name
}))}
selected={selectedSegment}
setSelected={setSelectedSegment}
/>
</div>
</div>
</>
);
}
3 changes: 1 addition & 2 deletions ui/src/components/rollouts/rules/ThresholdRuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import Input from '~/components/forms/Input';

interface ThresholdRuleFormInputsFields {
percentage: number;
value: boolean;
}

export default function InnerThresholdRuleFormInputs() {
export default function ThresholdRuleFormInputs() {
const { values, setFieldValue } =
useFormikContext<ThresholdRuleFormInputsFields>();
return (
Expand Down
29 changes: 9 additions & 20 deletions ui/src/components/rules/EditRuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,18 @@ import MoreInfo from '~/components/MoreInfo';
import { updateDistribution } from '~/data/api';
import { useError } from '~/data/hooks/error';
import { useSuccess } from '~/data/hooks/success';
import { IEvaluatable, IRollout } from '~/types/Evaluatable';
import { FilterableSegment, FilterableVariant } from './RuleForm';
import { IEvaluatable, IVariantRollout } from '~/types/Evaluatable';
import { FilterableSegment } from '~/types/Segment';
import { FilterableVariant } from '~/types/Variant';
import { distTypeMulti, distTypes, distTypeSingle } from './RuleForm';

type RuleFormProps = {
setOpen: (open: boolean) => void;
rule: IEvaluatable;
onSuccess: () => void;
};

const distTypes = [
{
id: 'single',
name: 'Single Variant',
description: 'Always returns the same variant'
},
{
id: 'multi',
name: 'Multi-Variant',
description: 'Returns different variants based on percentages'
}
];

const validRollout = (rollouts: IRollout[]): boolean => {
export const validRollout = (rollouts: IVariantRollout[]): boolean => {
const sum = rollouts.reduce(function (acc, d) {
return acc + Number(d.distribution.rollout);
}, 0);
Expand All @@ -55,11 +44,11 @@ export default function EditRuleForm(props: RuleFormProps) {
const [editingRule, setEditingRule] = useState<IEvaluatable>(cloneDeep(rule));

const [ruleType, setRuleType] = useState(
editingRule.rollouts.length > 1 ? 'multi' : 'single'
editingRule.rollouts.length > 1 ? distTypeMulti : distTypeSingle
);

useEffect(() => {
if (ruleType === 'multi' && !validRollout(editingRule.rollouts)) {
if (ruleType === distTypeMulti && !validRollout(editingRule.rollouts)) {
setDistributionsValid(false);
} else {
setDistributionsValid(true);
Expand Down Expand Up @@ -214,7 +203,7 @@ export default function EditRuleForm(props: RuleFormProps) {
</div>
</div>

{ruleType === 'single' && (
{ruleType === distTypeSingle && (
<div className="space-y-1 px-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:space-y-0 sm:px-6 sm:py-5">
<div>
<label
Expand All @@ -238,7 +227,7 @@ export default function EditRuleForm(props: RuleFormProps) {
</div>
)}

{ruleType === 'multi' && (
{ruleType === distTypeMulti && (
<div>
<div className="space-y-1 px-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:space-y-0 sm:px-6 sm:py-5">
<div>
Expand Down
Loading

0 comments on commit 64dbe40

Please sign in to comment.