From 7800d9d1b4a2ef3b4bcac5e23cfb15bb39483656 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 19 Dec 2023 08:57:10 +0100 Subject: [PATCH] feat: export all features in project (#5677) --- .../FeatureToggleList/ExportDialog.tsx | 24 ++++++++++-- .../ProjectFeatureTogglesHeader.tsx | 6 +-- .../export-import-service.ts | 19 ++++++++-- .../export-import.e2e.test.ts | 38 ++++++++++++++++++- .../feature-toggle/feature-toggle-store.ts | 1 + src/lib/openapi/spec/export-query-schema.ts | 18 +++++++-- 6 files changed, 91 insertions(+), 15 deletions(-) diff --git a/frontend/src/component/feature/FeatureToggleList/ExportDialog.tsx b/frontend/src/component/feature/FeatureToggleList/ExportDialog.tsx index 5a49906ec0..e1a5c4a2ab 100644 --- a/frontend/src/component/feature/FeatureToggleList/ExportDialog.tsx +++ b/frontend/src/component/feature/FeatureToggleList/ExportDialog.tsx @@ -7,10 +7,12 @@ import useToast from 'hooks/useToast'; import type { FeatureSchema } from 'openapi'; import { formatUnknownError } from 'utils/formatUnknownError'; +import { ConditionallyRender } from '../../common/ConditionallyRender/ConditionallyRender'; interface IExportDialogProps { showExportDialog: boolean; data: Pick[]; + project?: string; onClose: () => void; onConfirm?: () => void; environments: string[]; @@ -24,6 +26,7 @@ const StyledSelect = styled(GeneralSelect)(({ theme }) => ({ export const ExportDialog = ({ showExportDialog, data, + project, onClose, onConfirm, environments, @@ -63,6 +66,7 @@ export const ExportDialog = ({ const payload = { features: data.map((feature) => feature.name), environment: selected, + project, }; const res = await createExport(payload); const body = await res.json(); @@ -84,9 +88,23 @@ export const ExportDialog = ({ secondaryButtonText='Cancel' > - The current search filter will be used to export feature - toggles. Currently {data.length} feature toggles will be - exported. + 0} + show={ + + The current search filter will be used to export + feature toggles. Currently {data.length} feature + toggles will be exported. + + } + elseShow={ + + You will export all feature toggles from this + project. + + } + /> +

diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx index 7dcb8f3d7c..396482cc19 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx @@ -42,7 +42,6 @@ export const ProjectFeatureTogglesHeader: VFC< totalItems, searchQuery, onChangeSearchQuery, - dataToExport, environmentsToExport, actions, }) => { @@ -100,7 +99,7 @@ export const ProjectFeatureTogglesHeader: VFC< show={ <> setShowExportDialog(false) } diff --git a/src/lib/features/export-import-toggles/export-import-service.ts b/src/lib/features/export-import-toggles/export-import-service.ts index b8e74c95cc..f392b3944b 100644 --- a/src/lib/features/export-import-toggles/export-import-service.ts +++ b/src/lib/features/export-import-toggles/export-import-service.ts @@ -786,10 +786,21 @@ export default class ExportImportService userName: string, userId: number, ): Promise { - const featureNames = - typeof query.tag === 'string' - ? await this.featureTagService.listFeatures(query.tag) - : (query.features as string[]) || []; + let featureNames: string[] = []; + if (typeof query.tag === 'string') { + featureNames = await this.featureTagService.listFeatures(query.tag); + } else if (Array.isArray(query.features) && query.features.length) { + featureNames = query.features; + } else if (typeof query.project === 'string') { + const allProjectFeatures = await this.toggleStore.getAll({ + project: query.project, + }); + featureNames = allProjectFeatures.map((feature) => feature.name); + } else { + const allFeatures = await this.toggleStore.getAll(); + featureNames = allFeatures.map((feature) => feature.name); + } + const [ features, featureEnvironments, diff --git a/src/lib/features/export-import-toggles/export-import.e2e.test.ts b/src/lib/features/export-import-toggles/export-import.e2e.test.ts index f5001a6ad4..75b614cebb 100644 --- a/src/lib/features/export-import-toggles/export-import.e2e.test.ts +++ b/src/lib/features/export-import-toggles/export-import.e2e.test.ts @@ -548,7 +548,7 @@ test('should export tags', async () => { }); }); -test('returns no features, when no feature was requested', async () => { +test('returns all features, when no explicit feature was requested', async () => { await createProjects(); await createToggle({ name: defaultFeatureName, @@ -567,7 +567,41 @@ test('returns no features, when no feature was requested', async () => { .set('Content-Type', 'application/json') .expect(200); - expect(body.features).toHaveLength(0); + expect(body.features).toHaveLength(2); +}); + +test('returns all project features', async () => { + await createProjects(); + await createToggle({ + name: defaultFeatureName, + description: 'the #1 feature', + }); + await createToggle({ + name: 'second_feature', + description: 'the #1 feature', + }); + const { body } = await app.request + .post('/api/admin/features-batch/export') + .send({ + environment: 'default', + project: DEFAULT_PROJECT, + }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(body.features).toHaveLength(2); + + const { body: otherProject } = await app.request + .post('/api/admin/features-batch/export') + .send({ + environment: 'default', + features: [], // should be ignored because we have project + project: 'other_project', + }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(otherProject.features).toHaveLength(0); }); const variants: VariantsSchema = [ diff --git a/src/lib/features/feature-toggle/feature-toggle-store.ts b/src/lib/features/feature-toggle/feature-toggle-store.ts index 5d76efaad0..7d841b78dc 100644 --- a/src/lib/features/feature-toggle/feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-store.ts @@ -305,6 +305,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore { async getAllByNames(names: string[]): Promise { const query = this.db(TABLE).orderBy('name', 'asc'); query.whereIn('name', names); + const rows = await query; return rows.map(this.rowToFeature); } diff --git a/src/lib/openapi/spec/export-query-schema.ts b/src/lib/openapi/spec/export-query-schema.ts index 67b8f62cb0..55d114dc51 100644 --- a/src/lib/openapi/spec/export-query-schema.ts +++ b/src/lib/openapi/spec/export-query-schema.ts @@ -18,7 +18,7 @@ export const exportQuerySchema = { type: 'object', description: 'Available query parameters for the [deprecated export/import](https://docs.getunleash.io/reference/deploy/import-export) functionality.', - oneOf: [ + anyOf: [ { required: ['environment', 'features'], properties: { @@ -30,7 +30,8 @@ export const exportQuerySchema = { type: 'string', minLength: 1, }, - description: 'Selects features to export by name.', + description: + 'Selects features to export by name. If the list is empty all features are returned.', }, }, }, @@ -41,8 +42,19 @@ export const exportQuerySchema = { tag: { type: 'string', example: 'release', + description: 'Selects features to export by tag.', + }, + }, + }, + { + required: ['environment', 'project'], + properties: { + ...commonProps, + project: { + type: 'string', + example: 'my-project', description: - 'Selects features to export by tag. Takes precedence over the features field.', + 'Selects project to export the features from. Used when no tags or features are provided.', }, }, },