From f9bca20c785963f71a98724e43c4c6ce18478c41 Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Fri, 15 Sep 2023 11:01:25 +0300 Subject: [PATCH] Feat/project private mode (#4743) Adds `private` option to project mode Update schemas and models to accept and persist Closes # [1-1366](https://linear.app/unleash/issue/1-1366/introduce-private-in-collaboration-mode) --------- Signed-off-by: andreas-unleash --- .../ProjectForm/CollaborationModeTooltip.tsx | 61 ++++++++++++------- .../Project/ProjectForm/ProjectForm.tsx | 19 ++++-- .../project/Project/hooks/useProjectForm.ts | 2 +- .../actions/useProjectApi/useProjectApi.ts | 3 +- frontend/src/interfaces/project.ts | 5 +- frontend/src/interfaces/uiConfig.ts | 1 + .../__snapshots__/create-config.test.ts.snap | 2 + .../openapi/spec/health-overview-schema.ts | 2 +- .../openapi/spec/project-overview-schema.ts | 2 +- src/lib/openapi/spec/project-schema.ts | 2 +- src/lib/services/project-schema.ts | 5 +- src/lib/types/experimental.ts | 7 ++- src/lib/types/model.ts | 2 +- src/server-dev.ts | 1 + .../e2e/services/project-service.e2e.test.ts | 22 ++++++- 15 files changed, 99 insertions(+), 37 deletions(-) diff --git a/frontend/src/component/project/Project/ProjectForm/CollaborationModeTooltip.tsx b/frontend/src/component/project/Project/ProjectForm/CollaborationModeTooltip.tsx index 97c800112e..239d1a3c81 100644 --- a/frontend/src/component/project/Project/ProjectForm/CollaborationModeTooltip.tsx +++ b/frontend/src/component/project/Project/ProjectForm/CollaborationModeTooltip.tsx @@ -1,6 +1,8 @@ import { Box, styled, Typography } from '@mui/material'; import { FC } from 'react'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { useUiFlag } from 'hooks/useUiFlag'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; const StyledTitle = styled(Typography)(({ theme }) => ({ fontWeight: theme.fontWeight.bold, @@ -11,25 +13,40 @@ const StyledDescription = styled(Typography)(({ theme }) => ({ color: theme.palette.text.secondary, })); -export const CollaborationModeTooltip: FC = () => ( - - - open: - - everyone can submit change requests - - - - protected: - - only admins and project members can submit change - requests - - - - } - /> -); +export const CollaborationModeTooltip: FC = () => { + const privateProjects = useUiFlag('privateProjects'); + return ( + + + open: + + everyone can submit change requests + + + + protected: + + only admins and project members can submit change + requests + + + + private: + + only projects members can and access see the + project + + + } + /> + + } + /> + ); +}; diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index b8d5d15f79..93925cbd48 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -11,6 +11,7 @@ import { FeatureTogglesLimitTooltip } from './FeatureTogglesLimitTooltip'; import { FeatureFlagNamingTooltip } from './FeatureFlagNamingTooltip'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { useUiFlag } from 'hooks/useUiFlag'; interface IProjectForm { projectId: string; @@ -182,6 +183,19 @@ const ProjectForm: React.FC = ({ const { setPreviousPattern, trackPattern } = useFeatureNamePatternTracking(); + const privateProjects = useUiFlag('privateProjects'); + + const projectModeOptions = privateProjects + ? [ + { key: 'open', label: 'open' }, + { key: 'protected', label: 'protected' }, + { key: 'private', label: 'private' }, + ] + : [ + { key: 'open', label: 'open' }, + { key: 'protected', label: 'protected' }, + ]; + useEffect(() => { setPreviousPattern(featureNamingPattern || ''); }, [projectId]); @@ -342,10 +356,7 @@ const ProjectForm: React.FC = ({ onChange={e => { setProjectMode?.(e.target.value as ProjectMode); }} - options={[ - { key: 'open', label: 'open' }, - { key: 'protected', label: 'protected' }, - ]} + options={projectModeOptions} > <> diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 7683b19864..eba0f6dc8d 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import { formatUnknownError } from 'utils/formatUnknownError'; -export type ProjectMode = 'open' | 'protected'; +export type ProjectMode = 'open' | 'protected' | 'private'; export const DEFAULT_PROJECT_STICKINESS = 'default'; const useProjectForm = ( initialProjectId = '', diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 7e92c4df0e..318c132f3a 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -1,11 +1,12 @@ import type { BatchStaleSchema, CreateFeatureStrategySchema } from 'openapi'; import useAPI from '../useApi/useApi'; +import { ProjectMode } from 'component/project/Project/hooks/useProjectForm'; interface ICreatePayload { id: string; name: string; description: string; - mode: 'open' | 'protected'; + mode: ProjectMode; defaultStickiness: string; } diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index 17dce72f4c..8b8e7b339c 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -1,6 +1,7 @@ import { ProjectStatsSchema } from 'openapi'; import { IFeatureToggleListItem } from './featureToggle'; -import { ProjectEnvironmentType } from '../component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef'; +import { ProjectEnvironmentType } from 'component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef'; +import { ProjectMode } from 'component/project/Project/hooks/useProjectForm'; export interface IProjectCard { name: string; @@ -30,7 +31,7 @@ export interface IProject { stats: ProjectStatsSchema; favorite: boolean; features: IFeatureToggleListItem[]; - mode: 'open' | 'protected'; + mode: ProjectMode; defaultStickiness: string; featureLimit?: number; featureNaming?: FeatureNamingType; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index cdc94ecfa6..471cf28913 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -65,6 +65,7 @@ export type UiFlags = { featureNamingPattern?: boolean; doraMetrics?: boolean; variantTypeNumber?: boolean; + privateProjects?: boolean; [key: string]: boolean | Variant | undefined; }; diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 622373b754..81104ce51d 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -98,6 +98,7 @@ exports[`should create default config 1`] = ` "migrationLock": true, "multipleRoles": false, "personalAccessTokensKillSwitch": false, + "privateProjects": false, "proPlanAutoCharge": false, "responseTimeWithAppNameKillSwitch": false, "slackAppAddon": false, @@ -134,6 +135,7 @@ exports[`should create default config 1`] = ` "migrationLock": true, "multipleRoles": false, "personalAccessTokensKillSwitch": false, + "privateProjects": false, "proPlanAutoCharge": false, "responseTimeWithAppNameKillSwitch": false, "slackAppAddon": false, diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index 6556b1a95e..be590d973a 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -54,7 +54,7 @@ export const healthOverviewSchema = { }, mode: { type: 'string', - enum: ['open', 'protected'], + enum: ['open', 'protected', 'private'], example: 'open', description: "The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.", diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index c7151258dd..5c791d4702 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -51,7 +51,7 @@ export const projectOverviewSchema = { }, mode: { type: 'string', - enum: ['open', 'protected'], + enum: ['open', 'protected', 'private'], example: 'open', description: "The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.", diff --git a/src/lib/openapi/spec/project-schema.ts b/src/lib/openapi/spec/project-schema.ts index b340d15910..406b4466e9 100644 --- a/src/lib/openapi/spec/project-schema.ts +++ b/src/lib/openapi/spec/project-schema.ts @@ -61,7 +61,7 @@ export const projectSchema = { }, mode: { type: 'string', - enum: ['open', 'protected'], + enum: ['open', 'protected', 'private'], example: 'open', description: "The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.", diff --git a/src/lib/services/project-schema.ts b/src/lib/services/project-schema.ts index b11b8e667e..440899f0b3 100644 --- a/src/lib/services/project-schema.ts +++ b/src/lib/services/project-schema.ts @@ -7,7 +7,10 @@ export const projectSchema = joi id: nameType, name: joi.string().required(), description: joi.string().allow(null).allow('').optional(), - mode: joi.string().valid('open', 'protected').default('open'), + mode: joi + .string() + .valid('open', 'protected', 'private') + .default('open'), defaultStickiness: joi.string().default('default'), featureLimit: joi.number().allow(null).optional(), featureNaming: joi.object().keys({ diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index b38fd6f8d7..abfd26b0f0 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -28,7 +28,8 @@ export type IFlagKey = | 'multipleRoles' | 'featureNamingPattern' | 'doraMetrics' - | 'variantTypeNumber'; + | 'variantTypeNumber' + | 'privateProjects'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -132,6 +133,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_VARIANT_TYPE_NUMBER, false, ), + privateProjects: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_PRIVATE_PROJECTS, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index ac378d92a9..4b6725e96b 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -187,7 +187,7 @@ export interface IFeatureOverview { environments: IEnvironmentOverview[]; } -export type ProjectMode = 'open' | 'protected'; +export type ProjectMode = 'open' | 'protected' | 'private'; export interface IFeatureNaming { pattern: string | null; diff --git a/src/server-dev.ts b/src/server-dev.ts index a757ccda05..c9041eb559 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -43,6 +43,7 @@ process.nextTick(async () => { featureNamingPattern: true, doraMetrics: true, variantTypeNumber: true, + privateProjects: true, }, }, authentication: { diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index cd860a93f1..3cd3cfe34e 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -47,7 +47,9 @@ beforeAll(async () => { const config = createTestConfig({ getLogger, // @ts-ignore - experimental: { environments: { enabled: true } }, + experimental: { + flags: { privateProjects: true }, + }, }); groupService = new GroupService(stores, config); accessService = new AccessService(stores, config, groupService); @@ -134,6 +136,24 @@ test('should create new project', async () => { expect(ret.mode).toEqual('protected'); }); +test('should create new private project', async () => { + const project = { + id: 'testPrivate', + name: 'New private project', + description: 'Blah', + mode: 'private' as const, + defaultStickiness: 'default', + }; + + await projectService.createProject(project, user); + const ret = await projectService.getProject('testPrivate'); + expect(project.id).toEqual(ret.id); + expect(project.name).toEqual(ret.name); + expect(project.description).toEqual(ret.description); + expect(ret.createdAt).toBeTruthy(); + expect(ret.mode).toEqual('private'); +}); + test('should delete project', async () => { const project = { id: 'test-delete',