mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-19 01:17:18 +02:00
feat: variant dependencies ui (#6739)
This commit is contained in:
parent
9eee6546f8
commit
7f043c7cab
frontend/src
component
changeRequest
feature
Dependencies
AddDependencyDialogue.test.tsxAddDependencyDialogue.tsxFeatureStatusOptions.tsxLazyParentOptions.tsxParentVariantOptions.tsxconstants.tsuseManageDependency.ts
FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails
hooks/api/getters
interfaces
@ -42,8 +42,9 @@ export const DependencyChange: VFC<{
|
|||||||
>
|
>
|
||||||
{change.payload.feature}
|
{change.payload.feature}
|
||||||
</StyledLink>
|
</StyledLink>
|
||||||
{change.payload.enabled === false
|
{!change.payload.enabled ? ' (disabled)' : null}
|
||||||
? ' (disabled)'
|
{change.payload.variants?.length
|
||||||
|
? `(${change.payload.variants?.join(', ')})`
|
||||||
: null}
|
: null}
|
||||||
</AddDependencyWrapper>
|
</AddDependencyWrapper>
|
||||||
{actions}
|
{actions}
|
||||||
|
@ -223,7 +223,11 @@ type ChangeRequestVariantPatch = {
|
|||||||
|
|
||||||
type ChangeRequestEnabled = { enabled: boolean };
|
type ChangeRequestEnabled = { enabled: boolean };
|
||||||
|
|
||||||
type ChangeRequestAddDependency = { feature: string; enabled: boolean };
|
type ChangeRequestAddDependency = {
|
||||||
|
feature: string;
|
||||||
|
enabled: boolean;
|
||||||
|
variants?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ChangeRequestAddStrategy = Pick<
|
export type ChangeRequestAddStrategy = Pick<
|
||||||
IFeatureStrategy,
|
IFeatureStrategy,
|
||||||
|
@ -91,7 +91,7 @@ test('Edit dependency', async () => {
|
|||||||
<AddDependencyDialogue
|
<AddDependencyDialogue
|
||||||
project='default'
|
project='default'
|
||||||
featureId='child'
|
featureId='child'
|
||||||
parentFeatureId='parentB'
|
parentDependency={{ feature: 'parentB' }}
|
||||||
showDependencyDialogue={true}
|
showDependencyDialogue={true}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
@ -1,205 +1,60 @@
|
|||||||
import { type FC, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Box, styled, Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
|
|
||||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
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 { DependenciesUpgradeAlert } from './DependenciesUpgradeAlert';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
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 {
|
interface IAddDependencyDialogueProps {
|
||||||
project: string;
|
project: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
parentFeatureId?: string;
|
parentDependency?: IDependency;
|
||||||
parentFeatureValue?: ParentValue;
|
|
||||||
showDependencyDialogue: boolean;
|
showDependencyDialogue: boolean;
|
||||||
onClose: () => void;
|
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 = ({
|
export const AddDependencyDialogue = ({
|
||||||
project,
|
project,
|
||||||
featureId,
|
featureId,
|
||||||
parentFeatureId,
|
parentDependency,
|
||||||
parentFeatureValue,
|
|
||||||
showDependencyDialogue,
|
showDependencyDialogue,
|
||||||
onClose,
|
onClose,
|
||||||
}: IAddDependencyDialogueProps) => {
|
}: IAddDependencyDialogueProps) => {
|
||||||
const [parent, setParent] = useState(
|
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>(
|
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,
|
project,
|
||||||
featureId,
|
featureId,
|
||||||
parent,
|
parent,
|
||||||
@ -210,13 +65,38 @@ export const AddDependencyDialogue = ({
|
|||||||
useChangeRequestsEnabled(project);
|
useChangeRequestsEnabled(project);
|
||||||
|
|
||||||
const variantDependenciesEnabled = useUiFlag('variantDependencies');
|
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 (
|
return (
|
||||||
<Dialogue
|
<Dialogue
|
||||||
open={showDependencyDialogue}
|
open={showDependencyDialogue}
|
||||||
title='Add parent feature dependency'
|
title='Add parent feature dependency'
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onClick={handleClick}
|
onClick={manageDependency}
|
||||||
primaryButtonText={
|
primaryButtonText={
|
||||||
isChangeRequestConfiguredInAnyEnv()
|
isChangeRequestConfiguredInAnyEnv()
|
||||||
? 'Add change to draft'
|
? 'Add change to draft'
|
||||||
@ -245,7 +125,7 @@ export const AddDependencyDialogue = ({
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={showDependencyDialogue}
|
condition={showDependencyDialogue}
|
||||||
show={
|
show={
|
||||||
<LazyOptions
|
<LazyParentOptions
|
||||||
project={project}
|
project={project}
|
||||||
featureId={featureId}
|
featureId={featureId}
|
||||||
parent={parent}
|
parent={parent}
|
||||||
@ -258,30 +138,40 @@ export const AddDependencyDialogue = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={
|
condition={showStatus}
|
||||||
parent !== REMOVE_DEPENDENCY_OPTION.key &&
|
|
||||||
variantDependenciesEnabled
|
|
||||||
}
|
|
||||||
show={
|
show={
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Typography>
|
<Typography>
|
||||||
What <b>feature status</b> do you want to depend
|
What <b>feature status</b> do you want to depend
|
||||||
on?
|
on?
|
||||||
</Typography>
|
</Typography>
|
||||||
<FeatureValueOptions
|
<FeatureStatusOptions
|
||||||
parentValue={parentValue}
|
parentValue={parentValue}
|
||||||
onSelect={(value) =>
|
onSelect={selectStatus}
|
||||||
setParentValue({
|
|
||||||
status:
|
|
||||||
value === 'disabled'
|
|
||||||
? 'disabled'
|
|
||||||
: 'enabled',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
</Dialogue>
|
</Dialogue>
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -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[]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
12
frontend/src/component/feature/Dependencies/constants.ts
Normal file
12
frontend/src/component/feature/Dependencies/constants.ts
Normal 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)',
|
||||||
|
};
|
@ -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();
|
||||||
|
};
|
||||||
|
};
|
@ -17,6 +17,7 @@ import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPe
|
|||||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
|
import { VariantsTooltip } from './VariantsTooltip';
|
||||||
|
|
||||||
const useDeleteDependency = (project: string, featureId: string) => {
|
const useDeleteDependency = (project: string, featureId: string) => {
|
||||||
const { trackEvent } = usePlausibleTracker();
|
const { trackEvent } = usePlausibleTracker();
|
||||||
@ -152,11 +153,25 @@ export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => {
|
|||||||
<FlexRow>
|
<FlexRow>
|
||||||
<StyledDetail>
|
<StyledDetail>
|
||||||
<StyledLabel>Dependency value:</StyledLabel>
|
<StyledLabel>Dependency value:</StyledLabel>
|
||||||
<span>
|
<span>disabled</span>
|
||||||
{feature.dependencies[0]?.enabled
|
</StyledDetail>
|
||||||
? 'enabled'
|
</FlexRow>
|
||||||
: 'disabled'}
|
}
|
||||||
</span>
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={
|
||||||
|
hasParentDependency &&
|
||||||
|
Boolean(feature.dependencies[0]?.variants?.length)
|
||||||
|
}
|
||||||
|
show={
|
||||||
|
<FlexRow>
|
||||||
|
<StyledDetail>
|
||||||
|
<StyledLabel>Dependency value:</StyledLabel>
|
||||||
|
<VariantsTooltip
|
||||||
|
variants={
|
||||||
|
feature.dependencies[0]?.variants || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
</StyledDetail>
|
</StyledDetail>
|
||||||
</FlexRow>
|
</FlexRow>
|
||||||
}
|
}
|
||||||
@ -182,13 +197,7 @@ export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => {
|
|||||||
<AddDependencyDialogue
|
<AddDependencyDialogue
|
||||||
project={feature.project}
|
project={feature.project}
|
||||||
featureId={feature.name}
|
featureId={feature.name}
|
||||||
parentFeatureId={feature.dependencies[0]?.feature}
|
parentDependency={feature.dependencies[0]}
|
||||||
parentFeatureValue={{
|
|
||||||
status:
|
|
||||||
feature.dependencies[0]?.enabled === false
|
|
||||||
? 'disabled'
|
|
||||||
: 'enabled',
|
|
||||||
}}
|
|
||||||
onClose={() => setShowDependencyDialogue(false)}
|
onClose={() => setShowDependencyDialogue(false)}
|
||||||
showDependencyDialogue={showDependencyDialogue}
|
showDependencyDialogue={showDependencyDialogue}
|
||||||
/>
|
/>
|
||||||
|
@ -246,7 +246,6 @@ test('delete dependency with change request', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('edit dependency', async () => {
|
test('edit dependency', async () => {
|
||||||
setupChangeRequestApi();
|
|
||||||
render(
|
render(
|
||||||
<FeatureOverviewSidePanelDetails
|
<FeatureOverviewSidePanelDetails
|
||||||
feature={
|
feature={
|
||||||
@ -281,3 +280,27 @@ test('edit dependency', async () => {
|
|||||||
|
|
||||||
await screen.findByText('Add parent feature dependency');
|
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');
|
||||||
|
});
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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 };
|
||||||
|
};
|
@ -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;
|
|
||||||
};
|
|
@ -54,7 +54,8 @@ export interface IFeatureToggle {
|
|||||||
|
|
||||||
export interface IDependency {
|
export interface IDependency {
|
||||||
feature: string;
|
feature: string;
|
||||||
enabled: boolean;
|
enabled?: boolean;
|
||||||
|
variants?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureEnvironment {
|
export interface IFeatureEnvironment {
|
||||||
|
Loading…
Reference in New Issue
Block a user