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

feat: add segments (#780)

* feat: create segmentation structure and list

* feat: remove unused deps and change route

* feat: change header style and add renderNoSegments

* fix: style table header

* feat: create useSegments hook

* feat: add segmentApi hook

* feat: create segment

* fix: errors

* feat: add contextfields list

* fix: remove user from create segment api

* feat: add form structure

* feat: add SegmentFormStepOne

* fix: tests and routes

* feat: add constraint view

* feat: UI to match the sketch

* feat: add constraint on context select

* fix: duplication

* fix adding constraints

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

* fix: input date not showing up in constraint view

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

* fix: minor bugs

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

* fix: create context modal in segment page

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

* fix: validate constraint before create segment

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

* feat: create useSegment hook

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

* feat: create edit component

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

* refactor: move constraint validation endpoint

* refactor: add missing route snapshot

* refactor: fix segment constraints unsaved/editing state

* refactor: remove create segment from mobile header menu

* refactor: update segments form description

* refactor: extract SegmentFormStepList component

* refactor: add an optional FormTemplate docs link label

* refactor: fix update segment payload

* feat: finish edit component

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

* refactor: move step list above segment form

* fix: update PR based on feedback

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

* refactor: fix constraint validation endpoint path

* refactor: improve constraint state field name

* refactor: extract AutocompleteBox component

* feat: add strategy segment selection

* refactor: add strategy segment previews

* refactor: fix double section separator line

* feat: disable deleting a usable segment

* refactor: warn about segments without constraints

* refactor: update text in delete segment dialogue

* refactur: improve arg names

* refactor: improve index var name

* refactor: clarify steps list logic

* refactor: use a required prop for the segment name

* refactor: use ConditionallyRender for segment deletion

* refactor: fix segments refetch

* refactor: improve CreateUnleashContext component names

* refactor: adjust segment form styles

* refactor: adjust text

* refactor: fix info icon tooltip hover target

* refactor: add missing aria attrs to preview button

* refactor: add strat name to delete segment modal

* refactor: fix segment chip text alighment

* refactor: use bulk endpoint for strategy segments

* refactor: fix imports after merge

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,16 +78,16 @@ const FlexibleStrategy = ({
<br /> <br />
<div> <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
<Typography variant="subtitle2"
variant="subtitle2" style={{
style={{ marginBottom: '0.5rem',
marginBottom: '0.5rem', display: 'flex',
display: 'flex', alignItems: 'center',
alignItems: 'center', }}
}} >
> Stickiness
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 <Info
style={{ style={{
fontSize: '1rem', fontSize: '1rem',
@ -95,8 +95,8 @@ const FlexibleStrategy = ({
marginLeft: '0.2rem', marginLeft: '0.2rem',
}} }}
/> />
</Typography> </Tooltip>
</Tooltip> </Typography>
<Select <Select
id="stickiness-select" id="stickiness-select"
name="stickiness" name="stickiness"
@ -112,16 +112,16 @@ const FlexibleStrategy = ({
&nbsp; &nbsp;
<br /> <br />
<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
<Typography variant="subtitle2"
variant="subtitle2" style={{
style={{ marginBottom: '0.5rem',
marginBottom: '0.5rem', display: 'flex',
display: 'flex', alignItems: 'center',
alignItems: 'center', }}
}} >
> GroupId
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 <Info
style={{ style={{
fontSize: '1rem', fontSize: '1rem',
@ -129,8 +129,8 @@ const FlexibleStrategy = ({
marginLeft: '0.2rem', marginLeft: '0.2rem',
}} }}
/> />
</Typography> </Tooltip>
</Tooltip> </Typography>
<Input <Input
label="groupId" label="groupId"
value={groupId || ''} value={groupId || ''}

View File

@ -286,6 +286,26 @@ Array [
"title": "Addons", "title": "Addons",
"type": "protected", "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 { Object {
"component": [Function], "component": [Function],
"flag": "SE", "flag": "SE",

View File

@ -27,7 +27,6 @@ import EditUser from 'component/admin/users/EditUser/EditUser';
import { CreateApiToken } from 'component/admin/apiToken/CreateApiToken/CreateApiToken'; import { CreateApiToken } from 'component/admin/apiToken/CreateApiToken/CreateApiToken';
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment'; import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment'; import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment';
import { CreateContext } from 'component/context/CreateContext/CreateContext';
import { EditContext } from 'component/context/EditContext/EditContext'; import { EditContext } from 'component/context/EditContext/EditContext';
import EditTagType from 'component/tags/EditTagType/EditTagType'; import EditTagType from 'component/tags/EditTagType/EditTagType';
import CreateTagType from 'component/tags/CreateTagType/CreateTagType'; 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 { FeatureEventHistoryPage } from 'component/history/FeatureEventHistoryPage/FeatureEventHistoryPage';
import { CreateStrategy } from 'component/strategies/CreateStrategy/CreateStrategy'; import { CreateStrategy } from 'component/strategies/CreateStrategy/CreateStrategy';
import { EditStrategy } from 'component/strategies/EditStrategy/EditStrategy'; import { EditStrategy } from 'component/strategies/EditStrategy/EditStrategy';
import { SegmentsList } from 'component/segments/SegmentList/SegmentList';
import { SplashPage } from 'component/splash/SplashPage/SplashPage'; 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 = [ export const routes = [
// Splash // Splash
@ -198,7 +200,7 @@ export const routes = [
path: '/context/create', path: '/context/create',
parent: '/context', parent: '/context',
title: 'Create', title: 'Create',
component: CreateContext, component: CreateUnleashContextPage,
type: 'protected', type: 'protected',
flag: C, flag: C,
menu: {}, menu: {},
@ -329,7 +331,26 @@ export const routes = [
}, },
// Segments // 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', path: '/segments',
title: 'Segments', title: 'Segments',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,15 @@ export const defaultValue = {
version: '3.x', version: '3.x',
environment: '', environment: '',
slogan: 'The enterprise ready feature toggle service.', 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: [ links: [
{ {
value: 'Documentation', value: 'Documentation',

View File

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

View File

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

View File

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