mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-09 00:18:00 +01:00
feat: prevent delete and archive on parent feature (#4913)
This commit is contained in:
parent
296cc9a9f2
commit
88305a6388
@ -0,0 +1,44 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Dialogue } from '../Dialogue/Dialogue';
|
||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface IFeatureArchiveNotAllowedDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
features: string[];
|
||||||
|
project: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledLink = styled(Link)(({ theme }) => ({
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
}));
|
||||||
|
export const FeatureArchiveNotAllowedDialog: FC<
|
||||||
|
IFeatureArchiveNotAllowedDialogProps
|
||||||
|
> = ({ isOpen, onClose, features, project }) => {
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
title="You can't archive a feature that other features depend on"
|
||||||
|
open={isOpen}
|
||||||
|
primaryButtonText='OK'
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<p>The following features depend on your feature:</p>
|
||||||
|
<ul>
|
||||||
|
{features.map((feature) => (
|
||||||
|
<li key={feature}>
|
||||||
|
<StyledLink
|
||||||
|
to={`/projects/${project}/features/${feature}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{feature}
|
||||||
|
</StyledLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { styled, Tab, Tabs, useMediaQuery, Box, Card } from '@mui/material';
|
import { styled, Tab, Tabs, useMediaQuery } from '@mui/material';
|
||||||
import { Archive, FileCopy, Label, WatchLater } from '@mui/icons-material';
|
import { Archive, FileCopy, Label, WatchLater } from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
@ -29,13 +29,13 @@ import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureSta
|
|||||||
import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
|
import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
|
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
|
||||||
|
import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog';
|
||||||
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
||||||
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
|
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
|
||||||
import { ReactComponent as ChildLinkIcon } from 'assets/icons/link-child.svg';
|
import { ReactComponent as ChildLinkIcon } from 'assets/icons/link-child.svg';
|
||||||
import { ReactComponent as ParentLinkIcon } from 'assets/icons/link-parent.svg';
|
import { ReactComponent as ParentLinkIcon } from 'assets/icons/link-parent.svg';
|
||||||
import { TooltipLink } from '../../common/TooltipLink/TooltipLink';
|
|
||||||
import { ChildrenTooltip } from './FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/ChildrenTooltip';
|
import { ChildrenTooltip } from './FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/ChildrenTooltip';
|
||||||
import { useUiFlag } from '../../../hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
const StyledHeader = styled('div')(({ theme }) => ({
|
const StyledHeader = styled('div')(({ theme }) => ({
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
@ -321,6 +321,17 @@ export const FeatureView = () => {
|
|||||||
<Route path='settings' element={<FeatureSettings />} />
|
<Route path='settings' element={<FeatureSettings />} />
|
||||||
<Route path='*' element={<FeatureOverview />} />
|
<Route path='*' element={<FeatureOverview />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={feature.children.length > 0}
|
||||||
|
show={
|
||||||
|
<FeatureArchiveNotAllowedDialog
|
||||||
|
features={feature.children}
|
||||||
|
project={projectId}
|
||||||
|
isOpen={showDelDialog}
|
||||||
|
onClose={() => setShowDelDialog(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
<FeatureArchiveDialog
|
<FeatureArchiveDialog
|
||||||
isOpen={showDelDialog}
|
isOpen={showDelDialog}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
@ -331,6 +342,9 @@ export const FeatureView = () => {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
featureIds={[featureId]}
|
featureIds={[featureId]}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<FeatureStaleDialog
|
<FeatureStaleDialog
|
||||||
isStale={feature.stale}
|
isStale={feature.stale}
|
||||||
isOpen={openStaleDialog}
|
isOpen={openStaleDialog}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { IDependency } from '../../types';
|
import { IDependency } from '../../types';
|
||||||
|
|
||||||
export interface IDependentFeaturesReadModel {
|
export interface IDependentFeaturesReadModel {
|
||||||
getChildren(parent: string): Promise<string[]>;
|
getChildren(parents: string[]): Promise<string[]>;
|
||||||
getParents(child: string): Promise<IDependency[]>;
|
getParents(child: string): Promise<IDependency[]>;
|
||||||
getParentOptions(child: string): Promise<string[]>;
|
getParentOptions(child: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
@ -9,13 +9,13 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
|
|||||||
this.db = db;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChildren(parent: string): Promise<string[]> {
|
async getChildren(parents: string[]): Promise<string[]> {
|
||||||
const rows = await this.db('dependent_features').where(
|
const rows = await this.db('dependent_features').whereIn(
|
||||||
'parent',
|
'parent',
|
||||||
parent,
|
parents,
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map((row) => row.child);
|
return [...new Set(rows.map((row) => row.child))];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getParents(child: string): Promise<IDependency[]> {
|
async getParents(child: string): Promise<IDependency[]> {
|
||||||
|
@ -29,9 +29,9 @@ export class DependentFeaturesService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { enabled, feature: parent, variants } = dependentFeature;
|
const { enabled, feature: parent, variants } = dependentFeature;
|
||||||
|
|
||||||
const children = await this.dependentFeaturesReadModel.getChildren(
|
const children = await this.dependentFeaturesReadModel.getChildren([
|
||||||
child,
|
child,
|
||||||
);
|
]);
|
||||||
if (children.length > 0) {
|
if (children.length > 0) {
|
||||||
throw new InvalidOperationError(
|
throw new InvalidOperationError(
|
||||||
'Transitive dependency detected. Cannot add a dependency to the feature that other features depend on.',
|
'Transitive dependency detected. Cannot add a dependency to the feature that other features depend on.',
|
||||||
|
@ -251,6 +251,22 @@ class FeatureToggleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validateNoChildren(featureNames: string[]): Promise<void> {
|
||||||
|
if (this.flagResolver.isEnabled('dependentFeatures')) {
|
||||||
|
if (featureNames.length === 0) return;
|
||||||
|
const children = await this.dependentFeaturesReadModel.getChildren(
|
||||||
|
featureNames,
|
||||||
|
);
|
||||||
|
if (children.length > 0) {
|
||||||
|
throw new InvalidOperationError(
|
||||||
|
featureNames.length > 1
|
||||||
|
? `You can not archive/delete those features since other features depend on them.`
|
||||||
|
: `You can not archive/delete this feature since other features depend on it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
validateUpdatedProperties(
|
validateUpdatedProperties(
|
||||||
{ featureName, projectId }: IFeatureContext,
|
{ featureName, projectId }: IFeatureContext,
|
||||||
existingStrategy: IFeatureStrategy,
|
existingStrategy: IFeatureStrategy,
|
||||||
@ -925,7 +941,7 @@ class FeatureToggleService {
|
|||||||
if (this.flagResolver.isEnabled('dependentFeatures')) {
|
if (this.flagResolver.isEnabled('dependentFeatures')) {
|
||||||
[dependencies, children] = await Promise.all([
|
[dependencies, children] = await Promise.all([
|
||||||
this.dependentFeaturesReadModel.getParents(featureName),
|
this.dependentFeaturesReadModel.getParents(featureName),
|
||||||
this.dependentFeaturesReadModel.getChildren(featureName),
|
this.dependentFeaturesReadModel.getChildren([featureName]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1424,6 +1440,8 @@ class FeatureToggleService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.validateNoChildren([featureName]);
|
||||||
|
|
||||||
await this.featureToggleStore.archive(featureName);
|
await this.featureToggleStore.archive(featureName);
|
||||||
|
|
||||||
await this.eventService.storeEvent(
|
await this.eventService.storeEvent(
|
||||||
@ -1441,6 +1459,7 @@ class FeatureToggleService {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.validateFeaturesContext(featureNames, projectId);
|
await this.validateFeaturesContext(featureNames, projectId);
|
||||||
|
await this.validateNoChildren(featureNames);
|
||||||
|
|
||||||
const features = await this.featureToggleStore.getAllByNames(
|
const features = await this.featureToggleStore.getAllByNames(
|
||||||
featureNames,
|
featureNames,
|
||||||
@ -1734,6 +1753,7 @@ class FeatureToggleService {
|
|||||||
|
|
||||||
// TODO: add project id.
|
// TODO: add project id.
|
||||||
async deleteFeature(featureName: string, createdBy: string): Promise<void> {
|
async deleteFeature(featureName: string, createdBy: string): Promise<void> {
|
||||||
|
await this.validateNoChildren([featureName]);
|
||||||
const toggle = await this.featureToggleStore.get(featureName);
|
const toggle = await this.featureToggleStore.get(featureName);
|
||||||
const tags = await this.tagStore.getAllTagsForFeature(featureName);
|
const tags = await this.tagStore.getAllTagsForFeature(featureName);
|
||||||
await this.featureToggleStore.delete(featureName);
|
await this.featureToggleStore.delete(featureName);
|
||||||
@ -1755,6 +1775,7 @@ class FeatureToggleService {
|
|||||||
createdBy: string,
|
createdBy: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.validateFeaturesContext(featureNames, projectId);
|
await this.validateFeaturesContext(featureNames, projectId);
|
||||||
|
await this.validateNoChildren(featureNames);
|
||||||
|
|
||||||
const features = await this.featureToggleStore.getAllByNames(
|
const features = await this.featureToggleStore.getAllByNames(
|
||||||
featureNames,
|
featureNames,
|
||||||
|
@ -241,6 +241,29 @@ test('should list dependencies and children', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Should not allow to archive/delete feature with children', async () => {
|
||||||
|
const parent = uuidv4();
|
||||||
|
const child = uuidv4();
|
||||||
|
await app.createFeature(parent, 'default');
|
||||||
|
await app.createFeature(child, 'default');
|
||||||
|
await app.addDependency(child, parent);
|
||||||
|
|
||||||
|
const { body: archiveBody } = await app.request
|
||||||
|
.delete(`/api/admin/projects/default/features/${parent}`)
|
||||||
|
.expect(403);
|
||||||
|
const { body: deleteBody } = await app.request
|
||||||
|
.post(`/api/admin/projects/default/delete`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ features: [parent] })
|
||||||
|
.expect(403);
|
||||||
|
expect(archiveBody.message).toBe(
|
||||||
|
'You can not archive/delete this feature since other features depend on it.',
|
||||||
|
);
|
||||||
|
expect(deleteBody.message).toBe(
|
||||||
|
'You can not archive/delete this feature since other features depend on it.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Can get features for project', async () => {
|
test('Can get features for project', async () => {
|
||||||
await app.request
|
await app.request
|
||||||
.post('/api/admin/projects/default/features')
|
.post('/api/admin/projects/default/features')
|
||||||
|
Loading…
Reference in New Issue
Block a user