1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-17 13:46:47 +02:00

feat: add segments (#780)

* feat: create segmentation structure and list

* feat: remove unused deps and change route

* feat: change header style and add renderNoSegments

* fix: style table header

* feat: create useSegments hook

* feat: add segmentApi hook

* feat: create segment

* fix: errors

* feat: add contextfields list

* fix: remove user from create segment api

* feat: add form structure

* feat: add SegmentFormStepOne

* fix: tests and routes

* feat: add constraint view

* feat: UI to match the sketch

* feat: add constraint on context select

* fix: duplication

* fix adding constraints

Co-authored-by: olav <mail@olav.io>

* fix: input date not showing up in constraint view

Co-authored-by: olav <mail@olav.io>

* fix: minor bugs

Co-authored-by: olav <mail@olav.io>

* fix: create context modal in segment page

Co-authored-by: olav <mail@olav.io>

* fix: validate constraint before create segment

Co-authored-by: olav <mail@olav.io>

* feat: create useSegment hook

Co-authored-by: olav <mail@olav.io>

* feat: create edit component

Co-authored-by: olav <mail@olav.io>

* refactor: move constraint validation endpoint

* refactor: add missing route snapshot

* refactor: fix segment constraints unsaved/editing state

* refactor: remove create segment from mobile header menu

* refactor: update segments form description

* refactor: extract SegmentFormStepList component

* refactor: add an optional FormTemplate docs link label

* refactor: fix update segment payload

* feat: finish edit component

Co-authored-by: olav <mail@olav.io>

* refactor: move step list above segment form

* fix: update PR based on feedback

Co-authored-by: olav <mail@olav.io>

* refactor: fix constraint validation endpoint path

* refactor: improve constraint state field name

* refactor: extract AutocompleteBox component

* feat: add strategy segment selection

* refactor: add strategy segment previews

* refactor: fix double section separator line

* feat: disable deleting a usable segment

* refactor: warn about segments without constraints

* refactor: update text in delete segment dialogue

* refactur: improve arg names

* refactor: improve index var name

* refactor: clarify steps list logic

* refactor: use a required prop for the segment name

* refactor: use ConditionallyRender for segment deletion

* refactor: fix segments refetch

* refactor: improve CreateUnleashContext component names

* refactor: adjust segment form styles

* refactor: adjust text

* refactor: fix info icon tooltip hover target

* refactor: add missing aria attrs to preview button

* refactor: add strat name to delete segment modal

* refactor: fix segment chip text alighment

* refactor: use bulk endpoint for strategy segments

* refactor: fix imports after merge

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
Co-authored-by: olav <mail@olav.io>
This commit is contained in:
Youssef Khedher 2022-03-29 08:30:57 +01:00 committed by GitHub
parent 8568573b4b
commit eeda7ab5e4
63 changed files with 2052 additions and 448 deletions

View File

@ -0,0 +1,32 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
alignItems: 'center',
borderRadius: '1rem',
},
icon: {
background: theme.palette.primary.main,
height: 56,
display: 'flex',
alignItems: 'center',
width: 56,
justifyContent: 'center',
paddingLeft: 6,
borderTopLeftRadius: 50,
borderBottomLeftRadius: 50,
color: '#fff',
},
autocomplete: {
flex: 1,
},
inputRoot: {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
'& fieldset': {
borderColor: theme.palette,
borderLeftColor: 'transparent',
},
},
}));

View File

@ -0,0 +1,48 @@
import { useStyles } from 'component/common/AutocompleteBox/AutocompleteBox.styles';
import { Search, ArrowDropDown } from '@material-ui/icons';
import { Autocomplete, AutocompleteRenderInputParams } from '@material-ui/lab';
import { TextField } from '@material-ui/core';
interface IAutocompleteBoxProps {
label: string;
options: IAutocompleteBoxOption[];
value?: IAutocompleteBoxOption[];
onChange: (value: IAutocompleteBoxOption[]) => void;
}
export interface IAutocompleteBoxOption {
value: string;
label: string;
}
export const AutocompleteBox = ({
label,
options,
value = [],
onChange,
}: IAutocompleteBoxProps) => {
const styles = useStyles();
const renderInput = (params: AutocompleteRenderInputParams) => {
return <TextField {...params} variant="outlined" label={label} />;
};
return (
<div className={styles.container}>
<div className={styles.icon} aria-hidden>
<Search />
</div>
<Autocomplete
className={styles.autocomplete}
classes={{ inputRoot: styles.inputRoot }}
options={options}
value={value}
popupIcon={<ArrowDropDown />}
onChange={(event, value) => onChange(value || [])}
renderInput={renderInput}
getOptionLabel={value => value.label}
multiple
/>
</div>
);
};

View File

@ -12,9 +12,9 @@ export const useStyles = makeStyles(theme => ({
code: {
margin: 0,
wordBreak: 'break-all',
color: '#fff',
whiteSpace: 'pre-wrap',
fontSize: theme.fontSizes.smallBody,
color: '#fff',
fontSize: 14,
},
icon: {
fill: '#fff',

View File

@ -7,12 +7,12 @@ import { ConstraintAccordionView } from './ConstraintAccordionView/ConstraintAcc
interface IConstraintAccordionProps {
compact: boolean;
editing: boolean;
environmentId: string;
environmentId?: string;
constraint: IConstraint;
onEdit: () => void;
onCancel: () => void;
onDelete: () => void;
onSave: (constraint: IConstraint) => void;
onEdit?: () => void;
onDelete?: () => void;
onSave?: (constraint: IConstraint) => void;
}
export const ConstraintAccordion = ({
@ -29,12 +29,12 @@ export const ConstraintAccordion = ({
return (
<ConditionallyRender
condition={editing}
condition={Boolean(editing && onSave)}
show={
<ConstraintAccordionEdit
constraint={constraint}
onCancel={onCancel}
onSave={onSave}
onSave={onSave!}
compact={compact}
/>
}

View File

@ -13,8 +13,6 @@ import { cleanConstraint } from 'utils/cleanConstraint';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useParams } from 'react-router-dom';
import { IFeatureViewParams } from 'interfaces/params';
import { IUnleashContextDefinition } from 'interfaces/context';
import { useConstraintInput } from './ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
import { Operator } from 'constants/operators';
@ -63,7 +61,6 @@ export const ConstraintAccordionEdit = ({
const [contextDefinition, setContextDefinition] = useState(
resolveContextDefinition(context, localConstraint.contextName)
);
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { validateConstraint } = useFeatureApi();
const [expanded, setExpanded] = useState(false);
const [action, setAction] = useState('');
@ -160,8 +157,7 @@ export const ConstraintAccordionEdit = ({
if (typeValidatorResult) {
try {
await validateConstraint(projectId, featureId, localConstraint);
await validateConstraint(localConstraint);
setError('');
setAction(SAVE);
triggerTransition();

View File

@ -0,0 +1,154 @@
import { IConstraint } from 'interfaces/strategy';
import React, { forwardRef, useImperativeHandle } from 'react';
import { ConstraintAccordion } from 'component/common/ConstraintAccordion/ConstraintAccordion';
import produce from 'immer';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import {
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_STRATEGY,
} from 'component/providers/AccessProvider/permissions';
import { useWeakMap } from 'hooks/useWeakMap';
import { objectId } from 'utils/objectId';
import { useStyles } from './ConstraintAccordionList.styles';
import { createEmptyConstraint } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import ConditionallyRender from 'component/common/ConditionallyRender';
interface IConstraintAccordionListProps {
projectId?: string;
environmentId?: string;
constraints: IConstraint[];
setConstraints?: React.Dispatch<React.SetStateAction<IConstraint[]>>;
showCreateButton?: boolean;
}
// Ref methods exposed by this component.
export interface IConstraintAccordionListRef {
addConstraint?: (contextName: string) => void;
}
// Extra form state for each constraint.
interface IConstraintAccordionListItemState {
// Is the constraint new (never been saved)?
new?: boolean;
// Is the constraint currently being edited?
editing?: boolean;
}
export const constraintAccordionListId = 'constraintAccordionListId';
export const ConstraintAccordionList = forwardRef<
IConstraintAccordionListRef | undefined,
IConstraintAccordionListProps
>(
(
{
projectId,
environmentId,
constraints,
setConstraints,
showCreateButton,
},
ref
) => {
const state = useWeakMap<
IConstraint,
IConstraintAccordionListItemState
>();
const { context } = useUnleashContext();
const styles = useStyles();
const addConstraint =
setConstraints &&
((contextName: string) => {
const constraint = createEmptyConstraint(contextName);
state.set(constraint, { editing: true, new: true });
setConstraints(prev => [...prev, constraint]);
});
useImperativeHandle(ref, () => ({
addConstraint,
}));
const onAdd =
addConstraint &&
(() => {
addConstraint(context[0].name);
});
const onEdit =
setConstraints &&
((constraint: IConstraint) => {
state.set(constraint, { editing: true });
});
const onRemove =
setConstraints &&
((index: number) => {
const constraint = constraints[index];
state.set(constraint, {});
setConstraints(
produce(draft => {
draft.splice(index, 1);
})
);
});
const onSave =
setConstraints &&
((index: number, constraint: IConstraint) => {
state.set(constraint, {});
setConstraints(
produce(draft => {
draft[index] = constraint;
})
);
});
const onCancel = (index: number) => {
const constraint = constraints[index];
state.get(constraint)?.new && onRemove?.(index);
state.set(constraint, {});
};
if (context.length === 0) {
return null;
}
return (
<div className={styles.container} id={constraintAccordionListId}>
<ConditionallyRender
condition={Boolean(showCreateButton && setConstraints)}
show={
<PermissionButton
type="button"
onClick={onAdd}
variant="text"
permission={[
UPDATE_FEATURE_STRATEGY,
CREATE_FEATURE_STRATEGY,
]}
environmentId={environmentId}
projectId={projectId}
>
Add custom constraint
</PermissionButton>
}
/>
{constraints.map((constraint, index) => (
<ConstraintAccordion
key={objectId(constraint)}
environmentId={environmentId}
constraint={constraint}
onEdit={onEdit && onEdit.bind(null, constraint)}
onCancel={onCancel.bind(null, index)}
onDelete={onRemove && onRemove.bind(null, index)}
onSave={onSave && onSave.bind(null, index)}
editing={Boolean(state.get(constraint)?.editing)}
compact
/>
))}
</div>
);
}
);

View File

@ -0,0 +1,21 @@
import { dateOperators } from 'constants/operators';
import { IConstraint } from 'interfaces/strategy';
import { oneOf } from 'utils/oneOf';
import { operatorsForContext } from 'utils/operatorUtils';
export const createEmptyConstraint = (contextName: string): IConstraint => {
const operator = operatorsForContext(contextName)[0];
const value = oneOf(dateOperators, operator)
? new Date().toISOString()
: '';
return {
contextName,
operator,
value,
values: [],
caseInsensitive: false,
inverted: false,
};
};

View File

@ -17,10 +17,10 @@ import {
import { useStyles } from '../ConstraintAccordion.styles';
interface IConstraintAccordionViewProps {
environmentId: string;
environmentId?: string;
constraint: IConstraint;
onDelete: () => void;
onEdit: () => void;
onDelete?: () => void;
onEdit?: () => void;
compact: boolean;
}

View File

@ -17,10 +17,10 @@ import { useLocationSettings } from 'hooks/useLocationSettings';
interface IConstraintAccordionViewHeaderProps {
compact: boolean;
constraint: IConstraint;
onDelete: () => void;
onEdit: () => void;
onDelete?: () => void;
onEdit?: () => void;
singleValue: boolean;
environmentId: string;
environmentId?: string;
}
export const ConstraintAccordionViewHeader = ({
@ -38,15 +38,19 @@ export const ConstraintAccordionViewHeader = ({
const minWidthHeader = compact || smallScreen ? '100px' : '175px';
const onEditClick = (event: React.SyntheticEvent) => {
event.stopPropagation();
onEdit();
};
const onEditClick =
onEdit &&
((event: React.SyntheticEvent) => {
event.stopPropagation();
onEdit();
});
const onDeleteClick = (event: React.SyntheticEvent) => {
event.stopPropagation();
onDelete();
};
const onDeleteClick =
onDelete &&
((event: React.SyntheticEvent) => {
event.stopPropagation();
onDelete();
});
return (
<div className={styles.headerContainer}>
@ -92,22 +96,33 @@ export const ConstraintAccordionViewHeader = ({
</div>
</div>
<div className={styles.headerActions}>
<PermissionIconButton
onClick={onEditClick}
permission={UPDATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={environmentId}
>
<Edit titleAccess="edit constraint" />
</PermissionIconButton>
<PermissionIconButton
onClick={onDeleteClick}
permission={UPDATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={environmentId}
>
<Delete titleAccess="delete constraint" />
</PermissionIconButton>
<ConditionallyRender
condition={Boolean(onEditClick)}
show={
<PermissionIconButton
onClick={onEditClick}
permission={UPDATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={environmentId}
hidden={!onEdit}
>
<Edit titleAccess="edit constraint" />
</PermissionIconButton>
}
/>
<ConditionallyRender
condition={Boolean(onDeleteClick)}
show={
<PermissionIconButton
onClick={onDeleteClick}
permission={UPDATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={environmentId}
>
<Delete titleAccess="delete constraint" />
</PermissionIconButton>
}
/>
</div>
</div>
);

View File

@ -24,6 +24,7 @@ interface IDialogue {
disabledPrimaryButton?: boolean;
formId?: string;
permissionButton?: JSX.Element;
hideSecondaryButton?: boolean;
}
const Dialogue: React.FC<IDialogue> = ({
@ -39,6 +40,7 @@ const Dialogue: React.FC<IDialogue> = ({
fullWidth = false,
formId,
permissionButton,
hideSecondaryButton,
}) => {
const styles = useStyles();
const handleClick = formId
@ -92,7 +94,7 @@ const Dialogue: React.FC<IDialogue> = ({
/>
<ConditionallyRender
condition={Boolean(onClose)}
condition={Boolean(onClose || !hideSecondaryButton)}
show={
<Button onClick={onClose}>
{secondaryButtonText || 'No, take me back'}

View File

@ -1,5 +1,7 @@
import { makeStyles } from '@material-ui/core/styles';
export const formTemplateSidebarWidth = '27.5rem';
export const useStyles = makeStyles(theme => ({
container: {
minHeight: '80vh',
@ -8,8 +10,9 @@ export const useStyles = makeStyles(theme => ({
margin: '0 auto',
borderRadius: '1rem',
overflow: 'hidden',
[theme.breakpoints.down(900)]: {
[theme.breakpoints.down(1100)]: {
flexDirection: 'column',
minHeight: 0,
},
},
modal: {
@ -19,8 +22,10 @@ export const useStyles = makeStyles(theme => ({
sidebar: {
backgroundColor: theme.palette.primary.light,
padding: '2rem',
width: '35%',
[theme.breakpoints.down(900)]: {
flexGrow: 0,
flexShrink: 0,
width: formTemplateSidebarWidth,
[theme.breakpoints.down(1100)]: {
width: '100%',
},
[theme.breakpoints.down(500)]: {
@ -41,6 +46,8 @@ export const useStyles = makeStyles(theme => ({
},
description: {
color: '#fff',
zIndex: 1,
position: 'relative',
},
linkContainer: {
margin: '1.5rem 0',
@ -59,9 +66,12 @@ export const useStyles = makeStyles(theme => ({
backgroundColor: '#fff',
display: 'flex',
flexDirection: 'column',
padding: '2rem',
width: '65%',
[theme.breakpoints.down(900)]: {
padding: '3rem',
flexGrow: 1,
[theme.breakpoints.down(1200)]: {
padding: '2rem',
},
[theme.breakpoints.down(1100)]: {
width: '100%',
},
[theme.breakpoints.down(500)]: {
@ -70,15 +80,12 @@ export const useStyles = makeStyles(theme => ({
},
icon: { fill: '#fff' },
mobileGuidanceBgContainer: {
zIndex: 1,
position: 'absolute',
right: '-3px',
top: '-3px',
backgroundColor: theme.palette.primary.light,
right: -3,
top: -3,
},
mobileGuidanceBackground: {
position: 'absolute',
right: '-3px',
top: '-3px',
width: '75px',
height: '75px',
},

View File

@ -16,21 +16,18 @@ interface ICreateProps {
title: string;
description: string;
documentationLink: string;
documentationLinkLabel?: string;
loading?: boolean;
modal?: boolean;
formatApiCode: () => string;
}
// Components in this file:
// FormTemplate
// MobileGuidance
// Guidance
const FormTemplate: React.FC<ICreateProps> = ({
title,
description,
children,
documentationLink,
documentationLinkLabel,
loading,
modal,
formatApiCode,
@ -38,7 +35,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
const { setToastData } = useToast();
const styles = useStyles();
const commonStyles = useCommonStyles();
const smallScreen = useMediaQuery(`(max-width:${899}px)`);
const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
const copyCommand = () => {
if (copy(formatApiCode())) {
@ -71,6 +68,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
<MobileGuidance
description={description}
documentationLink={documentationLink}
documentationLinkLabel={documentationLinkLabel}
/>
</div>
}
@ -93,6 +91,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
<Guidance
description={description}
documentationLink={documentationLink}
documentationLinkLabel={documentationLinkLabel}
>
<h3 className={styles.subtitle}>
API Command{' '}
@ -111,11 +110,13 @@ const FormTemplate: React.FC<ICreateProps> = ({
interface IMobileGuidance {
description: string;
documentationLink: string;
documentationLinkLabel?: string;
}
const MobileGuidance = ({
description,
documentationLink,
documentationLinkLabel,
}: IMobileGuidance) => {
const [open, setOpen] = useState(false);
const styles = useStyles();
@ -135,6 +136,7 @@ const MobileGuidance = ({
<Guidance
description={description}
documentationLink={documentationLink}
documentationLinkLabel={documentationLinkLabel}
/>
</Collapse>
</>
@ -144,12 +146,14 @@ const MobileGuidance = ({
interface IGuidanceProps {
description: string;
documentationLink: string;
documentationLinkLabel?: string;
}
const Guidance: React.FC<IGuidanceProps> = ({
description,
children,
documentationLink,
documentationLinkLabel = 'Learn more',
}) => {
const styles = useStyles();
@ -165,7 +169,7 @@ const Guidance: React.FC<IGuidanceProps> = ({
rel="noopener noreferrer"
target="_blank"
>
Learn more
{documentationLinkLabel}
</a>
</div>

View File

@ -7,7 +7,8 @@ export const useStyles = makeStyles(() => ({
right: 0,
bottom: 0,
height: '100vh',
maxWidth: 1300,
maxWidth: '90vw',
width: 1300,
overflow: 'auto',
boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)',
},

View File

@ -1,4 +1,3 @@
import { useHistory } from 'react-router-dom';
import { CreateButton } from 'component/common/CreateButton/CreateButton';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { useContextForm } from '../hooks/useContextForm';
@ -10,10 +9,19 @@ import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashCon
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
export const CreateContext = () => {
interface ICreateContextProps {
onSubmit: () => void;
onCancel: () => void;
modal?: boolean;
}
export const CreateUnleashContext = ({
onSubmit,
onCancel,
modal,
}: ICreateContextProps) => {
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const history = useHistory();
const {
contextName,
contextDesc,
@ -34,6 +42,7 @@ export const CreateContext = () => {
const handleSubmit = async (e: Event) => {
e.preventDefault();
e.stopPropagation();
const validName = await validateContext();
if (validName) {
@ -41,12 +50,12 @@ export const CreateContext = () => {
try {
await createContext(payload);
refetchUnleashContext();
history.push('/context');
setToastData({
title: 'Context created',
confetti: true,
type: 'success',
});
onSubmit();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
@ -62,10 +71,6 @@ export const CreateContext = () => {
--data-raw '${JSON.stringify(getContextPayload(), undefined, 2)}'`;
};
const onCancel = () => {
history.goBack();
};
return (
<FormTemplate
loading={loading}
@ -74,6 +79,7 @@ export const CreateContext = () => {
They can be used together with strategy constraints as part of the activation strategy evaluation."
documentationLink="https://docs.getunleash.io/how-to/how-to-define-custom-context-fields"
formatApiCode={formatApiCode}
modal={modal}
>
<ContextForm
errors={errors}

View File

@ -0,0 +1,12 @@
import { useHistory } from 'react-router-dom';
import { CreateUnleashContext } from 'component/context/CreateUnleashContext/CreateUnleashContext';
export const CreateUnleashContextPage = () => {
const { push, goBack } = useHistory();
return (
<CreateUnleashContext
onSubmit={() => push('/context')}
onCancel={() => goBack()}
/>
);
};

View File

@ -63,7 +63,7 @@ export const FeatureStrategyConstraints = ({
};
return (
<>
<div>
<List disablePadding dense>
{strategy.constraints?.map((constraint, index) => (
<ListItem key={index} disableGutters dense>
@ -101,7 +101,7 @@ export const FeatureStrategyConstraints = ({
>
Add constraints
</PermissionButton>
</>
</div>
);
};

View File

@ -0,0 +1,40 @@
import { IConstraint, IFeatureStrategy } from 'interfaces/strategy';
import React, { useMemo } from 'react';
import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
interface IFeatureStrategyConstraintsProps {
projectId: string;
environmentId: string;
strategy: Partial<IFeatureStrategy>;
setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>>
>;
}
export const FeatureStrategyConstraintsCO = ({
projectId,
environmentId,
strategy,
setStrategy,
}: IFeatureStrategyConstraintsProps) => {
const constraints = useMemo(() => {
return strategy.constraints ?? [];
}, [strategy]);
const setConstraints = (value: React.SetStateAction<IConstraint[]>) => {
setStrategy(prev => ({
...prev,
constraints: value instanceof Function ? value(constraints) : value,
}));
};
return (
<ConstraintAccordionList
projectId={projectId}
environmentId={environmentId}
constraints={constraints}
setConstraints={setConstraints}
showCreateButton
/>
);
};

View File

@ -1,116 +0,0 @@
import { IFeatureStrategy, IConstraint } from 'interfaces/strategy';
import React from 'react';
import { ConstraintAccordion } from 'component/common/ConstraintAccordion/ConstraintAccordion';
import produce from 'immer';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import {
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_STRATEGY,
} from 'component/providers/AccessProvider/permissions';
import { createEmptyConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints2/createEmptyConstraint';
import { useWeakMap } from 'hooks/useWeakMap';
import { objectId } from 'utils/objectId';
import { useStyles } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2.styles';
interface IFeatureStrategyConstraints2Props {
projectId: string;
environmentId: string;
strategy: Partial<IFeatureStrategy>;
setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>>
>;
}
// Extra form state for each constraint.
interface IConstraintFormState {
// Is the constraint currently being edited?
editing?: boolean;
// Is the constraint new (not yet saved)?
unsaved?: boolean;
}
export const FeatureStrategyConstraints2 = ({
projectId,
environmentId,
strategy,
setStrategy,
}: IFeatureStrategyConstraints2Props) => {
const state = useWeakMap<IConstraint, IConstraintFormState>();
const { context } = useUnleashContext();
const { constraints = [] } = strategy;
const styles = useStyles();
const onEdit = (constraint: IConstraint) => {
state.set(constraint, { editing: true });
};
const onAdd = () => {
const constraint = createEmptyConstraint(context);
state.set(constraint, { editing: true, unsaved: true });
setStrategy(
produce(draft => {
draft.constraints = draft.constraints ?? [];
draft.constraints.push(constraint);
})
);
};
const onCancel = (index: number) => {
const constraint = constraints[index];
state.get(constraint)?.unsaved && onRemove(index);
state.set(constraint, {});
};
const onRemove = (index: number) => {
const constraint = constraints[index];
state.set(constraint, {});
setStrategy(
produce(draft => {
draft.constraints?.splice(index, 1);
})
);
};
const onSave = (index: number, constraint: IConstraint) => {
state.set(constraint, {});
setStrategy(
produce(draft => {
draft.constraints = draft.constraints ?? [];
draft.constraints[index] = constraint;
})
);
};
if (context.length === 0) {
return null;
}
return (
<div className={styles.container}>
<PermissionButton
type="button"
onClick={onAdd}
variant="text"
permission={[UPDATE_FEATURE_STRATEGY, CREATE_FEATURE_STRATEGY]}
environmentId={environmentId}
projectId={projectId}
>
Add constraint
</PermissionButton>
{strategy.constraints?.map((constraint, index) => (
<ConstraintAccordion
key={objectId(constraint)}
environmentId={environmentId}
constraint={constraint}
onEdit={onEdit.bind(null, constraint)}
onCancel={onCancel.bind(null, index, constraint)}
onDelete={onRemove.bind(null, index, constraint)}
onSave={onSave.bind(null, index)}
editing={Boolean(state.get(constraint)?.editing)}
compact
/>
))}
</div>
);
};

View File

@ -1,19 +0,0 @@
import { IUnleashContextDefinition } from 'interfaces/context';
import { IConstraint } from 'interfaces/strategy';
export const createEmptyConstraint = (
context: IUnleashContextDefinition[]
): IConstraint => {
if (context.length === 0) {
throw new Error('Expected at least one context definition');
}
return {
contextName: context[0].name,
operator: 'IN',
values: [],
value: '',
caseInsensitive: false,
inverted: false,
};
};

View File

@ -19,6 +19,9 @@ import {
import { getStrategyObject } from 'utils/getStrategyObject';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import { formatStrategyName } from 'utils/strategyNames';
export const FeatureStrategyCreate = () => {
const projectId = useRequiredPathParam('projectId');
@ -26,9 +29,11 @@ export const FeatureStrategyCreate = () => {
const environmentId = useRequiredQueryParam('environmentId');
const strategyName = useRequiredQueryParam('strategyName');
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]);
const { strategies } = useStrategies();
const { addStrategyToFeature, loading } = useFeatureStrategyApi();
const { setStrategySegments } = useSegmentsApi();
const { feature, refetchFeature } = useFeature(projectId, featureId);
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
@ -42,12 +47,18 @@ export const FeatureStrategyCreate = () => {
const onSubmit = async () => {
try {
await addStrategyToFeature(
const created = await addStrategyToFeature(
projectId,
featureId,
environmentId,
createStrategyPayload(strategy)
);
await setStrategySegments({
environmentId,
projectId,
strategyId: created.id,
segmentIds: segments.map(s => s.id),
});
setToastData({
title: 'Strategy created',
type: 'success',
@ -63,7 +74,7 @@ export const FeatureStrategyCreate = () => {
return (
<FormTemplate
modal
title="Add feature strategy"
title={formatStrategyName(strategyName)}
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
formatApiCode={() =>
@ -80,6 +91,8 @@ export const FeatureStrategyCreate = () => {
feature={feature}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environmentId}
onSubmit={onSubmit}
loading={loading}

View File

@ -11,6 +11,10 @@ import { useHistory } from 'react-router-dom';
import useToast from 'hooks/useToast';
import { IFeatureStrategy, IStrategyPayload } from 'interfaces/strategy';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { formatStrategyName } from 'utils/strategyNames';
export const FeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId');
@ -19,8 +23,11 @@ export const FeatureStrategyEdit = () => {
const strategyId = useRequiredQueryParam('strategyId');
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]);
const { updateStrategyOnFeature, loading } = useFeatureStrategyApi();
const { segments: savedStrategySegments } = useSegments(strategyId);
const { feature, refetchFeature } = useFeature(projectId, featureId);
const { setStrategySegments } = useSegmentsApi();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
@ -33,6 +40,11 @@ export const FeatureStrategyEdit = () => {
setStrategy(prev => ({ ...prev, ...savedStrategy }));
}, [strategyId, feature]);
useEffect(() => {
// Fill in the selected segments once they've been fetched.
savedStrategySegments && setSegments(savedStrategySegments);
}, [savedStrategySegments]);
const onSubmit = async () => {
try {
await updateStrategyOnFeature(
@ -42,6 +54,12 @@ export const FeatureStrategyEdit = () => {
strategyId,
createStrategyPayload(strategy)
);
await setStrategySegments({
environmentId,
projectId,
strategyId,
segmentIds: segments.map(s => s.id),
});
setToastData({
title: 'Strategy updated',
type: 'success',
@ -62,7 +80,7 @@ export const FeatureStrategyEdit = () => {
return (
<FormTemplate
modal
title="Edit feature strategy"
title={formatStrategyName(strategy.name ?? '')}
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
formatApiCode={() =>
@ -79,6 +97,8 @@ export const FeatureStrategyEdit = () => {
feature={feature}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environmentId}
onSubmit={onSubmit}
loading={loading}

View File

@ -2,13 +2,15 @@ import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
form: {
'& > * + *': {
paddingTop: theme.spacing(4),
marginTop: theme.spacing(4),
borderTopStyle: 'solid',
borderTopWidth: 1,
borderTopColor: theme.palette.grey[200],
},
display: 'grid',
gap: '1rem',
},
hr: {
width: '100%',
height: 1,
margin: '1rem 0',
border: 'none',
background: theme.palette.grey[200],
},
title: {
display: 'grid',

View File

@ -1,9 +1,5 @@
import React, { useState, useContext } from 'react';
import { IFeatureStrategy } from 'interfaces/strategy';
import {
getFeatureStrategyIcon,
formatStrategyName,
} from 'utils/strategyNames';
import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType';
import { FeatureStrategyEnabled } from '../FeatureStrategyEnabled/FeatureStrategyEnabled';
import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints';
@ -18,12 +14,13 @@ import { formatFeaturePath } from '../FeatureStrategyEdit/FeatureStrategyEdit';
import { useHistory } from 'react-router-dom';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import ConditionallyRender from 'component/common/ConditionallyRender';
import { C } from 'component/common/flags';
import { STRATEGY_FORM_SUBMIT_ID } from 'testIds';
import { FeatureStrategyConstraints2 } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2';
import { useConstraintsValidation } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints2/useConstraintsValidation';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
import AccessContext from 'contexts/AccessContext';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { FeatureStrategyConstraintsCO } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintsCO';
import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment';
import { ISegment } from 'interfaces/segment';
interface IFeatureStrategyFormProps {
feature: IFeatureToggle;
@ -35,22 +32,25 @@ interface IFeatureStrategyFormProps {
setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>>
>;
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
}
export const FeatureStrategyForm = ({
feature,
strategy,
setStrategy,
environmentId,
permission,
onSubmit,
loading,
strategy,
setStrategy,
segments,
setSegments,
}: IFeatureStrategyFormProps) => {
const styles = useStyles();
const [showProdGuard, setShowProdGuard] = useState(false);
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId);
const StrategyIcon = getFeatureStrategyIcon(strategy.name ?? '');
const strategyName = formatStrategyName(strategy.name ?? '');
const { hasAccess } = useContext(AccessContext);
const { push } = useHistory();
@ -73,12 +73,6 @@ export const FeatureStrategyForm = ({
}
};
const hasValidConstraints = useConstraintsValidation(
feature.project,
feature.name,
strategy.constraints
);
if (uiConfigError) {
throw uiConfigError;
}
@ -88,9 +82,9 @@ export const FeatureStrategyForm = ({
return null;
}
// TODO(olav): Remove uiConfig.flags.CO when new constraints are released.
// TODO(olav): Remove FeatureStrategyConstraints when CO is out.
const FeatureStrategyConstraintsImplementation = uiConfig.flags.CO
? FeatureStrategyConstraints2
? FeatureStrategyConstraintsCO
: FeatureStrategyConstraints;
const disableSubmitButtonFromConstraints = uiConfig.flags.CO
? !hasValidConstraints
@ -98,29 +92,37 @@ export const FeatureStrategyForm = ({
return (
<form className={styles.form} onSubmit={onSubmitOrProdGuard}>
<h2 className={styles.title}>
<StrategyIcon className={styles.icon} aria-hidden />
<span className={styles.name}>{strategyName}</span>
</h2>
<div>
<FeatureStrategyEnabled
feature={feature}
environmentId={environmentId}
/>
</div>
<hr className={styles.hr} />
<ConditionallyRender
condition={Boolean(uiConfig.flags[C])}
condition={Boolean(uiConfig.flags.SE)}
show={
<div>
<FeatureStrategyConstraintsImplementation
projectId={feature.project}
environmentId={environmentId}
strategy={strategy}
setStrategy={setStrategy}
/>
</div>
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
/>
}
/>
<ConditionallyRender
condition={Boolean(uiConfig.flags.C)}
show={
<FeatureStrategyConstraintsImplementation
projectId={feature.project}
environmentId={environmentId}
strategy={strategy}
setStrategy={setStrategy}
/>
}
/>
<ConditionallyRender
condition={Boolean(uiConfig.flags.SE || uiConfig.flags.C)}
show={<hr className={styles.hr} />}
/>
<FeatureStrategyType
strategy={strategy}
setStrategy={setStrategy}
@ -130,6 +132,7 @@ export const FeatureStrategyForm = ({
environmentId
)}
/>
<hr className={styles.hr} />
<div className={styles.buttons}>
<PermissionButton
permission={permission}

View File

@ -0,0 +1,9 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
title: {
margin: 0,
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.fontWeight.bold,
},
}));

View File

@ -0,0 +1,60 @@
import React from 'react';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { ISegment } from 'interfaces/segment';
import {
AutocompleteBox,
IAutocompleteBoxOption,
} from 'component/common/AutocompleteBox/AutocompleteBox';
import { FeatureStrategySegmentList } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList';
import { useStyles } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.styles';
interface IFeatureStrategySegmentProps {
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
}
export const FeatureStrategySegment = ({
segments: selectedSegments,
setSegments: setSelectedSegments,
}: IFeatureStrategySegmentProps) => {
const { segments: allSegments } = useSegments();
const styles = useStyles();
if (!allSegments || allSegments.length === 0) {
return null;
}
const unusedSegments = allSegments.filter(segment => {
return !selectedSegments.find(selected => selected.id === segment.id);
});
const autocompleteOptions = unusedSegments.map(segment => ({
value: String(segment.id),
label: segment.name,
}));
const onChange = ([option]: IAutocompleteBoxOption[]) => {
const selectedSegment = allSegments.find(segment => {
return String(segment.id) === option.value;
});
if (selectedSegment) {
setSelectedSegments(prev => [...prev, selectedSegment]);
}
};
return (
<>
<h3 className={styles.title}>Segmentation</h3>
<p>Add a predefined segment to constrain this feature toggle:</p>
<AutocompleteBox
label="Select segments"
options={autocompleteOptions}
onChange={onChange}
/>
<FeatureStrategySegmentList
segments={selectedSegments}
setSegments={setSelectedSegments}
/>
</>
);
};

View File

@ -0,0 +1,29 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
chip: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
paddingInlineStart: '1rem',
paddingInlineEnd: '0.5rem',
paddingBlockStart: 4,
paddingBlockEnd: 4,
borderRadius: '100rem',
background: theme.palette.primary.main,
color: 'white',
},
link: {
marginRight: '.5rem',
color: 'inherit',
textDecoration: 'none',
},
button: {
all: 'unset',
height: '1rem',
cursor: 'pointer',
},
icon: {
fontSize: '1rem',
},
}));

View File

@ -0,0 +1,80 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ISegment } from 'interfaces/segment';
import { Clear, VisibilityOff, Visibility } from '@material-ui/icons';
import { useStyles } from './FeatureStrategySegmentChip.styles';
import ConditionallyRender from 'component/common/ConditionallyRender';
import { constraintAccordionListId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
interface IFeatureStrategySegmentListProps {
segment: ISegment;
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
preview?: ISegment;
setPreview: React.Dispatch<React.SetStateAction<ISegment | undefined>>;
}
export const FeatureStrategySegmentChip = ({
segment,
setSegments,
preview,
setPreview,
}: IFeatureStrategySegmentListProps) => {
const styles = useStyles();
const onRemove = () => {
setSegments(prev => {
return prev.filter(s => s.id !== segment.id);
});
setPreview(prev => {
return prev === segment ? undefined : prev;
});
};
const onTogglePreview = () => {
setPreview(prev => {
return prev === segment ? undefined : segment;
});
};
const togglePreviewIcon = (
<ConditionallyRender
condition={segment === preview}
show={
<VisibilityOff
titleAccess="Hide preview"
className={styles.icon}
/>
}
elseShow={
<Visibility
titleAccess="Show preview"
className={styles.icon}
/>
}
/>
);
return (
<span className={styles.chip}>
<Link
to={`/segments/edit/${segment.id}`}
target="_blank"
className={styles.link}
>
{segment.name}
</Link>
<button
type="button"
onClick={onTogglePreview}
className={styles.button}
aria-expanded={segment === preview}
aria-controls={constraintAccordionListId}
>
{togglePreviewIcon}
</button>
<button type="button" onClick={onRemove} className={styles.button}>
<Clear titleAccess="Remove" className={styles.icon} />
</button>
</span>
);
};

View File

@ -0,0 +1,25 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
title: {
margin: 0,
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.fontWeight.thin,
},
list: {
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
},
and: {
color: theme.palette.grey[600],
fontSize: theme.fontSizes.smallerBody,
border: '1px solid',
borderColor: theme.palette.grey[300],
paddingInline: '0.4rem',
marginBlock: '0.2rem',
display: 'grid',
alignItems: 'center',
borderRadius: theme.borders.radius.main,
},
}));

View File

@ -0,0 +1,57 @@
import React, { Fragment, useState } from 'react';
import { ISegment } from 'interfaces/segment';
import { useStyles } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles';
import ConditionallyRender from 'component/common/ConditionallyRender';
import { FeatureStrategySegmentChip } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip';
import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
interface IFeatureStrategySegmentListProps {
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
}
export const FeatureStrategySegmentList = ({
segments,
setSegments,
}: IFeatureStrategySegmentListProps) => {
const styles = useStyles();
const [preview, setPreview] = useState<ISegment>();
const lastSegmentIndex = segments.length - 1;
if (segments.length === 0) {
return null;
}
return (
<>
<div className={styles.list}>
{segments.map((segment, i) => (
<Fragment key={segment.id}>
<FeatureStrategySegmentChip
segment={segment}
setSegments={setSegments}
preview={preview}
setPreview={setPreview}
/>
<ConditionallyRender
condition={i < lastSegmentIndex}
show={<span className={styles.and}>AND</span>}
/>
</Fragment>
))}
</div>
<ConditionallyRender
condition={Boolean(preview && preview.constraints.length === 0)}
show={() => <p>This segment has no constraints.</p>}
/>
<ConditionallyRender
condition={Boolean(preview)}
show={() => (
<ConstraintAccordionList
constraints={preview!.constraints}
/>
)}
/>
</>
);
};

View File

@ -6,7 +6,7 @@ interface IDefaultStrategyProps {
}
const DefaultStrategy = ({ strategyDefinition }: IDefaultStrategyProps) => {
return <h6>{strategyDefinition?.description}</h6>;
return <p>{strategyDefinition?.description}</p>;
};
export default DefaultStrategy;

View File

@ -78,16 +78,16 @@ const FlexibleStrategy = ({
<br />
<div>
<Tooltip title="Stickiness defines what parameter should be used to ensure that your users get consistency in features. By default unleash will use the first value present in the context in the order of userId, sessionId and random.">
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
Stickiness
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
Stickiness
<Tooltip title="Stickiness defines what parameter should be used to ensure that your users get consistency in features. By default unleash will use the first value present in the context in the order of userId, sessionId and random.">
<Info
style={{
fontSize: '1rem',
@ -95,8 +95,8 @@ const FlexibleStrategy = ({
marginLeft: '0.2rem',
}}
/>
</Typography>
</Tooltip>
</Tooltip>
</Typography>
<Select
id="stickiness-select"
name="stickiness"
@ -112,16 +112,16 @@ const FlexibleStrategy = ({
&nbsp;
<br />
<br />
<Tooltip title="GroupId is used to ensure that different toggles will hash differently for the same user. The groupId defaults to feature toggle name, but you can override it to correlate rollout of multiple feature toggles.">
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
GroupId
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
GroupId
<Tooltip title="GroupId is used to ensure that different toggles will hash differently for the same user. The groupId defaults to feature toggle name, but you can override it to correlate rollout of multiple feature toggles.">
<Info
style={{
fontSize: '1rem',
@ -129,8 +129,8 @@ const FlexibleStrategy = ({
marginLeft: '0.2rem',
}}
/>
</Typography>
</Tooltip>
</Tooltip>
</Typography>
<Input
label="groupId"
value={groupId || ''}

View File

@ -286,6 +286,26 @@ Array [
"title": "Addons",
"type": "protected",
},
Object {
"component": [Function],
"flag": "SE",
"hidden": false,
"layout": "main",
"menu": Object {},
"path": "/segments/create",
"title": "Segments",
"type": "protected",
},
Object {
"component": [Function],
"flag": "SE",
"hidden": false,
"layout": "main",
"menu": Object {},
"path": "/segments/edit/:segmentId",
"title": "Segments",
"type": "protected",
},
Object {
"component": [Function],
"flag": "SE",

View File

@ -27,7 +27,6 @@ import EditUser from 'component/admin/users/EditUser/EditUser';
import { CreateApiToken } from 'component/admin/apiToken/CreateApiToken/CreateApiToken';
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment';
import { CreateContext } from 'component/context/CreateContext/CreateContext';
import { EditContext } from 'component/context/EditContext/EditContext';
import EditTagType from 'component/tags/EditTagType/EditTagType';
import CreateTagType from 'component/tags/CreateTagType/CreateTagType';
@ -46,8 +45,11 @@ import { EventHistoryPage } from 'component/history/EventHistoryPage/EventHistor
import { FeatureEventHistoryPage } from 'component/history/FeatureEventHistoryPage/FeatureEventHistoryPage';
import { CreateStrategy } from 'component/strategies/CreateStrategy/CreateStrategy';
import { EditStrategy } from 'component/strategies/EditStrategy/EditStrategy';
import { SegmentsList } from 'component/segments/SegmentList/SegmentList';
import { SplashPage } from 'component/splash/SplashPage/SplashPage';
import { CreateUnleashContextPage } from 'component/context/CreateUnleashContext/CreateUnleashContextPage';
import { CreateSegment } from 'component/segments/CreateSegment/CreateSegment';
import { EditSegment } from 'component/segments/EditSegment/EditSegment';
import { SegmentsList } from 'component/segments/SegmentList/SegmentList';
export const routes = [
// Splash
@ -198,7 +200,7 @@ export const routes = [
path: '/context/create',
parent: '/context',
title: 'Create',
component: CreateContext,
component: CreateUnleashContextPage,
type: 'protected',
flag: C,
menu: {},
@ -329,7 +331,26 @@ export const routes = [
},
// Segments
{
path: '/segments/create',
title: 'Segments',
component: CreateSegment,
hidden: false,
type: 'protected',
layout: 'main',
menu: {},
flag: SE,
},
{
path: '/segments/edit/:segmentId',
title: 'Segments',
component: EditSegment,
hidden: false,
type: 'protected',
layout: 'main',
menu: {},
flag: SE,
},
{
path: '/segments',
title: 'Segments',

View File

@ -0,0 +1,100 @@
import { CreateButton } from 'component/common/CreateButton/CreateButton';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { CREATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useSegmentForm } from '../hooks/useSegmentForm';
import { SegmentForm } from '../SegmentForm/SegmentForm';
export const CreateSegment = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const history = useHistory();
const { createSegment, loading } = useSegmentsApi();
const { refetchSegments } = useSegments();
const {
name,
setName,
description,
setDescription,
constraints,
setConstraints,
getSegmentPayload,
errors,
clearErrors,
} = useSegmentForm();
const hasValidConstraints = useConstraintsValidation(constraints);
const formatApiCode = () => {
return `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/segments' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getSegmentPayload(), undefined, 2)}'`;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
try {
await createSegment(getSegmentPayload());
await refetchSegments();
history.push('/segments/');
setToastData({
title: 'Segment created',
confetti: true,
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<FormTemplate
loading={loading}
title="Create segment"
description={segmentsFormDescription}
documentationLink={segmentsFormDocsLink}
documentationLinkLabel="More about segments"
formatApiCode={formatApiCode}
>
<SegmentForm
handleSubmit={handleSubmit}
name={name}
setName={setName}
description={description}
setDescription={setDescription}
constraints={constraints}
setConstraints={setConstraints}
mode="Create"
errors={errors}
clearErrors={clearErrors}
>
<CreateButton
name="segment"
permission={CREATE_SEGMENT}
disabled={!hasValidConstraints}
/>
</SegmentForm>
</FormTemplate>
);
};
export const segmentsFormDescription = `
Segments make it easy for you to define which of your users should get access to a feature.
A segment is a reusable collection of constraints.
You can create and apply a segment when configuring activation strategies for a feature toggle or at any time from the segments page in the navigation menu.
`;
// TODO(olav): Update link when the segments docs are ready.
export const segmentsFormDocsLink = 'https://docs.getunleash.io';

View File

@ -0,0 +1,103 @@
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { UPDATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
import { useSegment } from 'hooks/api/getters/useSegment/useSegment';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useToast from 'hooks/useToast';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useSegmentForm } from '../hooks/useSegmentForm';
import { SegmentForm } from '../SegmentForm/SegmentForm';
import {
segmentsFormDocsLink,
segmentsFormDescription,
} from 'component/segments/CreateSegment/CreateSegment';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
export const EditSegment = () => {
const segmentId = useRequiredPathParam('segmentId');
const { segment } = useSegment(Number(segmentId));
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const history = useHistory();
const { updateSegment, loading } = useSegmentsApi();
const { refetchSegments } = useSegments();
const {
name,
setName,
description,
setDescription,
constraints,
setConstraints,
getSegmentPayload,
errors,
clearErrors,
} = useSegmentForm(
segment?.name,
segment?.description,
segment?.constraints
);
const hasValidConstraints = useConstraintsValidation(constraints);
const formatApiCode = () => {
return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/segments/${segmentId}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getSegmentPayload(), undefined, 2)}'`;
};
const handleSubmit = async (e: React.FormEvent) => {
if (segment) {
e.preventDefault();
clearErrors();
try {
await updateSegment(segment.id, getSegmentPayload());
await refetchSegments();
history.push('/segments/');
setToastData({
title: 'Segment updated',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
return (
<FormTemplate
loading={loading}
title="Edit segment"
description={segmentsFormDescription}
documentationLink={segmentsFormDocsLink}
documentationLinkLabel="More about segments"
formatApiCode={formatApiCode}
>
<SegmentForm
handleSubmit={handleSubmit}
name={name}
setName={setName}
description={description}
setDescription={setDescription}
constraints={constraints}
setConstraints={setConstraints}
mode="Edit"
errors={errors}
clearErrors={clearErrors}
>
<UpdateButton
permission={UPDATE_SEGMENT}
disabled={!hasValidConstraints}
/>
</SegmentForm>
</FormTemplate>
);
};

View File

@ -0,0 +1,43 @@
import ConditionallyRender from 'component/common/ConditionallyRender';
import { useStrategiesBySegment } from 'hooks/api/getters/useStrategiesBySegment/useStrategiesBySegment';
import { ISegment } from 'interfaces/segment';
import React from 'react';
import { SegmentDeleteConfirm } from './SegmentDeleteConfirm/SegmentDeleteConfirm';
import { SegmentDeleteUsedSegment } from './SegmentDeleteUsedSegment/SegmentDeleteUsedSegment';
interface ISegmentDeleteProps {
segment: ISegment;
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteSegment: (id: number) => Promise<void>;
}
export const SegmentDelete = ({
segment,
open,
setDeldialogue,
handleDeleteSegment,
}: ISegmentDeleteProps) => {
const { strategies } = useStrategiesBySegment(segment.id);
const canDeleteSegment = strategies?.length === 0;
return (
<ConditionallyRender
condition={canDeleteSegment}
show={
<SegmentDeleteConfirm
segment={segment}
open={open}
setDeldialogue={setDeldialogue}
handleDeleteSegment={handleDeleteSegment}
/>
}
elseShow={
<SegmentDeleteUsedSegment
segment={segment}
open={open}
setDeldialogue={setDeldialogue}
strategies={strategies}
/>
}
/>
);
};

View File

@ -7,4 +7,9 @@ export const useStyles = makeStyles(theme => ({
deleteInput: {
marginTop: '1rem',
},
link: {
textDecoration: 'none',
color: theme.palette.primary.main,
fontWeight: theme.fontWeight.bold,
},
}));

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import Dialogue from 'component/common/Dialogue';
import Input from 'component/common/Input/Input';
import { useStyles } from './SegmentDeleteConfirm.styles';
@ -9,8 +9,6 @@ interface ISegmentDeleteConfirmProps {
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteSegment: (id: number) => Promise<void>;
confirmName: string;
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
}
export const SegmentDeleteConfirm = ({
@ -18,10 +16,9 @@ export const SegmentDeleteConfirm = ({
open,
setDeldialogue,
handleDeleteSegment,
confirmName,
setConfirmName,
}: ISegmentDeleteConfirmProps) => {
const styles = useStyles();
const [confirmName, setConfirmName] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmName(e.currentTarget.value);
@ -37,7 +34,10 @@ export const SegmentDeleteConfirm = ({
open={open}
primaryButtonText="Delete segment"
secondaryButtonText="Cancel"
onClick={() => handleDeleteSegment(segment.id)}
onClick={() => {
handleDeleteSegment(segment.id);
setConfirmName('');
}}
disabledPrimaryButton={segment?.name !== confirmName}
onClose={handleCancel}
formId={formId}

View File

@ -0,0 +1,71 @@
import React from 'react';
import Dialogue from 'component/common/Dialogue';
import { useStyles } from '../SegmentDeleteConfirm/SegmentDeleteConfirm.styles';
import { ISegment } from 'interfaces/segment';
import { IFeatureStrategy } from 'interfaces/strategy';
import { Link } from 'react-router-dom';
import { formatEditStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { formatStrategyName } from 'utils/strategyNames';
interface ISegmentDeleteUsedSegmentProps {
segment: ISegment;
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
strategies: IFeatureStrategy[] | undefined;
}
export const SegmentDeleteUsedSegment = ({
segment,
open,
setDeldialogue,
strategies,
}: ISegmentDeleteUsedSegmentProps) => {
const styles = useStyles();
const handleCancel = () => {
setDeldialogue(false);
};
return (
<Dialogue
title="You can't delete a segment that's currently in use"
open={open}
primaryButtonText="OK"
onClick={handleCancel}
hideSecondaryButton
>
<p>
The following feature toggles are using the{' '}
<strong>{segment.name}</strong> segment for their strategies:
</p>
<ul>
{strategies?.map(strategy => (
<li key={strategy.id}>
<Link
to={formatEditStrategyPath(
strategy.projectId!,
strategy.featureName!,
strategy.environment!,
strategy.id
)}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
{strategy.featureName!}{' '}
{formatStrategyNameParens(strategy)}
</Link>
</li>
))}
</ul>
</Dialogue>
);
};
const formatStrategyNameParens = (strategy: IFeatureStrategy): string => {
if (!strategy.strategyName) {
return '';
}
return `(${formatStrategyName(strategy.strategyName)})`;
};

View File

@ -0,0 +1,53 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
maxWidth: '400px',
},
form: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
input: { width: '100%', marginBottom: '1rem' },
label: {
minWidth: '300px',
[theme.breakpoints.down(600)]: {
minWidth: 'auto',
},
},
buttonContainer: {
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
},
cancelButton: {
marginLeft: '1.5rem',
},
inputDescription: {
marginBottom: '0.5rem',
},
formHeader: {
fontWeight: 'normal',
marginTop: '0',
},
header: {
fontWeight: 'normal',
},
errorMessage: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
position: 'absolute',
top: '-8px',
},
userInfoContainer: {
margin: '-20px 0',
},
errorAlert: {
marginBottom: '1rem',
},
flexRow: {
display: 'flex',
alignItems: 'center',
},
}));

View File

@ -0,0 +1,72 @@
import { IConstraint } from 'interfaces/strategy';
import { useStyles } from './SegmentForm.styles';
import { SegmentFormStepOne } from '../SegmentFormStepOne/SegmentFormStepOne';
import { SegmentFormStepTwo } from '../SegmentFormStepTwo/SegmentFormStepTwo';
import React, { useState } from 'react';
import { SegmentFormStepList } from 'component/segments/SegmentFormStepList/SegmentFormStepList';
import ConditionallyRender from 'component/common/ConditionallyRender';
export type SegmentFormStep = 1 | 2;
interface ISegmentProps {
name: string;
description: string;
constraints: IConstraint[];
setName: React.Dispatch<React.SetStateAction<string>>;
setDescription: React.Dispatch<React.SetStateAction<string>>;
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
handleSubmit: (e: any) => void;
errors: { [key: string]: string };
mode: 'Create' | 'Edit';
clearErrors: () => void;
}
export const SegmentForm: React.FC<ISegmentProps> = ({
children,
name,
description,
constraints,
setName,
setDescription,
setConstraints,
handleSubmit,
errors,
clearErrors,
}) => {
const styles = useStyles();
const totalSteps = 2;
const [currentStep, setCurrentStep] = useState<SegmentFormStep>(1);
return (
<>
<SegmentFormStepList total={totalSteps} current={currentStep} />
<form onSubmit={handleSubmit} className={styles.form}>
<ConditionallyRender
condition={currentStep === 1}
show={
<SegmentFormStepOne
name={name}
description={description}
setName={setName}
setDescription={setDescription}
errors={errors}
clearErrors={clearErrors}
setCurrentStep={setCurrentStep}
/>
}
/>
<ConditionallyRender
condition={currentStep === 2}
show={
<SegmentFormStepTwo
constraints={constraints}
setConstraints={setConstraints}
setCurrentStep={setCurrentStep}
>
{children}
</SegmentFormStepTwo>
}
/>
</form>
</>
);
};

View File

@ -0,0 +1,40 @@
import { makeStyles } from '@material-ui/core/styles';
import { formTemplateSidebarWidth } from 'component/common/FormTemplate/FormTemplate.styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
top: 30,
left: 0,
right: formTemplateSidebarWidth,
[theme.breakpoints.down(1100)]: {
right: 0,
},
},
steps: {
position: 'relative',
borderRadius: 10,
background: '#fff',
padding: '0.6rem 1.5rem',
margin: 'auto',
display: 'flex',
alignItems: 'center',
},
stepsText: {
marginRight: 15,
fontSize: theme.fontSizes.smallBody,
},
circle: {
fill: theme.palette.primary.main,
fontSize: 17,
opacity: 0.4,
transition: 'opacity 0.4s ease',
},
filledCircle: {
opacity: 1,
fontSize: 20,
},
}));

View File

@ -0,0 +1,40 @@
import { FiberManualRecord } from '@material-ui/icons';
import { useStyles } from './SegmentFormStepList.styles';
import React from 'react';
import classNames from 'classnames';
interface ISegmentFormStepListProps {
total: number;
current: number;
}
export const SegmentFormStepList: React.FC<ISegmentFormStepListProps> = ({
total,
current,
}) => {
const styles = useStyles();
// Create a list with all the step numbers, e.g. [1, 2, 3].
const steps: number[] = Array.from({ length: total }).map((_, i) => {
return i + 1;
});
return (
<div className={styles.container}>
<div className={styles.steps}>
<span className={styles.stepsText}>
Step {current} of {total}
</span>
{steps.map(step => (
<FiberManualRecord
key={step}
className={classNames(
styles.circle,
step === current && styles.filledCircle
)}
/>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,50 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
maxWidth: '400px',
},
form: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
input: { width: '100%', marginBottom: '1rem' },
label: {
minWidth: '300px',
[theme.breakpoints.down(600)]: {
minWidth: 'auto',
},
},
buttonContainer: {
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
},
cancelButton: {
marginLeft: '1.5rem',
color: theme.palette.primary.light,
},
inputDescription: {
marginBottom: '0.5rem',
},
header: {
fontWeight: 'normal',
},
errorMessage: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
position: 'absolute',
top: '-8px',
},
userInfoContainer: {
margin: '-20px 0',
},
errorAlert: {
marginBottom: '1rem',
},
flexRow: {
display: 'flex',
alignItems: 'center',
},
}));

View File

@ -0,0 +1,83 @@
import { Button } from '@material-ui/core';
import Input from 'component/common/Input/Input';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { useStyles } from 'component/segments/SegmentFormStepOne/SegmentFormStepOne.styles';
import { SegmentFormStep } from '../SegmentForm/SegmentForm';
interface ISegmentFormPartOneProps {
name: string;
description: string;
setName: React.Dispatch<React.SetStateAction<string>>;
setDescription: React.Dispatch<React.SetStateAction<string>>;
errors: { [key: string]: string };
clearErrors: () => void;
setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>;
}
export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
children,
name,
description,
setName,
setDescription,
errors,
clearErrors,
setCurrentStep,
}) => {
const history = useHistory();
const styles = useStyles();
return (
<div className={styles.form}>
<div className={styles.container}>
<p className={styles.inputDescription}>
What is the segment name?
</p>
<Input
className={styles.input}
label="Segment name"
value={name}
onChange={e => setName(e.target.value)}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
autoFocus
required
/>
<p className={styles.inputDescription}>
What is the segment description?
</p>
<Input
className={styles.input}
label="Description (optional)"
value={description}
onChange={e => setDescription(e.target.value)}
error={Boolean(errors.description)}
errorText={errors.description}
onFocus={() => clearErrors()}
/>
</div>
<div className={styles.buttonContainer}>
<Button
type="button"
variant="contained"
color="primary"
onClick={() => setCurrentStep(2)}
disabled={name.length === 0}
>
Next
</Button>
<Button
type="button"
className={styles.cancelButton}
onClick={() => {
history.push('/segments');
}}
>
Cancel
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,96 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {},
form: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
input: { width: '100%', marginBottom: '1rem' },
label: {
minWidth: '300px',
[theme.breakpoints.down(600)]: {
minWidth: 'auto',
},
},
buttonContainer: {
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
borderTop: `1px solid ${theme.palette.grey[300]}`,
paddingTop: 15,
},
cancelButton: {
marginLeft: '1.5rem',
color: theme.palette.primary.light,
},
inputDescription: {
marginBottom: '1rem',
},
formHeader: {
fontWeight: 'normal',
marginTop: '0',
},
header: {
fontWeight: 'normal',
},
errorMessage: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
position: 'absolute',
top: '-8px',
},
userInfoContainer: {
margin: '-20px 0',
},
errorAlert: {
marginBottom: '1rem',
},
flexRow: {
display: 'flex',
alignItems: 'center',
},
backButton: {
marginRight: 'auto',
color: theme.palette.primary.light,
},
addContextContainer: {
marginTop: '1rem',
borderBottom: `1px solid ${theme.palette.grey[300]}`,
paddingBottom: '2rem',
},
addContextButton: {
color: theme.palette.primary.dark,
background: 'transparent',
boxShadow: 'none',
border: '1px solid',
'&:hover': {
background: 'transparent',
boxShadow: 'none',
},
},
divider: {
borderStyle: 'solid',
borderColor: `${theme.palette.grey[300]}`,
marginTop: '1rem !important',
},
noConstraintText: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: '6rem',
},
subtitle: {
fontSize: theme.fontSizes.bodySize,
color: theme.palette.grey[600],
maxWidth: 515,
marginBottom: 20,
wordBreak: 'break-word',
whiteSpace: 'normal',
textAlign: 'center',
},
constraintContainer: {
marginBlock: '2rem',
},
}));

View File

@ -0,0 +1,134 @@
import React, { useRef, useState } from 'react';
import { Button } from '@material-ui/core';
import { Add } from '@material-ui/icons';
import ConditionallyRender from 'component/common/ConditionallyRender';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { CreateUnleashContext } from 'component/context/CreateUnleashContext/CreateUnleashContext';
import { CREATE_CONTEXT_FIELD } from 'component/providers/AccessProvider/permissions';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { IConstraint } from 'interfaces/strategy';
import { useHistory } from 'react-router-dom';
import { useStyles } from 'component/segments/SegmentFormStepTwo/SegmentFormStepTwo.styles';
import {
ConstraintAccordionList,
IConstraintAccordionListRef,
} from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
import { SegmentFormStep } from '../SegmentForm/SegmentForm';
import {
AutocompleteBox,
IAutocompleteBoxOption,
} from 'component/common/AutocompleteBox/AutocompleteBox';
interface ISegmentFormPartTwoProps {
constraints: IConstraint[];
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>;
}
export const SegmentFormStepTwo: React.FC<ISegmentFormPartTwoProps> = ({
children,
constraints,
setConstraints,
setCurrentStep,
}) => {
const constraintsAccordionListRef = useRef<IConstraintAccordionListRef>();
const history = useHistory();
const styles = useStyles();
const { context = [] } = useUnleashContext();
const [open, setOpen] = useState(false);
const autocompleteOptions = context.map(c => ({
value: c.name,
label: c.name,
}));
const onChange = ([option]: IAutocompleteBoxOption[]) => {
constraintsAccordionListRef.current?.addConstraint?.(option.value);
};
return (
<div className={styles.form}>
<div className={styles.container}>
<div>
<p className={styles.inputDescription}>
Select the context fields you want to include in the
segment.
</p>
<p className={styles.inputDescription}>
Use a predefined context field:
</p>
<AutocompleteBox
label="Select a context"
options={autocompleteOptions}
onChange={onChange}
/>
</div>
<div className={styles.addContextContainer}>
<p className={styles.inputDescription}>
...or add a new context field:
</p>
<SidebarModal
label="Create new context"
onClose={() => setOpen(false)}
open={open}
>
<CreateUnleashContext
onSubmit={() => setOpen(false)}
onCancel={() => setOpen(false)}
modal
/>
</SidebarModal>
<PermissionButton
permission={CREATE_CONTEXT_FIELD}
className={styles.addContextButton}
startIcon={<Add />}
onClick={() => setOpen(true)}
>
Add context field
</PermissionButton>
</div>
<ConditionallyRender
condition={constraints.length === 0}
show={
<div className={styles.noConstraintText}>
<p className={styles.subtitle}>
Start adding context fields by selecting an
option from above, or you can create a new
context field and use it right away
</p>
</div>
}
/>
<div className={styles.constraintContainer}>
<ConstraintAccordionList
ref={constraintsAccordionListRef}
constraints={constraints}
setConstraints={setConstraints}
/>
</div>
</div>
<div className={styles.buttonContainer}>
<Button
type="button"
onClick={() => setCurrentStep(1)}
className={styles.backButton}
>
Back
</Button>
{children}
<Button
type="button"
className={styles.cancelButton}
onClick={() => {
history.push('/segments');
}}
>
Cancel
</Button>
</div>
</div>
);
};

View File

@ -1,33 +1,31 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
main: {
paddingBottom: '2rem',
},
container: {
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: '5rem',
marginBlock: '5rem',
},
title: {
fontSize: theme.fontSizes.mainHeader,
marginBottom: 12,
marginBottom: '1.25rem',
},
subtitle: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.grey[600],
maxWidth: 515,
marginBottom: 20,
wordBreak: 'break-all',
whiteSpace: 'normal',
textAlign: 'center',
},
tableRow: {
background: '#F6F6FA',
borderRadius: '8px',
},
paramButton: {
color: theme.palette.primary.dark,
textDecoration: 'none',
color: theme.palette.primary.main,
fontWeight: theme.fontWeight.bold,
},
cell: {
borderBottom: 'none',

View File

@ -18,7 +18,6 @@ import { SegmentListItem } from './SegmentListItem/SegmentListItem';
import { ISegment } from 'interfaces/segment';
import { useStyles } from './SegmentList.styles';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { SegmentDeleteConfirm } from '../SegmentDeleteConfirm/SegmentDeleteConfirm';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
@ -27,17 +26,17 @@ import ConditionallyRender from 'component/common/ConditionallyRender';
import HeaderTitle from 'component/common/HeaderTitle';
import PageContent from 'component/common/PageContent';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { SegmentDelete } from '../SegmentDelete/SegmentDelete';
export const SegmentsList = () => {
const history = useHistory();
const { hasAccess } = useContext(AccessContext);
const { segments, refetchSegments } = useSegments();
const { segments = [], refetchSegments } = useSegments();
const { deleteSegment } = useSegmentsApi();
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
usePagination(segments, 10);
const [currentSegment, setCurrentSegment] = useState<ISegment>();
const [delDialog, setDelDialog] = useState(false);
const [confirmName, setConfirmName] = useState('');
const { setToastData, setToastApiError } = useToast();
const styles = useStyles();
@ -46,7 +45,7 @@ export const SegmentsList = () => {
if (!currentSegment?.id) return;
try {
await deleteSegment(currentSegment?.id);
refetchSegments();
await refetchSegments();
setToastData({
type: 'success',
title: 'Successfully deleted segment',
@ -55,7 +54,6 @@ export const SegmentsList = () => {
setToastApiError(formatUnknownError(error));
}
setDelDialog(false);
setConfirmName('');
};
const renderSegments = () => {
@ -77,9 +75,9 @@ export const SegmentsList = () => {
const renderNoSegments = () => {
return (
<div className={styles.container}>
<div className={styles.empty}>
<Typography className={styles.title}>
There are no segments created yet.
No segments yet!
</Typography>
<p className={styles.subtitle}>
Segment makes it easy for you to define who should be
@ -109,73 +107,72 @@ export const SegmentsList = () => {
/>
}
>
<div className={styles.main}>
<Table>
<TableHead>
<TableRow className={styles.tableRow}>
<TableCell
className={styles.firstHeader}
classes={{ root: styles.cell }}
>
Name
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideSM}
>
Description
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideXS}
>
Created on
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideXS}
>
Created By
</TableCell>
<TableCell
align="right"
classes={{ root: styles.cell }}
className={styles.lastHeader}
>
{hasAccess(UPDATE_SEGMENT) ? 'Actions' : ''}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
<ConditionallyRender
condition={segments.length > 0}
show={renderSegments()}
/>
</TableBody>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
<Table>
<TableHead>
<TableRow className={styles.tableRow}>
<TableCell
className={styles.firstHeader}
classes={{ root: styles.cell }}
>
Name
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideSM}
>
Description
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideXS}
>
Created on
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideXS}
>
Created By
</TableCell>
<TableCell
align="right"
classes={{ root: styles.cell }}
className={styles.lastHeader}
>
{hasAccess(UPDATE_SEGMENT) ? 'Actions' : ''}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
<ConditionallyRender
condition={segments.length > 0}
show={renderSegments()}
/>
</Table>
<ConditionallyRender
condition={segments.length === 0}
show={renderNoSegments()}
/>
{currentSegment && (
<SegmentDeleteConfirm
segment={currentSegment}
</TableBody>
</Table>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
style={{ position: 'static', marginTop: '2rem' }}
/>
<ConditionallyRender
condition={segments.length === 0}
show={renderNoSegments()}
/>
<ConditionallyRender
condition={Boolean(currentSegment)}
show={() => (
<SegmentDelete
segment={currentSegment!}
open={delDialog}
setDeldialogue={setDelDialog}
handleDeleteSegment={onDeleteSegment}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
)}
</div>
/>
</PageContent>
);
};

View File

@ -1,10 +1,14 @@
import { useStyles } from './SegmentListItem.styles';
import { TableCell, TableRow, Typography } from '@material-ui/core';
import { Delete, Edit } from '@material-ui/icons';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import {
ADMIN,
UPDATE_SEGMENT,
} from 'component/providers/AccessProvider/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import TimeAgo from 'react-timeago';
import { ISegment } from 'interfaces/segment';
import { useHistory } from 'react-router-dom';
interface ISegmentListItemProps {
id: number;
@ -28,6 +32,7 @@ export const SegmentListItem = ({
setDelDialog,
}: ISegmentListItemProps) => {
const styles = useStyles();
const { push } = useHistory();
return (
<TableRow className={styles.tableRow}>
@ -55,11 +60,12 @@ export const SegmentListItem = ({
<TableCell align="right">
<PermissionIconButton
data-loading
aria-label="Edit"
onClick={() => {}}
permission={ADMIN}
onClick={() => {
push(`/segments/edit/${id}`);
}}
permission={UPDATE_SEGMENT}
>
<Edit />
<Edit titleAccess="Edit segment" />
</PermissionIconButton>
<PermissionIconButton
data-loading

View File

@ -0,0 +1,51 @@
import { IConstraint } from 'interfaces/strategy';
import { useEffect, useState } from 'react';
export const useSegmentForm = (
initialName = '',
initialDescription = '',
initialConstraints: IConstraint[] = []
) => {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [constraints, setConstraints] =
useState<IConstraint[]>(initialConstraints);
const [errors, setErrors] = useState({});
useEffect(() => {
setName(initialName);
}, [initialName]);
useEffect(() => {
setDescription(initialDescription);
}, [initialDescription]);
useEffect(() => {
setConstraints(initialConstraints);
// eslint-disable-next-line
}, [JSON.stringify(initialConstraints)]);
const getSegmentPayload = () => {
return {
name,
description,
constraints,
};
};
const clearErrors = () => {
setErrors({});
};
return {
name,
setName,
description,
setDescription,
constraints,
setConstraints,
getSegmentPayload,
clearErrors,
errors,
};
};

View File

@ -26,23 +26,14 @@ const useFeatureApi = () => {
};
const validateConstraint = async (
projectId: string,
featureName: string,
constraint: IConstraint
) => {
const path = `api/admin/projects/${projectId}/features/${featureName}/constraint/validate`;
): Promise<void> => {
const path = `api/admin/constraints/validate`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(constraint),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
await makeRequest(req.caller, req.id);
};
const createFeatureToggle = async (

View File

@ -1,4 +1,4 @@
import { IStrategyPayload } from 'interfaces/strategy';
import { IStrategyPayload, IFeatureStrategy } from 'interfaces/strategy';
import useAPI from '../useApi/useApi';
const useFeatureStrategyApi = () => {
@ -11,21 +11,14 @@ const useFeatureStrategyApi = () => {
featureId: string,
environmentId: string,
payload: IStrategyPayload
) => {
): Promise<IFeatureStrategy> => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`;
const req = createRequest(
path,
{ method: 'POST', body: JSON.stringify(payload) },
'addStrategyToFeature'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
return (await makeRequest(req.caller, req.id)).json();
};
const deleteStrategyFromFeature = async (
@ -33,21 +26,14 @@ const useFeatureStrategyApi = () => {
featureId: string,
environmentId: string,
strategyId: string
) => {
): Promise<void> => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`;
const req = createRequest(
path,
{ method: 'DELETE' },
'deleteStrategyFromFeature'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
await makeRequest(req.caller, req.id);
};
const updateStrategyOnFeature = async (
@ -56,21 +42,14 @@ const useFeatureStrategyApi = () => {
environmentId: string,
strategyId: string,
payload: IStrategyPayload
) => {
): Promise<void> => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`;
const req = createRequest(
path,
{ method: 'PUT', body: JSON.stringify(payload) },
'updateStrategyOnFeature'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
await makeRequest(req.caller, req.id);
};
return {

View File

@ -6,10 +6,8 @@ export const useSegmentsApi = () => {
propagateErrors: true,
});
const PATH = 'api/admin/segments';
const createSegment = async (segment: ISegmentPayload, user: any) => {
const req = createRequest(PATH, {
const createSegment = async (segment: ISegmentPayload) => {
const req = createRequest(formatSegmentsPath(), {
method: 'POST',
body: JSON.stringify(segment),
});
@ -17,16 +15,11 @@ export const useSegmentsApi = () => {
return makeRequest(req.caller, req.id);
};
const deleteSegment = async (id: number) => {
const req = createRequest(`${PATH}/${id}`, {
method: 'DELETE',
});
return makeRequest(req.caller, req.id);
};
const updateSegment = async (segment: ISegmentPayload) => {
const req = createRequest(PATH, {
const updateSegment = async (
segmentId: number,
segment: ISegmentPayload
) => {
const req = createRequest(formatSegmentPath(segmentId), {
method: 'PUT',
body: JSON.stringify(segment),
});
@ -34,5 +27,45 @@ export const useSegmentsApi = () => {
return makeRequest(req.caller, req.id);
};
return { createSegment, deleteSegment, updateSegment, errors, loading };
const deleteSegment = async (segmentId: number) => {
const req = createRequest(formatSegmentPath(segmentId), {
method: 'DELETE',
});
return makeRequest(req.caller, req.id);
};
const setStrategySegments = async (payload: {
projectId: string;
environmentId: string;
strategyId: string;
segmentIds: number[];
}) => {
const req = createRequest(formatStrategiesPath(), {
method: 'POST',
body: JSON.stringify(payload),
});
return makeRequest(req.caller, req.id);
};
return {
createSegment,
deleteSegment,
updateSegment,
setStrategySegments,
errors,
loading,
};
};
const formatSegmentsPath = (): string => {
return 'api/admin/segments';
};
const formatSegmentPath = (segmentId: number): string => {
return `${formatSegmentsPath()}/${segmentId}`;
};
const formatStrategiesPath = (): string => {
return `${formatSegmentsPath()}/strategies`;
};

View File

@ -3,8 +3,6 @@ import { useEffect, useState } from 'react';
import { IConstraint } from 'interfaces/strategy';
export const useConstraintsValidation = (
projectId: string,
featureId: string,
constraints?: IConstraint[]
): boolean => {
// An empty list of constraints is valid. An undefined list is not.
@ -19,7 +17,7 @@ export const useConstraintsValidation = (
}
const validationRequests = constraints.map(constraint => {
return validateConstraint(projectId, featureId, constraint);
return validateConstraint(constraint);
});
Promise.all(validationRequests)
@ -27,7 +25,7 @@ export const useConstraintsValidation = (
.catch(() => setValid(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, featureId, constraints]);
}, [constraints]);
return valid;
};

View File

@ -0,0 +1,34 @@
import useSWR, { mutate } from 'swr';
import { useCallback } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { ISegment } from 'interfaces/segment';
export interface UseSegmentOutput {
segment?: ISegment;
refetchSegment: () => void;
loading: boolean;
error?: Error;
}
export const useSegment = (id: number): UseSegmentOutput => {
const path = formatApiPath(`api/admin/segments/${id}`);
const { data, error } = useSWR<ISegment>(path, () => fetchSegment(path));
const refetchSegment = useCallback(() => {
mutate(path).catch(console.warn);
}, [path]);
return {
segment: data,
refetchSegment,
loading: !error && !data,
error,
};
};
const fetchSegment = (path: string) => {
return fetch(path, { method: 'GET' })
.then(handleErrorResponses('Segment'))
.then(res => res.json());
};

View File

@ -1,39 +1,54 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import useSWR from 'swr';
import { useCallback } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { ISegment } from 'interfaces/segment';
const PATH = formatApiPath('api/admin/segments');
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { IFlags } from 'interfaces/uiConfig';
export interface UseSegmentsOutput {
segments: ISegment[];
segments?: ISegment[];
refetchSegments: () => void;
loading: boolean;
error?: Error;
}
export const useSegments = (options?: SWRConfiguration): UseSegmentsOutput => {
const { data, error } = useSWR<{ segments: ISegment[] }>(
PATH,
fetchSegments,
options
export const useSegments = (strategyId?: string): UseSegmentsOutput => {
const { uiConfig } = useUiConfig();
const { data, error, mutate } = useSWR(
[strategyId, uiConfig.flags],
fetchSegments
);
const refetchSegments = useCallback(() => {
mutate(PATH).catch(console.warn);
}, []);
mutate().catch(console.warn);
}, [mutate]);
return {
segments: data?.segments || [],
segments: data,
refetchSegments,
loading: !error && !data,
error,
};
};
const fetchSegments = () => {
return fetch(PATH, { method: 'GET' })
export const fetchSegments = async (
strategyId?: string,
flags?: IFlags
): Promise<ISegment[]> => {
if (!flags?.SE) {
return [];
}
return fetch(formatSegmentsPath(strategyId))
.then(handleErrorResponses('Segments'))
.then(res => res.json());
.then(res => res.json())
.then(res => res.segments);
};
const formatSegmentsPath = (strategyId?: string): string => {
return strategyId
? formatApiPath(`api/admin/segments/strategies/${strategyId}`)
: formatApiPath('api/admin/segments');
};

View File

@ -0,0 +1,36 @@
import useSWR, { mutate } from 'swr';
import { useCallback } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IFeatureStrategy } from 'interfaces/strategy';
export interface useStrategiesBySegmentOutput {
strategies?: IFeatureStrategy[];
refetchUsedSegments: () => void;
loading: boolean;
error?: Error;
}
export const useStrategiesBySegment = (
id: number
): useStrategiesBySegmentOutput => {
const path = formatApiPath(`api/admin/segments/${id}/strategies`);
const { data, error } = useSWR(path, () => fetchUsedSegment(path));
const refetchUsedSegments = useCallback(() => {
mutate(path).catch(console.warn);
}, [path]);
return {
strategies: data?.strategies,
refetchUsedSegments,
loading: !error && !data,
error,
};
};
const fetchUsedSegment = (path: string) => {
return fetch(path, { method: 'GET' })
.then(handleErrorResponses('Strategies by segment'))
.then(res => res.json());
};

View File

@ -5,7 +5,15 @@ export const defaultValue = {
version: '3.x',
environment: '',
slogan: 'The enterprise ready feature toggle service.',
flags: { P: false, C: false, E: false, RE: false, EEA: false, CO: false },
flags: {
P: false,
C: false,
E: false,
RE: false,
EEA: false,
CO: false,
SE: false,
},
links: [
{
value: 'Documentation',

View File

@ -9,8 +9,7 @@ export interface ISegment {
constraints: IConstraint[];
}
export interface ISegmentPayload {
name: string;
description: string;
constraints: IConstraint[];
}
export type ISegmentPayload = Pick<
ISegment,
'name' | 'description' | 'constraints'
>;

View File

@ -2,9 +2,13 @@ import { Operator } from 'constants/operators';
export interface IFeatureStrategy {
id: string;
strategyName?: string;
name: string;
constraints: IConstraint[];
parameters: IParameter;
featureName?: string;
projectId?: string;
environment?: string;
}
export interface IStrategy {

View File

@ -0,0 +1,23 @@
import { CURRENT_TIME_CONTEXT_FIELD } from 'component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader';
import { allOperators, dateOperators, Operator } from 'constants/operators';
import { oneOf } from 'utils/oneOf';
export const operatorsForContext = (contextName: string): Operator[] => {
return allOperators.filter(operator => {
if (
oneOf(dateOperators, operator) &&
contextName !== CURRENT_TIME_CONTEXT_FIELD
) {
return false;
}
if (
!oneOf(dateOperators, operator) &&
contextName === CURRENT_TIME_CONTEXT_FIELD
) {
return false;
}
return true;
});
};