Skip to content

Commit

Permalink
Fix/constraint accordion autosave (#5825)
Browse files Browse the repository at this point in the history
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
FredrikOseberg authored Jan 10, 2024
1 parent d711085 commit d6afe69
Show file tree
Hide file tree
Showing 45 changed files with 3,576 additions and 43 deletions.
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>
);
};
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>
</>
);
};
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>;
};
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();
});
Loading

0 comments on commit d6afe69

Please sign in to comment.