From 9d215a7a6850f41d95841c529f6db866e8e94a6b Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Wed, 5 Jul 2023 13:33:00 -0400 Subject: [PATCH 01/14] feat(wip): ui rollout threshold crud --- ui/src/app/flags/EditFlag.tsx | 4 + ui/src/app/flags/rollouts/Rollouts.tsx | 137 ++++++++++++++++++++++++ ui/src/components/flags/RolloutForm.tsx | 1 + ui/src/data/api.ts | 36 +++++++ ui/src/types/Rollout.ts | 34 ++++++ 5 files changed, 212 insertions(+) create mode 100644 ui/src/app/flags/rollouts/Rollouts.tsx create mode 100644 ui/src/components/flags/RolloutForm.tsx create mode 100644 ui/src/types/Rollout.ts diff --git a/ui/src/app/flags/EditFlag.tsx b/ui/src/app/flags/EditFlag.tsx index 190227c1eb..e2dfe7b917 100644 --- a/ui/src/app/flags/EditFlag.tsx +++ b/ui/src/app/flags/EditFlag.tsx @@ -4,6 +4,7 @@ import MoreInfo from '~/components/MoreInfo'; import { FlagType } from '~/types/Flag'; import { FlagProps } from './FlagProps'; import Variants from './variants/Variants'; +import Rollouts from './rollouts/Rollouts'; export default function EditFlag() { const { flag, onFlagChange } = useOutletContext(); @@ -36,6 +37,9 @@ export default function EditFlag() { {flagTypeToLabel(flag.type) === FlagType.VARIANT_FLAG_TYPE && ( )} + {flagTypeToLabel(flag.type) === FlagType.BOOLEAN_FLAG_TYPE && ( + + )} ); diff --git a/ui/src/app/flags/rollouts/Rollouts.tsx b/ui/src/app/flags/rollouts/Rollouts.tsx new file mode 100644 index 0000000000..3c5eacb41c --- /dev/null +++ b/ui/src/app/flags/rollouts/Rollouts.tsx @@ -0,0 +1,137 @@ +import { PlusIcon } from '@heroicons/react/24/outline'; +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 RolloutForm from '~/components/flags/RolloutForm'; +import Button from '~/components/forms/buttons/Button'; +import Modal from '~/components/Modal'; +import DeletePanel from '~/components/panels/DeletePanel'; +import Slideover from '~/components/Slideover'; +import { deleteRollout, listRollouts } from '~/data/api'; +import { IFlag } from '~/types/Flag'; +import { IRollout, IRolloutList } from '~/types/Rollout'; + +type RolloutsProps = { + flag: IFlag; + flagChanged: () => void; +}; + +export default function Rollouts(props: RolloutsProps) { + const { flag, flagChanged } = props; + + const [rollouts, setRollouts] = useState([]); + + const [rolloutsVersion, setRolloutsVersion] = useState(0); + const [showRolloutForm, setShowRolloutForm] = useState(false); + + const [editingRollout, setEditingRollout] = useState(null); + const [showDeleteRolloutModal, setShowDeleteRolloutModal] = + useState(false); + const [deletingRollout, setDeletingRollout] = useState(null); + + const rolloutFormRef = useRef(null); + + const namespace = useSelector(selectCurrentNamespace); + const readOnly = useSelector(selectReadonly); + + const loadData = useCallback(async () => { + // TODO: load segments + + const rolloutList = (await listRollouts( + namespace.key, + flag.key + )) as IRolloutList; + + setRollouts(rolloutList.rollouts); + }, [namespace.key, flag.key]); + + const incrementRolloutsVersion = () => { + setRolloutsVersion(rolloutsVersion + 1); + }; + + useEffect(() => { + loadData(); + }, [loadData, rolloutsVersion]); + + return ( + <> + {/* rollout edit form */} + + { + setShowRolloutForm(false); + flagChanged(); + }} + /> + + + {/* rollout delete modal */} + + + Are you sure you want to delete this rule at + + {' '} + position {deletingRollout?.rank} + + ? This action cannot be undone. + + } + panelType="Rollout" + setOpen={setShowDeleteRolloutModal} + handleDelete={ + () => + deleteRollout(namespace.key, flag.key, deletingRollout?.id ?? '') // TODO: Determine impact of blank ID param + } + onSuccess={() => { + flagChanged(); + }} + /> + + + {/* rollouts */} +
+
+
+

+ Rollouts +

+

+ Return boolean values based on rules you define +

+
+ {rollouts && rollouts.length > 0 && ( +
+ +
+ )} +
+
+ + ); +} diff --git a/ui/src/components/flags/RolloutForm.tsx b/ui/src/components/flags/RolloutForm.tsx new file mode 100644 index 0000000000..cce475ecc2 --- /dev/null +++ b/ui/src/components/flags/RolloutForm.tsx @@ -0,0 +1 @@ +export default function RolloutForm() {} diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index 812f87c6b9..96f659a917 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -6,6 +6,7 @@ import { IFlagBase } from 'types/Flag'; import { IRuleBase } from 'types/Rule'; import { ISegmentBase } from 'types/Segment'; import { IVariantBase } from 'types/Variant'; +import { IRolloutBase } from '~/types/Rollout'; const apiURL = '/api/v1'; const authURL = '/auth/v1'; @@ -171,6 +172,41 @@ export async function copyFlag( } } +// 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 deleteRollout( + namespaceKey: string, + flagKey: string, + rolloutId: string +) { + return del( + `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/${rolloutId}` + ); +} + +export async function updateRollout( + namespaceKey: string, + flagKey: string, + rolloutId: string, + values: IRolloutBase +) { + return put( + `/namespaces/${namespaceKey}/flags/${flagKey}/rollouts/${rolloutId}`, + values + ); +} + // // rules export async function listRules(namespaceKey: string, flagKey: string) { diff --git a/ui/src/types/Rollout.ts b/ui/src/types/Rollout.ts new file mode 100644 index 0000000000..a6c38371c7 --- /dev/null +++ b/ui/src/types/Rollout.ts @@ -0,0 +1,34 @@ +import { IPageable } from './Pageable'; + +export enum RolloutType { + UNKNOW_ROLLOUT_TYPE = 'Unknown', + SEGMENT_ROLLOUT_TYPE = 'Segment', + THRESHOLD_ROLLOUT_TYPE = 'Threshold' +} + +export interface RolloutSegment { + segmentKey: string; + value: boolean; +} + +export interface RolloutThreshold { + percentage: number; + value: boolean; +} + +export interface IRolloutBase { + type: RolloutType; + rank: number; + description?: string; + rule: RolloutSegment | RolloutThreshold; +} + +export interface IRollout extends IRolloutBase { + id: string; + createdAt: string; + updatedAt: string; +} + +export interface IRolloutList extends IPageable { + rollouts: IRollout[]; +} From 0b78dded0a35233abc5d9dc839073ec19c970c61 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Wed, 5 Jul 2023 14:18:14 -0400 Subject: [PATCH 02/14] feat(wip): show new rollout button --- ui/src/app/flags/rollouts/Rollouts.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ui/src/app/flags/rollouts/Rollouts.tsx b/ui/src/app/flags/rollouts/Rollouts.tsx index 3c5eacb41c..f79a4788de 100644 --- a/ui/src/app/flags/rollouts/Rollouts.tsx +++ b/ui/src/app/flags/rollouts/Rollouts.tsx @@ -3,6 +3,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 EmptyState from '~/components/EmptyState'; import RolloutForm from '~/components/flags/RolloutForm'; import Button from '~/components/forms/buttons/Button'; import Modal from '~/components/Modal'; @@ -131,6 +132,20 @@ export default function Rollouts(props: RolloutsProps) { )} +
+ {rollouts && rollouts.length > 0 ? ( + <> + ) : ( + { + setEditingRollout(null); + setShowRolloutForm(true); + }} + /> + )} +
); From 4baf5eeb90d9f701765d2ff03c9029a6b84153d5 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Wed, 5 Jul 2023 15:51:05 -0400 Subject: [PATCH 03/14] chore: add type to flag table --- ui/src/components/flags/FlagTable.tsx | 10 +++++++++- ui/src/components/segments/SegmentTable.tsx | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/src/components/flags/FlagTable.tsx b/ui/src/components/flags/FlagTable.tsx index cb1d37e2fa..42730d20fe 100644 --- a/ui/src/components/flags/FlagTable.tsx +++ b/ui/src/components/flags/FlagTable.tsx @@ -18,7 +18,7 @@ import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import Pagination from '~/components/Pagination'; import Searchbox from '~/components/Searchbox'; import { useTimezone } from '~/data/hooks/timezone'; -import { IFlag } from '~/types/Flag'; +import { FlagType, IFlag } from '~/types/Flag'; import { truncateKey } from '~/utils/helpers'; type FlagTableProps = { @@ -83,6 +83,14 @@ export default function FlagTable(props: FlagTableProps) { className: 'whitespace-nowrap py-4 px-3 text-sm' } }), + columnHelper.accessor('type', { + header: 'Type', + cell: (info) => + FlagType[info.getValue() as unknown as keyof typeof FlagType], + meta: { + className: 'whitespace-nowrap py-4 px-3 text-sm text-gray-600' + } + }), columnHelper.accessor('description', { header: 'Description', cell: (info) => info.getValue(), diff --git a/ui/src/components/segments/SegmentTable.tsx b/ui/src/components/segments/SegmentTable.tsx index b7264744ea..b68b907371 100644 --- a/ui/src/components/segments/SegmentTable.tsx +++ b/ui/src/components/segments/SegmentTable.tsx @@ -73,7 +73,7 @@ export default function SegmentTable(props: SegmentTableProps) { info.getValue() as unknown as keyof typeof SegmentMatchType ], meta: { - className: 'whitespace-nowrap py-4 px-3 text-sm' + className: 'whitespace-nowrap py-4 px-3 text-sm text-gray-600' } }), columnHelper.accessor('description', { From 66fcda96a7c790dd024bf494a7a01fabc1a03e1f Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Wed, 5 Jul 2023 21:43:58 -0400 Subject: [PATCH 04/14] feat(wip): threshold rule form --- ui/src/app/flags/rollouts/Rollouts.tsx | 2 +- ui/src/components/flags/RolloutForm.tsx | 1 - ui/src/components/forms/Combobox.tsx | 9 +- ui/src/components/rollouts/RolloutForm.tsx | 193 ++++++++++++++++++ .../rollouts/rules/ThresholdRuleForm.tsx | 29 +++ ui/src/components/rules/EditRuleForm.tsx | 13 +- ui/src/components/rules/RuleForm.tsx | 65 +++--- .../distributions/SingleDistributionForm.tsx | 9 +- ui/src/types/Rollout.ts | 7 +- ui/src/types/Variant.ts | 4 - 10 files changed, 276 insertions(+), 56 deletions(-) delete mode 100644 ui/src/components/flags/RolloutForm.tsx create mode 100644 ui/src/components/rollouts/RolloutForm.tsx create mode 100644 ui/src/components/rollouts/rules/ThresholdRuleForm.tsx diff --git a/ui/src/app/flags/rollouts/Rollouts.tsx b/ui/src/app/flags/rollouts/Rollouts.tsx index f79a4788de..3b82fe652a 100644 --- a/ui/src/app/flags/rollouts/Rollouts.tsx +++ b/ui/src/app/flags/rollouts/Rollouts.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import EmptyState from '~/components/EmptyState'; -import RolloutForm from '~/components/flags/RolloutForm'; +import RolloutForm from '~/components/rollouts/RolloutForm'; import Button from '~/components/forms/buttons/Button'; import Modal from '~/components/Modal'; import DeletePanel from '~/components/panels/DeletePanel'; diff --git a/ui/src/components/flags/RolloutForm.tsx b/ui/src/components/flags/RolloutForm.tsx deleted file mode 100644 index cce475ecc2..0000000000 --- a/ui/src/components/flags/RolloutForm.tsx +++ /dev/null @@ -1 +0,0 @@ -export default function RolloutForm() {} diff --git a/ui/src/components/forms/Combobox.tsx b/ui/src/components/forms/Combobox.tsx index e9ead7089c..7919c78b1f 100644 --- a/ui/src/components/forms/Combobox.tsx +++ b/ui/src/components/forms/Combobox.tsx @@ -3,8 +3,9 @@ import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline'; import { useField } from 'formik'; import { useState } from 'react'; import { classNames } from '~/utils/helpers'; +import { ISelectable } from './Listbox'; -type ComboboxProps = { +type ComboboxProps = { id: string; name: string; placeholder?: string; @@ -15,14 +16,12 @@ type ComboboxProps = { className?: string; }; -export interface ISelectable { - key: string; +export interface IFilterable extends ISelectable { status?: 'active' | 'inactive'; filterValue: string; - displayValue: string; } -export default function Combobox( +export default function Combobox( props: ComboboxProps ) { const { diff --git a/ui/src/components/rollouts/RolloutForm.tsx b/ui/src/components/rollouts/RolloutForm.tsx new file mode 100644 index 0000000000..1bf6f2359d --- /dev/null +++ b/ui/src/components/rollouts/RolloutForm.tsx @@ -0,0 +1,193 @@ +import { Dialog } from '@headlessui/react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { Formik } from 'formik'; +import { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Form } from 'react-router-dom'; +import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; +import Button from '~/components/forms/buttons/Button'; +import Loading from '~/components/Loading'; +import MoreInfo from '~/components/MoreInfo'; +import { useError } from '~/data/hooks/error'; +import { useSuccess } from '~/data/hooks/success'; +import { IRollout, IRolloutRuleThreshold, RolloutType } from '~/types/Rollout'; +import ThresholdRuleFormInputs from './rules/ThresholdRuleForm'; + +const rolloutRuleTypeSegment = 'SEGMENT_ROLLOUT_TYPE'; +const rolloutRuleTypeThreshold = 'THRESHOLD_ROLLOUT_TYPE'; + +const rolloutRuleTypes = [ + { + id: rolloutRuleTypeSegment, + name: RolloutType.SEGMENT_ROLLOUT_TYPE, + description: 'Rollout to a specific segment' + }, + { + id: rolloutRuleTypeThreshold, + name: RolloutType.THRESHOLD_ROLLOUT_TYPE, + description: 'Rollout to a percentage of entities' + } +]; + +type RolloutFormProps = { + setOpen: (open: boolean) => void; + rulesChanged: () => void; + flagKey: string; + rollout: IRollout | undefined; + rank: number; +}; + +export default function RolloutForm(props: RolloutFormProps) { + const { setOpen, rulesChanged, flagKey, rollout, rank } = props; + + const { setError, clearError } = useError(); + const { setSuccess } = useSuccess(); + + const namespace = useSelector(selectCurrentNamespace); + + const [rolloutRuleType, setRolloutRuleType] = useState( + rolloutRuleTypeThreshold + ); + + const [thresholdRule, setThresholdRule] = useState( + (rollout?.rule as IRolloutRuleThreshold) || { + percentage: 50, + value: true + } + ); + + const handleSubmit = () => { + return Promise.resolve(); + }; + + return ( + { + handleSubmit() + .then(() => { + rulesChanged(); + clearError(); + setSuccess('Successfully created rollout'); + setOpen(false); + }) + .catch((err) => { + setError(err); + }) + .finally(() => { + setSubmitting(false); + }); + }} + > + {(formik) => { + return ( +
+
+
+
+
+ + New Rollout + + + Learn more about rollouts + +
+
+ +
+
+
+
+
+
+ +
+
+
+ Type +
+ {rolloutRuleTypes.map((rolloutRule) => ( +
+
+ { + setRolloutRuleType(rolloutRule.id); + }} + checked={rolloutRule.id === rolloutRuleType} + value={rolloutRule.id} + /> +
+
+ +

+ {rolloutRule.description} +

+
+
+ ))} +
+
+
+
+ {rolloutRuleType === rolloutRuleTypeThreshold && ( + + )} +
+
+
+
+ + +
+
+
+ ); + }} +
+ ); +} diff --git a/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx b/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx new file mode 100644 index 0000000000..6c6c20a1cb --- /dev/null +++ b/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx @@ -0,0 +1,29 @@ +import { IRolloutRuleThreshold } from '~/types/Rollout'; + +type ThresholdRuleFormInputProps = { + rule: IRolloutRuleThreshold; +}; + +export default function ThresholdRuleFormInputs( + props: ThresholdRuleFormInputProps +) { + const { rule } = props; + + return ( +
+ + +
+ ); +} diff --git a/ui/src/components/rules/EditRuleForm.tsx b/ui/src/components/rules/EditRuleForm.tsx index 0faaf90de8..cc609c553c 100644 --- a/ui/src/components/rules/EditRuleForm.tsx +++ b/ui/src/components/rules/EditRuleForm.tsx @@ -6,15 +6,14 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import Button from '~/components/forms/buttons/Button'; -import Combobox, { ISelectable } from '~/components/forms/Combobox'; +import Combobox from '~/components/forms/Combobox'; import Loading from '~/components/Loading'; 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 { ISegment } from '~/types/Segment'; -import { IVariant } from '~/types/Variant'; +import { FilterableSegment, FilterableVariant } from './RuleForm'; type RuleFormProps = { setOpen: (open: boolean) => void; @@ -43,10 +42,6 @@ const validRollout = (rollouts: IRollout[]): boolean => { return sum <= 100; }; -type SelectableSegment = ISegment & ISelectable; - -type SelectableVariant = IVariant & ISelectable; - export default function EditRuleForm(props: RuleFormProps) { const { setOpen, rule, onSuccess } = props; @@ -153,7 +148,7 @@ export default function EditRuleForm(props: RuleFormProps) {
- + id="segmentKey" name="segmentKey" disabled @@ -230,7 +225,7 @@ export default function EditRuleForm(props: RuleFormProps) {
- + id="variant" name="variant" selected={{ diff --git a/ui/src/components/rules/RuleForm.tsx b/ui/src/components/rules/RuleForm.tsx index fda4ceaadb..7463921515 100644 --- a/ui/src/components/rules/RuleForm.tsx +++ b/ui/src/components/rules/RuleForm.tsx @@ -7,7 +7,8 @@ import { Link } from 'react-router-dom'; import * as Yup from 'yup'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import Button from '~/components/forms/buttons/Button'; -import Combobox, { ISelectable } from '~/components/forms/Combobox'; +import Combobox, { IFilterable } from '~/components/forms/Combobox'; +import { ISelectable } from '~/components/forms/Listbox'; import Loading from '~/components/Loading'; import MoreInfo from '~/components/MoreInfo'; import { createDistribution, createRule } from '~/data/api'; @@ -17,27 +18,27 @@ import { keyValidation } from '~/data/validations'; import { IDistributionVariant } from '~/types/Distribution'; import { IFlag } from '~/types/Flag'; import { ISegment } from '~/types/Segment'; -import { SelectableVariant } from '~/types/Variant'; +import { IVariant } from '~/types/Variant'; import { truncateKey } from '~/utils/helpers'; import MultiDistributionFormInputs from './distributions/MultiDistributionForm'; import SingleDistributionFormInput from './distributions/SingleDistributionForm'; -type RuleFormProps = { - setOpen: (open: boolean) => void; - rulesChanged: () => void; - flag: IFlag; - rank: number; - segments: ISegment[]; -}; +export type FilterableSegment = ISegment & IFilterable; +export type FilterableVariant = IVariant & IFilterable; +export type SelectableSegment = ISegment & ISelectable; +export type SelectableVariant = IVariant & ISelectable; + +const distTypeSingle = 'single'; +const distTypeMulti = 'multi'; const distTypes = [ { - id: 'single', + id: distTypeSingle, name: 'Single Variant', description: 'Always returns the same variant' }, { - id: 'multi', + id: distTypeMulti, name: 'Multi-Variant', description: 'Returns different variants based on percentages' } @@ -67,7 +68,13 @@ const validRollout = (distributions: IDistributionVariant[]): boolean => { return sum <= 100; }; -type SelectableSegment = ISegment & ISelectable; +type RuleFormProps = { + setOpen: (open: boolean) => void; + rulesChanged: () => void; + flag: IFlag; + rank: number; + segments: ISegment[]; +}; export default function RuleForm(props: RuleFormProps) { const { setOpen, rulesChanged, flag, rank, segments } = props; @@ -79,12 +86,12 @@ export default function RuleForm(props: RuleFormProps) { const [distributionsValid, setDistributionsValid] = useState(true); - const [ruleType, setRuleType] = useState('single'); + const [ruleType, setRuleType] = useState(distTypeSingle); const [selectedSegment, setSelectedSegment] = - useState(null); + useState(null); const [selectedVariant, setSelectedVariant] = - useState(null); + useState(null); const [distributions, setDistributions] = useState(() => { const percentages = computePercentages(flag.variants?.length || 0); @@ -97,7 +104,11 @@ export default function RuleForm(props: RuleFormProps) { }); useEffect(() => { - if (ruleType === 'multi' && distributions && !validRollout(distributions)) { + if ( + ruleType === distTypeSingle && + distributions && + !validRollout(distributions) + ) { setDistributionsValid(false); } else { setDistributionsValid(true); @@ -115,7 +126,7 @@ export default function RuleForm(props: RuleFormProps) { rank }); - if (ruleType === 'multi') { + if (ruleType === distTypeMulti) { const distPromises = distributions?.map((dist: IDistributionVariant) => createDistribution(namespace.key, flag.key, rule.id, { variantId: dist.variantId, @@ -123,15 +134,13 @@ export default function RuleForm(props: RuleFormProps) { }) ); if (distPromises) await Promise.all(distPromises); - } else { - if (selectedVariant) { - // we allow creating rules without variants + } else if (selectedVariant) { + // we allow creating rules without variants - await createDistribution(namespace.key, flag.key, rule.id, { - variantId: selectedVariant.id, - rollout: 100 - }); - } + await createDistribution(namespace.key, flag.key, rule.id, { + variantId: selectedVariant.id, + rollout: 100 + }); } }; @@ -196,7 +205,7 @@ export default function RuleForm(props: RuleFormProps) {
- + id="segmentKey" name="segmentKey" placeholder="Select or search for a segment" @@ -264,14 +273,14 @@ export default function RuleForm(props: RuleFormProps) {
)} - {flag.variants && ruleType === 'single' && ( + {flag.variants && ruleType === distTypeSingle && ( )} - {flag.variants && ruleType === 'multi' && ( + {flag.variants && ruleType === distTypeMulti && ( void; + selectedVariant: FilterableVariant | null; + setSelectedVariant: (variant: FilterableVariant | null) => void; }; export default function SingleDistributionFormInput( @@ -22,7 +23,7 @@ export default function SingleDistributionFormInput(
- + id="variant" name="variant" placeholder="Select or search for a variant" diff --git a/ui/src/types/Rollout.ts b/ui/src/types/Rollout.ts index a6c38371c7..297a08ccee 100644 --- a/ui/src/types/Rollout.ts +++ b/ui/src/types/Rollout.ts @@ -1,17 +1,16 @@ import { IPageable } from './Pageable'; export enum RolloutType { - UNKNOW_ROLLOUT_TYPE = 'Unknown', SEGMENT_ROLLOUT_TYPE = 'Segment', THRESHOLD_ROLLOUT_TYPE = 'Threshold' } -export interface RolloutSegment { +export interface IRolloutRuleSegment { segmentKey: string; value: boolean; } -export interface RolloutThreshold { +export interface IRolloutRuleThreshold { percentage: number; value: boolean; } @@ -20,7 +19,7 @@ export interface IRolloutBase { type: RolloutType; rank: number; description?: string; - rule: RolloutSegment | RolloutThreshold; + rule: IRolloutRuleSegment | IRolloutRuleThreshold; } export interface IRollout extends IRolloutBase { diff --git a/ui/src/types/Variant.ts b/ui/src/types/Variant.ts index 2fe7089885..814b0250c5 100644 --- a/ui/src/types/Variant.ts +++ b/ui/src/types/Variant.ts @@ -1,5 +1,3 @@ -import { ISelectable } from '~/components/forms/Combobox'; - export interface IVariantBase { key: string; name: string; @@ -12,5 +10,3 @@ export interface IVariant extends IVariantBase { createdAt: string; updatedAt: string; } - -export type SelectableVariant = IVariant & ISelectable; From 4955dc12c069970f52f8b14899a6f857a0395135 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Thu, 6 Jul 2023 16:47:21 -0400 Subject: [PATCH 05/14] feat(wip): rollout thresholds --- ui/src/app/flags/EditFlag.tsx | 2 +- ui/src/app/flags/Evaluation.tsx | 6 +- ui/src/app/flags/rollouts/Rollouts.tsx | 7 +- ui/src/components/forms/Select.tsx | 4 +- ui/src/components/rollouts/RolloutForm.tsx | 239 +++++++++--------- .../rollouts/rules/ThresholdRuleForm.tsx | 83 ++++-- ui/src/components/rules/RuleForm.tsx | 6 +- ui/src/components/segments/ConstraintForm.tsx | 2 +- ui/src/types/Rollout.ts | 3 +- 9 files changed, 194 insertions(+), 158 deletions(-) diff --git a/ui/src/app/flags/EditFlag.tsx b/ui/src/app/flags/EditFlag.tsx index e2dfe7b917..b8cd9fd3a0 100644 --- a/ui/src/app/flags/EditFlag.tsx +++ b/ui/src/app/flags/EditFlag.tsx @@ -3,8 +3,8 @@ import FlagForm from '~/components/flags/FlagForm'; import MoreInfo from '~/components/MoreInfo'; import { FlagType } from '~/types/Flag'; import { FlagProps } from './FlagProps'; -import Variants from './variants/Variants'; import Rollouts from './rollouts/Rollouts'; +import Variants from './variants/Variants'; export default function EditFlag() { const { flag, onFlagChange } = useOutletContext(); diff --git a/ui/src/app/flags/Evaluation.tsx b/ui/src/app/flags/Evaluation.tsx index 44bfb6456f..f74e0bbfeb 100644 --- a/ui/src/app/flags/Evaluation.tsx +++ b/ui/src/app/flags/Evaluation.tsx @@ -190,9 +190,7 @@ export default function Evaluation() { handleDelete={() => deleteRule(namespace.key, flag.key, deletingRule?.id ?? '') } - onSuccess={() => { - incrementRulesVersion(); - }} + onSuccess={incrementRulesVersion} /> @@ -203,7 +201,7 @@ export default function Evaluation() { rank={(rules?.length || 0) + 1} segments={segments} setOpen={setShowRuleForm} - rulesChanged={incrementRulesVersion} + onSuccess={incrementRulesVersion} /> diff --git a/ui/src/app/flags/rollouts/Rollouts.tsx b/ui/src/app/flags/rollouts/Rollouts.tsx index 3b82fe652a..b2df8d6853 100644 --- a/ui/src/app/flags/rollouts/Rollouts.tsx +++ b/ui/src/app/flags/rollouts/Rollouts.tsx @@ -4,10 +4,10 @@ import { useSelector } from 'react-redux'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; import EmptyState from '~/components/EmptyState'; -import RolloutForm from '~/components/rollouts/RolloutForm'; import Button from '~/components/forms/buttons/Button'; 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 { IFlag } from '~/types/Flag'; @@ -64,12 +64,13 @@ export default function Rollouts(props: RolloutsProps) { ref={rolloutFormRef} > { setShowRolloutForm(false); + incrementRolloutsVersion(); flagChanged(); }} /> diff --git a/ui/src/components/forms/Select.tsx b/ui/src/components/forms/Select.tsx index fb6b648ef5..715c9d7259 100644 --- a/ui/src/components/forms/Select.tsx +++ b/ui/src/components/forms/Select.tsx @@ -6,12 +6,13 @@ type SelectProps = { options?: { value: string; label: string }[]; children?: React.ReactNode; className?: string; + defaultValue?: string; value?: string; onChange?: (e: React.ChangeEvent) => void; }; export default function Select(props: SelectProps) { - const { id, name, options, children, className, value, onChange } = props; + const { id, name, options, value, children, className, onChange } = props; const [field] = useField({ name, @@ -22,6 +23,7 @@ export default function Select(props: SelectProps) { { - setRolloutRuleType(rolloutRule.id); - }} - checked={rolloutRule.id === rolloutRuleType} - value={rolloutRule.id} - /> -
-
- -

- {rolloutRule.description} -

-
+ +
+
+
+ +
+
+
+ Type +
+ {rolloutRuleTypes.map((rolloutRule) => ( +
+
+ { + setRolloutRuleType(rolloutRule.id); + }} + checked={rolloutRule.id === rolloutRuleType} + value={rolloutRule.id} + /> +
+
+ +

+ {rolloutRule.description} +

- ))} -
-
-
+
+ ))} +
+ - {rolloutRuleType === rolloutRuleTypeThreshold && ( - - )} + {rolloutRuleType === rolloutRuleTypeThreshold && ( + + )} -
-
- - -
+
+
+
+ +
- - ); - }} +
+ + )} ); } diff --git a/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx b/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx index 6c6c20a1cb..e948430d0a 100644 --- a/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx +++ b/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx @@ -1,29 +1,64 @@ -import { IRolloutRuleThreshold } from '~/types/Rollout'; +import { useFormikContext } from 'formik'; +import Input from '~/components/forms/Input'; +import Select from '~/components/forms/Select'; -type ThresholdRuleFormInputProps = { - rule: IRolloutRuleThreshold; -}; - -export default function ThresholdRuleFormInputs( - props: ThresholdRuleFormInputProps -) { - const { rule } = props; +interface ThresholdRuleFormInputsFields { + percentage: number; + value: boolean; +} +export default function InnerThresholdRuleFormInputs() { + const { values, setFieldValue } = + useFormikContext(); return ( -
- - -
+ <> +
+ + + setFieldValue('percentage', parseInt(e.target.value)) + } + className="h-2 w-full cursor-pointer appearance-none self-center rounded-lg align-middle bg-gray-200 dark:bg-gray-700" + /> + + setFieldValue('percentage', parseInt(e.target.value)) + } + className="w-20 text-center" + /> +
+
+ + +
+
diff --git a/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx b/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx index e948430d0a..cf1f7da61c 100644 --- a/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx +++ b/ui/src/components/rollouts/rules/ThresholdRuleForm.tsx @@ -32,12 +32,14 @@ export default function InnerThresholdRuleFormInputs() { setFieldValue('percentage', parseInt(e.target.value)) } - className="w-20 text-center" + className="text-center" />
From 4625459beabd4232d007c9c0baf56f527c1c2cb1 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Thu, 6 Jul 2023 19:57:55 -0400 Subject: [PATCH 11/14] fix: typing --- ui/src/components/rollouts/RolloutForm.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/components/rollouts/RolloutForm.tsx b/ui/src/components/rollouts/RolloutForm.tsx index be72e3ab89..0d391e4716 100644 --- a/ui/src/components/rollouts/RolloutForm.tsx +++ b/ui/src/components/rollouts/RolloutForm.tsx @@ -51,7 +51,9 @@ export default function RolloutForm(props: RolloutFormProps) { rolloutRuleTypeThreshold ); - const handleThresholdSubmit = (values: IRollout & IRolloutRuleThreshold) => { + const handleThresholdSubmit = ( + values: IRolloutBase & IRolloutRuleThreshold + ) => { return createRollout(namespace.key, flag.key, { rank, type: rolloutRuleType as RolloutType, From 10796c0448b2980a19c57b7bb7c3afe9cd06d403 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Thu, 6 Jul 2023 20:02:07 -0400 Subject: [PATCH 12/14] chore: type the form values --- ui/src/components/rollouts/RolloutForm.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/ui/src/components/rollouts/RolloutForm.tsx b/ui/src/components/rollouts/RolloutForm.tsx index 0d391e4716..789bc49fdf 100644 --- a/ui/src/components/rollouts/RolloutForm.tsx +++ b/ui/src/components/rollouts/RolloutForm.tsx @@ -12,7 +12,7 @@ import { createRollout } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { IFlag } from '~/types/Flag'; -import { IRollout, IRolloutRuleThreshold, RolloutType } from '~/types/Rollout'; +import { IRollout, RolloutType } from '~/types/Rollout'; import ThresholdRuleFormInputs from './rules/ThresholdRuleForm'; const rolloutRuleTypeSegment = 'SEGMENT_ROLLOUT_TYPE'; @@ -39,6 +39,13 @@ type RolloutFormProps = { rank: number; }; +interface RolloutFormValues { + type: string; + description?: string; + percentage?: number; + value: boolean; +} + export default function RolloutForm(props: RolloutFormProps) { const { setOpen, onSuccess, flag, rank } = props; @@ -51,24 +58,22 @@ export default function RolloutForm(props: RolloutFormProps) { rolloutRuleTypeThreshold ); - const handleThresholdSubmit = ( - values: IRolloutBase & IRolloutRuleThreshold - ) => { + const handleThresholdSubmit = (values: RolloutFormValues) => { return createRollout(namespace.key, flag.key, { rank, type: rolloutRuleType as RolloutType, description: values.description, threshold: { - percentage: values.percentage, + percentage: values.percentage || 0, value: values.value } }); }; - const initialValues = { + const initialValues: RolloutFormValues = { type: rolloutRuleType, description: '', - percentage: 50, + percentage: 50, // TODO: make this 0? value: true }; From ae4b7022a86bfd5755911fea4a96650f8f9ed00f Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Thu, 6 Jul 2023 20:12:23 -0400 Subject: [PATCH 13/14] chore: move value to common form --- ui/src/components/rollouts/RolloutForm.tsx | 28 +++++++++++++++---- .../rollouts/rules/ThresholdRuleForm.tsx | 20 ------------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/ui/src/components/rollouts/RolloutForm.tsx b/ui/src/components/rollouts/RolloutForm.tsx index 789bc49fdf..36b969e781 100644 --- a/ui/src/components/rollouts/RolloutForm.tsx +++ b/ui/src/components/rollouts/RolloutForm.tsx @@ -6,6 +6,7 @@ import { useSelector } from 'react-redux'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; 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 { createRollout } from '~/data/api'; @@ -43,7 +44,7 @@ interface RolloutFormValues { type: string; description?: string; percentage?: number; - value: boolean; + value: string; } export default function RolloutForm(props: RolloutFormProps) { @@ -65,7 +66,7 @@ export default function RolloutForm(props: RolloutFormProps) { description: values.description, threshold: { percentage: values.percentage || 0, - value: values.value + value: values.value === 'true' } }); }; @@ -74,7 +75,7 @@ export default function RolloutForm(props: RolloutFormProps) { type: rolloutRuleType, description: '', percentage: 50, // TODO: make this 0? - value: true + value: 'true' }; return ( @@ -178,6 +179,23 @@ export default function RolloutForm(props: RolloutFormProps) { {rolloutRuleType === rolloutRuleTypeThreshold && ( )} +
+ + setFieldValue('value', e.target.value === 'true')} - options={[ - { label: 'True', value: 'true' }, - { label: 'False', value: 'false' } - ]} - className="w-full cursor-pointer appearance-none self-center rounded-lg align-middle bg-gray-200 dark:bg-gray-700" - /> -
); } From 3d833d4455ddf15ea6e6bf0f2c2980fcf7b9c333 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Thu, 6 Jul 2023 20:28:02 -0400 Subject: [PATCH 14/14] chore: disable evaluation tab for boolean flag --- ui/src/app/flags/EditFlag.tsx | 4 +-- ui/src/app/flags/Flag.tsx | 25 ++++++++++--------- ui/src/app/segments/Segment.tsx | 8 ++---- ui/src/components/TabBar.tsx | 44 ++++++++++++++++++++------------- ui/src/types/Constraint.ts | 5 ++++ ui/src/types/Flag.ts | 3 +++ 6 files changed, 51 insertions(+), 38 deletions(-) diff --git a/ui/src/app/flags/EditFlag.tsx b/ui/src/app/flags/EditFlag.tsx index b8cd9fd3a0..efef83dad1 100644 --- a/ui/src/app/flags/EditFlag.tsx +++ b/ui/src/app/flags/EditFlag.tsx @@ -1,7 +1,7 @@ import { useOutletContext } from 'react-router-dom'; import FlagForm from '~/components/flags/FlagForm'; import MoreInfo from '~/components/MoreInfo'; -import { FlagType } from '~/types/Flag'; +import { FlagType, flagTypeToLabel } from '~/types/Flag'; import { FlagProps } from './FlagProps'; import Rollouts from './rollouts/Rollouts'; import Variants from './variants/Variants'; @@ -9,8 +9,6 @@ import Variants from './variants/Variants'; export default function EditFlag() { const { flag, onFlagChange } = useOutletContext(); - const flagTypeToLabel = (t: string) => FlagType[t as keyof typeof FlagType]; - return ( <>
diff --git a/ui/src/app/flags/Flag.tsx b/ui/src/app/flags/Flag.tsx index c57bdd7ddb..8e5abe21cc 100644 --- a/ui/src/app/flags/Flag.tsx +++ b/ui/src/app/flags/Flag.tsx @@ -22,7 +22,7 @@ import { copyFlag, deleteFlag, getFlag } from '~/data/api'; import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { useTimezone } from '~/data/hooks/timezone'; -import { IFlag } from '~/types/Flag'; +import { FlagType, flagTypeToLabel, IFlag } from '~/types/Flag'; export default function Flag() { let { flagKey } = useParams(); @@ -47,17 +47,6 @@ export default function Flag() { setFlagVersion(flagVersion + 1); }; - const tabs = [ - { - name: 'Details', - to: '' - }, - { - name: 'Evaluation', - to: 'evaluation' - } - ]; - useEffect(() => { if (!flagKey) return; @@ -72,6 +61,18 @@ export default function Flag() { if (!flag) return ; + const tabs = [ + { + name: 'Details', + to: '' + }, + { + name: 'Evaluation', + to: 'evaluation', + disabled: flagTypeToLabel(flag.type) === FlagType.BOOLEAN_FLAG_TYPE + } + ]; + return ( <> {/* flag delete modal */} diff --git a/ui/src/app/segments/Segment.tsx b/ui/src/app/segments/Segment.tsx index 035ec7ef8a..b10c6a87e7 100644 --- a/ui/src/app/segments/Segment.tsx +++ b/ui/src/app/segments/Segment.tsx @@ -34,7 +34,8 @@ import { useSuccess } from '~/data/hooks/success'; import { useTimezone } from '~/data/hooks/timezone'; import { ComparisonType, - ConstraintOperators, + constraintOperatorToLabel, + constraintTypeToLabel, IConstraint } from '~/types/Constraint'; import { ISegment } from '~/types/Segment'; @@ -83,11 +84,6 @@ export default function Segment() { }); }, [segmentVersion, namespace.key, segmentKey, clearError, setError]); - const constraintTypeToLabel = (t: string) => - ComparisonType[t as keyof typeof ComparisonType]; - - const constraintOperatorToLabel = (o: string) => ConstraintOperators[o]; - const constraintFormRef = useRef(null); if (!segment) return ; diff --git a/ui/src/components/TabBar.tsx b/ui/src/components/TabBar.tsx index 1edfa1c36d..c9adf758cd 100644 --- a/ui/src/components/TabBar.tsx +++ b/ui/src/components/TabBar.tsx @@ -4,6 +4,7 @@ import { classNames } from '~/utils/helpers'; export interface Tab { name: string; to: string; + disabled?: boolean; } type TabBarProps = { @@ -17,23 +18,32 @@ export default function TabBar(props: TabBarProps) {
diff --git a/ui/src/types/Constraint.ts b/ui/src/types/Constraint.ts index ba58ca8fb1..3ee63055c2 100644 --- a/ui/src/types/Constraint.ts +++ b/ui/src/types/Constraint.ts @@ -70,3 +70,8 @@ export const ConstraintOperators: Record = { ...ConstraintBooleanOperators, ...ConstraintDateTimeOperators }; + +export const constraintTypeToLabel = (t: string) => + ComparisonType[t as keyof typeof ComparisonType]; + +export const constraintOperatorToLabel = (o: string) => ConstraintOperators[o]; diff --git a/ui/src/types/Flag.ts b/ui/src/types/Flag.ts index 32592d8f6a..3fc4ce8b49 100644 --- a/ui/src/types/Flag.ts +++ b/ui/src/types/Flag.ts @@ -23,3 +23,6 @@ export interface IFlag extends IFlagBase { export interface IFlagList extends IPageable { flags: IFlag[]; } + +export const flagTypeToLabel = (t: string) => + FlagType[t as keyof typeof FlagType];