1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

feat: variant dependencies ui (#6739)

This commit is contained in:
Mateusz Kwasniewski 2024-03-29 19:28:49 +01:00 committed by GitHub
parent 9eee6546f8
commit 7f043c7cab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 437 additions and 238 deletions

View File

@ -42,8 +42,9 @@ export const DependencyChange: VFC<{
>
{change.payload.feature}
</StyledLink>
{change.payload.enabled === false
? ' (disabled)'
{!change.payload.enabled ? ' (disabled)' : null}
{change.payload.variants?.length
? `(${change.payload.variants?.join(', ')})`
: null}
</AddDependencyWrapper>
{actions}

View File

@ -223,7 +223,11 @@ type ChangeRequestVariantPatch = {
type ChangeRequestEnabled = { enabled: boolean };
type ChangeRequestAddDependency = { feature: string; enabled: boolean };
type ChangeRequestAddDependency = {
feature: string;
enabled: boolean;
variants?: string[];
};
export type ChangeRequestAddStrategy = Pick<
IFeatureStrategy,

View File

@ -91,7 +91,7 @@ test('Edit dependency', async () => {
<AddDependencyDialogue
project='default'
featureId='child'
parentFeatureId='parentB'
parentDependency={{ feature: 'parentB' }}
showDependencyDialogue={true}
onClose={() => {
closed = true;

View File

@ -1,205 +1,60 @@
import { type FC, useState } from 'react';
import { Box, styled, Typography } from '@mui/material';
import { useEffect, useState } from 'react';
import { Box, Typography } from '@mui/material';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { useDependentFeaturesApi } from 'hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi';
import { useParentOptions } from 'hooks/api/getters/useParentOptions/useParentOptions';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { DependenciesUpgradeAlert } from './DependenciesUpgradeAlert';
import { useUiFlag } from 'hooks/useUiFlag';
import type { IDependency } from '../../../interfaces/featureToggle';
import { ParentVariantOptions } from './ParentVariantOptions';
import { type ParentValue, REMOVE_DEPENDENCY_OPTION } from './constants';
import { FeatureStatusOptions } from './FeatureStatusOptions';
import { useManageDependency } from './useManageDependency';
import { LazyParentOptions } from './LazyParentOptions';
interface IAddDependencyDialogueProps {
project: string;
featureId: string;
parentFeatureId?: string;
parentFeatureValue?: ParentValue;
parentDependency?: IDependency;
showDependencyDialogue: boolean;
onClose: () => void;
}
const StyledSelect = styled(GeneralSelect)(({ theme }) => ({
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1.5),
}));
const REMOVE_DEPENDENCY_OPTION = {
key: 'none (remove dependency)',
label: 'none (remove dependency)',
};
// Project can have 100s of parents. We want to read them only when the modal for dependencies opens.
const LazyOptions: FC<{
project: string;
featureId: string;
parent: string;
onSelect: (parent: string) => void;
}> = ({ project, featureId, parent, onSelect }) => {
const { parentOptions } = useParentOptions(project, featureId);
const options = parentOptions
? [
REMOVE_DEPENDENCY_OPTION,
...parentOptions.map((parent) => ({
key: parent,
label: parent,
})),
]
: [REMOVE_DEPENDENCY_OPTION];
return (
<StyledSelect
fullWidth
options={options}
value={parent}
onChange={onSelect}
/>
);
};
const FeatureValueOptions: FC<{
parentValue: ParentValue;
onSelect: (parent: string) => void;
}> = ({ onSelect, parentValue }) => {
return (
<StyledSelect
fullWidth
options={[
{ key: 'enabled', label: 'enabled' },
{ key: 'disabled', label: 'disabled' },
]}
value={parentValue.status}
onChange={onSelect}
/>
);
};
type ParentValue = { status: 'enabled' } | { status: 'disabled' };
const useManageDependency = (
project: string,
featureId: string,
parent: string,
parentValue: ParentValue,
onClose: () => void,
) => {
const { trackEvent } = usePlausibleTracker();
const { addChange } = useChangeRequestApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(project);
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(project, featureId);
const environment = useHighestPermissionChangeRequestEnvironment(project)();
const { isChangeRequestConfiguredInAnyEnv } =
useChangeRequestsEnabled(project);
const { addDependency, removeDependencies } =
useDependentFeaturesApi(project);
const handleAddChange = async (
actionType: 'addDependency' | 'deleteDependency',
) => {
if (!environment) {
console.error('No change request environment');
return;
}
if (actionType === 'addDependency') {
await addChange(project, environment, [
{
action: actionType,
feature: featureId,
payload: {
feature: parent,
enabled: parentValue.status !== 'disabled',
},
},
]);
trackEvent('dependent_features', {
props: {
eventType: 'dependency added',
},
});
}
if (actionType === 'deleteDependency') {
await addChange(project, environment, [
{ action: actionType, feature: featureId, payload: undefined },
]);
}
void refetchChangeRequests();
setToastData({
text:
actionType === 'addDependency'
? `${featureId} will depend on ${parent}`
: `${featureId} dependency will be removed`,
type: 'success',
title: 'Change added to a draft',
});
};
return async () => {
try {
if (isChangeRequestConfiguredInAnyEnv()) {
const actionType =
parent === REMOVE_DEPENDENCY_OPTION.key
? 'deleteDependency'
: 'addDependency';
await handleAddChange(actionType);
trackEvent('dependent_features', {
props: {
eventType:
actionType === 'addDependency'
? 'add dependency added to change request'
: 'delete dependency added to change request',
},
});
} else if (parent === REMOVE_DEPENDENCY_OPTION.key) {
await removeDependencies(featureId);
trackEvent('dependent_features', {
props: {
eventType: 'dependency removed',
},
});
setToastData({ title: 'Dependency removed', type: 'success' });
} else {
await addDependency(featureId, {
feature: parent,
enabled: parentValue.status !== 'disabled',
});
trackEvent('dependent_features', {
props: {
eventType: 'dependency added',
},
});
setToastData({ title: 'Dependency added', type: 'success' });
}
} catch (error) {
setToastApiError(formatUnknownError(error));
}
void refetchFeature();
onClose();
};
};
export const AddDependencyDialogue = ({
project,
featureId,
parentFeatureId,
parentFeatureValue,
parentDependency,
showDependencyDialogue,
onClose,
}: IAddDependencyDialogueProps) => {
const [parent, setParent] = useState(
parentFeatureId || REMOVE_DEPENDENCY_OPTION.key,
parentDependency?.feature || REMOVE_DEPENDENCY_OPTION.key,
);
const getInitialParentValue = (): ParentValue => {
if (!parentDependency) return { status: 'enabled' };
if (parentDependency.variants?.length)
return {
status: 'enabled_with_variants',
variants: parentDependency.variants,
};
if (parentDependency.enabled === false) return { status: 'disabled' };
return { status: 'enabled' };
};
const [parentValue, setParentValue] = useState<ParentValue>(
parentFeatureValue || { status: 'enabled' },
getInitialParentValue,
);
const handleClick = useManageDependency(
const resetState = () => {
setParent(parentDependency?.feature || REMOVE_DEPENDENCY_OPTION.key);
setParentValue(getInitialParentValue());
};
useEffect(() => {
resetState();
}, [JSON.stringify(parentDependency)]);
const manageDependency = useManageDependency(
project,
featureId,
parent,
@ -210,13 +65,38 @@ export const AddDependencyDialogue = ({
useChangeRequestsEnabled(project);
const variantDependenciesEnabled = useUiFlag('variantDependencies');
const showStatus =
parent !== REMOVE_DEPENDENCY_OPTION.key && variantDependenciesEnabled;
const showVariants =
parent !== REMOVE_DEPENDENCY_OPTION.key &&
variantDependenciesEnabled &&
parentValue.status === 'enabled_with_variants';
const selectStatus = (value: string) => {
if (value === 'enabled' || value === 'disabled') {
setParentValue({ status: value });
}
if (value === 'enabled_with_variants') {
setParentValue({
status: value,
variants: [],
});
}
};
const selectVariants = (variants: string[]) => {
setParentValue({
status: 'enabled_with_variants',
variants,
});
};
return (
<Dialogue
open={showDependencyDialogue}
title='Add parent feature dependency'
onClose={onClose}
onClick={handleClick}
onClick={manageDependency}
primaryButtonText={
isChangeRequestConfiguredInAnyEnv()
? 'Add change to draft'
@ -245,7 +125,7 @@ export const AddDependencyDialogue = ({
<ConditionallyRender
condition={showDependencyDialogue}
show={
<LazyOptions
<LazyParentOptions
project={project}
featureId={featureId}
parent={parent}
@ -258,30 +138,40 @@ export const AddDependencyDialogue = ({
/>
<ConditionallyRender
condition={
parent !== REMOVE_DEPENDENCY_OPTION.key &&
variantDependenciesEnabled
}
condition={showStatus}
show={
<Box sx={{ mt: 2 }}>
<Typography>
What <b>feature status</b> do you want to depend
on?
</Typography>
<FeatureValueOptions
<FeatureStatusOptions
parentValue={parentValue}
onSelect={(value) =>
setParentValue({
status:
value === 'disabled'
? 'disabled'
: 'enabled',
})
}
onSelect={selectStatus}
/>
</Box>
}
/>
<ConditionallyRender
condition={showVariants}
show={
parentValue.status === 'enabled_with_variants' && (
<Box sx={{ mt: 2 }}>
<Typography>
What <b>variant</b> do you want to depend
on?
</Typography>
<ParentVariantOptions
parent={parent}
project={project}
selectedValues={parentValue.variants}
onSelect={selectVariants}
/>
</Box>
)
}
/>
</Box>
</Dialogue>
);

View File

@ -0,0 +1,29 @@
import type { FC } from 'react';
import type { ParentValue } from './constants';
import { styled } from '@mui/material';
import GeneralSelect from '../../common/GeneralSelect/GeneralSelect';
export const StyledSelect = styled(GeneralSelect)(({ theme }) => ({
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1.5),
}));
export const FeatureStatusOptions: FC<{
parentValue: ParentValue;
onSelect: (parent: string) => void;
}> = ({ onSelect, parentValue }) => {
return (
<StyledSelect
fullWidth
options={[
{ key: 'enabled', label: 'enabled' },
{
key: 'enabled_with_variants',
label: 'enabled with variants',
},
{ key: 'disabled', label: 'disabled' },
]}
value={parentValue.status}
onChange={onSelect}
/>
);
};

View File

@ -0,0 +1,33 @@
import type { FC } from 'react';
import { useParentOptions } from 'hooks/api/getters/useFeatureDependencyOptions/useFeatureDependencyOptions';
import { REMOVE_DEPENDENCY_OPTION } from './constants';
import { StyledSelect } from './FeatureStatusOptions';
// Project can have 100s of parents. We want to read them only when the modal for dependencies opens.
export const LazyParentOptions: FC<{
project: string;
featureId: string;
parent: string;
onSelect: (parent: string) => void;
}> = ({ project, featureId, parent, onSelect }) => {
const { parentOptions } = useParentOptions(project, featureId);
const options = parentOptions
? [
REMOVE_DEPENDENCY_OPTION,
...parentOptions.map((parent) => ({
key: parent,
label: parent,
})),
]
: [REMOVE_DEPENDENCY_OPTION];
return (
<StyledSelect
fullWidth
options={options}
value={parent}
onChange={onSelect}
/>
);
};

View File

@ -0,0 +1,48 @@
import { Autocomplete, Checkbox, styled, TextField } from '@mui/material';
import type { FC } from 'react';
import { useParentVariantOptions } from 'hooks/api/getters/useFeatureDependencyOptions/useFeatureDependencyOptions';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
const StyledAutocomplete = styled(Autocomplete)(({ theme }) => ({
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1.5),
}));
export const ParentVariantOptions: FC<{
project: string;
parent: string;
selectedValues: string[];
onSelect: (values: string[]) => void;
}> = ({ project, parent, onSelect, selectedValues }) => {
const { parentVariantOptions } = useParentVariantOptions(project, parent);
const icon = <CheckBoxOutlineBlankIcon fontSize='small' />;
const checkedIcon = <CheckBoxIcon fontSize='small' />;
return (
<StyledAutocomplete
multiple
id='parent-variant-options'
options={parentVariantOptions}
disableCloseOnSelect
renderOption={(props, option, { selected }) => (
<li {...props}>
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
style={{ marginRight: 8 }}
checked={selected}
/>
{option}
</li>
)}
renderInput={(params) => (
<TextField {...params} placeholder='Select values' />
)}
fullWidth
value={selectedValues}
onChange={(_, selectedValues) => {
onSelect(selectedValues as string[]);
}}
/>
);
};

View File

@ -0,0 +1,12 @@
export type ParentValue =
| { status: 'enabled' }
| { status: 'disabled' }
| {
status: 'enabled_with_variants';
variants: string[];
};
export const REMOVE_DEPENDENCY_OPTION = {
key: 'none (remove dependency)',
label: 'none (remove dependency)',
};

View File

@ -0,0 +1,121 @@
import { type ParentValue, REMOVE_DEPENDENCY_OPTION } from './constants';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import useToast from 'hooks/useToast';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useDependentFeaturesApi } from 'hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi';
import { formatUnknownError } from '../../../utils/formatUnknownError';
export const useManageDependency = (
project: string,
featureId: string,
parent: string,
parentValue: ParentValue,
onClose: () => void,
) => {
const { trackEvent } = usePlausibleTracker();
const { addChange } = useChangeRequestApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(project);
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(project, featureId);
const environment = useHighestPermissionChangeRequestEnvironment(project)();
const { isChangeRequestConfiguredInAnyEnv } =
useChangeRequestsEnabled(project);
const { addDependency, removeDependencies } =
useDependentFeaturesApi(project);
const handleAddChange = async (
actionType: 'addDependency' | 'deleteDependency',
) => {
if (!environment) {
console.error('No change request environment');
return;
}
if (actionType === 'addDependency') {
await addChange(project, environment, [
{
action: actionType,
feature: featureId,
payload: {
feature: parent,
enabled: parentValue.status !== 'disabled',
variants:
parentValue.status === 'enabled_with_variants'
? parentValue.variants
: [],
},
},
]);
trackEvent('dependent_features', {
props: {
eventType: 'dependency added',
},
});
}
if (actionType === 'deleteDependency') {
await addChange(project, environment, [
{ action: actionType, feature: featureId, payload: undefined },
]);
}
void refetchChangeRequests();
setToastData({
text:
actionType === 'addDependency'
? `${featureId} will depend on ${parent}`
: `${featureId} dependency will be removed`,
type: 'success',
title: 'Change added to a draft',
});
};
return async () => {
try {
if (isChangeRequestConfiguredInAnyEnv()) {
const actionType =
parent === REMOVE_DEPENDENCY_OPTION.key
? 'deleteDependency'
: 'addDependency';
await handleAddChange(actionType);
trackEvent('dependent_features', {
props: {
eventType:
actionType === 'addDependency'
? 'add dependency added to change request'
: 'delete dependency added to change request',
},
});
} else if (parent === REMOVE_DEPENDENCY_OPTION.key) {
await removeDependencies(featureId);
trackEvent('dependent_features', {
props: {
eventType: 'dependency removed',
},
});
setToastData({ title: 'Dependency removed', type: 'success' });
} else {
await addDependency(featureId, {
feature: parent,
enabled: parentValue.status !== 'disabled',
variants:
parentValue.status === 'enabled_with_variants'
? parentValue.variants
: [],
});
trackEvent('dependent_features', {
props: {
eventType: 'dependency added',
},
});
setToastData({ title: 'Dependency added', type: 'success' });
}
} catch (error) {
setToastApiError(formatUnknownError(error));
}
void refetchFeature();
onClose();
};
};

View File

@ -17,6 +17,7 @@ import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPe
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { formatUnknownError } from 'utils/formatUnknownError';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { VariantsTooltip } from './VariantsTooltip';
const useDeleteDependency = (project: string, featureId: string) => {
const { trackEvent } = usePlausibleTracker();
@ -152,11 +153,25 @@ export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => {
<FlexRow>
<StyledDetail>
<StyledLabel>Dependency value:</StyledLabel>
<span>
{feature.dependencies[0]?.enabled
? 'enabled'
: 'disabled'}
</span>
<span>disabled</span>
</StyledDetail>
</FlexRow>
}
/>
<ConditionallyRender
condition={
hasParentDependency &&
Boolean(feature.dependencies[0]?.variants?.length)
}
show={
<FlexRow>
<StyledDetail>
<StyledLabel>Dependency value:</StyledLabel>
<VariantsTooltip
variants={
feature.dependencies[0]?.variants || []
}
/>
</StyledDetail>
</FlexRow>
}
@ -182,13 +197,7 @@ export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => {
<AddDependencyDialogue
project={feature.project}
featureId={feature.name}
parentFeatureId={feature.dependencies[0]?.feature}
parentFeatureValue={{
status:
feature.dependencies[0]?.enabled === false
? 'disabled'
: 'enabled',
}}
parentDependency={feature.dependencies[0]}
onClose={() => setShowDependencyDialogue(false)}
showDependencyDialogue={showDependencyDialogue}
/>

View File

@ -246,7 +246,6 @@ test('delete dependency with change request', async () => {
});
test('edit dependency', async () => {
setupChangeRequestApi();
render(
<FeatureOverviewSidePanelDetails
feature={
@ -281,3 +280,27 @@ test('edit dependency', async () => {
await screen.findByText('Add parent feature dependency');
});
test('show variant dependencies', async () => {
render(
<FeatureOverviewSidePanelDetails
feature={
{
name: 'feature',
project: 'default',
dependencies: [
{
feature: 'some_parent',
enabled: true,
variants: ['a', 'b'],
},
],
children: [] as string[],
} as IFeatureToggle
}
header={''}
/>,
);
await screen.findByText('2 variants');
});

View File

@ -0,0 +1,25 @@
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import type { FC } from 'react';
export const VariantsTooltip: FC<{
variants: string[];
}> = ({ variants }) => {
if (variants.length === 1 && variants[0].length < 20) {
return <span>{variants[0]}</span>;
}
return (
<TooltipLink
tooltip={
<>
{variants.map((child) => (
<div>{child}</div>
))}
</>
}
>
{variants.length === 1
? '1 variant'
: `${variants.length} variants`}
</TooltipLink>
);
};

View File

@ -0,0 +1,29 @@
import { formatApiPath } from 'utils/formatPath';
import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter';
const parentOptionsPath = (projectId: string, childFeatureId: string) =>
`/api/admin/projects/${projectId}/features/${childFeatureId}/parents`;
export const useParentOptions = (projectId: string, childFeatureId: string) => {
const path = formatApiPath(parentOptionsPath(projectId, childFeatureId));
const { data, refetch, loading, error } = useApiGetter<string[]>(path, () =>
fetcher(path, 'Parent Options'),
);
return { parentOptions: data, loading, error };
};
const parentVariantsPath = (projectId: string, parentFeatureId: string) =>
`/api/admin/projects/${projectId}/features/${parentFeatureId}/parent-variants`;
export const useParentVariantOptions = (
projectId: string,
parentFeatureId: string,
) => {
const path = formatApiPath(parentVariantsPath(projectId, parentFeatureId));
const { data, refetch, loading, error } = useApiGetter<string[]>(path, () =>
fetcher(path, 'Parent Variant Options'),
);
return { parentVariantOptions: data || [], loading, error };
};

View File

@ -1,26 +0,0 @@
import useSWR, { type SWRConfiguration } from 'swr';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
export const useParentOptions = (
project: string,
childFeatureId: string,
options: SWRConfiguration = {},
) => {
const path = formatApiPath(
`/api/admin/projects/${project}/features/${childFeatureId}/parents`,
);
const { data, error, mutate } = useSWR(path, fetcher, options);
return {
parentOptions: data,
error,
loading: !error && !data,
};
};
const fetcher = async (path: string): Promise<string[]> => {
const res = await fetch(path).then(handleErrorResponses('Parent Options'));
const data = await res.json();
return data;
};

View File

@ -54,7 +54,8 @@ export interface IFeatureToggle {
export interface IDependency {
feature: string;
enabled: boolean;
enabled?: boolean;
variants?: string[];
}
export interface IFeatureEnvironment {