diff --git a/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx b/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx index 28278d8262..7a35172ca1 100644 --- a/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx @@ -1,57 +1,57 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import { FeatureView } from '../feature/FeatureView/FeatureView'; -import { ThemeProvider } from 'themes/ThemeProvider'; -import { AccessProvider } from '../providers/AccessProvider/AccessProvider'; -import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/AnnouncerProvider'; -import { testServerRoute, testServerSetup } from '../../utils/testServer'; -import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer'; -import { FC } from 'react'; -import { IPermission } from '../../interfaces/user'; -import { ProjectMode } from '../project/Project/hooks/useProjectForm'; -import { SWRConfig } from 'swr'; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { FeatureView } from "../feature/FeatureView/FeatureView"; +import { ThemeProvider } from "themes/ThemeProvider"; +import { AccessProvider } from "../providers/AccessProvider/AccessProvider"; +import { AnnouncerProvider } from "../common/Announcer/AnnouncerProvider/AnnouncerProvider"; +import { testServerRoute, testServerSetup } from "../../utils/testServer"; +import { UIProviderContainer } from "../providers/UIProvider/UIProviderContainer"; +import { FC } from "react"; +import { IPermission } from "../../interfaces/user"; +import { SWRConfig } from "swr"; +import { ProjectMode } from "../project/Project/hooks/useProjectEnterpriseSettingsForm"; const server = testServerSetup(); const projectWithCollaborationMode = (mode: ProjectMode) => - testServerRoute(server, '/api/admin/projects/default', { mode }); + testServerRoute(server, "/api/admin/projects/default", { mode }); const changeRequestsEnabledIn = ( - env: 'development' | 'production' | 'custom', + env: "development" | "production" | "custom" ) => testServerRoute( server, - '/api/admin/projects/default/change-requests/config', + "/api/admin/projects/default/change-requests/config", [ { - environment: 'development', - type: 'development', + environment: "development", + type: "development", requiredApprovals: null, - changeRequestEnabled: env === 'development', + changeRequestEnabled: env === "development", }, { - environment: 'production', - type: 'production', + environment: "production", + type: "production", requiredApprovals: 1, - changeRequestEnabled: env === 'production', + changeRequestEnabled: env === "production", }, { - environment: 'custom', - type: 'production', + environment: "custom", + type: "production", requiredApprovals: null, - changeRequestEnabled: env === 'custom', + changeRequestEnabled: env === "custom", }, - ], + ] ); const uiConfigForEnterprise = () => - testServerRoute(server, '/api/admin/ui-config', { - environment: 'Open Source', + testServerRoute(server, "/api/admin/ui-config", { + environment: "Open Source", flags: { changeRequests: true, }, versionInfo: { - current: { oss: '4.18.0-beta.5', enterprise: '4.17.0-beta.1' }, + current: { oss: "4.18.0-beta.5", enterprise: "4.17.0-beta.1" }, }, disablePasswordAuth: false, }); @@ -59,12 +59,12 @@ const uiConfigForEnterprise = () => const setupOtherRoutes = (feature: string) => { testServerRoute( server, - 'api/admin/projects/default/change-requests/pending', - [], + "api/admin/projects/default/change-requests/pending", + [] ); testServerRoute(server, `api/admin/client-metrics/features/${feature}`, { version: 1, - maturity: 'stable', + maturity: "stable", featureName: feature, lastHourUsage: [], seenApplications: [], @@ -86,25 +86,25 @@ const setupOtherRoutes = (feature: string) => { version: 1, strategies: [ { - displayName: 'Standard', - name: 'default', + displayName: "Standard", + name: "default", editable: false, description: - 'The standard strategy is strictly on / off for your entire userbase.', + "The standard strategy is strictly on / off for your entire userbase.", parameters: [], deprecated: false, }, { - displayName: 'UserIDs', - name: 'userWithId', + displayName: "UserIDs", + name: "userWithId", editable: false, description: - 'Enable the feature for a specific set of userIds.', + "Enable the feature for a specific set of userIds.", parameters: [ { - name: 'userIds', - type: 'list', - description: '', + name: "userIds", + type: "list", + description: "", required: false, }, ], @@ -115,17 +115,17 @@ const setupOtherRoutes = (feature: string) => { }; const userHasPermissions = (permissions: Array<IPermission>) => { - testServerRoute(server, 'api/admin/user', { + testServerRoute(server, "api/admin/user", { user: { isAPI: false, id: 2, - name: 'Test', - email: 'test@getunleash.ai', + name: "Test", + email: "test@getunleash.ai", imageUrl: - 'https://gravatar.com/avatar/e55646b526ff342ff8b43721f0cbdd8e?size=42&default=retro', - seenAt: '2022-11-29T08:21:52.581Z', + "https://gravatar.com/avatar/e55646b526ff342ff8b43721f0cbdd8e?size=42&default=retro", + seenAt: "2022-11-29T08:21:52.581Z", loginAttempts: 0, - createdAt: '2022-11-21T10:10:33.074Z', + createdAt: "2022-11-21T10:10:33.074Z", }, permissions, feedback: [], @@ -136,21 +136,21 @@ const userIsMemberOfProjects = (projects: string[]) => { userHasPermissions( projects.map((project) => ({ project, - environment: 'irrelevant', - permission: 'irrelevant', - })), + environment: "irrelevant", + permission: "irrelevant", + })) ); }; const featureEnvironments = ( feature: string, - environments: Array<{ name: string; strategies: Array<string> }>, + environments: Array<{ name: string; strategies: Array<string> }> ) => { testServerRoute(server, `/api/admin/projects/default/features/${feature}`, { environments: environments.map((env) => ({ name: env.name, enabled: false, - type: 'production', + type: "production", sortOrder: 1, strategies: env.strategies.map((strategy) => ({ name: strategy, @@ -162,13 +162,13 @@ const featureEnvironments = ( })), name: feature, impressionData: false, - description: '', - project: 'default', + description: "", + project: "default", stale: false, variants: [], - createdAt: '2022-11-14T08:16:33.338Z', + createdAt: "2022-11-14T08:16:33.338Z", lastSeenAt: null, - type: 'release', + type: "release", archived: false, children: [], dependencies: [], @@ -199,7 +199,7 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({ const strategiesAreDisplayed = async ( firstStrategy: string, - secondStrategy: string, + secondStrategy: string ) => { await screen.findByText(firstStrategy); await screen.findByText(secondStrategy); @@ -213,10 +213,10 @@ const getDeleteButtons = async () => { removeMenus.map(async (menu) => { menu.click(); const removeButton = screen.getAllByTestId( - 'STRATEGY_FORM_REMOVE_ID', + "STRATEGY_FORM_REMOVE_ID" ); deleteButtons.push(...removeButton); - }), + }) ); return deleteButtons; }; @@ -229,12 +229,12 @@ const deleteButtonsActiveInChangeRequestEnv = async () => { await waitFor(() => { // production const productionStrategyDeleteButton = deleteButtons[1]; - expect(productionStrategyDeleteButton).not.toHaveClass('Mui-disabled'); + expect(productionStrategyDeleteButton).not.toHaveClass("Mui-disabled"); }); await waitFor(() => { // custom env const customEnvStrategyDeleteButton = deleteButtons[2]; - expect(customEnvStrategyDeleteButton).toHaveClass('Mui-disabled'); + expect(customEnvStrategyDeleteButton).toHaveClass("Mui-disabled"); }); }; @@ -246,17 +246,17 @@ const deleteButtonsInactiveInChangeRequestEnv = async () => { await waitFor(() => { // production const productionStrategyDeleteButton = deleteButtons[1]; - expect(productionStrategyDeleteButton).toHaveClass('Mui-disabled'); + expect(productionStrategyDeleteButton).toHaveClass("Mui-disabled"); }); await waitFor(() => { // custom env const customEnvStrategyDeleteButton = deleteButtons[2]; - expect(customEnvStrategyDeleteButton).toHaveClass('Mui-disabled'); + expect(customEnvStrategyDeleteButton).toHaveClass("Mui-disabled"); }); }; const copyButtonsActiveInOtherEnv = async () => { - const copyButtons = screen.getAllByTestId('STRATEGY_FORM_COPY_ID'); + const copyButtons = screen.getAllByTestId("STRATEGY_FORM_COPY_ID"); expect(copyButtons.length).toBe(2); // production @@ -274,92 +274,92 @@ const openEnvironments = async (envNames: string[]) => { } }; -test('open mode + non-project member can perform basic change request actions', async () => { - const project = 'default'; - const featureName = 'test'; +test("open mode + non-project member can perform basic change request actions", async () => { + const project = "default"; + const featureName = "test"; featureEnvironments(featureName, [ - { name: 'development', strategies: [] }, - { name: 'production', strategies: ['userWithId'] }, - { name: 'custom', strategies: ['default'] }, + { name: "development", strategies: [] }, + { name: "production", strategies: ["userWithId"] }, + { name: "custom", strategies: ["default"] }, ]); userIsMemberOfProjects([]); - changeRequestsEnabledIn('production'); - projectWithCollaborationMode('open'); + changeRequestsEnabledIn("production"); + projectWithCollaborationMode("open"); uiConfigForEnterprise(); setupOtherRoutes(featureName); render( <UnleashUiSetup - pathTemplate='/projects/:projectId/features/:featureId/*' + pathTemplate="/projects/:projectId/features/:featureId/*" path={`/projects/${project}/features/${featureName}`} > <FeatureView /> - </UnleashUiSetup>, + </UnleashUiSetup> ); - await openEnvironments(['development', 'production', 'custom']); + await openEnvironments(["development", "production", "custom"]); - await strategiesAreDisplayed('UserIDs', 'Standard'); + await strategiesAreDisplayed("UserIDs", "Standard"); await deleteButtonsActiveInChangeRequestEnv(); await copyButtonsActiveInOtherEnv(); }); -test('protected mode + project member can perform basic change request actions', async () => { - const project = 'default'; - const featureName = 'test'; +test("protected mode + project member can perform basic change request actions", async () => { + const project = "default"; + const featureName = "test"; featureEnvironments(featureName, [ - { name: 'development', strategies: [] }, - { name: 'production', strategies: ['userWithId'] }, - { name: 'custom', strategies: ['default'] }, + { name: "development", strategies: [] }, + { name: "production", strategies: ["userWithId"] }, + { name: "custom", strategies: ["default"] }, ]); userIsMemberOfProjects([project]); - changeRequestsEnabledIn('production'); - projectWithCollaborationMode('protected'); + changeRequestsEnabledIn("production"); + projectWithCollaborationMode("protected"); uiConfigForEnterprise(); setupOtherRoutes(featureName); render( <UnleashUiSetup - pathTemplate='/projects/:projectId/features/:featureId/*' + pathTemplate="/projects/:projectId/features/:featureId/*" path={`/projects/${project}/features/${featureName}`} > <FeatureView /> - </UnleashUiSetup>, + </UnleashUiSetup> ); - await openEnvironments(['development', 'production', 'custom']); + await openEnvironments(["development", "production", "custom"]); - await strategiesAreDisplayed('UserIDs', 'Standard'); + await strategiesAreDisplayed("UserIDs", "Standard"); await deleteButtonsActiveInChangeRequestEnv(); await copyButtonsActiveInOtherEnv(); }); -test('protected mode + non-project member cannot perform basic change request actions', async () => { - const project = 'default'; - const featureName = 'test'; +test("protected mode + non-project member cannot perform basic change request actions", async () => { + const project = "default"; + const featureName = "test"; featureEnvironments(featureName, [ - { name: 'development', strategies: [] }, - { name: 'production', strategies: ['userWithId'] }, - { name: 'custom', strategies: ['default'] }, + { name: "development", strategies: [] }, + { name: "production", strategies: ["userWithId"] }, + { name: "custom", strategies: ["default"] }, ]); userIsMemberOfProjects([]); - changeRequestsEnabledIn('production'); - projectWithCollaborationMode('protected'); + changeRequestsEnabledIn("production"); + projectWithCollaborationMode("protected"); uiConfigForEnterprise(); setupOtherRoutes(featureName); render( <UnleashUiSetup - pathTemplate='/projects/:projectId/features/:featureId/*' + pathTemplate="/projects/:projectId/features/:featureId/*" path={`/projects/${project}/features/${featureName}`} > <FeatureView /> - </UnleashUiSetup>, + </UnleashUiSetup> ); - await openEnvironments(['development', 'production', 'custom']); + await openEnvironments(["development", "production", "custom"]); - await strategiesAreDisplayed('UserIDs', 'Standard'); + await strategiesAreDisplayed("UserIDs", "Standard"); await deleteButtonsInactiveInChangeRequestEnv(); await copyButtonsActiveInOtherEnv(); }); diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx index 9f5395b98f..783e91ed8c 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -1,5 +1,5 @@ -import MenuBookIcon from '@mui/icons-material/MenuBook'; -import Codebox from '../Codebox/Codebox'; +import MenuBookIcon from "@mui/icons-material/MenuBook"; +import Codebox from "../Codebox/Codebox"; import { Collapse, IconButton, @@ -7,16 +7,16 @@ import { Tooltip, Divider, styled, -} from '@mui/material'; -import { FileCopy, Info } from '@mui/icons-material'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import Loader from '../Loader/Loader'; -import copy from 'copy-to-clipboard'; -import useToast from 'hooks/useToast'; -import React, { ReactNode, useState } from 'react'; -import { ReactComponent as MobileGuidanceBG } from 'assets/img/mobileGuidanceBg.svg'; -import { formTemplateSidebarWidth } from './FormTemplate.styles'; -import { relative } from 'themes/themeStyles'; +} from "@mui/material"; +import { FileCopy, Info } from "@mui/icons-material"; +import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender"; +import Loader from "../Loader/Loader"; +import copy from "copy-to-clipboard"; +import useToast from "hooks/useToast"; +import React, { ReactNode, useState } from "react"; +import { ReactComponent as MobileGuidanceBG } from "assets/img/mobileGuidanceBg.svg"; +import { formTemplateSidebarWidth } from "./FormTemplate.styles"; +import { relative } from "themes/themeStyles"; interface ICreateProps { title?: ReactNode; @@ -26,61 +26,74 @@ interface ICreateProps { loading?: boolean; modal?: boolean; disablePadding?: boolean; + compactPadding?: boolean; + showDescription?: boolean; + showLink?: boolean; formatApiCode?: () => string; footer?: ReactNode; + compact?: boolean; } -const StyledContainer = styled('section', { - shouldForwardProp: (prop) => prop !== 'modal', -})<{ modal?: boolean }>(({ theme, modal }) => ({ - minHeight: modal ? '100vh' : '80vh', +const StyledContainer = styled("section", { + shouldForwardProp: (prop) => + !["modal", "compact"].includes(prop.toString()), +})<{ modal?: boolean; compact?: boolean }>(({ theme, modal, compact }) => ({ + minHeight: modal ? "100vh" : compact ? 0 : "80vh", borderRadius: modal ? 0 : theme.spacing(2), - width: '100%', - display: 'flex', - margin: '0 auto', - overflow: modal ? 'unset' : 'hidden', + width: "100%", + display: "flex", + margin: "0 auto", + overflow: modal ? "unset" : "hidden", [theme.breakpoints.down(1100)]: { - flexDirection: 'column', + flexDirection: "column", minHeight: 0, }, })); -const StyledRelativeDiv = styled('div')(({ theme }) => relative); +const StyledRelativeDiv = styled("div")(({ theme }) => relative); -const StyledMain = styled('div')(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', +const StyledMain = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", flexGrow: 1, flexShrink: 1, - width: '100%', + width: "100%", [theme.breakpoints.down(1100)]: { - width: '100%', + width: "100%", }, })); -const StyledFormContent = styled('div', { - shouldForwardProp: (prop) => prop !== 'disablePadding', -})<{ disablePadding?: boolean }>(({ theme, disablePadding }) => ({ - backgroundColor: theme.palette.background.paper, - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - padding: disablePadding ? 0 : theme.spacing(6), - [theme.breakpoints.down('lg')]: { - padding: disablePadding ? 0 : theme.spacing(4), +const StyledFormContent = styled("div", { + shouldForwardProp: (prop) => { + return !["disablePadding", "compactPadding"].includes(prop.toString()); }, - [theme.breakpoints.down(1100)]: { - width: '100%', - }, - [theme.breakpoints.down(500)]: { - padding: disablePadding ? 0 : theme.spacing(4, 2), - }, -})); +})<{ disablePadding?: boolean; compactPadding?: boolean }>( + ({ theme, disablePadding, compactPadding }) => ({ + backgroundColor: theme.palette.background.paper, + display: "flex", + flexDirection: "column", + flexGrow: 1, + padding: disablePadding + ? 0 + : compactPadding + ? theme.spacing(4) + : theme.spacing(6), + [theme.breakpoints.down("lg")]: { + padding: disablePadding ? 0 : theme.spacing(4), + }, + [theme.breakpoints.down(1100)]: { + width: "100%", + }, + [theme.breakpoints.down(500)]: { + padding: disablePadding ? 0 : theme.spacing(4, 2), + }, + }) +); -const StyledFooter = styled('div')(({ theme }) => ({ +const StyledFooter = styled("div")(({ theme }) => ({ backgroundColor: theme.palette.background.paper, padding: theme.spacing(4, 6), - [theme.breakpoints.down('lg')]: { + [theme.breakpoints.down("lg")]: { padding: theme.spacing(4), }, [theme.breakpoints.down(500)]: { @@ -88,9 +101,9 @@ const StyledFooter = styled('div')(({ theme }) => ({ }, })); -const StyledTitle = styled('h1')(({ theme }) => ({ +const StyledTitle = styled("h1")(({ theme }) => ({ marginBottom: theme.fontSizes.mainHeader, - fontWeight: 'normal', + fontWeight: "normal", })); const StyledSidebarDivider = styled(Divider)(({ theme }) => ({ @@ -98,12 +111,12 @@ const StyledSidebarDivider = styled(Divider)(({ theme }) => ({ marginBottom: theme.spacing(0.5), })); -const StyledSubtitle = styled('h2')(({ theme }) => ({ +const StyledSubtitle = styled("h2")(({ theme }) => ({ color: theme.palette.common.white, marginBottom: theme.spacing(2), - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', + display: "flex", + justifyContent: "space-between", + alignItems: "center", fontWeight: theme.fontWeight.bold, fontSize: theme.fontSizes.bodySize, })); @@ -112,20 +125,20 @@ const StyledIcon = styled(FileCopy)(({ theme }) => ({ fill: theme.palette.primary.contrastText, })); -const StyledMobileGuidanceContainer = styled('div')(() => ({ +const StyledMobileGuidanceContainer = styled("div")(() => ({ zIndex: 1, - position: 'absolute', + position: "absolute", right: -3, top: -3, })); const StyledMobileGuidanceBackground = styled(MobileGuidanceBG)(() => ({ - width: '75px', - height: '75px', + width: "75px", + height: "75px", })); const StyledMobileGuidanceButton = styled(IconButton)(() => ({ - position: 'absolute', + position: "absolute", zIndex: 400, right: 0, })); @@ -134,31 +147,31 @@ const StyledInfoIcon = styled(Info)(({ theme }) => ({ fill: theme.palette.primary.contrastText, })); -const StyledSidebar = styled('aside')(({ theme }) => ({ +const StyledSidebar = styled("aside")(({ theme }) => ({ backgroundColor: theme.palette.background.sidebar, padding: theme.spacing(4), flexGrow: 0, flexShrink: 0, width: formTemplateSidebarWidth, [theme.breakpoints.down(1100)]: { - width: '100%', - color: 'red', + width: "100%", + color: "red", }, [theme.breakpoints.down(500)]: { padding: theme.spacing(4, 2), }, })); -const StyledDescription = styled('p')(({ theme }) => ({ +const StyledDescription = styled("p")(({ theme }) => ({ color: theme.palette.common.white, zIndex: 1, - position: 'relative', + position: "relative", })); -const StyledLinkContainer = styled('div')(({ theme }) => ({ +const StyledLinkContainer = styled("div")(({ theme }) => ({ margin: theme.spacing(3, 0), - display: 'flex', - alignItems: 'center', + display: "flex", + alignItems: "center", })); const StyledLinkIcon = styled(MenuBookIcon)(({ theme }) => ({ @@ -166,11 +179,11 @@ const StyledLinkIcon = styled(MenuBookIcon)(({ theme }) => ({ color: theme.palette.primary.contrastText, })); -const StyledDocumentationLink = styled('a')(({ theme }) => ({ +const StyledDocumentationLink = styled("a")(({ theme }) => ({ color: theme.palette.primary.contrastText, - display: 'block', - '&:hover': { - textDecoration: 'none', + display: "block", + "&:hover": { + textDecoration: "none", }, })); @@ -184,7 +197,11 @@ const FormTemplate: React.FC<ICreateProps> = ({ modal, formatApiCode, disablePadding, + compactPadding = false, + showDescription = true, + showLink = true, footer, + compact, }) => { const { setToastData } = useToast(); const smallScreen = useMediaQuery(`(max-width:${1099}px)`); @@ -192,45 +209,48 @@ const FormTemplate: React.FC<ICreateProps> = ({ if (formatApiCode !== undefined) { if (copy(formatApiCode())) { setToastData({ - title: 'Successfully copied the command', - text: 'The command should now be automatically copied to your clipboard', + title: "Successfully copied the command", + text: "The command should now be automatically copied to your clipboard", autoHideDuration: 6000, - type: 'success', + type: "success", show: true, }); } else { setToastData({ - title: 'Could not copy the command', - text: 'Sorry, but we could not copy the command.', + title: "Could not copy the command", + text: "Sorry, but we could not copy the command.", autoHideDuration: 6000, - type: 'error', + type: "error", show: true, }); } } }; - const renderApiInfo = (apiDisabled: boolean) => { + const renderApiInfo = (apiDisabled: boolean, dividerDisabled = false) => { if (!apiDisabled) { return ( <> - <StyledSidebarDivider /> + <ConditionallyRender + condition={!dividerDisabled} + show={<StyledSidebarDivider />} + /> <StyledSubtitle> - API Command{' '} - <Tooltip title='Copy command' arrow> - <IconButton onClick={copyCommand} size='large'> + API Command{" "} + <Tooltip title="Copy command" arrow> + <IconButton onClick={copyCommand} size="large"> <StyledIcon /> </IconButton> </Tooltip> </StyledSubtitle> - <Codebox text={formatApiCode!()} />{' '} + <Codebox text={formatApiCode!()} />{" "} </> ); } }; return ( - <StyledContainer modal={modal}> + <StyledContainer modal={modal} compact={compact}> <ConditionallyRender condition={smallScreen} show={ @@ -244,7 +264,10 @@ const FormTemplate: React.FC<ICreateProps> = ({ } /> <StyledMain> - <StyledFormContent disablePadding={disablePadding}> + <StyledFormContent + disablePadding={disablePadding} + compactPadding={compactPadding} + > <ConditionallyRender condition={loading || false} show={<Loader />} @@ -276,8 +299,13 @@ const FormTemplate: React.FC<ICreateProps> = ({ description={description} documentationLink={documentationLink} documentationLinkLabel={documentationLinkLabel} + showDescription={showDescription} + showLink={showLink} > - {renderApiInfo(formatApiCode === undefined)} + {renderApiInfo( + formatApiCode === undefined, + !(showDescription || showLink) + )} </Guidance> } /> @@ -303,10 +331,10 @@ const MobileGuidance = ({ <StyledMobileGuidanceContainer> <StyledMobileGuidanceBackground /> </StyledMobileGuidanceContainer> - <Tooltip title='Toggle help' arrow> + <Tooltip title="Toggle help" arrow> <StyledMobileGuidanceButton onClick={() => setOpen((prev) => !prev)} - size='large' + size="large" > <StyledInfoIcon /> </StyledMobileGuidanceButton> @@ -326,28 +354,40 @@ interface IGuidanceProps { description: string; documentationLink: string; documentationLinkLabel?: string; + showDescription?: boolean; + showLink?: boolean; } const Guidance: React.FC<IGuidanceProps> = ({ description, children, documentationLink, - documentationLinkLabel = 'Learn more', + documentationLinkLabel = "Learn more", + showDescription = true, + showLink = true, }) => { return ( <StyledSidebar> - <StyledDescription>{description}</StyledDescription> + <ConditionallyRender + condition={showDescription} + show={<StyledDescription>{description}</StyledDescription>} + /> - <StyledLinkContainer> - <StyledLinkIcon /> - <StyledDocumentationLink - href={documentationLink} - rel='noopener noreferrer' - target='_blank' - > - {documentationLinkLabel} - </StyledDocumentationLink> - </StyledLinkContainer> + <ConditionallyRender + condition={showLink} + show={ + <StyledLinkContainer> + <StyledLinkIcon /> + <StyledDocumentationLink + href={documentationLink} + rel="noopener noreferrer" + target="_blank" + > + {documentationLinkLabel} + </StyledDocumentationLink> + </StyledLinkContainer> + } + /> {children} </StyledSidebar> diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 6d2589ba0f..e9f333f98f 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -26,15 +26,6 @@ exports[`returns all baseRoutes 1`] = ` "title": "Create", "type": "protected", }, - { - "component": [Function], - "enterprise": true, - "menu": {}, - "parent": "/projects", - "path": "/projects/:projectId/edit", - "title": ":projectId", - "type": "protected", - }, { "component": [Function], "menu": {}, diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 9d4e45933c..42d7110f80 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -15,7 +15,6 @@ import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironm import { EditContext } from 'component/context/EditContext/EditContext'; import EditTagType from 'component/tags/EditTagType/EditTagType'; import CreateTagType from 'component/tags/CreateTagType/CreateTagType'; -import EditProject from 'component/project/Project/EditProject/EditProject'; import CreateFeature from 'component/feature/CreateFeature/CreateFeature'; import EditFeature from 'component/feature/EditFeature/EditFeature'; import { ApplicationEdit } from 'component/application/ApplicationEdit/ApplicationEdit'; @@ -68,15 +67,6 @@ export const routes: IRoute[] = [ enterprise: true, menu: {}, }, - { - path: '/projects/:projectId/edit', - parent: '/projects', - title: ':projectId', - component: EditProject, - type: 'protected', - enterprise: true, - menu: {}, - }, { path: '/projects/:projectId/archived', title: ':projectId', diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx index 4a872c50eb..acc0185def 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -1,21 +1,21 @@ -import { useNavigate } from 'react-router-dom'; -import ProjectForm from '../ProjectForm/ProjectForm'; +import { useNavigate } from "react-router-dom"; +import ProjectForm from "../ProjectForm/ProjectForm"; import useProjectForm, { DEFAULT_PROJECT_STICKINESS, -} from '../hooks/useProjectForm'; -import { CreateButton } from 'component/common/CreateButton/CreateButton'; -import FormTemplate from 'component/common/FormTemplate/FormTemplate'; -import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions'; -import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; -import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import useToast from 'hooks/useToast'; -import { formatUnknownError } from 'utils/formatUnknownError'; -import { GO_BACK } from 'constants/navigate'; -import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; -import { Button, styled } from '@mui/material'; +} from "../hooks/useProjectForm"; +import { CreateButton } from "component/common/CreateButton/CreateButton"; +import FormTemplate from "component/common/FormTemplate/FormTemplate"; +import { CREATE_PROJECT } from "component/providers/AccessProvider/permissions"; +import useProjectApi from "hooks/api/actions/useProjectApi/useProjectApi"; +import { useAuthUser } from "hooks/api/getters/useAuth/useAuthUser"; +import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig"; +import useToast from "hooks/useToast"; +import { formatUnknownError } from "utils/formatUnknownError"; +import { GO_BACK } from "constants/navigate"; +import { usePlausibleTracker } from "hooks/usePlausibleTracker"; +import { Button, styled } from "@mui/material"; -const CREATE_PROJECT_BTN = 'CREATE_PROJECT_BTN'; +const CREATE_PROJECT_BTN = "CREATE_PROJECT_BTN"; const StyledButton = styled(Button)(({ theme }) => ({ marginLeft: theme.spacing(3), @@ -30,25 +30,17 @@ const CreateProject = () => { const { projectId, projectName, - projectMode, projectDesc, - featureLimit, - featureNamingPattern, - featureNamingExample, - featureNamingDescription, - setFeatureNamingExample, - setFeatureNamingPattern, - setFeatureNamingDescription, + projectMode, + setProjectMode, setProjectId, setProjectName, setProjectDesc, - getProjectPayload, + getCreateProjectPayload, clearErrors, validateProjectId, validateName, setProjectStickiness, - setFeatureLimit, - setProjectMode, projectStickiness, errors, } = useProjectForm(); @@ -62,21 +54,24 @@ const CreateProject = () => { const validId = await validateProjectId(); if (validName && validId) { - const payload = getProjectPayload(); + const payload = getCreateProjectPayload(); try { await createProject(payload); refetchUser(); navigate(`/projects/${projectId}`); setToastData({ - title: 'Project created', - text: 'Now you can add toggles to this project', + title: "Project created", + text: "Now you can add toggles to this project", confetti: true, - type: 'success', + type: "success", }); if (projectStickiness !== DEFAULT_PROJECT_STICKINESS) { - trackEvent('project_stickiness_set'); + trackEvent("project_stickiness_set"); } + trackEvent("project-mode", { + props: { mode: projectMode, action: "added" }, + }); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); } @@ -84,10 +79,12 @@ const CreateProject = () => { }; const formatApiCode = () => { - return `curl --location --request POST '${uiConfig.unleashUrl}/api/admin/projects' \\ + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/projects' \\ --header 'Authorization: INSERT_API_KEY' \\ --header 'Content-Type: application/json' \\ ---data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`; +--data-raw '${JSON.stringify(getCreateProjectPayload(), undefined, 2)}'`; }; const handleCancel = () => { @@ -97,10 +94,10 @@ const CreateProject = () => { return ( <FormTemplate loading={loading} - title='Create project' - description='Projects allows you to group feature toggles together in the management UI.' - documentationLink='https://docs.getunleash.io/reference/projects' - documentationLinkLabel='Projects documentation' + title="Create project" + description="Projects allows you to group feature toggles together in the management UI." + documentationLink="https://docs.getunleash.io/reference/projects" + documentationLinkLabel="Projects documentation" formatApiCode={formatApiCode} > <ProjectForm @@ -109,27 +106,19 @@ const CreateProject = () => { projectId={projectId} setProjectId={setProjectId} projectName={projectName} - projectMode={projectMode} projectStickiness={projectStickiness} - featureLimit={featureLimit} - featureNamingExample={featureNamingExample} - featureNamingPattern={featureNamingPattern} - setFeatureNamingPattern={setFeatureNamingPattern} - featureNamingDescription={featureNamingDescription} - setFeatureNamingDescription={setFeatureNamingDescription} - setFeatureNamingExample={setFeatureNamingExample} - setProjectStickiness={setProjectStickiness} - setFeatureLimit={setFeatureLimit} + projectMode={projectMode} setProjectMode={setProjectMode} + setProjectStickiness={setProjectStickiness} setProjectName={setProjectName} projectDesc={projectDesc} setProjectDesc={setProjectDesc} - mode='Create' + mode="Create" clearErrors={clearErrors} validateProjectId={validateProjectId} > <CreateButton - name='project' + name="project" permission={CREATE_PROJECT} data-testid={CREATE_PROJECT_BTN} /> diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx deleted file mode 100644 index a9580b136c..0000000000 --- a/frontend/src/component/project/Project/EditProject/EditProject.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useNavigate } from 'react-router-dom'; -import ProjectForm from '../ProjectForm/ProjectForm'; -import useProjectForm, { - DEFAULT_PROJECT_STICKINESS, -} from '../hooks/useProjectForm'; -import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; -import FormTemplate from 'component/common/FormTemplate/FormTemplate'; -import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; -import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; -import useProject from 'hooks/api/getters/useProject/useProject'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import useToast from 'hooks/useToast'; -import { formatUnknownError } from 'utils/formatUnknownError'; -import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { useContext } from 'react'; -import AccessContext from 'contexts/AccessContext'; -import { Alert, Button, styled } from '@mui/material'; -import { GO_BACK } from 'constants/navigate'; -import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; -import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; - -const EDIT_PROJECT_BTN = 'EDIT_PROJECT_BTN'; - -const StyledButton = styled(Button)(({ theme }) => ({ - marginLeft: theme.spacing(3), -})); - -const EditProject = () => { - const { uiConfig } = useUiConfig(); - const { setToastData, setToastApiError } = useToast(); - const { hasAccess } = useContext(AccessContext); - const id = useRequiredPathParam('projectId'); - const { project } = useProject(id); - const { defaultStickiness } = useDefaultProjectSettings(id); - const navigate = useNavigate(); - const { trackEvent } = usePlausibleTracker(); - - const { - projectId, - projectName, - projectDesc, - projectStickiness, - projectMode, - featureNamingPattern, - featureNamingExample, - featureNamingDescription, - setProjectId, - setProjectName, - setProjectDesc, - setProjectStickiness, - setProjectMode, - setFeatureNamingExample, - setFeatureNamingPattern, - setFeatureNamingDescription, - getProjectPayload, - clearErrors, - validateProjectId, - validateName, - errors, - } = useProjectForm( - id, - project.name, - project.description, - defaultStickiness, - project.mode, - String(project.featureLimit), - project?.featureNaming?.pattern || '', - project?.featureNaming?.example || '', - project?.featureNaming?.description || '', - ); - - const formatApiCode = () => { - return `curl --location --request PUT '${ - uiConfig.unleashUrl - }/api/admin/projects/${id}' \\ ---header 'Authorization: INSERT_API_KEY' \\ ---header 'Content-Type: application/json' \\ ---data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`; - }; - - const { refetch } = useProject(id); - const { editProject, loading } = useProjectApi(); - - const handleSubmit = async (e: Event) => { - e.preventDefault(); - const payload = getProjectPayload(); - - const validName = validateName(); - - if (validName) { - try { - await editProject(id, payload); - refetch(); - navigate(`/projects/${id}`); - setToastData({ - title: 'Project information updated', - type: 'success', - }); - if (projectStickiness !== DEFAULT_PROJECT_STICKINESS) { - trackEvent('project_stickiness_set'); - } - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - } - }; - - const handleCancel = () => { - navigate(GO_BACK); - }; - - const accessDeniedAlert = !hasAccess(UPDATE_PROJECT, projectId) && ( - <Alert severity='error' sx={{ mb: 4 }}> - You do not have the required permissions to edit this project. - </Alert> - ); - - return ( - <FormTemplate - loading={loading} - title='Edit project' - description='Projects allows you to group feature toggles together in the management UI.' - documentationLink='https://docs.getunleash.io/reference/projects' - documentationLinkLabel='Projects documentation' - formatApiCode={formatApiCode} - > - {accessDeniedAlert} - <ProjectForm - errors={errors} - handleSubmit={handleSubmit} - projectId={projectId} - setProjectId={setProjectId} - projectName={projectName} - projectMode={projectMode} - featureNamingPattern={featureNamingPattern} - featureNamingExample={featureNamingExample} - featureNamingDescription={featureNamingDescription} - setProjectName={setProjectName} - projectStickiness={projectStickiness} - setProjectStickiness={setProjectStickiness} - setProjectMode={setProjectMode} - setFeatureLimit={() => {}} - setFeatureNamingExample={setFeatureNamingExample} - setFeatureNamingPattern={setFeatureNamingPattern} - setFeatureNamingDescription={setFeatureNamingDescription} - featureLimit={''} - projectDesc={projectDesc} - setProjectDesc={setProjectDesc} - mode='Edit' - clearErrors={clearErrors} - validateProjectId={validateProjectId} - > - <UpdateButton - permission={UPDATE_PROJECT} - projectId={projectId} - data-testid={EDIT_PROJECT_BTN} - />{' '} - <StyledButton onClick={handleCancel}>Cancel</StyledButton> - </ProjectForm> - </FormTemplate> - ); -}; - -export default EditProject; diff --git a/frontend/src/component/project/Project/ProjectForm/CollaborationModeTooltip.tsx b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/CollaborationModeTooltip.tsx similarity index 79% rename from frontend/src/component/project/Project/ProjectForm/CollaborationModeTooltip.tsx rename to frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/CollaborationModeTooltip.tsx index 239d1a3c81..5557a61107 100644 --- a/frontend/src/component/project/Project/ProjectForm/CollaborationModeTooltip.tsx +++ b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/CollaborationModeTooltip.tsx @@ -1,20 +1,20 @@ -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'; +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, - display: ' inline', + display: "inline", })); const StyledDescription = styled(Typography)(({ theme }) => ({ - display: ' inline', + display: "inline", color: theme.palette.text.secondary, })); export const CollaborationModeTooltip: FC = () => { - const privateProjects = useUiFlag('privateProjects'); + const privateProjects = useUiFlag("privateProjects"); return ( <HelpIcon htmlTooltip diff --git a/frontend/src/component/project/Project/ProjectForm/FeatureFlagNamingTooltip.tsx b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/FeatureFlagNamingTooltip.tsx similarity index 58% rename from frontend/src/component/project/Project/ProjectForm/FeatureFlagNamingTooltip.tsx rename to frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/FeatureFlagNamingTooltip.tsx index 0c2b463d9a..14e7d97b79 100644 --- a/frontend/src/component/project/Project/ProjectForm/FeatureFlagNamingTooltip.tsx +++ b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/FeatureFlagNamingTooltip.tsx @@ -1,6 +1,6 @@ -import { Box } from '@mui/material'; -import { FC } from 'react'; -import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { Box } from "@mui/material"; +import { FC } from "react"; +import { HelpIcon } from "component/common/HelpIcon/HelpIcon"; export const FeatureFlagNamingTooltip: FC = () => { return ( @@ -9,8 +9,8 @@ export const FeatureFlagNamingTooltip: FC = () => { tooltip={ <Box> <p> - For example, the pattern{' '} - <code>{'[a-z0-9]{2}\\.[a-z]{4,12}'}</code> matches + For example, the pattern{" "} + <code>{"[a-z0-9]{2}\\.[a-z]{4,12}"}</code> matches 'a1.project', but not 'a1.project.feature-1'. </p> </Box> diff --git a/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx new file mode 100644 index 0000000000..bd22161294 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx @@ -0,0 +1,367 @@ +import React, { useEffect } from "react"; +import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender"; +import Select from "component/common/select"; +import { ProjectMode } from "../hooks/useProjectEnterpriseSettingsForm"; +import { Box, InputAdornment, styled, TextField } from "@mui/material"; +import { CollaborationModeTooltip } from "./CollaborationModeTooltip"; +import Input from "component/common/Input/Input"; +import { FeatureFlagNamingTooltip } from "./FeatureFlagNamingTooltip"; +import { usePlausibleTracker } from "hooks/usePlausibleTracker"; +import { useUiFlag } from "hooks/useUiFlag"; + +interface IProjectEnterpriseSettingsForm { + projectId: string; + projectMode?: string; + featureNamingPattern?: string; + featureNamingExample?: string; + featureNamingDescription?: string; + setFeatureNamingPattern?: React.Dispatch<React.SetStateAction<string>>; + setFeatureNamingExample?: React.Dispatch<React.SetStateAction<string>>; + setFeatureNamingDescription?: React.Dispatch<React.SetStateAction<string>>; + setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>; + handleSubmit: (e: any) => void; + errors: { [key: string]: string }; + clearErrors: () => void; +} + +const StyledForm = styled("form")(({ theme }) => ({ + height: "100%", + paddingBottom: theme.spacing(4), +})); + +const StyledSubtitle = styled("div")(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallerBody, + lineHeight: 1.25, + paddingBottom: theme.spacing(1), +})); + +const StyledInput = styled(Input)(({ theme }) => ({ + width: "100%", + marginBottom: theme.spacing(2), + paddingRight: theme.spacing(1), +})); + +const StyledTextField = styled(TextField)(({ theme }) => ({ + width: "100%", + marginBottom: theme.spacing(2), +})); + +const StyledFieldset = styled("fieldset")(() => ({ + padding: 0, + border: "none", +})); + +const StyledSelect = styled(Select)(({ theme }) => ({ + marginBottom: theme.spacing(2), + minWidth: "200px", +})); + +const StyledButtonContainer = styled("div")(() => ({ + marginTop: "auto", + display: "flex", + justifyContent: "flex-end", +})); + +const StyledFlagNamingContainer = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: theme.spacing(1), + "& > *": { width: "100%" }, +})); + +const StyledPatternNamingExplanation = styled("div")(({ theme }) => ({ + "p + p": { marginTop: theme.spacing(1) }, +})); + +export const validateFeatureNamingExample = ({ + pattern, + example, + featureNamingPatternError, +}: { + pattern: string; + example: string; + featureNamingPatternError: string | undefined; +}): { state: "valid" } | { state: "invalid"; reason: string } => { + if (featureNamingPatternError || !example || !pattern) { + return { state: "valid" }; + } else if (example && pattern) { + const regex = new RegExp(`^${pattern}$`); + const matches = regex.test(example); + if (!matches) { + return { state: "invalid", reason: "Example does not match regex" }; + } else { + return { state: "valid" }; + } + } + return { state: "valid" }; +}; + +const useFeatureNamePatternTracking = () => { + const [previousPattern, setPreviousPattern] = React.useState<string>(""); + const { trackEvent } = usePlausibleTracker(); + const eventName = "feature-naming-pattern" as const; + + const trackPattern = (pattern: string = "") => { + if (pattern === previousPattern) { + // do nothing; they've probably updated something else in the + // project. + } else if (pattern === "" && previousPattern !== "") { + trackEvent(eventName, { props: { action: "removed" } }); + } else if (pattern !== "" && previousPattern === "") { + trackEvent(eventName, { props: { action: "added" } }); + } else if (pattern !== "" && previousPattern !== "") { + trackEvent(eventName, { props: { action: "edited" } }); + } + }; + + return { trackPattern, setPreviousPattern }; +}; + +const ProjectEnterpriseSettingsForm: React.FC< + IProjectEnterpriseSettingsForm +> = ({ + children, + handleSubmit, + projectId, + projectMode, + featureNamingExample, + featureNamingPattern, + featureNamingDescription, + setFeatureNamingExample, + setFeatureNamingPattern, + setFeatureNamingDescription, + setProjectMode, + errors, + clearErrors, +}) => { + const privateProjects = useUiFlag("privateProjects"); + const shouldShowFlagNaming = useUiFlag("featureNamingPattern"); + + const { setPreviousPattern, trackPattern } = + useFeatureNamePatternTracking(); + + 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]); + + const updateNamingExampleError = ({ + example, + pattern, + }: { + example: string; + pattern: string; + }) => { + const validationResult = validateFeatureNamingExample({ + pattern, + example, + featureNamingPatternError: errors.featureNamingPattern, + }); + + switch (validationResult.state) { + case "invalid": + errors.namingExample = validationResult.reason; + break; + case "valid": + delete errors.namingExample; + break; + } + }; + + const onSetFeatureNamingPattern = (regex: string) => { + const disallowedStrings = [ + " ", + "\\t", + "\\s", + "\\n", + "\\r", + "\\f", + "\\v", + ]; + if ( + disallowedStrings.some((blockedString) => + regex.includes(blockedString) + ) + ) { + errors.featureNamingPattern = + "Whitespace is not allowed in the expression"; + } else { + try { + new RegExp(regex); + delete errors.featureNamingPattern; + } catch (e) { + errors.featureNamingPattern = "Invalid regular expression"; + } + } + setFeatureNamingPattern?.(regex); + updateNamingExampleError({ + pattern: regex, + example: featureNamingExample || "", + }); + }; + + const onSetFeatureNamingExample = (example: string) => { + setFeatureNamingExample && setFeatureNamingExample(example); + updateNamingExampleError({ + pattern: featureNamingPattern || "", + example, + }); + }; + + const onSetFeatureNamingDescription = (description: string) => { + setFeatureNamingDescription?.(description); + }; + + return ( + <StyledForm + onSubmit={(submitEvent) => { + handleSubmit(submitEvent); + trackPattern(featureNamingPattern); + }} + > + <> + <Box + sx={{ + display: "flex", + alignItems: "center", + marginBottom: 1, + gap: 1, + }} + > + <p>What is your project collaboration mode?</p> + <CollaborationModeTooltip /> + </Box> + <StyledSelect + id="project-mode" + value={projectMode} + label="Project collaboration mode" + name="Project collaboration mode" + onChange={(e) => { + setProjectMode?.(e.target.value as ProjectMode); + }} + options={projectModeOptions} + /> + </> + <ConditionallyRender + condition={Boolean(shouldShowFlagNaming)} + show={ + <StyledFieldset> + <Box + sx={{ + display: "flex", + alignItems: "center", + marginBottom: 1, + gap: 1, + }} + > + <legend>Feature flag naming pattern?</legend> + <FeatureFlagNamingTooltip /> + </Box> + <StyledSubtitle> + <StyledPatternNamingExplanation id="pattern-naming-description"> + <p> + Define a{" "} + <a + href={`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Cheatsheet`} + target="_blank" + rel="noreferrer" + > + JavaScript RegEx + </a>{" "} + used to enforce feature flag names within + this project. The regex will be surrounded + by a leading <code>^</code> and a trailing{" "} + <code>$</code>. + </p> + <p> + Leave it empty if you don’t want to add a + naming pattern. + </p> + </StyledPatternNamingExplanation> + </StyledSubtitle> + <StyledFlagNamingContainer> + <StyledInput + label={"Naming Pattern"} + name="feature flag naming pattern" + aria-describedby="pattern-naming-description" + placeholder="[A-Za-z]+\.[A-Za-z]+\.[A-Za-z0-9-]+" + InputProps={{ + startAdornment: ( + <InputAdornment position="start"> + ^ + </InputAdornment> + ), + endAdornment: ( + <InputAdornment position="end"> + $ + </InputAdornment> + ), + }} + type={"text"} + value={featureNamingPattern || ""} + error={Boolean(errors.featureNamingPattern)} + errorText={errors.featureNamingPattern} + onChange={(e) => + onSetFeatureNamingPattern(e.target.value) + } + /> + <StyledSubtitle> + <p id="pattern-additional-description"> + The example and description will be shown to + users when they create a new feature flag in + this project. + </p> + </StyledSubtitle> + + <StyledInput + label={"Naming Example"} + name="feature flag naming example" + type={"text"} + aria-describedby="pattern-additional-description" + value={featureNamingExample || ""} + placeholder="dx.feature1.1-135" + error={Boolean(errors.namingExample)} + errorText={errors.namingExample} + onChange={(e) => + onSetFeatureNamingExample(e.target.value) + } + /> + <StyledTextField + label={"Naming pattern description"} + name="feature flag naming description" + type={"text"} + aria-describedby="pattern-additional-description" + placeholder={`<project>.<featureName>.<ticket> + +The flag name should contain the project name, the feature name, and the ticket number, each separated by a dot.`} + multiline + minRows={5} + value={featureNamingDescription || ""} + onChange={(e) => + onSetFeatureNamingDescription( + e.target.value + ) + } + /> + </StyledFlagNamingContainer> + </StyledFieldset> + } + /> + <StyledButtonContainer>{children}</StyledButtonContainer> + </StyledForm> + ); +}; + +export default ProjectEnterpriseSettingsForm; diff --git a/frontend/src/component/project/Project/ProjectForm/validate-feature-naming.test.ts b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/validate-feature-naming.test.ts similarity index 95% rename from frontend/src/component/project/Project/ProjectForm/validate-feature-naming.test.ts rename to frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/validate-feature-naming.test.ts index 08133be909..fc53480660 100644 --- a/frontend/src/component/project/Project/ProjectForm/validate-feature-naming.test.ts +++ b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/validate-feature-naming.test.ts @@ -1,4 +1,4 @@ -import { validateFeatureNamingExample } from './ProjectForm'; +import { validateFeatureNamingExample } from './ProjectEnterpriseSettingsForm'; describe('validateFeatureNaming', () => { test.each(['+', 'valid regex$'])( diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index b750ab8699..2e7c7dbc2d 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -1,61 +1,58 @@ -import React, { useEffect } from 'react'; -import { trim } from 'component/common/util'; -import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import Select from 'component/common/select'; -import { ProjectMode } from '../hooks/useProjectForm'; -import { Box, InputAdornment, styled, TextField } from '@mui/material'; -import { CollaborationModeTooltip } from './CollaborationModeTooltip'; -import Input from 'component/common/Input/Input'; -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'; +import React from "react"; +import { trim } from "component/common/util"; +import { StickinessSelect } from "component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect"; +import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender"; +import { Box, styled, TextField } from "@mui/material"; +import Input from "component/common/Input/Input"; +import { FeatureTogglesLimitTooltip } from "./FeatureTogglesLimitTooltip"; +import { ProjectMode } from "../hooks/useProjectEnterpriseSettingsForm"; +import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig"; +import { CollaborationModeTooltip } from "../ProjectEnterpriseSettingsForm/CollaborationModeTooltip"; +import Select from "component/common/select"; +import { useUiFlag } from "hooks/useUiFlag"; interface IProjectForm { projectId: string; projectName: string; projectDesc: string; projectStickiness?: string; - projectMode?: string; - featureLimit: string; + featureLimit?: string; featureCount?: number; - featureNamingPattern?: string; - featureNamingExample?: string; - featureNamingDescription?: string; - setFeatureNamingPattern?: React.Dispatch<React.SetStateAction<string>>; - setFeatureNamingExample?: React.Dispatch<React.SetStateAction<string>>; - setFeatureNamingDescription?: React.Dispatch<React.SetStateAction<string>>; + projectMode?: string; setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>; - setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>; setProjectId: React.Dispatch<React.SetStateAction<string>>; setProjectName: React.Dispatch<React.SetStateAction<string>>; setProjectDesc: React.Dispatch<React.SetStateAction<string>>; - setFeatureLimit: React.Dispatch<React.SetStateAction<string>>; + setFeatureLimit?: React.Dispatch<React.SetStateAction<string>>; + setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>; handleSubmit: (e: any) => void; errors: { [key: string]: string }; - mode: 'Create' | 'Edit'; + mode: "Create" | "Edit"; clearErrors: () => void; validateProjectId: () => void; } -const PROJECT_STICKINESS_SELECT = 'PROJECT_STICKINESS_SELECT'; -const PROJECT_ID_INPUT = 'PROJECT_ID_INPUT'; -const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT'; -const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_INPUT'; +const PROJECT_STICKINESS_SELECT = "PROJECT_STICKINESS_SELECT"; +const PROJECT_ID_INPUT = "PROJECT_ID_INPUT"; +const PROJECT_NAME_INPUT = "PROJECT_NAME_INPUT"; +const PROJECT_DESCRIPTION_INPUT = "PROJECT_DESCRIPTION_INPUT"; -const StyledForm = styled('form')(({ theme }) => ({ - height: '100%', - paddingBottom: theme.spacing(4), +const StyledForm = styled("form")(({ theme }) => ({ + height: "100%", + paddingBottom: theme.spacing(1), })); -const StyledDescription = styled('p')(({ theme }) => ({ +const StyledDescription = styled("p")(({ theme }) => ({ marginBottom: theme.spacing(1), marginRight: theme.spacing(1), })); -const StyledSubtitle = styled('div')(({ theme }) => ({ +const StyledSelect = styled(Select)(({ theme }) => ({ + marginBottom: theme.spacing(2), + minWidth: "200px", +})); + +const StyledSubtitle = styled("div")(({ theme }) => ({ color: theme.palette.text.secondary, fontSize: theme.fontSizes.smallerBody, lineHeight: 1.25, @@ -63,93 +60,27 @@ const StyledSubtitle = styled('div')(({ theme }) => ({ })); const StyledInput = styled(Input)(({ theme }) => ({ - width: '100%', + width: "100%", marginBottom: theme.spacing(2), paddingRight: theme.spacing(1), })); const StyledTextField = styled(TextField)(({ theme }) => ({ - width: '100%', + width: "100%", marginBottom: theme.spacing(2), })); -const StyledFieldset = styled('fieldset')(() => ({ - padding: 0, - border: 'none', +const StyledButtonContainer = styled("div")(() => ({ + marginTop: "auto", + display: "flex", + justifyContent: "flex-end", })); -const StyledSelect = styled(Select)(({ theme }) => ({ - marginBottom: theme.spacing(2), - minWidth: '200px', +const StyledInputContainer = styled("div")(() => ({ + display: "flex", + alignItems: "center", })); -const StyledButtonContainer = styled('div')(() => ({ - marginTop: 'auto', - display: 'flex', - justifyContent: 'flex-end', -})); - -const StyledInputContainer = styled('div')(() => ({ - display: 'flex', - alignItems: 'center', -})); - -const StyledFlagNamingContainer = styled('div')(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: theme.spacing(1), - '& > *': { width: '100%' }, -})); - -const StyledPatternNamingExplanation = styled('div')(({ theme }) => ({ - 'p + p': { marginTop: theme.spacing(1) }, -})); - -export const validateFeatureNamingExample = ({ - pattern, - example, - featureNamingPatternError, -}: { - pattern: string; - example: string; - featureNamingPatternError: string | undefined; -}): { state: 'valid' } | { state: 'invalid'; reason: string } => { - if (featureNamingPatternError || !example || !pattern) { - return { state: 'valid' }; - } else if (example && pattern) { - const regex = new RegExp(`^${pattern}$`); - const matches = regex.test(example); - if (!matches) { - return { state: 'invalid', reason: 'Example does not match regex' }; - } else { - return { state: 'valid' }; - } - } - return { state: 'valid' }; -}; - -const useFeatureNamePatternTracking = () => { - const [previousPattern, setPreviousPattern] = React.useState<string>(''); - const { trackEvent } = usePlausibleTracker(); - const eventName = 'feature-naming-pattern' as const; - - const trackPattern = (pattern: string = '') => { - if (pattern === previousPattern) { - // do nothing; they've probably updated something else in the - // project. - } else if (pattern === '' && previousPattern !== '') { - trackEvent(eventName, { props: { action: 'removed' } }); - } else if (pattern !== '' && previousPattern === '') { - trackEvent(eventName, { props: { action: 'added' } }); - } else if (pattern !== '' && previousPattern !== '') { - trackEvent(eventName, { props: { action: 'edited' } }); - } - }; - - return { trackPattern, setPreviousPattern }; -}; - const ProjectForm: React.FC<IProjectForm> = ({ children, handleSubmit, @@ -157,133 +88,50 @@ const ProjectForm: React.FC<IProjectForm> = ({ projectName, projectDesc, projectStickiness, - projectMode, featureLimit, featureCount, - featureNamingExample, - featureNamingPattern, - featureNamingDescription, - setFeatureNamingExample, - setFeatureNamingPattern, - setFeatureNamingDescription, + projectMode, + setProjectMode, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, - setProjectMode, setFeatureLimit, errors, mode, validateProjectId, clearErrors, }) => { - const { uiConfig } = useUiConfig(); - const shouldShowFlagNaming = uiConfig.flags.featureNamingPattern; - - const { setPreviousPattern, trackPattern } = - useFeatureNamePatternTracking(); - - const privateProjects = useUiFlag('privateProjects'); + const { isEnterprise } = useUiConfig(); + 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" }, + { key: "private", label: "private" }, ] : [ - { key: 'open', label: 'open' }, - { key: 'protected', label: 'protected' }, + { key: "open", label: "open" }, + { key: "protected", label: "protected" }, ]; - useEffect(() => { - setPreviousPattern(featureNamingPattern || ''); - }, [projectId]); - - const updateNamingExampleError = ({ - example, - pattern, - }: { - example: string; - pattern: string; - }) => { - const validationResult = validateFeatureNamingExample({ - pattern, - example, - featureNamingPatternError: errors.featureNamingPattern, - }); - - switch (validationResult.state) { - case 'invalid': - errors.namingExample = validationResult.reason; - break; - case 'valid': - delete errors.namingExample; - break; - } - }; - - const onSetFeatureNamingPattern = (regex: string) => { - const disallowedStrings = [ - ' ', - '\\t', - '\\s', - '\\n', - '\\r', - '\\f', - '\\v', - ]; - if ( - disallowedStrings.some((blockedString) => - regex.includes(blockedString), - ) - ) { - errors.featureNamingPattern = - 'Whitespace is not allowed in the expression'; - } else { - try { - new RegExp(regex); - delete errors.featureNamingPattern; - } catch (e) { - errors.featureNamingPattern = 'Invalid regular expression'; - } - } - setFeatureNamingPattern?.(regex); - updateNamingExampleError({ - pattern: regex, - example: featureNamingExample || '', - }); - }; - - const onSetFeatureNamingExample = (example: string) => { - setFeatureNamingExample?.(example); - updateNamingExampleError({ - pattern: featureNamingPattern || '', - example, - }); - }; - - const onSetFeatureNamingDescription = (description: string) => { - setFeatureNamingDescription?.(description); - }; - return ( <StyledForm onSubmit={(submitEvent) => { handleSubmit(submitEvent); - trackPattern(featureNamingPattern); }} > <StyledDescription>What is your project Id?</StyledDescription> <StyledInput - label='Project Id' + label="Project Id" value={projectId} onChange={(e) => setProjectId(trim(e.target.value))} error={Boolean(errors.id)} errorText={errors.id} onFocus={() => clearErrors()} onBlur={validateProjectId} - disabled={mode === 'Edit'} + disabled={mode === "Edit"} data-testid={PROJECT_ID_INPUT} autoFocus required @@ -291,7 +139,7 @@ const ProjectForm: React.FC<IProjectForm> = ({ <StyledDescription>What is your project name?</StyledDescription> <StyledInput - label='Project name' + label="Project name" value={projectName} onChange={(e) => setProjectName(e.target.value)} error={Boolean(errors.name)} @@ -307,8 +155,8 @@ const ProjectForm: React.FC<IProjectForm> = ({ What is your project description? </StyledDescription> <StyledTextField - label='Project description' - variant='outlined' + label="Project description" + variant="outlined" multiline maxRows={4} value={projectDesc} @@ -324,7 +172,7 @@ const ProjectForm: React.FC<IProjectForm> = ({ What is the default stickiness for the project? </StyledDescription> <StickinessSelect - label='Stickiness' + label="Stickiness" value={projectStickiness} data-testid={PROJECT_STICKINESS_SELECT} onChange={(e) => @@ -335,167 +183,77 @@ const ProjectForm: React.FC<IProjectForm> = ({ </> } /> - <> - <Box - sx={{ - display: 'flex', - alignItems: 'center', - marginBottom: 1, - gap: 1, - }} - > - <p>What is your project collaboration mode?</p> - <CollaborationModeTooltip /> - </Box> - <StyledSelect - id='project-mode' - value={projectMode} - label='Project collaboration mode' - name='Project collaboration mode' - onChange={(e) => { - setProjectMode?.(e.target.value as ProjectMode); - }} - options={projectModeOptions} - /> - </> - <> - <Box - sx={{ - display: 'flex', - alignItems: 'center', - marginBottom: 1, - gap: 1, - }} - > - <p>Feature flag limit?</p> - <FeatureTogglesLimitTooltip /> - </Box> - <StyledSubtitle> - Leave it empty if you don’t want to add a limit - </StyledSubtitle> - <StyledInputContainer> - <StyledInput - label={'Limit'} - name='value' - type={'number'} - value={featureLimit} - onChange={(e) => setFeatureLimit(e.target.value)} - /> - <ConditionallyRender - condition={ - featureCount !== undefined && Boolean(featureLimit) - } - show={ - <Box> - ({featureCount} of {featureLimit} used) - </Box> - } - /> - </StyledInputContainer> - </> <ConditionallyRender - condition={Boolean(shouldShowFlagNaming)} + condition={mode === "Edit" && Boolean(setFeatureLimit)} show={ - <StyledFieldset> + <> <Box sx={{ - display: 'flex', - alignItems: 'center', + display: "flex", + alignItems: "center", marginBottom: 1, gap: 1, }} > - <legend>Feature flag naming pattern?</legend> - <FeatureFlagNamingTooltip /> + <p>Feature flag limit?</p> + <FeatureTogglesLimitTooltip /> </Box> <StyledSubtitle> - <StyledPatternNamingExplanation id='pattern-naming-description'> - <p> - Define a{' '} - <a - href={`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Cheatsheet`} - target='_blank' - rel='noreferrer' - > - JavaScript RegEx - </a>{' '} - used to enforce feature flag names within - this project. The regex will be surrounded - by a leading <code>^</code> and a trailing{' '} - <code>$</code>. - </p> - <p> - Leave it empty if you don’t want to add a - naming pattern. - </p> - </StyledPatternNamingExplanation> + Leave it empty if you don’t want to add a limit </StyledSubtitle> - <StyledFlagNamingContainer> - <StyledInput - label={'Naming Pattern'} - name='feature flag naming pattern' - aria-describedby='pattern-naming-description' - placeholder='[A-Za-z]+.[A-Za-z]+.[A-Za-z0-9-]+' - InputProps={{ - startAdornment: ( - <InputAdornment position='start'> - ^ - </InputAdornment> - ), - endAdornment: ( - <InputAdornment position='end'> - $ - </InputAdornment> - ), - }} - type={'text'} - value={featureNamingPattern || ''} - error={Boolean(errors.featureNamingPattern)} - errorText={errors.featureNamingPattern} - onChange={(e) => - onSetFeatureNamingPattern(e.target.value) + <StyledInputContainer> + {featureLimit && setFeatureLimit && ( + <StyledInput + label={"Limit"} + name="value" + type={"number"} + value={featureLimit} + onChange={(e) => + setFeatureLimit(e.target.value) + } + /> + )} + <ConditionallyRender + condition={ + featureCount !== undefined && + Boolean(featureLimit) + } + show={ + <Box> + ({featureCount} of {featureLimit} used) + </Box> } /> - <StyledSubtitle> - <p id='pattern-additional-description'> - The example and description will be shown to - users when they create a new feature flag in - this project. - </p> - </StyledSubtitle> - - <StyledInput - label={'Naming Example'} - name='feature flag naming example' - type={'text'} - aria-describedby='pattern-additional-description' - value={featureNamingExample || ''} - placeholder='dx.feature1.1-135' - error={Boolean(errors.namingExample)} - errorText={errors.namingExample} - onChange={(e) => - onSetFeatureNamingExample(e.target.value) - } - /> - <StyledTextField - label={'Naming pattern description'} - name='feature flag naming description' - type={'text'} - aria-describedby='pattern-additional-description' - placeholder={`<project>.<featureName>.<ticket> - -The flag name should contain the project name, the feature name, and the ticket number, each separated by a dot.`} - multiline - minRows={5} - value={featureNamingDescription || ''} - onChange={(e) => - onSetFeatureNamingDescription( - e.target.value, - ) - } - /> - </StyledFlagNamingContainer> - </StyledFieldset> + </StyledInputContainer> + </> + } + /> + <ConditionallyRender + condition={mode === "Create" && isEnterprise()} + show={ + <> + <Box + sx={{ + display: "flex", + alignItems: "center", + marginBottom: 1, + gap: 1, + }} + > + <p>What is your project collaboration mode?</p> + <CollaborationModeTooltip /> + </Box> + <StyledSelect + id="project-mode" + value={projectMode} + label="Project collaboration mode" + name="Project collaboration mode" + onChange={(e) => { + setProjectMode?.(e.target.value as ProjectMode); + }} + options={projectModeOptions} + /> + </> } /> <StyledButtonContainer>{children}</StyledButtonContainer> diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx deleted file mode 100644 index 868259bbd4..0000000000 --- a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import FormTemplate from 'component/common/FormTemplate/FormTemplate'; -import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; -import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; -import useProject from 'hooks/api/getters/useProject/useProject'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import useToast from 'hooks/useToast'; -import { formatUnknownError } from 'utils/formatUnknownError'; -import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { useContext } from 'react'; -import AccessContext from 'contexts/AccessContext'; -import { Alert } from '@mui/material'; -import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; -import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; -import useProjectForm, { - DEFAULT_PROJECT_STICKINESS, -} from '../../hooks/useProjectForm'; -import { PageContent } from 'component/common/PageContent/PageContent'; -import { PageHeader } from 'component/common/PageHeader/PageHeader'; -import { DeleteProject } from './DeleteProject'; -import PermissionButton from 'component/common/PermissionButton/PermissionButton'; -import ProjectForm from '../../ProjectForm/ProjectForm'; - -const EditProject = () => { - const { uiConfig } = useUiConfig(); - const { setToastData, setToastApiError } = useToast(); - const { hasAccess } = useContext(AccessContext); - const id = useRequiredPathParam('projectId'); - const { project } = useProject(id); - const { defaultStickiness } = useDefaultProjectSettings(id); - const { trackEvent } = usePlausibleTracker(); - - const { - projectId, - projectName, - projectDesc, - projectStickiness, - projectMode, - featureLimit, - featureNamingPattern, - featureNamingExample, - featureNamingDescription, - setProjectId, - setProjectName, - setProjectDesc, - setProjectStickiness, - setProjectMode, - setFeatureLimit, - setFeatureNamingPattern, - setFeatureNamingExample, - setFeatureNamingDescription, - getProjectPayload, - clearErrors, - validateProjectId, - validateName, - errors, - } = useProjectForm( - id, - project.name, - project.description, - defaultStickiness, - project.mode, - project.featureLimit ? String(project.featureLimit) : '', - project.featureNaming?.pattern || '', - project.featureNaming?.example || '', - project.featureNaming?.description || '', - ); - - const formatApiCode = () => { - return `curl --location --request PUT '${ - uiConfig.unleashUrl - }/api/admin/projects/${id}' \\ ---header 'Authorization: INSERT_API_KEY' \\ ---header 'Content-Type: application/json' \\ ---data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`; - }; - - const { editProject, loading } = useProjectApi(); - - const handleSubmit = async (e: Event) => { - e.preventDefault(); - const payload = getProjectPayload(); - - const validName = validateName(); - - if (validName) { - try { - await editProject(id, payload); - setToastData({ - title: 'Project information updated', - type: 'success', - }); - if (projectStickiness !== DEFAULT_PROJECT_STICKINESS) { - trackEvent('project_stickiness_set'); - } - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - } - }; - - const accessDeniedAlert = !hasAccess(UPDATE_PROJECT, projectId) && ( - <Alert severity='error' sx={{ mb: 4 }}> - You do not have the required permissions to edit this project. - </Alert> - ); - - return ( - <FormTemplate - loading={loading} - disablePadding={true} - description='Projects allows you to group feature toggles together in the management UI.' - documentationLink='https://docs.getunleash.io/reference/projects' - documentationLinkLabel='Projects documentation' - formatApiCode={formatApiCode} - > - {accessDeniedAlert} - <PageContent header={<PageHeader title='Settings' />}> - <ProjectForm - errors={errors} - handleSubmit={handleSubmit} - projectId={projectId} - setProjectId={setProjectId} - projectName={projectName} - projectMode={projectMode} - featureLimit={featureLimit} - featureCount={project.features.length} - featureNamingPattern={featureNamingPattern} - featureNamingExample={featureNamingExample} - featureNamingDescription={featureNamingDescription} - setProjectName={setProjectName} - projectStickiness={projectStickiness} - setProjectStickiness={setProjectStickiness} - setProjectMode={setProjectMode} - setFeatureNamingPattern={setFeatureNamingPattern} - setFeatureNamingExample={setFeatureNamingExample} - setFeatureNamingDescription={setFeatureNamingDescription} - projectDesc={projectDesc} - mode='Edit' - setProjectDesc={setProjectDesc} - setFeatureLimit={setFeatureLimit} - clearErrors={clearErrors} - validateProjectId={validateProjectId} - > - <PermissionButton - type='submit' - permission={UPDATE_PROJECT} - projectId={projectId} - > - Save changes - </PermissionButton> - </ProjectForm> - <DeleteProject - projectId={projectId} - featureCount={project.features.length} - /> - </PageContent> - </FormTemplate> - ); -}; - -export default EditProject; diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/DeleteProjectForm.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/DeleteProjectForm.tsx new file mode 100644 index 0000000000..a40bee0f97 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/DeleteProjectForm.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { DeleteProject } from "../DeleteProject"; +import FormTemplate from "component/common/FormTemplate/FormTemplate"; +import { useRequiredPathParam } from "hooks/useRequiredPathParam"; +import useProjectApi from "hooks/api/actions/useProjectApi/useProjectApi"; +import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig"; + +interface IDeleteProjectForm { + featureCount: number; +} +export const DeleteProjectForm = ({ featureCount }: IDeleteProjectForm) => { + const id = useRequiredPathParam("projectId"); + const { uiConfig } = useUiConfig(); + const { loading } = useProjectApi(); + const formatProjectDeleteApiCode = () => { + return `curl --location --request DELETE '${uiConfig.unleashUrl}/api/admin/projects/${id}' \\ +--header 'Authorization: INSERT_API_KEY' '`; + }; + + return ( + <FormTemplate + loading={loading} + title="Delete Project" + description="" + documentationLink="https://docs.getunleash.io/reference/projects" + documentationLinkLabel="Projects documentation" + formatApiCode={formatProjectDeleteApiCode} + compact + compactPadding + showDescription={false} + showLink={false} + > + <DeleteProject projectId={id} featureCount={featureCount} /> + </FormTemplate> + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/EditProject.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/EditProject.tsx new file mode 100644 index 0000000000..8e5719dc24 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/EditProject.tsx @@ -0,0 +1,46 @@ +import { UPDATE_PROJECT } from "component/providers/AccessProvider/permissions"; +import useProject from "hooks/api/getters/useProject/useProject"; +import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig"; +import { useRequiredPathParam } from "hooks/useRequiredPathParam"; +import React, { useContext } from "react"; +import AccessContext from "contexts/AccessContext"; +import { Alert, styled } from "@mui/material"; +import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender"; +import { UpdateEnterpriseSettings } from "./UpdateEnterpriseSettings"; +import { UpdateProject } from "./UpdateProject"; +import { DeleteProjectForm } from "./DeleteProjectForm"; + +const StyledFormContainer = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), +})); + +const EditProject = () => { + const { isEnterprise } = useUiConfig(); + const { hasAccess } = useContext(AccessContext); + const id = useRequiredPathParam("projectId"); + const { project } = useProject(id); + + const accessDeniedAlert = !hasAccess(UPDATE_PROJECT, id) && ( + <Alert severity="error" sx={{ mb: 4 }}> + You do not have the required permissions to edit this project. + </Alert> + ); + + return ( + <> + {accessDeniedAlert} + <StyledFormContainer> + <UpdateProject project={project} /> + <ConditionallyRender + condition={isEnterprise()} + show={<UpdateEnterpriseSettings project={project} />} + /> + <DeleteProjectForm featureCount={project.features.length} /> + </StyledFormContainer> + </> + ); +}; + +export default EditProject; diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateEnterpriseSettings.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateEnterpriseSettings.tsx new file mode 100644 index 0000000000..bbc8aa3543 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateEnterpriseSettings.tsx @@ -0,0 +1,187 @@ +import React, { useEffect } from "react"; +import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig"; +import useToast from "hooks/useToast"; +import { useRequiredPathParam } from "hooks/useRequiredPathParam"; +import useProjectEnterpriseSettingsForm from "component/project/Project/hooks/useProjectEnterpriseSettingsForm"; +import useProject from "hooks/api/getters/useProject/useProject"; +import useProjectApi from "hooks/api/actions/useProjectApi/useProjectApi"; +import { formatUnknownError } from "utils/formatUnknownError"; +import FormTemplate from "component/common/FormTemplate/FormTemplate"; +import ProjectEnterpriseSettingsForm from "component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm"; +import PermissionButton from "component/common/PermissionButton/PermissionButton"; +import { UPDATE_PROJECT } from "component/providers/AccessProvider/permissions"; +import { IProject } from "component/../interfaces/project"; +import { styled } from "@mui/material"; +import { usePlausibleTracker } from "hooks/usePlausibleTracker"; + +const StyledContainer = styled("div")(({ theme }) => ({ + minHeight: 0, + borderRadius: theme.spacing(2), + border: `1px solid ${theme.palette.divider}`, + width: "100%", + display: "flex", + margin: "0 auto", + overflow: "hidden", + [theme.breakpoints.down(1100)]: { + flexDirection: "column", + minHeight: 0, + }, +})); + +const StyledFormContainer = styled("div")(({ theme }) => ({ + borderTop: `1px solid ${theme.palette.divider}`, + paddingTop: theme.spacing(4), +})); + +interface IUpdateEnterpriseSettings { + project: IProject; +} +const EDIT_PROJECT_SETTINGS_BTN = "EDIT_PROJECT_SETTINGS_BTN"; + +export const useModeTracking = () => { + const [previousMode, setPreviousMode] = React.useState<string>(""); + const { trackEvent } = usePlausibleTracker(); + const eventName = "project-mode" as const; + + const trackModePattern = (newMode: string) => { + if (newMode !== previousMode) { + trackEvent(eventName, { + props: { mode: newMode, action: "updated" }, + }); + } + }; + + return { trackModePattern, setPreviousMode }; +}; + +export const UpdateEnterpriseSettings = ({ + project, +}: IUpdateEnterpriseSettings) => { + const { uiConfig } = useUiConfig(); + const { setToastData, setToastApiError } = useToast(); + const id = useRequiredPathParam("projectId"); + + const { + projectMode, + featureNamingExample, + featureNamingDescription, + featureNamingPattern, + setFeatureNamingPattern, + setFeatureNamingExample, + setFeatureNamingDescription, + setProjectMode, + getEnterpriseSettingsPayload, + errors: settingsErrors = {}, + clearErrors: clearSettingsErrors, + } = useProjectEnterpriseSettingsForm( + project.mode, + project?.featureNaming?.pattern, + project?.featureNaming?.example, + project?.featureNaming?.description + ); + + const formatProjectSettingsApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/projects/${id}/settings' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getEnterpriseSettingsPayload(), undefined, 2)}'`; + }; + + const { refetch } = useProject(id); + const { editProjectSettings, loading } = useProjectApi(); + + const useFeatureNamePatternTracking = () => { + const [previousPattern, setPreviousPattern] = + React.useState<string>(""); + const { trackEvent } = usePlausibleTracker(); + const eventName = "feature-naming-pattern" as const; + + const trackPattern = (newPattern: string = "") => { + if (newPattern === previousPattern) { + // do nothing; they've probably updated something else in the + // project. + } else if (newPattern === "" && previousPattern !== "") { + trackEvent(eventName, { props: { action: "removed" } }); + } else if (newPattern !== "" && previousPattern === "") { + trackEvent(eventName, { props: { action: "added" } }); + } else if (newPattern !== "" && previousPattern !== "") { + trackEvent(eventName, { props: { action: "edited" } }); + } + }; + + return { trackPattern, setPreviousPattern }; + }; + + const { setPreviousPattern, trackPattern } = + useFeatureNamePatternTracking(); + + const { setPreviousMode, trackModePattern } = useModeTracking(); + + const handleEditProjectSettings = async (e: Event) => { + e.preventDefault(); + const payload = getEnterpriseSettingsPayload(); + try { + await editProjectSettings(id, payload); + refetch(); + setToastData({ + title: "Project information updated", + type: "success", + }); + trackPattern(featureNamingPattern); + trackModePattern(projectMode); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + useEffect(() => { + setPreviousPattern(featureNamingPattern || ""); + setPreviousMode(projectMode); + }, [project]); + + return ( + <StyledContainer> + <FormTemplate + loading={loading} + title="Enterprise Settings" + description="" + documentationLink="https://docs.getunleash.io/reference/projects" + documentationLinkLabel="Projects documentation" + formatApiCode={formatProjectSettingsApiCode} + compactPadding + showDescription={false} + showLink={false} + > + <StyledFormContainer> + <ProjectEnterpriseSettingsForm + projectId={id} + projectMode={projectMode} + featureNamingPattern={featureNamingPattern} + featureNamingExample={featureNamingExample} + featureNamingDescription={featureNamingDescription} + setFeatureNamingPattern={setFeatureNamingPattern} + setFeatureNamingExample={setFeatureNamingExample} + setFeatureNamingDescription={ + setFeatureNamingDescription + } + setProjectMode={setProjectMode} + handleSubmit={handleEditProjectSettings} + errors={settingsErrors} + clearErrors={clearSettingsErrors} + > + <PermissionButton + type="submit" + permission={UPDATE_PROJECT} + projectId={id} + data-testid={EDIT_PROJECT_SETTINGS_BTN} + > + Save changes + </PermissionButton> + </ProjectEnterpriseSettingsForm> + </StyledFormContainer> + </FormTemplate> + </StyledContainer> + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateProject.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateProject.tsx new file mode 100644 index 0000000000..3796984bd5 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateProject.tsx @@ -0,0 +1,152 @@ +import FormTemplate from "component/common/FormTemplate/FormTemplate"; +import ProjectForm from "../../../ProjectForm/ProjectForm"; +import PermissionButton from "component/common/PermissionButton/PermissionButton"; +import { UPDATE_PROJECT } from "component/providers/AccessProvider/permissions"; +import React from "react"; +import useProjectForm, { + DEFAULT_PROJECT_STICKINESS, +} from "../../../hooks/useProjectForm"; +import { useDefaultProjectSettings } from "hooks/useDefaultProjectSettings"; +import { formatUnknownError } from "utils/formatUnknownError"; +import useToast from "hooks/useToast"; +import { usePlausibleTracker } from "hooks/usePlausibleTracker"; +import useProjectApi from "hooks/api/actions/useProjectApi/useProjectApi"; +import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig"; +import { IProject } from "interfaces/project"; +import useProject from "hooks/api/getters/useProject/useProject"; +import { useRequiredPathParam } from "hooks/useRequiredPathParam"; +import { styled } from "@mui/material"; + +const StyledContainer = styled("div")<{ isPro: boolean }>( + ({ theme, isPro }) => ({ + minHeight: 0, + borderRadius: theme.spacing(2), + border: isPro ? "0" : `1px solid ${theme.palette.divider}`, + width: "100%", + display: "flex", + margin: "0 auto", + overflow: "hidden", + [theme.breakpoints.down(1100)]: { + flexDirection: "column", + minHeight: 0, + }, + }) +); + +const StyledFormContainer = styled("div")(({ theme }) => ({ + borderTop: `1px solid ${theme.palette.divider}`, + paddingTop: theme.spacing(4), +})); + +interface IUpdateProject { + project: IProject; +} +const EDIT_PROJECT_BTN = "EDIT_PROJECT_BTN"; +export const UpdateProject = ({ project }: IUpdateProject) => { + const id = useRequiredPathParam("projectId"); + const { uiConfig, isPro } = useUiConfig(); + const { setToastData, setToastApiError } = useToast(); + const { defaultStickiness } = useDefaultProjectSettings(id); + const { trackEvent } = usePlausibleTracker(); + const { + projectId, + projectName, + projectDesc, + projectStickiness, + featureLimit, + setFeatureLimit, + setProjectId, + setProjectName, + setProjectDesc, + setProjectStickiness, + getEditProjectPayload, + clearErrors, + validateProjectId, + validateName, + errors, + } = useProjectForm( + id, + project.name, + project.description, + defaultStickiness, + String(project.featureLimit) + ); + + const { editProject, loading } = useProjectApi(); + const { refetch } = useProject(id); + const formatProjectApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/projects/${project.id}' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getEditProjectPayload(), undefined, 2)}'`; + }; + + const handleEditProject = async (e: Event) => { + e.preventDefault(); + const payload = getEditProjectPayload(); + + const validName = validateName(); + + if (validName) { + try { + await editProject(id, payload); + refetch(); + setToastData({ + title: "Project information updated", + type: "success", + }); + if (projectStickiness !== DEFAULT_PROJECT_STICKINESS) { + trackEvent("project_stickiness_set"); + } + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + return ( + <StyledContainer isPro={isPro()}> + <FormTemplate + loading={loading} + title="General Settings" + description="Projects allows you to group feature toggles together in the management UI." + documentationLink="https://docs.getunleash.io/reference/projects" + documentationLinkLabel="Projects documentation" + formatApiCode={formatProjectApiCode} + compactPadding + compact + > + <StyledFormContainer> + <ProjectForm + errors={errors} + handleSubmit={handleEditProject} + projectId={projectId} + setProjectId={setProjectId} + projectName={projectName} + setProjectName={setProjectName} + projectStickiness={projectStickiness} + setProjectStickiness={setProjectStickiness} + setFeatureLimit={setFeatureLimit} + featureLimit={featureLimit} + projectDesc={projectDesc} + setProjectDesc={setProjectDesc} + mode="Edit" + clearErrors={clearErrors} + validateProjectId={validateProjectId} + > + <PermissionButton + type="submit" + permission={UPDATE_PROJECT} + projectId={projectId} + data-testid={EDIT_PROJECT_BTN} + > + Save changes + </PermissionButton> + </ProjectForm> + </StyledFormContainer> + </FormTemplate> + </StyledContainer> + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/Settings.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/Settings.tsx index 61ea95d516..8f9abe3dc9 100644 --- a/frontend/src/component/project/Project/ProjectSettings/Settings/Settings.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/Settings.tsx @@ -7,7 +7,7 @@ import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { usePageTitle } from 'hooks/usePageTitle'; import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject'; -import EditProject from './EditProject'; +import EditProject from './EditProject/EditProject'; import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; diff --git a/frontend/src/component/project/Project/hooks/useProjectEnterpriseSettingsForm.ts b/frontend/src/component/project/Project/hooks/useProjectEnterpriseSettingsForm.ts new file mode 100644 index 0000000000..c4998a677d --- /dev/null +++ b/frontend/src/component/project/Project/hooks/useProjectEnterpriseSettingsForm.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +export type ProjectMode = 'open' | 'protected' | 'private'; +const useProjectEnterpriseSettingsForm = ( + initialProjectMode: ProjectMode = 'open', + initialFeatureNamingPattern = '', + initialFeatureNamingExample = '', + initialFeatureNamingDescription = '', +) => { + const [projectMode, setProjectMode] = + useState<ProjectMode>(initialProjectMode); + const [featureNamingPattern, setFeatureNamingPattern] = useState( + initialFeatureNamingPattern, + ); + const [featureNamingExample, setFeatureNamingExample] = useState( + initialFeatureNamingExample, + ); + + const [featureNamingDescription, setFeatureNamingDescription] = useState( + initialFeatureNamingDescription, + ); + + const [errors, setErrors] = useState({}); + + useEffect(() => { + setProjectMode(initialProjectMode); + }, [initialProjectMode]); + + useEffect(() => { + setFeatureNamingPattern(initialFeatureNamingPattern); + }, [initialFeatureNamingPattern]); + + useEffect(() => { + setFeatureNamingExample(initialFeatureNamingExample); + }, [initialFeatureNamingExample]); + + useEffect(() => { + setFeatureNamingDescription(initialFeatureNamingDescription); + }, [initialFeatureNamingDescription]); + + const getEnterpriseSettingsPayload = () => { + return { + mode: projectMode, + featureNaming: { + pattern: featureNamingPattern, + example: featureNamingExample, + description: featureNamingDescription, + }, + }; + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + projectMode, + featureNamingPattern, + featureNamingExample, + featureNamingDescription, + setFeatureNamingPattern, + setFeatureNamingExample, + setFeatureNamingDescription, + setProjectMode, + getEnterpriseSettingsPayload, + clearErrors, + errors, + }; +}; + +export default useProjectEnterpriseSettingsForm; diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 84b2369733..6d9e57903a 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -1,41 +1,29 @@ import { useEffect, useState } from 'react'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import { formatUnknownError } from 'utils/formatUnknownError'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { ProjectMode } from './useProjectEnterpriseSettingsForm'; -export type ProjectMode = 'open' | 'protected' | 'private'; export const DEFAULT_PROJECT_STICKINESS = 'default'; const useProjectForm = ( initialProjectId = '', initialProjectName = '', initialProjectDesc = '', initialProjectStickiness = DEFAULT_PROJECT_STICKINESS, - initialProjectMode: ProjectMode = 'open', initialFeatureLimit = '', - initialFeatureNamingPattern = '', - initialFeatureNamingExample = '', - initialFeatureNamingDescription = '', + initialProjectMode: ProjectMode = 'open', ) => { + const { isEnterprise } = useUiConfig(); const [projectId, setProjectId] = useState(initialProjectId); - + const [projectMode, setProjectMode] = + useState<ProjectMode>(initialProjectMode); const [projectName, setProjectName] = useState(initialProjectName); const [projectDesc, setProjectDesc] = useState(initialProjectDesc); const [projectStickiness, setProjectStickiness] = useState<string>( initialProjectStickiness, ); - const [projectMode, setProjectMode] = - useState<ProjectMode>(initialProjectMode); const [featureLimit, setFeatureLimit] = useState<string>(initialFeatureLimit); - const [featureNamingPattern, setFeatureNamingPattern] = useState( - initialFeatureNamingPattern, - ); - const [featureNamingExample, setFeatureNamingExample] = useState( - initialFeatureNamingExample, - ); - - const [featureNamingDescription, setFeatureNamingDescription] = useState( - initialFeatureNamingDescription, - ); const [errors, setErrors] = useState({}); @@ -53,43 +41,42 @@ const useProjectForm = ( setProjectDesc(initialProjectDesc); }, [initialProjectDesc]); - useEffect(() => { - setProjectMode(initialProjectMode); - }, [initialProjectMode]); - useEffect(() => { setFeatureLimit(initialFeatureLimit); }, [initialFeatureLimit]); - useEffect(() => { - setFeatureNamingPattern(initialFeatureNamingPattern); - }, [initialFeatureNamingPattern]); - - useEffect(() => { - setFeatureNamingExample(initialFeatureNamingExample); - }, [initialFeatureNamingExample]); - - useEffect(() => { - setFeatureNamingDescription(initialFeatureNamingDescription); - }, [initialFeatureNamingDescription]); - useEffect(() => { setProjectStickiness(initialProjectStickiness); }, [initialProjectStickiness]); - const getProjectPayload = () => { + useEffect(() => { + setProjectMode(initialProjectMode); + }, [initialProjectMode]); + + const getCreateProjectPayload = () => { + return isEnterprise() + ? { + id: projectId, + name: projectName, + description: projectDesc, + defaultStickiness: projectStickiness, + mode: projectMode, + } + : { + id: projectId, + name: projectName, + description: projectDesc, + defaultStickiness: projectStickiness, + }; + }; + + const getEditProjectPayload = () => { return { id: projectId, name: projectName, description: projectDesc, defaultStickiness: projectStickiness, featureLimit: getFeatureLimitAsNumber(), - mode: projectMode, - featureNaming: { - pattern: featureNamingPattern, - example: featureNamingExample, - description: featureNamingDescription, - }, }; }; @@ -106,7 +93,7 @@ const useProjectForm = ( return false; } try { - await validateId(getProjectPayload().id); + await validateId(getCreateProjectPayload().id); return true; } catch (error: unknown) { setErrors((prev) => ({ ...prev, id: formatUnknownError(error) })); @@ -131,22 +118,17 @@ const useProjectForm = ( projectId, projectName, projectDesc, - projectStickiness, projectMode, + projectStickiness, featureLimit, - featureNamingPattern, - featureNamingExample, - featureNamingDescription, - setFeatureNamingPattern, - setFeatureNamingExample, - setFeatureNamingDescription, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, - setProjectMode, setFeatureLimit, - getProjectPayload, + setProjectMode, + getCreateProjectPayload, + getEditProjectPayload, validateName, validateProjectId, clearErrors, diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index e0b832a4fa..6fc767f064 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -1,14 +1,11 @@ -import type { BatchStaleSchema, CreateFeatureStrategySchema } from 'openapi'; +import type { + BatchStaleSchema, + CreateFeatureStrategySchema, + CreateProjectSchema, + UpdateProjectSchema, + UpdateProjectEnterpriseSettingsSchema, +} from 'openapi'; import useAPI from '../useApi/useApi'; -import { ProjectMode } from 'component/project/Project/hooks/useProjectForm'; - -interface ICreatePayload { - id: string; - name: string; - description: string; - mode: ProjectMode; - defaultStickiness: string; -} interface IAccessPayload { roles: number[]; @@ -21,41 +18,63 @@ const useProjectApi = () => { propagateErrors: true, }); - const createProject = async (payload: ICreatePayload) => { + const createProject = async (payload: CreateProjectSchema) => { const path = `api/admin/projects`; const req = createRequest(path, { method: 'POST', body: JSON.stringify(payload), }); - return makeRequest(req.caller, req.id); + const res = await makeRequest(req.caller, req.id); + + return res; }; - const validateId = async (id: ICreatePayload['id']) => { + const validateId = async (id: CreateProjectSchema['id']) => { const path = `api/admin/projects/validate`; const req = createRequest(path, { method: 'POST', body: JSON.stringify({ id }), }); + const res = await makeRequest(req.caller, req.id); - return makeRequest(req.caller, req.id); + return res; }; - const editProject = async (id: string, payload: ICreatePayload) => { + const editProject = async (id: string, payload: UpdateProjectSchema) => { const path = `api/admin/projects/${id}`; const req = createRequest(path, { method: 'PUT', body: JSON.stringify(payload), }); - return makeRequest(req.caller, req.id); + const res = await makeRequest(req.caller, req.id); + + return res; + }; + + const editProjectSettings = async ( + id: string, + payload: UpdateProjectEnterpriseSettingsSchema, + ) => { + const path = `api/admin/projects/${id}/settings`; + const req = createRequest(path, { + method: 'PUT', + body: JSON.stringify(payload), + }); + + const res = await makeRequest(req.caller, req.id); + + return res; }; const deleteProject = async (projectId: string) => { const path = `api/admin/projects/${projectId}`; const req = createRequest(path, { method: 'DELETE' }); - return makeRequest(req.caller, req.id); + const res = await makeRequest(req.caller, req.id); + + return res; }; const addEnvironmentToProject = async ( @@ -68,7 +87,9 @@ const useProjectApi = () => { body: JSON.stringify({ environment }), }); - return makeRequest(req.caller, req.id); + const res = await makeRequest(req.caller, req.id); + + return res; }; const removeEnvironmentFromProject = async ( @@ -78,7 +99,9 @@ const useProjectApi = () => { const path = `api/admin/projects/${projectId}/environments/${environment}`; const req = createRequest(path, { method: 'DELETE' }); - return makeRequest(req.caller, req.id); + const res = await makeRequest(req.caller, req.id); + + return res; }; const addAccessToProject = async ( @@ -91,21 +114,21 @@ const useProjectApi = () => { body: JSON.stringify(payload), }); - return makeRequest(req.caller, req.id); + return await makeRequest(req.caller, req.id); }; const removeUserAccess = async (projectId: string, userId: number) => { const path = `api/admin/projects/${projectId}/users/${userId}/roles`; const req = createRequest(path, { method: 'DELETE' }); - return makeRequest(req.caller, req.id); + return await makeRequest(req.caller, req.id); }; const removeGroupAccess = async (projectId: string, groupId: number) => { const path = `api/admin/projects/${projectId}/groups/${groupId}/roles`; const req = createRequest(path, { method: 'DELETE' }); - return makeRequest(req.caller, req.id); + return await makeRequest(req.caller, req.id); }; const setUserRoles = ( @@ -212,6 +235,7 @@ const useProjectApi = () => { createProject, validateId, editProject, + editProjectSettings, deleteProject, addEnvironmentToProject, removeEnvironmentFromProject, diff --git a/frontend/src/hooks/usePlausibleTracker.ts b/frontend/src/hooks/usePlausibleTracker.ts index c72f37189c..0cfccdf2c3 100644 --- a/frontend/src/hooks/usePlausibleTracker.ts +++ b/frontend/src/hooks/usePlausibleTracker.ts @@ -47,7 +47,8 @@ export type CustomEvents = | 'search-filter-suggestions' | 'project-metrics' | 'open-integration' - | 'feature-naming-pattern'; + | 'feature-naming-pattern' + | 'project-mode'; export const usePlausibleTracker = () => { const plausible = useContext(PlausibleContext); diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index 8b8e7b339c..06d4a3a9d2 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -1,7 +1,7 @@ import { ProjectStatsSchema } from 'openapi'; import { IFeatureToggleListItem } from './featureToggle'; import { ProjectEnvironmentType } from 'component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef'; -import { ProjectMode } from 'component/project/Project/hooks/useProjectForm'; +import { ProjectMode } from 'component/project/Project/hooks/useProjectEnterpriseSettingsForm'; export interface IProjectCard { name: string;