-
-
Notifications
You must be signed in to change notification settings - Fork 723
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix/constraint accordion autosave (#5825)
This PR adds autosave to the constraint accordion which means that when you add values to it, it will automatically save the constraint locally. If you unmount the constraint component without any valid values, it will remove the constraint from the list.
- Loading branch information
1 parent
d711085
commit d6afe69
Showing
45 changed files
with
3,576 additions
and
43 deletions.
There are no files selected for viewing
278 changes: 278 additions & 0 deletions
278
...mponent/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,278 @@ | ||
import { useCallback, useEffect, useState } from 'react'; | ||
import { IConstraint } from 'interfaces/strategy'; | ||
import { ConstraintAccordionEditBody } from './ConstraintAccordionEditBody/ConstraintAccordionEditBody'; | ||
import { ConstraintAccordionEditHeader } from './ConstraintAccordionEditHeader/ConstraintAccordionEditHeader'; | ||
import { | ||
Accordion, | ||
AccordionDetails, | ||
AccordionSummary, | ||
styled, | ||
} from '@mui/material'; | ||
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 { IUnleashContextDefinition } from 'interfaces/context'; | ||
import { useConstraintInput } from './ConstraintAccordionEditBody/useConstraintInput/useConstraintInput'; | ||
import { Operator } from 'constants/operators'; | ||
import { ResolveInput } from './ConstraintAccordionEditBody/ResolveInput/ResolveInput'; | ||
|
||
interface IConstraintAccordionEditProps { | ||
constraint: IConstraint; | ||
onCancel: () => void; | ||
onSave: (constraint: IConstraint) => void; | ||
compact: boolean; | ||
onDelete?: () => void; | ||
onAutoSave?: (constraint: IConstraint) => void; | ||
} | ||
|
||
export const CANCEL = 'cancel'; | ||
export const SAVE = 'save'; | ||
|
||
const resolveContextDefinition = ( | ||
context: IUnleashContextDefinition[], | ||
contextName: string, | ||
): IUnleashContextDefinition => { | ||
const definition = context.find( | ||
(contextDef) => contextDef.name === contextName, | ||
); | ||
|
||
return ( | ||
definition || { | ||
name: '', | ||
description: '', | ||
createdAt: '', | ||
sortOrder: 1, | ||
stickiness: false, | ||
} | ||
); | ||
}; | ||
|
||
const StyledForm = styled('div')({ padding: 0, margin: 0, width: '100%' }); | ||
|
||
const StyledAccordion = styled(Accordion)(({ theme }) => ({ | ||
border: `1px solid ${theme.palette.divider}`, | ||
borderRadius: theme.shape.borderRadiusMedium, | ||
backgroundColor: theme.palette.background.elevation1, | ||
boxShadow: 'none', | ||
margin: 0, | ||
'& .expanded': { | ||
'&:before': { | ||
opacity: '0 !important', | ||
}, | ||
}, | ||
})); | ||
|
||
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ | ||
border: 'none', | ||
padding: theme.spacing(0.5, 3), | ||
'&:hover .valuesExpandLabel': { | ||
textDecoration: 'underline', | ||
}, | ||
})); | ||
|
||
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ | ||
borderTop: `1px dashed ${theme.palette.divider}`, | ||
display: 'flex', | ||
flexDirection: 'column', | ||
padding: 0, | ||
})); | ||
|
||
export const ConstraintAccordionEdit = ({ | ||
constraint, | ||
compact, | ||
onCancel, | ||
onSave, | ||
onDelete, | ||
onAutoSave, | ||
}: IConstraintAccordionEditProps) => { | ||
const [localConstraint, setLocalConstraint] = useState<IConstraint>( | ||
cleanConstraint(constraint), | ||
); | ||
|
||
const { context } = useUnleashContext(); | ||
const [contextDefinition, setContextDefinition] = useState( | ||
resolveContextDefinition(context, localConstraint.contextName), | ||
); | ||
const { validateConstraint } = useFeatureApi(); | ||
const [expanded, setExpanded] = useState(false); | ||
const [action, setAction] = useState(''); | ||
|
||
useEffect(() => { | ||
// Setting expanded to true on mount will cause the accordion | ||
// animation to take effect and transition the expanded accordion in | ||
setExpanded(true); | ||
}, []); | ||
|
||
useEffect(() => { | ||
if (onAutoSave) { | ||
onAutoSave(localConstraint); | ||
} | ||
}, [JSON.stringify(localConstraint)]); | ||
|
||
useEffect(() => { | ||
setContextDefinition( | ||
resolveContextDefinition(context, localConstraint.contextName), | ||
); | ||
}, [localConstraint.contextName, context]); | ||
|
||
const setContextName = useCallback((contextName: string) => { | ||
setLocalConstraint((prev) => ({ | ||
...prev, | ||
contextName, | ||
values: [], | ||
value: '', | ||
})); | ||
}, []); | ||
|
||
const setOperator = useCallback((operator: Operator) => { | ||
setLocalConstraint((prev) => ({ | ||
...prev, | ||
operator, | ||
values: [], | ||
value: '', | ||
})); | ||
}, []); | ||
|
||
const setValues = useCallback((values: string[]) => { | ||
setLocalConstraint((prev) => { | ||
const localConstraint = { ...prev, values }; | ||
|
||
return localConstraint; | ||
}); | ||
}, []); | ||
|
||
const setValue = useCallback((value: string) => { | ||
setLocalConstraint((prev) => ({ ...prev, value })); | ||
}, []); | ||
|
||
const setInvertedOperator = () => { | ||
setLocalConstraint((prev) => ({ ...prev, inverted: !prev.inverted })); | ||
}; | ||
|
||
const setCaseInsensitive = useCallback(() => { | ||
setLocalConstraint((prev) => ({ | ||
...prev, | ||
caseInsensitive: !prev.caseInsensitive, | ||
})); | ||
}, []); | ||
|
||
const removeValue = useCallback( | ||
(index: number) => { | ||
const valueCopy = [...localConstraint.values!]; | ||
valueCopy.splice(index, 1); | ||
|
||
setValues(valueCopy); | ||
}, | ||
[localConstraint, setValues], | ||
); | ||
|
||
const triggerTransition = () => { | ||
setExpanded(false); | ||
}; | ||
|
||
const validateConstraintValues = () => { | ||
const hasValues = | ||
Array.isArray(localConstraint.values) && | ||
Boolean(localConstraint.values.length > 0); | ||
const hasValue = Boolean(localConstraint.value); | ||
|
||
if (hasValues || hasValue) { | ||
setError(''); | ||
return true; | ||
} | ||
setError('You must provide a value for the constraint'); | ||
return false; | ||
}; | ||
|
||
const onSubmit = async () => { | ||
const hasValues = validateConstraintValues(); | ||
if (!hasValues) return; | ||
const [typeValidatorResult, err] = validator(); | ||
|
||
if (!typeValidatorResult) { | ||
setError(err); | ||
} | ||
|
||
if (typeValidatorResult) { | ||
try { | ||
await validateConstraint(localConstraint); | ||
setError(''); | ||
setAction(SAVE); | ||
triggerTransition(); | ||
return; | ||
} catch (error: unknown) { | ||
setError(formatUnknownError(error)); | ||
} | ||
} | ||
}; | ||
|
||
const { input, validator, setError, error } = useConstraintInput({ | ||
contextDefinition, | ||
localConstraint, | ||
}); | ||
|
||
useEffect(() => { | ||
setError(''); | ||
setLocalConstraint((localConstraint) => | ||
cleanConstraint(localConstraint), | ||
); | ||
}, [localConstraint.operator, localConstraint.contextName, setError]); | ||
|
||
return ( | ||
<StyledForm> | ||
<StyledAccordion | ||
expanded={expanded} | ||
TransitionProps={{ | ||
onExited: () => { | ||
if (action === CANCEL) { | ||
setAction(''); | ||
onCancel(); | ||
} else if (action === SAVE) { | ||
setAction(''); | ||
onSave(localConstraint); | ||
} | ||
}, | ||
}} | ||
> | ||
<StyledAccordionSummary> | ||
<ConstraintAccordionEditHeader | ||
localConstraint={localConstraint} | ||
setLocalConstraint={setLocalConstraint} | ||
setContextName={setContextName} | ||
setOperator={setOperator} | ||
action={action} | ||
compact={compact} | ||
setInvertedOperator={setInvertedOperator} | ||
setCaseInsensitive={setCaseInsensitive} | ||
onDelete={onDelete} | ||
/> | ||
</StyledAccordionSummary> | ||
|
||
<StyledAccordionDetails> | ||
<ConstraintAccordionEditBody | ||
localConstraint={localConstraint} | ||
setValues={setValues} | ||
setValue={setValue} | ||
triggerTransition={triggerTransition} | ||
setAction={setAction} | ||
onSubmit={onSubmit} | ||
> | ||
<ResolveInput | ||
setValues={setValues} | ||
setValue={setValue} | ||
setError={setError} | ||
localConstraint={localConstraint} | ||
constraintValues={constraint?.values || []} | ||
constraintValue={constraint?.value || ''} | ||
input={input} | ||
error={error} | ||
contextDefinition={contextDefinition} | ||
removeValue={removeValue} | ||
/> | ||
</ConstraintAccordionEditBody> | ||
</StyledAccordionDetails> | ||
</StyledAccordion> | ||
</StyledForm> | ||
); | ||
}; |
84 changes: 84 additions & 0 deletions
84
...rdion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import { Button, styled } from '@mui/material'; | ||
import { IConstraint } from 'interfaces/strategy'; | ||
import { CANCEL } from '../ConstraintAccordionEdit'; | ||
|
||
import React from 'react'; | ||
import { newOperators } from 'constants/operators'; | ||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||
import { oneOf } from 'utils/oneOf'; | ||
import { OperatorUpgradeAlert } from 'component/common/OperatorUpgradeAlert/OperatorUpgradeAlert'; | ||
|
||
interface IConstraintAccordionBody { | ||
localConstraint: IConstraint; | ||
setValues: (values: string[]) => void; | ||
triggerTransition: () => void; | ||
setValue: (value: string) => void; | ||
setAction: React.Dispatch<React.SetStateAction<string>>; | ||
onSubmit: () => void; | ||
} | ||
|
||
const StyledInputContainer = styled('div')(({ theme }) => ({ | ||
padding: theme.spacing(2), | ||
})); | ||
|
||
const StyledButtonContainer = styled('div')(({ theme }) => ({ | ||
display: 'flex', | ||
alignItems: 'center', | ||
marginTop: theme.spacing(2), | ||
borderTop: `1px solid ${theme.palette.divider}`, | ||
width: '100%', | ||
padding: theme.spacing(2), | ||
})); | ||
|
||
const StyledInputButtonContainer = styled('div')({ | ||
marginLeft: 'auto', | ||
}); | ||
|
||
const StyledLeftButton = styled(Button)(({ theme }) => ({ | ||
marginRight: theme.spacing(1), | ||
minWidth: '125px', | ||
})); | ||
|
||
const StyledRightButton = styled(Button)(({ theme }) => ({ | ||
marginLeft: theme.spacing(1), | ||
minWidth: '125px', | ||
})); | ||
|
||
export const ConstraintAccordionEditBody: React.FC<IConstraintAccordionBody> = | ||
({ localConstraint, children, triggerTransition, setAction, onSubmit }) => { | ||
return ( | ||
<> | ||
<StyledInputContainer> | ||
<ConditionallyRender | ||
condition={oneOf( | ||
newOperators, | ||
localConstraint.operator, | ||
)} | ||
show={<OperatorUpgradeAlert />} | ||
/> | ||
{children} | ||
</StyledInputContainer> | ||
<StyledButtonContainer> | ||
<StyledInputButtonContainer> | ||
<StyledLeftButton | ||
type='button' | ||
onClick={onSubmit} | ||
variant='outlined' | ||
color='primary' | ||
data-testid='CONSTRAINT_SAVE_BUTTON' | ||
> | ||
Done | ||
</StyledLeftButton> | ||
<StyledRightButton | ||
onClick={() => { | ||
setAction(CANCEL); | ||
triggerTransition(); | ||
}} | ||
> | ||
Cancel | ||
</StyledRightButton> | ||
</StyledInputButtonContainer> | ||
</StyledButtonContainer> | ||
</> | ||
); | ||
}; |
15 changes: 15 additions & 0 deletions
15
...ntAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import React from 'react'; | ||
import { styled } from '@mui/material'; | ||
|
||
const StyledHeader = styled('h3')(({ theme }) => ({ | ||
fontSize: theme.fontSizes.bodySize, | ||
fontWeight: theme.typography.fontWeightRegular, | ||
marginTop: theme.spacing(2), | ||
marginBottom: theme.spacing(0.5), | ||
})); | ||
|
||
export const ConstraintFormHeader: React.FC< | ||
React.HTMLAttributes<HTMLDivElement> | ||
> = ({ children, ...rest }) => { | ||
return <StyledHeader {...rest}>{children}</StyledHeader>; | ||
}; |
18 changes: 18 additions & 0 deletions
18
...straintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { parseDateValue } from 'component/common/util'; | ||
|
||
test(`Date component is able to parse midnight when it's 00`, () => { | ||
const f = parseDateValue('2022-03-15T12:27'); | ||
const midnight = parseDateValue('2022-03-15T00:27'); | ||
expect(f).toEqual('2022-03-15T12:27'); | ||
expect(midnight).toEqual('2022-03-15T00:27'); | ||
}); | ||
|
||
test(`Date component - snapshot matching`, () => { | ||
const midnight = '2022-03-15T00:00'; | ||
const midday = '2022-03-15T12:00'; | ||
const obj = { | ||
midnight: parseDateValue(midnight), | ||
midday: parseDateValue(midday), | ||
}; | ||
expect(obj).toMatchSnapshot(); | ||
}); |
Oops, something went wrong.