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

feat(rollouts/rules): UI for rollout/rule editing of segments #1953

Merged
merged 6 commits into from
Aug 8, 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
15 changes: 15 additions & 0 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"react-redux": "^8.1.1",
"react-router-dom": "^6.14.1",
"swr": "^2.2.0",
"tailwind-merge": "^1.14.0",
"uuid": "^9.0.0",
"yup": "^0.32.11"
},
Expand Down
33 changes: 30 additions & 3 deletions ui/src/app/flags/Evaluation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { IDistribution } from '~/types/Distribution';
import { IEvaluatable } from '~/types/Evaluatable';
import { FlagType } from '~/types/Flag';
import { IRule, IRuleList } from '~/types/Rule';
import { ISegment, ISegmentList } from '~/types/Segment';
import { ISegment, ISegmentList, SegmentOperatorType } from '~/types/Segment';
import { IVariant } from '~/types/Variant';
import { FlagProps } from './FlagProps';

Expand Down Expand Up @@ -87,17 +87,44 @@ export default function Evaluation() {
}
);

const ruleSegments: ISegment[] = [];

const size = rule.segmentKeys ? rule.segmentKeys.length : 0;

// Combine both segment and segments for legacy purposes.
// TODO(yquansah): Should be removed once there are no more references to `segmentKey`.
for (let i = 0; i < size; i++) {
const ruleSegment = rule.segmentKeys && rule.segmentKeys[i];
const segment = segments.find(
(segment: ISegment) => ruleSegment === segment.key
);
if (segment) {
ruleSegments.push(segment);
}
}

const segment = segments.find(
(segment: ISegment) => segment.key === rule.segmentKey
);
if (!segment) {

if (segment) {
ruleSegments.push(segment);
}

// If there are no ruleSegments return an empty array.
if (ruleSegments.length === 0) {
return [];
}

const operator = rule.segmentOperator
? rule.segmentOperator
: SegmentOperatorType.OR;

return {
id: rule.id,
flag,
segment,
segments: ruleSegments,
operator,
rank: rule.rank,
rollouts,
createdAt: rule.createdAt,
Expand Down
34 changes: 32 additions & 2 deletions ui/src/app/flags/rollouts/Rollouts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ 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 } from '~/types/Segment';
import { ISegment, ISegmentList, SegmentOperatorType } from '~/types/Segment';

type RolloutsProps = {
flag: IFlag;
Expand Down Expand Up @@ -84,7 +84,37 @@ export default function Rollouts(props: RolloutsProps) {
flag.key
)) as IRolloutList;

setRollouts(rolloutList.rules);
// 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) => {
if (rollout.segment) {
let segmentKeys: string[] = [];
if (
rollout.segment.segmentKeys &&
rollout.segment.segmentKeys.length > 0
) {
segmentKeys = rollout.segment.segmentKeys;
} else if (rollout.segment.segmentKey) {
segmentKeys = [rollout.segment.segmentKey];
}

return {
...rollout,
segment: {
segmentOperator:
rollout.segment.segmentOperator || SegmentOperatorType.OR,
segmentKeys,
value: rollout.segment.value
}
};
}

return {
...rollout
};
});

setRollouts(rolloutRules);
}, [namespace.key, flag.key]);

const incrementRolloutsVersion = () => {
Expand Down
7 changes: 6 additions & 1 deletion ui/src/components/forms/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Combobox as C } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline';
import { useField } from 'formik';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { IFilterable } from '~/types/Selectable';
import { classNames } from '~/utils/helpers';

Expand All @@ -14,6 +15,7 @@ type ComboboxProps<T extends IFilterable> = {
setSelected?: (v: T | null) => void;
disabled?: boolean;
className?: string;
inputClassName?: string;
};

export default function Combobox<T extends IFilterable>(
Expand All @@ -22,6 +24,7 @@ export default function Combobox<T extends IFilterable>(
const {
id,
className,
inputClassName,
values,
selected,
setSelected,
Expand Down Expand Up @@ -51,7 +54,9 @@ export default function Combobox<T extends IFilterable>(
<div className="relative flex w-full flex-row">
<C.Input
id={id}
className="text-gray-900 bg-gray-50 border-gray-300 w-full rounded-md border py-2 pl-3 pr-10 shadow-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 sm:text-sm"
className={twMerge(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does twMerge replace the classNames util helper?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@markphelps I think so? Was the classNames util helper for handling overrides of tailwind styles as well? If so, it didn't seem to be working for me for some reason 🤔. This article explains why twMerge should be used.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think twMerge is like the functionality of classNames + handles tailwind specifically. classNames is just a way to dynamically apply classes given a boolean condition. so it might make sense for us to favor twMerge instead of classNames in the future as it has the added ability of working with tw classes like we might expect (last one wins).

So maybe the rule should be, if we find ourselves needing to use classNames + twMerge then just use twMerge as it can do both I think?

`text-gray-900 bg-gray-50 border-gray-300 w-full rounded-md border py-2 pl-3 pr-10 shadow-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 sm:text-sm ${inputClassName}`
)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
}}
Expand Down
116 changes: 77 additions & 39 deletions ui/src/components/forms/SegmentsPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,45 @@
import { MinusSmallIcon } from '@heroicons/react/24/outline';
import { useRef } from 'react';
import { MinusSmallIcon, PlusSmallIcon } from '@heroicons/react/24/outline';
import { useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import Combobox from '~/components/forms/Combobox';
import { FilterableSegment, ISegment } from '~/types/Segment';
import { truncateKey } from '~/utils/helpers';

type SegmentPickerProps = {
readonly?: boolean;
editMode?: boolean;
segments: ISegment[];
selectedSegments: FilterableSegment[];
segmentAdd: (segment: FilterableSegment) => void;
segmentReplace: (index: number, segment: FilterableSegment) => void;
segmentRemove: (index: number) => void;
};

export default function SegmentsPicker(props: SegmentPickerProps) {
const {
segmentAdd,
selectedSegments: parentSegments,
segments,
segmentRemove,
segmentReplace
} = props;
export default function SegmentsPicker({
readonly = false,
editMode = false,
markphelps marked this conversation as resolved.
Show resolved Hide resolved
segments,
selectedSegments: parentSegments,
segmentAdd,
segmentReplace,
segmentRemove
}: SegmentPickerProps) {
const segmentsSet = useRef<Set<string>>(
new Set<string>(parentSegments.map((s) => s.key))
);

const segmentsSet = useRef<Set<string>>(new Set<string>());
const [editing, setEditing] = useState<boolean>(editMode);

const handleSegmentRemove = (index: number) => {
const filterableSegment = parentSegments[index];

// Remove references to the segment that is being deleted.
segmentsSet.current!.delete(filterableSegment.key);
segmentRemove(index);

if (editMode && parentSegments.length == 1) {
setEditing(true);
}
};

const handleSegmentSelected = (
Expand Down Expand Up @@ -68,43 +79,70 @@ export default function SegmentsPicker(props: SegmentPickerProps) {
filterValue: truncateKey(s.key),
displayValue: s.name
}))}
disabled={readonly}
selected={selectedSegment}
setSelected={(filterableSegment) => {
handleSegmentSelected(index, filterableSegment);
}}
inputClassName={
readonly
? 'cursor-not-allowed bg-gray-100 text-gray-500'
: undefined
}
/>
</div>
<div>
<button
type="button"
className="text-gray-400 mt-2 hover:text-gray-500"
onClick={() => handleSegmentRemove(index)}
>
<MinusSmallIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
{editing && parentSegments.length - 1 === index ? (
<div>
<button
type="button"
className={twMerge(`
text-gray-400 mt-2 hover:text-gray-500 ${
readonly ? 'hover:text-gray-400' : undefined
}`)}
onClick={() => setEditing(false)}
title={readonly ? 'Not allowed in Read-Only mode' : undefined}
disabled={readonly}
>
<PlusSmallIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
) : (
<div>
<button
type="button"
className="text-gray-400 mt-2 hover:text-gray-500"
onClick={() => handleSegmentRemove(index)}
title={readonly ? 'Not allowed in Read-Only mode' : undefined}
disabled={readonly}
>
<MinusSmallIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
)}
</div>
))}
<div className="w-full">
<div className="w-5/6">
<Combobox<FilterableSegment>
id={`segmentKey-${parentSegments.length}`}
name={`segmentKey-${parentSegments.length}`}
placeholder="Select or search for a segment"
values={segments
.filter((s) => !segmentsSet.current!.has(s.key))
.map((s) => ({
...s,
filterValue: truncateKey(s.key),
displayValue: s.name
}))}
selected={null}
setSelected={(filterableSegment) => {
handleSegmentSelected(parentSegments.length, filterableSegment);
}}
/>
{(!editing || parentSegments.length === 0) && (
<div className="w-full">
<div className="w-5/6">
<Combobox<FilterableSegment>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we set disabled=${readOnly} here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean like this?

Or something else?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yah, nvm i see that the above is only rendered when in 'editing' mode

id={`segmentKey-${parentSegments.length}`}
name={`segmentKey-${parentSegments.length}`}
placeholder="Select or search for a segment"
values={segments
.filter((s) => !segmentsSet.current!.has(s.key))
.map((s) => ({
...s,
filterValue: truncateKey(s.key),
displayValue: s.name
}))}
selected={null}
setSelected={(filterableSegment) => {
handleSegmentSelected(parentSegments.length, filterableSegment);
}}
/>
</div>
</div>
</div>
)}
</div>
);
}
18 changes: 16 additions & 2 deletions ui/src/components/forms/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useField } from 'formik';
import { twMerge } from 'tailwind-merge';

type SelectProps = {
id: string;
Expand All @@ -8,10 +9,20 @@ type SelectProps = {
className?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
disabled?: boolean;
};

export default function Select(props: SelectProps) {
const { id, name, options, children, className, value, onChange } = props;
const {
id,
name,
options,
children,
className,
value,
onChange,
disabled = false
} = props;

const [field] = useField({
name,
Expand All @@ -23,9 +34,12 @@ export default function Select(props: SelectProps) {
{...field}
id={id}
name={name}
className={`text-gray-900 bg-gray-50 border-gray-300 block rounded-md py-2 pl-3 pr-10 text-base focus:border-violet-300 focus:outline-none focus:ring-violet-300 sm:text-sm ${className}`}
className={twMerge(
`text-gray-900 bg-gray-50 border-gray-300 block rounded-md py-2 pl-3 pr-10 text-base focus:border-violet-300 focus:outline-none focus:ring-violet-300 sm:text-sm ${className}`
)}
value={value}
onChange={onChange || field.onChange}
disabled={disabled}
>
{options &&
options.map((option) => (
Expand Down
Loading