diff --git a/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts new file mode 100644 index 0000000000..48a20e5f32 --- /dev/null +++ b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts @@ -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', + }, + }, +})); diff --git a/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx new file mode 100644 index 0000000000..370dbbdf2f --- /dev/null +++ b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx @@ -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 ; + }; + + return ( +
+
+ +
+ } + onChange={(event, value) => onChange(value || [])} + renderInput={renderInput} + getOptionLabel={value => value.label} + multiple + /> +
+ ); +}; diff --git a/frontend/src/component/common/Codebox/Codebox.styles.ts b/frontend/src/component/common/Codebox/Codebox.styles.ts index c92f5a3c94..2fff263419 100644 --- a/frontend/src/component/common/Codebox/Codebox.styles.ts +++ b/frontend/src/component/common/Codebox/Codebox.styles.ts @@ -12,9 +12,9 @@ export const useStyles = makeStyles(theme => ({ code: { margin: 0, wordBreak: 'break-all', - color: '#fff', whiteSpace: 'pre-wrap', - fontSize: theme.fontSizes.smallBody, + color: '#fff', + fontSize: 14, }, icon: { fill: '#fff', diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx index 56ce020049..f68861211e 100644 --- a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx @@ -7,12 +7,12 @@ import { ConstraintAccordionView } from './ConstraintAccordionView/ConstraintAcc interface IConstraintAccordionProps { compact: boolean; editing: boolean; - environmentId: string; + environmentId?: string; constraint: IConstraint; - onEdit: () => void; onCancel: () => void; - onDelete: () => void; - onSave: (constraint: IConstraint) => void; + onEdit?: () => void; + onDelete?: () => void; + onSave?: (constraint: IConstraint) => void; } export const ConstraintAccordion = ({ @@ -29,12 +29,12 @@ export const ConstraintAccordion = ({ return ( } diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx index 7ac0dde533..9217289c35 100644 --- a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx @@ -13,8 +13,6 @@ import { cleanConstraint } from 'utils/cleanConstraint'; import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; import { formatUnknownError } from 'utils/formatUnknownError'; -import { useParams } from 'react-router-dom'; -import { IFeatureViewParams } from 'interfaces/params'; import { IUnleashContextDefinition } from 'interfaces/context'; import { useConstraintInput } from './ConstraintAccordionEditBody/useConstraintInput/useConstraintInput'; import { Operator } from 'constants/operators'; @@ -63,7 +61,6 @@ export const ConstraintAccordionEdit = ({ const [contextDefinition, setContextDefinition] = useState( resolveContextDefinition(context, localConstraint.contextName) ); - const { projectId, featureId } = useParams(); const { validateConstraint } = useFeatureApi(); const [expanded, setExpanded] = useState(false); const [action, setAction] = useState(''); @@ -160,8 +157,7 @@ export const ConstraintAccordionEdit = ({ if (typeValidatorResult) { try { - await validateConstraint(projectId, featureId, localConstraint); - + await validateConstraint(localConstraint); setError(''); setAction(SAVE); triggerTransition(); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts similarity index 100% rename from frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2.styles.ts rename to frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx new file mode 100644 index 0000000000..bfc01d7ee7 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx @@ -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>; + 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 ( +
+ + Add custom constraint + + } + /> + {constraints.map((constraint, index) => ( + + ))} +
+ ); + } +); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts new file mode 100644 index 0000000000..df0290a592 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts @@ -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, + }; +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx index 0b1a8717b0..35cdcdbab3 100644 --- a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx @@ -17,10 +17,10 @@ import { import { useStyles } from '../ConstraintAccordion.styles'; interface IConstraintAccordionViewProps { - environmentId: string; + environmentId?: string; constraint: IConstraint; - onDelete: () => void; - onEdit: () => void; + onDelete?: () => void; + onEdit?: () => void; compact: boolean; } diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx index e5366e2a37..b0b1137c80 100644 --- a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx @@ -17,10 +17,10 @@ import { useLocationSettings } from 'hooks/useLocationSettings'; interface IConstraintAccordionViewHeaderProps { compact: boolean; constraint: IConstraint; - onDelete: () => void; - onEdit: () => void; + onDelete?: () => void; + onEdit?: () => void; singleValue: boolean; - environmentId: string; + environmentId?: string; } export const ConstraintAccordionViewHeader = ({ @@ -38,15 +38,19 @@ export const ConstraintAccordionViewHeader = ({ const minWidthHeader = compact || smallScreen ? '100px' : '175px'; - const onEditClick = (event: React.SyntheticEvent) => { - event.stopPropagation(); - onEdit(); - }; + const onEditClick = + onEdit && + ((event: React.SyntheticEvent) => { + event.stopPropagation(); + onEdit(); + }); - const onDeleteClick = (event: React.SyntheticEvent) => { - event.stopPropagation(); - onDelete(); - }; + const onDeleteClick = + onDelete && + ((event: React.SyntheticEvent) => { + event.stopPropagation(); + onDelete(); + }); return (
@@ -92,22 +96,33 @@ export const ConstraintAccordionViewHeader = ({
- - - - - - +
); diff --git a/frontend/src/component/common/Dialogue/Dialogue.tsx b/frontend/src/component/common/Dialogue/Dialogue.tsx index b093722f10..12ce84d2a6 100644 --- a/frontend/src/component/common/Dialogue/Dialogue.tsx +++ b/frontend/src/component/common/Dialogue/Dialogue.tsx @@ -24,6 +24,7 @@ interface IDialogue { disabledPrimaryButton?: boolean; formId?: string; permissionButton?: JSX.Element; + hideSecondaryButton?: boolean; } const Dialogue: React.FC = ({ @@ -39,6 +40,7 @@ const Dialogue: React.FC = ({ fullWidth = false, formId, permissionButton, + hideSecondaryButton, }) => { const styles = useStyles(); const handleClick = formId @@ -92,7 +94,7 @@ const Dialogue: React.FC = ({ /> {secondaryButtonText || 'No, take me back'} diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts index 45829eba08..8500120ddc 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts +++ b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts @@ -1,5 +1,7 @@ import { makeStyles } from '@material-ui/core/styles'; +export const formTemplateSidebarWidth = '27.5rem'; + export const useStyles = makeStyles(theme => ({ container: { minHeight: '80vh', @@ -8,8 +10,9 @@ export const useStyles = makeStyles(theme => ({ margin: '0 auto', borderRadius: '1rem', overflow: 'hidden', - [theme.breakpoints.down(900)]: { + [theme.breakpoints.down(1100)]: { flexDirection: 'column', + minHeight: 0, }, }, modal: { @@ -19,8 +22,10 @@ export const useStyles = makeStyles(theme => ({ sidebar: { backgroundColor: theme.palette.primary.light, padding: '2rem', - width: '35%', - [theme.breakpoints.down(900)]: { + flexGrow: 0, + flexShrink: 0, + width: formTemplateSidebarWidth, + [theme.breakpoints.down(1100)]: { width: '100%', }, [theme.breakpoints.down(500)]: { @@ -41,6 +46,8 @@ export const useStyles = makeStyles(theme => ({ }, description: { color: '#fff', + zIndex: 1, + position: 'relative', }, linkContainer: { margin: '1.5rem 0', @@ -59,9 +66,12 @@ export const useStyles = makeStyles(theme => ({ backgroundColor: '#fff', display: 'flex', flexDirection: 'column', - padding: '2rem', - width: '65%', - [theme.breakpoints.down(900)]: { + padding: '3rem', + flexGrow: 1, + [theme.breakpoints.down(1200)]: { + padding: '2rem', + }, + [theme.breakpoints.down(1100)]: { width: '100%', }, [theme.breakpoints.down(500)]: { @@ -70,15 +80,12 @@ export const useStyles = makeStyles(theme => ({ }, icon: { fill: '#fff' }, mobileGuidanceBgContainer: { + zIndex: 1, position: 'absolute', - right: '-3px', - top: '-3px', - backgroundColor: theme.palette.primary.light, + right: -3, + top: -3, }, mobileGuidanceBackground: { - position: 'absolute', - right: '-3px', - top: '-3px', width: '75px', height: '75px', }, diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx index 49b267b37b..0096f57aa2 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -16,21 +16,18 @@ interface ICreateProps { title: string; description: string; documentationLink: string; + documentationLinkLabel?: string; loading?: boolean; modal?: boolean; formatApiCode: () => string; } -// Components in this file: -// FormTemplate -// MobileGuidance -// Guidance - const FormTemplate: React.FC = ({ title, description, children, documentationLink, + documentationLinkLabel, loading, modal, formatApiCode, @@ -38,7 +35,7 @@ const FormTemplate: React.FC = ({ const { setToastData } = useToast(); const styles = useStyles(); const commonStyles = useCommonStyles(); - const smallScreen = useMediaQuery(`(max-width:${899}px)`); + const smallScreen = useMediaQuery(`(max-width:${1099}px)`); const copyCommand = () => { if (copy(formatApiCode())) { @@ -71,6 +68,7 @@ const FormTemplate: React.FC = ({ } @@ -93,6 +91,7 @@ const FormTemplate: React.FC = ({

API Command{' '} @@ -111,11 +110,13 @@ const FormTemplate: React.FC = ({ interface IMobileGuidance { description: string; documentationLink: string; + documentationLinkLabel?: string; } const MobileGuidance = ({ description, documentationLink, + documentationLinkLabel, }: IMobileGuidance) => { const [open, setOpen] = useState(false); const styles = useStyles(); @@ -135,6 +136,7 @@ const MobileGuidance = ({ @@ -144,12 +146,14 @@ const MobileGuidance = ({ interface IGuidanceProps { description: string; documentationLink: string; + documentationLinkLabel?: string; } const Guidance: React.FC = ({ description, children, documentationLink, + documentationLinkLabel = 'Learn more', }) => { const styles = useStyles(); @@ -165,7 +169,7 @@ const Guidance: React.FC = ({ rel="noopener noreferrer" target="_blank" > - Learn more + {documentationLinkLabel} diff --git a/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts b/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts index 4640a4a56b..cc2a1fc4a0 100644 --- a/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts +++ b/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts @@ -7,7 +7,8 @@ export const useStyles = makeStyles(() => ({ right: 0, bottom: 0, height: '100vh', - maxWidth: 1300, + maxWidth: '90vw', + width: 1300, overflow: 'auto', boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)', }, diff --git a/frontend/src/component/context/CreateContext/CreateContext.tsx b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContext.tsx similarity index 92% rename from frontend/src/component/context/CreateContext/CreateContext.tsx rename to frontend/src/component/context/CreateUnleashContext/CreateUnleashContext.tsx index 4cb470b4ce..4d7c017d9d 100644 --- a/frontend/src/component/context/CreateContext/CreateContext.tsx +++ b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContext.tsx @@ -1,4 +1,3 @@ -import { useHistory } from 'react-router-dom'; import { CreateButton } from 'component/common/CreateButton/CreateButton'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import { useContextForm } from '../hooks/useContextForm'; @@ -10,10 +9,19 @@ import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashCon import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; -export const CreateContext = () => { +interface ICreateContextProps { + onSubmit: () => void; + onCancel: () => void; + modal?: boolean; +} + +export const CreateUnleashContext = ({ + onSubmit, + onCancel, + modal, +}: ICreateContextProps) => { const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); - const history = useHistory(); const { contextName, contextDesc, @@ -34,6 +42,7 @@ export const CreateContext = () => { const handleSubmit = async (e: Event) => { e.preventDefault(); + e.stopPropagation(); const validName = await validateContext(); if (validName) { @@ -41,12 +50,12 @@ export const CreateContext = () => { try { await createContext(payload); refetchUnleashContext(); - history.push('/context'); setToastData({ title: 'Context created', confetti: true, type: 'success', }); + onSubmit(); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); } @@ -62,10 +71,6 @@ export const CreateContext = () => { --data-raw '${JSON.stringify(getContextPayload(), undefined, 2)}'`; }; - const onCancel = () => { - history.goBack(); - }; - return ( { They can be used together with strategy constraints as part of the activation strategy evaluation." documentationLink="https://docs.getunleash.io/how-to/how-to-define-custom-context-fields" formatApiCode={formatApiCode} + modal={modal} > { + const { push, goBack } = useHistory(); + return ( + push('/context')} + onCancel={() => goBack()} + /> + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints.tsx index b17876d3db..fff9a899c3 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints.tsx @@ -63,7 +63,7 @@ export const FeatureStrategyConstraints = ({ }; return ( - <> +
{strategy.constraints?.map((constraint, index) => ( @@ -101,7 +101,7 @@ export const FeatureStrategyConstraints = ({ > Add constraints - +
); }; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintsCO.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintsCO.tsx new file mode 100644 index 0000000000..aee63c1c10 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintsCO.tsx @@ -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; + setStrategy: React.Dispatch< + React.SetStateAction> + >; +} + +export const FeatureStrategyConstraintsCO = ({ + projectId, + environmentId, + strategy, + setStrategy, +}: IFeatureStrategyConstraintsProps) => { + const constraints = useMemo(() => { + return strategy.constraints ?? []; + }, [strategy]); + + const setConstraints = (value: React.SetStateAction) => { + setStrategy(prev => ({ + ...prev, + constraints: value instanceof Function ? value(constraints) : value, + })); + }; + + return ( + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2.tsx deleted file mode 100644 index f4d46fea5e..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2.tsx +++ /dev/null @@ -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; - setStrategy: React.Dispatch< - React.SetStateAction> - >; -} - -// 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(); - 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 ( -
- - Add constraint - - {strategy.constraints?.map((constraint, index) => ( - - ))} -
- ); -}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/createEmptyConstraint.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/createEmptyConstraint.ts deleted file mode 100644 index 34290cc1b0..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/createEmptyConstraint.ts +++ /dev/null @@ -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, - }; -}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx index 88114d45b2..f660d5da12 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx @@ -19,6 +19,9 @@ import { import { getStrategyObject } from 'utils/getStrategyObject'; import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies'; import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { ISegment } from 'interfaces/segment'; +import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'; +import { formatStrategyName } from 'utils/strategyNames'; export const FeatureStrategyCreate = () => { const projectId = useRequiredPathParam('projectId'); @@ -26,9 +29,11 @@ export const FeatureStrategyCreate = () => { const environmentId = useRequiredQueryParam('environmentId'); const strategyName = useRequiredQueryParam('strategyName'); const [strategy, setStrategy] = useState>({}); + const [segments, setSegments] = useState([]); const { strategies } = useStrategies(); const { addStrategyToFeature, loading } = useFeatureStrategyApi(); + const { setStrategySegments } = useSegmentsApi(); const { feature, refetchFeature } = useFeature(projectId, featureId); const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); @@ -42,12 +47,18 @@ export const FeatureStrategyCreate = () => { const onSubmit = async () => { try { - await addStrategyToFeature( + const created = await addStrategyToFeature( projectId, featureId, environmentId, createStrategyPayload(strategy) ); + await setStrategySegments({ + environmentId, + projectId, + strategyId: created.id, + segmentIds: segments.map(s => s.id), + }); setToastData({ title: 'Strategy created', type: 'success', @@ -63,7 +74,7 @@ export const FeatureStrategyCreate = () => { return ( @@ -80,6 +91,8 @@ export const FeatureStrategyCreate = () => { feature={feature} strategy={strategy} setStrategy={setStrategy} + segments={segments} + setSegments={setSegments} environmentId={environmentId} onSubmit={onSubmit} loading={loading} diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx index 5a8d3301f7..29629e653e 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx @@ -11,6 +11,10 @@ import { useHistory } from 'react-router-dom'; import useToast from 'hooks/useToast'; import { IFeatureStrategy, IStrategyPayload } from 'interfaces/strategy'; import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { ISegment } from 'interfaces/segment'; +import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'; +import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; +import { formatStrategyName } from 'utils/strategyNames'; export const FeatureStrategyEdit = () => { const projectId = useRequiredPathParam('projectId'); @@ -19,8 +23,11 @@ export const FeatureStrategyEdit = () => { const strategyId = useRequiredQueryParam('strategyId'); const [strategy, setStrategy] = useState>({}); + const [segments, setSegments] = useState([]); const { updateStrategyOnFeature, loading } = useFeatureStrategyApi(); + const { segments: savedStrategySegments } = useSegments(strategyId); const { feature, refetchFeature } = useFeature(projectId, featureId); + const { setStrategySegments } = useSegmentsApi(); const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); const { unleashUrl } = uiConfig; @@ -33,6 +40,11 @@ export const FeatureStrategyEdit = () => { setStrategy(prev => ({ ...prev, ...savedStrategy })); }, [strategyId, feature]); + useEffect(() => { + // Fill in the selected segments once they've been fetched. + savedStrategySegments && setSegments(savedStrategySegments); + }, [savedStrategySegments]); + const onSubmit = async () => { try { await updateStrategyOnFeature( @@ -42,6 +54,12 @@ export const FeatureStrategyEdit = () => { strategyId, createStrategyPayload(strategy) ); + await setStrategySegments({ + environmentId, + projectId, + strategyId, + segmentIds: segments.map(s => s.id), + }); setToastData({ title: 'Strategy updated', type: 'success', @@ -62,7 +80,7 @@ export const FeatureStrategyEdit = () => { return ( @@ -79,6 +97,8 @@ export const FeatureStrategyEdit = () => { feature={feature} strategy={strategy} setStrategy={setStrategy} + segments={segments} + setSegments={setSegments} environmentId={environmentId} onSubmit={onSubmit} loading={loading} diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.styles.ts index beae13f499..a0c9510f64 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.styles.ts +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.styles.ts @@ -2,13 +2,15 @@ import { makeStyles } from '@material-ui/core/styles'; export const useStyles = makeStyles(theme => ({ form: { - '& > * + *': { - paddingTop: theme.spacing(4), - marginTop: theme.spacing(4), - borderTopStyle: 'solid', - borderTopWidth: 1, - borderTopColor: theme.palette.grey[200], - }, + display: 'grid', + gap: '1rem', + }, + hr: { + width: '100%', + height: 1, + margin: '1rem 0', + border: 'none', + background: theme.palette.grey[200], }, title: { display: 'grid', diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx index 505396cb50..946d100244 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx @@ -1,9 +1,5 @@ import React, { useState, useContext } from 'react'; import { IFeatureStrategy } from 'interfaces/strategy'; -import { - getFeatureStrategyIcon, - formatStrategyName, -} from 'utils/strategyNames'; import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType'; import { FeatureStrategyEnabled } from '../FeatureStrategyEnabled/FeatureStrategyEnabled'; import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints'; @@ -18,12 +14,13 @@ import { formatFeaturePath } from '../FeatureStrategyEdit/FeatureStrategyEdit'; import { useHistory } from 'react-router-dom'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import ConditionallyRender from 'component/common/ConditionallyRender'; -import { C } from 'component/common/flags'; import { STRATEGY_FORM_SUBMIT_ID } from 'testIds'; -import { FeatureStrategyConstraints2 } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints2/FeatureStrategyConstraints2'; -import { useConstraintsValidation } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints2/useConstraintsValidation'; +import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation'; import AccessContext from 'contexts/AccessContext'; import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { FeatureStrategyConstraintsCO } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintsCO'; +import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment'; +import { ISegment } from 'interfaces/segment'; interface IFeatureStrategyFormProps { feature: IFeatureToggle; @@ -35,22 +32,25 @@ interface IFeatureStrategyFormProps { setStrategy: React.Dispatch< React.SetStateAction> >; + segments: ISegment[]; + setSegments: React.Dispatch>; } export const FeatureStrategyForm = ({ feature, - strategy, - setStrategy, environmentId, permission, onSubmit, loading, + strategy, + setStrategy, + segments, + setSegments, }: IFeatureStrategyFormProps) => { const styles = useStyles(); const [showProdGuard, setShowProdGuard] = useState(false); + const hasValidConstraints = useConstraintsValidation(strategy.constraints); const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId); - const StrategyIcon = getFeatureStrategyIcon(strategy.name ?? ''); - const strategyName = formatStrategyName(strategy.name ?? ''); const { hasAccess } = useContext(AccessContext); const { push } = useHistory(); @@ -73,12 +73,6 @@ export const FeatureStrategyForm = ({ } }; - const hasValidConstraints = useConstraintsValidation( - feature.project, - feature.name, - strategy.constraints - ); - if (uiConfigError) { throw uiConfigError; } @@ -88,9 +82,9 @@ export const FeatureStrategyForm = ({ return null; } - // TODO(olav): Remove uiConfig.flags.CO when new constraints are released. + // TODO(olav): Remove FeatureStrategyConstraints when CO is out. const FeatureStrategyConstraintsImplementation = uiConfig.flags.CO - ? FeatureStrategyConstraints2 + ? FeatureStrategyConstraintsCO : FeatureStrategyConstraints; const disableSubmitButtonFromConstraints = uiConfig.flags.CO ? !hasValidConstraints @@ -98,29 +92,37 @@ export const FeatureStrategyForm = ({ return (
-

- - {strategyName} -

+
- - + } /> + + } + /> + } + /> +
({ + title: { + margin: 0, + fontSize: theme.fontSizes.bodySize, + fontWeight: theme.fontWeight.bold, + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.tsx new file mode 100644 index 0000000000..8dce966001 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.tsx @@ -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>; +} + +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 ( + <> +

Segmentation

+

Add a predefined segment to constrain this feature toggle:

+ + + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.styles.ts new file mode 100644 index 0000000000..133bcdf689 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.tsx new file mode 100644 index 0000000000..cb2aa610d8 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.tsx @@ -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>; + preview?: ISegment; + setPreview: React.Dispatch>; +} + +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 = ( + + } + elseShow={ + + } + /> + ); + + return ( + + + {segment.name} + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles.ts new file mode 100644 index 0000000000..9f3a670600 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles.ts @@ -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, + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.tsx new file mode 100644 index 0000000000..66b79ec996 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.tsx @@ -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>; +} + +export const FeatureStrategySegmentList = ({ + segments, + setSegments, +}: IFeatureStrategySegmentListProps) => { + const styles = useStyles(); + const [preview, setPreview] = useState(); + const lastSegmentIndex = segments.length - 1; + + if (segments.length === 0) { + return null; + } + + return ( + <> +
+ {segments.map((segment, i) => ( + + + AND} + /> + + ))} +
+

This segment has no constraints.

} + /> + ( + + )} + /> + + ); +}; diff --git a/frontend/src/component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy.tsx b/frontend/src/component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy.tsx index cfcb5aadd6..dd75dcee75 100644 --- a/frontend/src/component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy.tsx +++ b/frontend/src/component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy.tsx @@ -6,7 +6,7 @@ interface IDefaultStrategyProps { } const DefaultStrategy = ({ strategyDefinition }: IDefaultStrategyProps) => { - return
{strategyDefinition?.description}
; + return

{strategyDefinition?.description}

; }; export default DefaultStrategy; diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx index f6d3e876bd..0481f9bbeb 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx @@ -78,16 +78,16 @@ const FlexibleStrategy = ({
- - - Stickiness + + Stickiness + - - + + { + 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 ( + + + + + + ); +}; + +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'; diff --git a/frontend/src/component/segments/EditSegment/EditSegment.tsx b/frontend/src/component/segments/EditSegment/EditSegment.tsx new file mode 100644 index 0000000000..62c961bffd --- /dev/null +++ b/frontend/src/component/segments/EditSegment/EditSegment.tsx @@ -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 ( + + + + + + ); +}; diff --git a/frontend/src/component/segments/SegmentDelete/SegmentDelete.tsx b/frontend/src/component/segments/SegmentDelete/SegmentDelete.tsx new file mode 100644 index 0000000000..a276ab8927 --- /dev/null +++ b/frontend/src/component/segments/SegmentDelete/SegmentDelete.tsx @@ -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>; + handleDeleteSegment: (id: number) => Promise; +} +export const SegmentDelete = ({ + segment, + open, + setDeldialogue, + handleDeleteSegment, +}: ISegmentDeleteProps) => { + const { strategies } = useStrategiesBySegment(segment.id); + const canDeleteSegment = strategies?.length === 0; + return ( + + } + elseShow={ + + } + /> + ); +}; diff --git a/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts b/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts similarity index 61% rename from frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts rename to frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts index 6c4bba514d..3faefca510 100644 --- a/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts +++ b/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts @@ -7,4 +7,9 @@ export const useStyles = makeStyles(theme => ({ deleteInput: { marginTop: '1rem', }, + link: { + textDecoration: 'none', + color: theme.palette.primary.main, + fontWeight: theme.fontWeight.bold, + }, })); diff --git a/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx b/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx similarity index 88% rename from frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx rename to frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx index ecfe5a61da..6f3605f0ed 100644 --- a/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx +++ b/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import Dialogue from 'component/common/Dialogue'; import Input from 'component/common/Input/Input'; import { useStyles } from './SegmentDeleteConfirm.styles'; @@ -9,8 +9,6 @@ interface ISegmentDeleteConfirmProps { open: boolean; setDeldialogue: React.Dispatch>; handleDeleteSegment: (id: number) => Promise; - confirmName: string; - setConfirmName: React.Dispatch>; } export const SegmentDeleteConfirm = ({ @@ -18,10 +16,9 @@ export const SegmentDeleteConfirm = ({ open, setDeldialogue, handleDeleteSegment, - confirmName, - setConfirmName, }: ISegmentDeleteConfirmProps) => { const styles = useStyles(); + const [confirmName, setConfirmName] = useState(''); const handleChange = (e: React.ChangeEvent) => setConfirmName(e.currentTarget.value); @@ -37,7 +34,10 @@ export const SegmentDeleteConfirm = ({ open={open} primaryButtonText="Delete segment" secondaryButtonText="Cancel" - onClick={() => handleDeleteSegment(segment.id)} + onClick={() => { + handleDeleteSegment(segment.id); + setConfirmName(''); + }} disabledPrimaryButton={segment?.name !== confirmName} onClose={handleCancel} formId={formId} diff --git a/frontend/src/component/segments/SegmentDelete/SegmentDeleteUsedSegment/SegmentDeleteUsedSegment.tsx b/frontend/src/component/segments/SegmentDelete/SegmentDeleteUsedSegment/SegmentDeleteUsedSegment.tsx new file mode 100644 index 0000000000..0cfbe278ab --- /dev/null +++ b/frontend/src/component/segments/SegmentDelete/SegmentDeleteUsedSegment/SegmentDeleteUsedSegment.tsx @@ -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>; + strategies: IFeatureStrategy[] | undefined; +} + +export const SegmentDeleteUsedSegment = ({ + segment, + open, + setDeldialogue, + strategies, +}: ISegmentDeleteUsedSegmentProps) => { + const styles = useStyles(); + + const handleCancel = () => { + setDeldialogue(false); + }; + + return ( + +

+ The following feature toggles are using the{' '} + {segment.name} segment for their strategies: +

+
    + {strategies?.map(strategy => ( +
  • + + {strategy.featureName!}{' '} + {formatStrategyNameParens(strategy)} + +
  • + ))} +
+
+ ); +}; + +const formatStrategyNameParens = (strategy: IFeatureStrategy): string => { + if (!strategy.strategyName) { + return ''; + } + + return `(${formatStrategyName(strategy.strategyName)})`; +}; diff --git a/frontend/src/component/segments/SegmentForm/SegmentForm.styles.ts b/frontend/src/component/segments/SegmentForm/SegmentForm.styles.ts new file mode 100644 index 0000000000..9d9a66a241 --- /dev/null +++ b/frontend/src/component/segments/SegmentForm/SegmentForm.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/segments/SegmentForm/SegmentForm.tsx b/frontend/src/component/segments/SegmentForm/SegmentForm.tsx new file mode 100644 index 0000000000..5c169df69a --- /dev/null +++ b/frontend/src/component/segments/SegmentForm/SegmentForm.tsx @@ -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>; + setDescription: React.Dispatch>; + setConstraints: React.Dispatch>; + handleSubmit: (e: any) => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; + clearErrors: () => void; +} + +export const SegmentForm: React.FC = ({ + children, + name, + description, + constraints, + setName, + setDescription, + setConstraints, + handleSubmit, + errors, + clearErrors, +}) => { + const styles = useStyles(); + const totalSteps = 2; + const [currentStep, setCurrentStep] = useState(1); + + return ( + <> + + + + } + /> + + {children} + + } + /> + + + ); +}; diff --git a/frontend/src/component/segments/SegmentFormStepList/SegmentFormStepList.styles.ts b/frontend/src/component/segments/SegmentFormStepList/SegmentFormStepList.styles.ts new file mode 100644 index 0000000000..d31272e625 --- /dev/null +++ b/frontend/src/component/segments/SegmentFormStepList/SegmentFormStepList.styles.ts @@ -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, + }, +})); diff --git a/frontend/src/component/segments/SegmentFormStepList/SegmentFormStepList.tsx b/frontend/src/component/segments/SegmentFormStepList/SegmentFormStepList.tsx new file mode 100644 index 0000000000..7c416be4af --- /dev/null +++ b/frontend/src/component/segments/SegmentFormStepList/SegmentFormStepList.tsx @@ -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 = ({ + 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 ( +
+
+ + Step {current} of {total} + + {steps.map(step => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.styles.ts b/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.styles.ts new file mode 100644 index 0000000000..33f994c199 --- /dev/null +++ b/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.tsx b/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.tsx new file mode 100644 index 0000000000..21180f1b6e --- /dev/null +++ b/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.tsx @@ -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>; + setDescription: React.Dispatch>; + errors: { [key: string]: string }; + clearErrors: () => void; + setCurrentStep: React.Dispatch>; +} + +export const SegmentFormStepOne: React.FC = ({ + children, + name, + description, + setName, + setDescription, + errors, + clearErrors, + setCurrentStep, +}) => { + const history = useHistory(); + const styles = useStyles(); + + return ( +
+
+

+ What is the segment name? +

+ setName(e.target.value)} + error={Boolean(errors.name)} + errorText={errors.name} + onFocus={() => clearErrors()} + autoFocus + required + /> +

+ What is the segment description? +

+ setDescription(e.target.value)} + error={Boolean(errors.description)} + errorText={errors.description} + onFocus={() => clearErrors()} + /> +
+
+ + +
+
+ ); +}; diff --git a/frontend/src/component/segments/SegmentFormStepTwo/SegmentFormStepTwo.styles.ts b/frontend/src/component/segments/SegmentFormStepTwo/SegmentFormStepTwo.styles.ts new file mode 100644 index 0000000000..dfbeaf045b --- /dev/null +++ b/frontend/src/component/segments/SegmentFormStepTwo/SegmentFormStepTwo.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/segments/SegmentFormStepTwo/SegmentFormStepTwo.tsx b/frontend/src/component/segments/SegmentFormStepTwo/SegmentFormStepTwo.tsx new file mode 100644 index 0000000000..54c9d86cb9 --- /dev/null +++ b/frontend/src/component/segments/SegmentFormStepTwo/SegmentFormStepTwo.tsx @@ -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>; + setCurrentStep: React.Dispatch>; +} + +export const SegmentFormStepTwo: React.FC = ({ + children, + constraints, + setConstraints, + setCurrentStep, +}) => { + const constraintsAccordionListRef = useRef(); + 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 ( +
+
+
+

+ Select the context fields you want to include in the + segment. +

+

+ Use a predefined context field: +

+ +
+
+

+ ...or add a new context field: +

+ setOpen(false)} + open={open} + > + setOpen(false)} + onCancel={() => setOpen(false)} + modal + /> + + } + onClick={() => setOpen(true)} + > + Add context field + +
+ + +

+ Start adding context fields by selecting an + option from above, or you can create a new + context field and use it right away +

+
+ } + /> +
+ +
+
+ +
+ + {children} + +
+
+ ); +}; diff --git a/frontend/src/component/segments/SegmentList/SegmentList.styles.ts b/frontend/src/component/segments/SegmentList/SegmentList.styles.ts index 1aa31752ff..1788c1678d 100644 --- a/frontend/src/component/segments/SegmentList/SegmentList.styles.ts +++ b/frontend/src/component/segments/SegmentList/SegmentList.styles.ts @@ -1,33 +1,31 @@ import { makeStyles } from '@material-ui/core/styles'; export const useStyles = makeStyles(theme => ({ - main: { - paddingBottom: '2rem', - }, - container: { + empty: { display: 'flex', flexDirection: 'column', alignItems: 'center', - marginTop: '5rem', + marginBlock: '5rem', }, title: { fontSize: theme.fontSizes.mainHeader, - marginBottom: 12, + marginBottom: '1.25rem', }, subtitle: { fontSize: theme.fontSizes.smallBody, color: theme.palette.grey[600], maxWidth: 515, marginBottom: 20, - wordBreak: 'break-all', - whiteSpace: 'normal', + textAlign: 'center', }, tableRow: { background: '#F6F6FA', borderRadius: '8px', }, paramButton: { - color: theme.palette.primary.dark, + textDecoration: 'none', + color: theme.palette.primary.main, + fontWeight: theme.fontWeight.bold, }, cell: { borderBottom: 'none', diff --git a/frontend/src/component/segments/SegmentList/SegmentList.tsx b/frontend/src/component/segments/SegmentList/SegmentList.tsx index 32cd92f9ef..291703a195 100644 --- a/frontend/src/component/segments/SegmentList/SegmentList.tsx +++ b/frontend/src/component/segments/SegmentList/SegmentList.tsx @@ -18,7 +18,6 @@ import { SegmentListItem } from './SegmentListItem/SegmentListItem'; import { ISegment } from 'interfaces/segment'; import { useStyles } from './SegmentList.styles'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; -import { SegmentDeleteConfirm } from '../SegmentDeleteConfirm/SegmentDeleteConfirm'; import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; @@ -27,17 +26,17 @@ import ConditionallyRender from 'component/common/ConditionallyRender'; import HeaderTitle from 'component/common/HeaderTitle'; import PageContent from 'component/common/PageContent'; import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { SegmentDelete } from '../SegmentDelete/SegmentDelete'; export const SegmentsList = () => { const history = useHistory(); const { hasAccess } = useContext(AccessContext); - const { segments, refetchSegments } = useSegments(); + const { segments = [], refetchSegments } = useSegments(); const { deleteSegment } = useSegmentsApi(); const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } = usePagination(segments, 10); const [currentSegment, setCurrentSegment] = useState(); const [delDialog, setDelDialog] = useState(false); - const [confirmName, setConfirmName] = useState(''); const { setToastData, setToastApiError } = useToast(); const styles = useStyles(); @@ -46,7 +45,7 @@ export const SegmentsList = () => { if (!currentSegment?.id) return; try { await deleteSegment(currentSegment?.id); - refetchSegments(); + await refetchSegments(); setToastData({ type: 'success', title: 'Successfully deleted segment', @@ -55,7 +54,6 @@ export const SegmentsList = () => { setToastApiError(formatUnknownError(error)); } setDelDialog(false); - setConfirmName(''); }; const renderSegments = () => { @@ -77,9 +75,9 @@ export const SegmentsList = () => { const renderNoSegments = () => { return ( -
+
- There are no segments created yet. + No segments yet!

Segment makes it easy for you to define who should be @@ -109,73 +107,72 @@ export const SegmentsList = () => { /> } > -

- - - - - Name - - - Description - - - Created on - - - Created By - - - {hasAccess(UPDATE_SEGMENT) ? 'Actions' : ''} - - - - - 0} - show={renderSegments()} - /> - - - + + + + Name + + + Description + + + Created on + + + Created By + + + {hasAccess(UPDATE_SEGMENT) ? 'Actions' : ''} + + + + + 0} + show={renderSegments()} /> -
- - {currentSegment && ( - + + + + ( + )} -
+ /> ); }; diff --git a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx index 1e2ea9e7fd..1d0289ab0c 100644 --- a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx +++ b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx @@ -1,10 +1,14 @@ import { useStyles } from './SegmentListItem.styles'; import { TableCell, TableRow, Typography } from '@material-ui/core'; import { Delete, Edit } from '@material-ui/icons'; -import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { + ADMIN, + UPDATE_SEGMENT, +} from 'component/providers/AccessProvider/permissions'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import TimeAgo from 'react-timeago'; import { ISegment } from 'interfaces/segment'; +import { useHistory } from 'react-router-dom'; interface ISegmentListItemProps { id: number; @@ -28,6 +32,7 @@ export const SegmentListItem = ({ setDelDialog, }: ISegmentListItemProps) => { const styles = useStyles(); + const { push } = useHistory(); return ( @@ -55,11 +60,12 @@ export const SegmentListItem = ({ {}} - permission={ADMIN} + onClick={() => { + push(`/segments/edit/${id}`); + }} + permission={UPDATE_SEGMENT} > - + { + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription); + const [constraints, setConstraints] = + useState(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, + }; +}; diff --git a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts index c9e76b9b6b..ca05839e43 100644 --- a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts @@ -26,23 +26,14 @@ const useFeatureApi = () => { }; const validateConstraint = async ( - projectId: string, - featureName: string, constraint: IConstraint - ) => { - const path = `api/admin/projects/${projectId}/features/${featureName}/constraint/validate`; + ): Promise => { + const path = `api/admin/constraints/validate`; const req = createRequest(path, { method: 'POST', body: JSON.stringify(constraint), }); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } + await makeRequest(req.caller, req.id); }; const createFeatureToggle = async ( diff --git a/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts b/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts index 8c8bb7254a..d32a8fd614 100644 --- a/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts @@ -1,4 +1,4 @@ -import { IStrategyPayload } from 'interfaces/strategy'; +import { IStrategyPayload, IFeatureStrategy } from 'interfaces/strategy'; import useAPI from '../useApi/useApi'; const useFeatureStrategyApi = () => { @@ -11,21 +11,14 @@ const useFeatureStrategyApi = () => { featureId: string, environmentId: string, payload: IStrategyPayload - ) => { + ): Promise => { const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`; const req = createRequest( path, { method: 'POST', body: JSON.stringify(payload) }, 'addStrategyToFeature' ); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } + return (await makeRequest(req.caller, req.id)).json(); }; const deleteStrategyFromFeature = async ( @@ -33,21 +26,14 @@ const useFeatureStrategyApi = () => { featureId: string, environmentId: string, strategyId: string - ) => { + ): Promise => { const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`; const req = createRequest( path, { method: 'DELETE' }, 'deleteStrategyFromFeature' ); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } + await makeRequest(req.caller, req.id); }; const updateStrategyOnFeature = async ( @@ -56,21 +42,14 @@ const useFeatureStrategyApi = () => { environmentId: string, strategyId: string, payload: IStrategyPayload - ) => { + ): Promise => { const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`; const req = createRequest( path, { method: 'PUT', body: JSON.stringify(payload) }, 'updateStrategyOnFeature' ); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } + await makeRequest(req.caller, req.id); }; return { diff --git a/frontend/src/hooks/api/actions/useSegmentsApi/useSegmentsApi.ts b/frontend/src/hooks/api/actions/useSegmentsApi/useSegmentsApi.ts index ec4d5888e0..14e870c09f 100644 --- a/frontend/src/hooks/api/actions/useSegmentsApi/useSegmentsApi.ts +++ b/frontend/src/hooks/api/actions/useSegmentsApi/useSegmentsApi.ts @@ -6,10 +6,8 @@ export const useSegmentsApi = () => { propagateErrors: true, }); - const PATH = 'api/admin/segments'; - - const createSegment = async (segment: ISegmentPayload, user: any) => { - const req = createRequest(PATH, { + const createSegment = async (segment: ISegmentPayload) => { + const req = createRequest(formatSegmentsPath(), { method: 'POST', body: JSON.stringify(segment), }); @@ -17,16 +15,11 @@ export const useSegmentsApi = () => { return makeRequest(req.caller, req.id); }; - const deleteSegment = async (id: number) => { - const req = createRequest(`${PATH}/${id}`, { - method: 'DELETE', - }); - - return makeRequest(req.caller, req.id); - }; - - const updateSegment = async (segment: ISegmentPayload) => { - const req = createRequest(PATH, { + const updateSegment = async ( + segmentId: number, + segment: ISegmentPayload + ) => { + const req = createRequest(formatSegmentPath(segmentId), { method: 'PUT', body: JSON.stringify(segment), }); @@ -34,5 +27,45 @@ export const useSegmentsApi = () => { return makeRequest(req.caller, req.id); }; - return { createSegment, deleteSegment, updateSegment, errors, loading }; + const deleteSegment = async (segmentId: number) => { + const req = createRequest(formatSegmentPath(segmentId), { + method: 'DELETE', + }); + return makeRequest(req.caller, req.id); + }; + + const setStrategySegments = async (payload: { + projectId: string; + environmentId: string; + strategyId: string; + segmentIds: number[]; + }) => { + const req = createRequest(formatStrategiesPath(), { + method: 'POST', + body: JSON.stringify(payload), + }); + + return makeRequest(req.caller, req.id); + }; + + return { + createSegment, + deleteSegment, + updateSegment, + setStrategySegments, + errors, + loading, + }; +}; + +const formatSegmentsPath = (): string => { + return 'api/admin/segments'; +}; + +const formatSegmentPath = (segmentId: number): string => { + return `${formatSegmentsPath()}/${segmentId}`; +}; + +const formatStrategiesPath = (): string => { + return `${formatSegmentsPath()}/strategies`; }; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/useConstraintsValidation.ts b/frontend/src/hooks/api/getters/useConstraintsValidation/useConstraintsValidation.ts similarity index 85% rename from frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/useConstraintsValidation.ts rename to frontend/src/hooks/api/getters/useConstraintsValidation/useConstraintsValidation.ts index efddd08515..944404d4e3 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints2/useConstraintsValidation.ts +++ b/frontend/src/hooks/api/getters/useConstraintsValidation/useConstraintsValidation.ts @@ -3,8 +3,6 @@ import { useEffect, useState } from 'react'; import { IConstraint } from 'interfaces/strategy'; export const useConstraintsValidation = ( - projectId: string, - featureId: string, constraints?: IConstraint[] ): boolean => { // An empty list of constraints is valid. An undefined list is not. @@ -19,7 +17,7 @@ export const useConstraintsValidation = ( } const validationRequests = constraints.map(constraint => { - return validateConstraint(projectId, featureId, constraint); + return validateConstraint(constraint); }); Promise.all(validationRequests) @@ -27,7 +25,7 @@ export const useConstraintsValidation = ( .catch(() => setValid(false)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, featureId, constraints]); + }, [constraints]); return valid; }; diff --git a/frontend/src/hooks/api/getters/useSegment/useSegment.ts b/frontend/src/hooks/api/getters/useSegment/useSegment.ts new file mode 100644 index 0000000000..e3506c9784 --- /dev/null +++ b/frontend/src/hooks/api/getters/useSegment/useSegment.ts @@ -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(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()); +}; diff --git a/frontend/src/hooks/api/getters/useSegments/useSegments.ts b/frontend/src/hooks/api/getters/useSegments/useSegments.ts index 581170139a..41ef8483be 100644 --- a/frontend/src/hooks/api/getters/useSegments/useSegments.ts +++ b/frontend/src/hooks/api/getters/useSegments/useSegments.ts @@ -1,39 +1,54 @@ -import useSWR, { mutate, SWRConfiguration } from 'swr'; +import useSWR from 'swr'; import { useCallback } from 'react'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import { ISegment } from 'interfaces/segment'; - -const PATH = formatApiPath('api/admin/segments'); +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { IFlags } from 'interfaces/uiConfig'; export interface UseSegmentsOutput { - segments: ISegment[]; + segments?: ISegment[]; refetchSegments: () => void; loading: boolean; error?: Error; } -export const useSegments = (options?: SWRConfiguration): UseSegmentsOutput => { - const { data, error } = useSWR<{ segments: ISegment[] }>( - PATH, - fetchSegments, - options +export const useSegments = (strategyId?: string): UseSegmentsOutput => { + const { uiConfig } = useUiConfig(); + + const { data, error, mutate } = useSWR( + [strategyId, uiConfig.flags], + fetchSegments ); const refetchSegments = useCallback(() => { - mutate(PATH).catch(console.warn); - }, []); + mutate().catch(console.warn); + }, [mutate]); return { - segments: data?.segments || [], + segments: data, refetchSegments, loading: !error && !data, error, }; }; -const fetchSegments = () => { - return fetch(PATH, { method: 'GET' }) +export const fetchSegments = async ( + strategyId?: string, + flags?: IFlags +): Promise => { + if (!flags?.SE) { + return []; + } + + return fetch(formatSegmentsPath(strategyId)) .then(handleErrorResponses('Segments')) - .then(res => res.json()); + .then(res => res.json()) + .then(res => res.segments); +}; + +const formatSegmentsPath = (strategyId?: string): string => { + return strategyId + ? formatApiPath(`api/admin/segments/strategies/${strategyId}`) + : formatApiPath('api/admin/segments'); }; diff --git a/frontend/src/hooks/api/getters/useStrategiesBySegment/useStrategiesBySegment.ts b/frontend/src/hooks/api/getters/useStrategiesBySegment/useStrategiesBySegment.ts new file mode 100644 index 0000000000..7999065720 --- /dev/null +++ b/frontend/src/hooks/api/getters/useStrategiesBySegment/useStrategiesBySegment.ts @@ -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()); +}; diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts index f32e029756..a89da3f230 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts @@ -5,7 +5,15 @@ export const defaultValue = { version: '3.x', environment: '', slogan: 'The enterprise ready feature toggle service.', - flags: { P: false, C: false, E: false, RE: false, EEA: false, CO: false }, + flags: { + P: false, + C: false, + E: false, + RE: false, + EEA: false, + CO: false, + SE: false, + }, links: [ { value: 'Documentation', diff --git a/frontend/src/interfaces/segment.ts b/frontend/src/interfaces/segment.ts index 32bc4f2ac6..5b35ba5ab6 100644 --- a/frontend/src/interfaces/segment.ts +++ b/frontend/src/interfaces/segment.ts @@ -9,8 +9,7 @@ export interface ISegment { constraints: IConstraint[]; } -export interface ISegmentPayload { - name: string; - description: string; - constraints: IConstraint[]; -} +export type ISegmentPayload = Pick< + ISegment, + 'name' | 'description' | 'constraints' +>; diff --git a/frontend/src/interfaces/strategy.ts b/frontend/src/interfaces/strategy.ts index 7408c077e2..7c3ddd394b 100644 --- a/frontend/src/interfaces/strategy.ts +++ b/frontend/src/interfaces/strategy.ts @@ -2,9 +2,13 @@ import { Operator } from 'constants/operators'; export interface IFeatureStrategy { id: string; + strategyName?: string; name: string; constraints: IConstraint[]; parameters: IParameter; + featureName?: string; + projectId?: string; + environment?: string; } export interface IStrategy { diff --git a/frontend/src/utils/operatorUtils.ts b/frontend/src/utils/operatorUtils.ts new file mode 100644 index 0000000000..1c31802ea3 --- /dev/null +++ b/frontend/src/utils/operatorUtils.ts @@ -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; + }); +};