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:
parent
8568573b4b
commit
eeda7ab5e4
@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
|
@ -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}
|
||||
/>
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
@ -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,
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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'}
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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)',
|
||||
},
|
||||
|
@ -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}
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -6,7 +6,7 @@ interface IDefaultStrategyProps {
|
||||
}
|
||||
|
||||
const DefaultStrategy = ({ strategyDefinition }: IDefaultStrategyProps) => {
|
||||
return <h6>{strategyDefinition?.description}</h6>;
|
||||
return <p>{strategyDefinition?.description}</p>;
|
||||
};
|
||||
|
||||
export default DefaultStrategy;
|
||||
|
@ -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 = ({
|
||||
|
||||
<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 || ''}
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
100
frontend/src/component/segments/CreateSegment/CreateSegment.tsx
Normal file
100
frontend/src/component/segments/CreateSegment/CreateSegment.tsx
Normal 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';
|
103
frontend/src/component/segments/EditSegment/EditSegment.tsx
Normal file
103
frontend/src/component/segments/EditSegment/EditSegment.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -7,4 +7,9 @@ export const useStyles = makeStyles(theme => ({
|
||||
deleteInput: {
|
||||
marginTop: '1rem',
|
||||
},
|
||||
link: {
|
||||
textDecoration: 'none',
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
},
|
||||
}));
|
@ -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}
|
@ -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)})`;
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
72
frontend/src/component/segments/SegmentForm/SegmentForm.tsx
Normal file
72
frontend/src/component/segments/SegmentForm/SegmentForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
|
51
frontend/src/component/segments/hooks/useSegmentForm.ts
Normal file
51
frontend/src/component/segments/hooks/useSegmentForm.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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 (
|
||||
|
@ -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 {
|
||||
|
@ -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`;
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
34
frontend/src/hooks/api/getters/useSegment/useSegment.ts
Normal file
34
frontend/src/hooks/api/getters/useSegment/useSegment.ts
Normal 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());
|
||||
};
|
@ -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');
|
||||
};
|
||||
|
@ -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());
|
||||
};
|
@ -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',
|
||||
|
@ -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'
|
||||
>;
|
||||
|
@ -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 {
|
||||
|
23
frontend/src/utils/operatorUtils.ts
Normal file
23
frontend/src/utils/operatorUtils.ts
Normal 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;
|
||||
});
|
||||
};
|
Loading…
Reference in New Issue
Block a user