mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-12 13:48:35 +02:00
feat: show warning about dependencies removed on archive (#5104)
This commit is contained in:
parent
d212917fd0
commit
b890df6e12
@ -38,7 +38,10 @@ const setupArchiveValidation = (orphanParents: string[]) => {
|
|||||||
testServerRoute(
|
testServerRoute(
|
||||||
server,
|
server,
|
||||||
'/api/admin/projects/projectId/archive/validate',
|
'/api/admin/projects/projectId/archive/validate',
|
||||||
orphanParents,
|
{
|
||||||
|
hasDeletedDependencies: true,
|
||||||
|
parentsWithChildFeatures: orphanParents,
|
||||||
|
},
|
||||||
'post',
|
'post',
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -60,6 +63,9 @@ test('Add single archive feature change to change request', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Archive feature toggle')).toBeInTheDocument();
|
expect(screen.getByText('Archive feature toggle')).toBeInTheDocument();
|
||||||
|
await screen.findByText(
|
||||||
|
'Archiving features with dependencies will also remove those dependencies.',
|
||||||
|
);
|
||||||
const button = await screen.findByText('Add change to draft');
|
const button = await screen.findByText('Add change to draft');
|
||||||
|
|
||||||
button.click();
|
button.click();
|
||||||
@ -87,6 +93,9 @@ test('Add multiple archive feature changes to change request', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText('Archive feature toggles');
|
await screen.findByText('Archive feature toggles');
|
||||||
|
await screen.findByText(
|
||||||
|
'Archiving features with dependencies will also remove those dependencies.',
|
||||||
|
);
|
||||||
const button = await screen.findByText('Add to change request');
|
const button = await screen.findByText('Add to change request');
|
||||||
|
|
||||||
button.click();
|
button.click();
|
||||||
@ -144,6 +153,11 @@ test('Show error message when multiple parents of orphaned children are archived
|
|||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
'have child features that depend on them and are not part of the archive operation. These parent features can not be archived:',
|
'have child features that depend on them and are not part of the archive operation. These parent features can not be archived:',
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'Archiving features with dependencies will also remove those dependencies.',
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Show error message when 1 parent of orphaned children is archived', async () => {
|
test('Show error message when 1 parent of orphaned children is archived', async () => {
|
||||||
@ -165,4 +179,9 @@ test('Show error message when 1 parent of orphaned children is archived', async
|
|||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
'has child features that depend on it and are not part of the archive operation.',
|
'has child features that depend on it and are not part of the archive operation.',
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'Archiving features with dependencies will also remove those dependencies.',
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -23,6 +23,15 @@ interface IFeatureArchiveDialogProps {
|
|||||||
featuresWithUsage?: string[];
|
featuresWithUsage?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RemovedDependenciesAlert = () => {
|
||||||
|
return (
|
||||||
|
<Alert severity='warning' sx={{ m: (theme) => theme.spacing(2, 0) }}>
|
||||||
|
Archiving features with dependencies will also remove those
|
||||||
|
dependencies.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const UsageWarning = ({
|
const UsageWarning = ({
|
||||||
ids,
|
ids,
|
||||||
projectId,
|
projectId,
|
||||||
@ -228,21 +237,25 @@ const useVerifyArchive = (
|
|||||||
) => {
|
) => {
|
||||||
const [disableArchive, setDisableArchive] = useState(true);
|
const [disableArchive, setDisableArchive] = useState(true);
|
||||||
const [offendingParents, setOffendingParents] = useState<string[]>([]);
|
const [offendingParents, setOffendingParents] = useState<string[]>([]);
|
||||||
|
const [hasDeletedDependencies, setHasDeletedDependencies] = useState(false);
|
||||||
const { verifyArchiveFeatures } = useProjectApi();
|
const { verifyArchiveFeatures } = useProjectApi();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
verifyArchiveFeatures(projectId, featureIds)
|
verifyArchiveFeatures(projectId, featureIds)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((offendingParents) => {
|
.then(
|
||||||
if (offendingParents.length === 0) {
|
({ hasDeletedDependencies, parentsWithChildFeatures }) => {
|
||||||
setDisableArchive(false);
|
if (parentsWithChildFeatures.length === 0) {
|
||||||
setOffendingParents(offendingParents);
|
setDisableArchive(false);
|
||||||
} else {
|
setOffendingParents(parentsWithChildFeatures);
|
||||||
setDisableArchive(true);
|
} else {
|
||||||
setOffendingParents(offendingParents);
|
setDisableArchive(true);
|
||||||
}
|
setOffendingParents(parentsWithChildFeatures);
|
||||||
});
|
}
|
||||||
|
setHasDeletedDependencies(hasDeletedDependencies);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
JSON.stringify(featureIds),
|
JSON.stringify(featureIds),
|
||||||
@ -250,9 +263,10 @@ const useVerifyArchive = (
|
|||||||
projectId,
|
projectId,
|
||||||
setOffendingParents,
|
setOffendingParents,
|
||||||
setDisableArchive,
|
setDisableArchive,
|
||||||
|
setHasDeletedDependencies,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { disableArchive, offendingParents };
|
return { disableArchive, offendingParents, hasDeletedDependencies };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
||||||
@ -285,14 +299,16 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { disableArchive, offendingParents } = useVerifyArchive(
|
const { disableArchive, offendingParents, hasDeletedDependencies } =
|
||||||
featureIds,
|
useVerifyArchive(featureIds, projectId, isOpen);
|
||||||
projectId,
|
|
||||||
isOpen,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dependentFeatures = useUiFlag('dependentFeatures');
|
const dependentFeatures = useUiFlag('dependentFeatures');
|
||||||
|
|
||||||
|
const removeDependenciesWarning =
|
||||||
|
dependentFeatures &&
|
||||||
|
offendingParents.length === 0 &&
|
||||||
|
hasDeletedDependencies;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialogue
|
<Dialogue
|
||||||
onClick={archiveAction}
|
onClick={archiveAction}
|
||||||
@ -312,6 +328,7 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
|||||||
<strong>{featureIds?.length}</strong> feature
|
<strong>{featureIds?.length}</strong> feature
|
||||||
toggles?
|
toggles?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(
|
condition={Boolean(
|
||||||
uiConfig.flags.lastSeenByEnvironment &&
|
uiConfig.flags.lastSeenByEnvironment &&
|
||||||
@ -336,6 +353,10 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={removeDependenciesWarning}
|
||||||
|
show={<RemovedDependenciesAlert />}
|
||||||
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={featureIds?.length <= 5}
|
condition={featureIds?.length <= 5}
|
||||||
show={
|
show={
|
||||||
@ -368,6 +389,10 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={removeDependenciesWarning}
|
||||||
|
show={<RemovedDependenciesAlert />}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -9,6 +9,6 @@ export interface IDependentFeaturesReadModel {
|
|||||||
getParents(child: string): Promise<IDependency[]>;
|
getParents(child: string): Promise<IDependency[]>;
|
||||||
getDependencies(children: string[]): Promise<IFeatureDependency[]>;
|
getDependencies(children: string[]): Promise<IFeatureDependency[]>;
|
||||||
getParentOptions(child: string): Promise<string[]>;
|
getParentOptions(child: string): Promise<string[]>;
|
||||||
hasDependencies(feature: string): Promise<boolean>;
|
haveDependencies(features: string[]): Promise<boolean>;
|
||||||
hasAnyDependencies(): Promise<boolean>;
|
hasAnyDependencies(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
@ -82,10 +82,10 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
|
|||||||
return rows.map((item) => item.name);
|
return rows.map((item) => item.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasDependencies(feature: string): Promise<boolean> {
|
async haveDependencies(features: string[]): Promise<boolean> {
|
||||||
const parents = await this.db('dependent_features')
|
const parents = await this.db('dependent_features')
|
||||||
.where('parent', feature)
|
.whereIn('parent', features)
|
||||||
.orWhere('child', feature)
|
.orWhereIn('child', features)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return parents.length > 0;
|
return parents.length > 0;
|
||||||
|
@ -20,7 +20,7 @@ export class FakeDependentFeaturesReadModel
|
|||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasDependencies(): Promise<boolean> {
|
haveDependencies(): Promise<boolean> {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1587,8 +1587,22 @@ class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateArchiveToggles(featureNames: string[]): Promise<string[]> {
|
async validateArchiveToggles(featureNames: string[]): Promise<{
|
||||||
return this.dependentFeaturesReadModel.getOrphanParents(featureNames);
|
hasDeletedDependencies: boolean;
|
||||||
|
parentsWithChildFeatures: string[];
|
||||||
|
}> {
|
||||||
|
const hasDeletedDependencies =
|
||||||
|
await this.dependentFeaturesReadModel.haveDependencies(
|
||||||
|
featureNames,
|
||||||
|
);
|
||||||
|
const parentsWithChildFeatures =
|
||||||
|
await this.dependentFeaturesReadModel.getOrphanParents(
|
||||||
|
featureNames,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
hasDeletedDependencies,
|
||||||
|
parentsWithChildFeatures,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async unprotectedArchiveToggles(
|
async unprotectedArchiveToggles(
|
||||||
@ -1880,7 +1894,9 @@ class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
await this.dependentFeaturesReadModel.hasDependencies(featureName)
|
await this.dependentFeaturesReadModel.haveDependencies([
|
||||||
|
featureName,
|
||||||
|
])
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenError(
|
throw new ForbiddenError(
|
||||||
'Changing project not allowed. Feature has dependencies.',
|
'Changing project not allowed. Feature has dependencies.',
|
||||||
|
@ -165,6 +165,7 @@ import {
|
|||||||
createDependentFeatureSchema,
|
createDependentFeatureSchema,
|
||||||
parentFeatureOptionsSchema,
|
parentFeatureOptionsSchema,
|
||||||
dependenciesExistSchema,
|
dependenciesExistSchema,
|
||||||
|
validateArchiveFeaturesSchema,
|
||||||
} from './spec';
|
} from './spec';
|
||||||
import { IServerOption } from '../types';
|
import { IServerOption } from '../types';
|
||||||
import { mapValues, omitKeys } from '../util';
|
import { mapValues, omitKeys } from '../util';
|
||||||
@ -393,6 +394,7 @@ export const schemas: UnleashSchemas = {
|
|||||||
parentFeatureOptionsSchema,
|
parentFeatureOptionsSchema,
|
||||||
featureDependenciesSchema,
|
featureDependenciesSchema,
|
||||||
dependenciesExistSchema,
|
dependenciesExistSchema,
|
||||||
|
validateArchiveFeaturesSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
||||||
|
@ -165,3 +165,4 @@ export * from './create-dependent-feature-schema';
|
|||||||
export * from './parent-feature-options-schema';
|
export * from './parent-feature-options-schema';
|
||||||
export * from './feature-dependencies-schema';
|
export * from './feature-dependencies-schema';
|
||||||
export * from './dependencies-exist-schema';
|
export * from './dependencies-exist-schema';
|
||||||
|
export * from './validate-archive-features-schema';
|
||||||
|
33
src/lib/openapi/spec/validate-archive-features-schema.ts
Normal file
33
src/lib/openapi/spec/validate-archive-features-schema.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const validateArchiveFeaturesSchema = {
|
||||||
|
$id: '#/components/schemas/validateArchiveFeaturesSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
description: 'Validation details for features archive operation',
|
||||||
|
required: ['parentsWithChildFeatures', 'hasDeletedDependencies'],
|
||||||
|
properties: {
|
||||||
|
parentsWithChildFeatures: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'List of parent features that would orphan child features that are not part of the archive operation',
|
||||||
|
example: ['my-feature-4', 'my-feature-5', 'my-feature-6'],
|
||||||
|
},
|
||||||
|
hasDeletedDependencies: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether any dependencies will be deleted as part of archive',
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ValidateArchiveFeaturesSchema = FromSchema<
|
||||||
|
typeof validateArchiveFeaturesSchema
|
||||||
|
>;
|
@ -124,11 +124,13 @@ export default class ProjectArchiveController extends Controller {
|
|||||||
tags: ['Features'],
|
tags: ['Features'],
|
||||||
operationId: 'validateArchiveFeatures',
|
operationId: 'validateArchiveFeatures',
|
||||||
description:
|
description:
|
||||||
'This endpoint validated if a list of features can be archived. Returns a list of parent features that would orphan some child features. If archive can process then empty list is returned.',
|
'This endpoint return info about the archive features impact.',
|
||||||
summary: 'Validates if a list of features can be archived',
|
summary: 'Validates archive features',
|
||||||
requestBody: createRequestSchema('batchFeaturesSchema'),
|
requestBody: createRequestSchema('batchFeaturesSchema'),
|
||||||
responses: {
|
responses: {
|
||||||
200: createResponseSchema('batchFeaturesSchema'),
|
200: createResponseSchema(
|
||||||
|
'validateArchiveFeaturesSchema',
|
||||||
|
),
|
||||||
...getStandardResponses(400, 401, 403, 415),
|
...getStandardResponses(400, 401, 403, 415),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -209,10 +211,10 @@ export default class ProjectArchiveController extends Controller {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { features } = req.body;
|
const { features } = req.body;
|
||||||
|
|
||||||
const offendingParents =
|
const { parentsWithChildFeatures, hasDeletedDependencies } =
|
||||||
await this.featureService.validateArchiveToggles(features);
|
await this.featureService.validateArchiveToggles(features);
|
||||||
|
|
||||||
res.send(offendingParents);
|
res.send({ parentsWithChildFeatures, hasDeletedDependencies });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -290,8 +290,20 @@ test('Should validate if a list of features with dependencies can be archived',
|
|||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(allChildrenAndParent).toEqual([]);
|
expect(allChildrenAndParent).toEqual({
|
||||||
expect(allChildren).toEqual([]);
|
hasDeletedDependencies: true,
|
||||||
expect(onlyParent).toEqual([parent]);
|
parentsWithChildFeatures: [],
|
||||||
expect(oneChildAndParent).toEqual([parent]);
|
});
|
||||||
|
expect(allChildren).toEqual({
|
||||||
|
hasDeletedDependencies: true,
|
||||||
|
parentsWithChildFeatures: [],
|
||||||
|
});
|
||||||
|
expect(onlyParent).toEqual({
|
||||||
|
hasDeletedDependencies: true,
|
||||||
|
parentsWithChildFeatures: [parent],
|
||||||
|
});
|
||||||
|
expect(oneChildAndParent).toEqual({
|
||||||
|
hasDeletedDependencies: true,
|
||||||
|
parentsWithChildFeatures: [parent],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user