1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

fix: separate project and project enterprise settings forms (#4911)

Separates ProjectForm and ProjectEnterpriseSettings forms

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>
This commit is contained in:
andreas-unleash 2023-10-04 10:42:02 +03:00 committed by GitHub
parent 88305a6388
commit c1f8929ddf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1346 additions and 1035 deletions

View File

@ -1,57 +1,57 @@
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { MemoryRouter, Routes, Route } from "react-router-dom";
import { FeatureView } from '../feature/FeatureView/FeatureView'; import { FeatureView } from "../feature/FeatureView/FeatureView";
import { ThemeProvider } from 'themes/ThemeProvider'; import { ThemeProvider } from "themes/ThemeProvider";
import { AccessProvider } from '../providers/AccessProvider/AccessProvider'; import { AccessProvider } from "../providers/AccessProvider/AccessProvider";
import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/AnnouncerProvider'; import { AnnouncerProvider } from "../common/Announcer/AnnouncerProvider/AnnouncerProvider";
import { testServerRoute, testServerSetup } from '../../utils/testServer'; import { testServerRoute, testServerSetup } from "../../utils/testServer";
import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer'; import { UIProviderContainer } from "../providers/UIProvider/UIProviderContainer";
import { FC } from 'react'; import { FC } from "react";
import { IPermission } from '../../interfaces/user'; import { IPermission } from "../../interfaces/user";
import { ProjectMode } from '../project/Project/hooks/useProjectForm'; import { SWRConfig } from "swr";
import { SWRConfig } from 'swr'; import { ProjectMode } from "../project/Project/hooks/useProjectEnterpriseSettingsForm";
const server = testServerSetup(); const server = testServerSetup();
const projectWithCollaborationMode = (mode: ProjectMode) => const projectWithCollaborationMode = (mode: ProjectMode) =>
testServerRoute(server, '/api/admin/projects/default', { mode }); testServerRoute(server, "/api/admin/projects/default", { mode });
const changeRequestsEnabledIn = ( const changeRequestsEnabledIn = (
env: 'development' | 'production' | 'custom', env: "development" | "production" | "custom"
) => ) =>
testServerRoute( testServerRoute(
server, server,
'/api/admin/projects/default/change-requests/config', "/api/admin/projects/default/change-requests/config",
[ [
{ {
environment: 'development', environment: "development",
type: 'development', type: "development",
requiredApprovals: null, requiredApprovals: null,
changeRequestEnabled: env === 'development', changeRequestEnabled: env === "development",
}, },
{ {
environment: 'production', environment: "production",
type: 'production', type: "production",
requiredApprovals: 1, requiredApprovals: 1,
changeRequestEnabled: env === 'production', changeRequestEnabled: env === "production",
}, },
{ {
environment: 'custom', environment: "custom",
type: 'production', type: "production",
requiredApprovals: null, requiredApprovals: null,
changeRequestEnabled: env === 'custom', changeRequestEnabled: env === "custom",
}, },
], ]
); );
const uiConfigForEnterprise = () => const uiConfigForEnterprise = () =>
testServerRoute(server, '/api/admin/ui-config', { testServerRoute(server, "/api/admin/ui-config", {
environment: 'Open Source', environment: "Open Source",
flags: { flags: {
changeRequests: true, changeRequests: true,
}, },
versionInfo: { 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, disablePasswordAuth: false,
}); });
@ -59,12 +59,12 @@ const uiConfigForEnterprise = () =>
const setupOtherRoutes = (feature: string) => { const setupOtherRoutes = (feature: string) => {
testServerRoute( testServerRoute(
server, server,
'api/admin/projects/default/change-requests/pending', "api/admin/projects/default/change-requests/pending",
[], []
); );
testServerRoute(server, `api/admin/client-metrics/features/${feature}`, { testServerRoute(server, `api/admin/client-metrics/features/${feature}`, {
version: 1, version: 1,
maturity: 'stable', maturity: "stable",
featureName: feature, featureName: feature,
lastHourUsage: [], lastHourUsage: [],
seenApplications: [], seenApplications: [],
@ -86,25 +86,25 @@ const setupOtherRoutes = (feature: string) => {
version: 1, version: 1,
strategies: [ strategies: [
{ {
displayName: 'Standard', displayName: "Standard",
name: 'default', name: "default",
editable: false, editable: false,
description: description:
'The standard strategy is strictly on / off for your entire userbase.', "The standard strategy is strictly on / off for your entire userbase.",
parameters: [], parameters: [],
deprecated: false, deprecated: false,
}, },
{ {
displayName: 'UserIDs', displayName: "UserIDs",
name: 'userWithId', name: "userWithId",
editable: false, editable: false,
description: description:
'Enable the feature for a specific set of userIds.', "Enable the feature for a specific set of userIds.",
parameters: [ parameters: [
{ {
name: 'userIds', name: "userIds",
type: 'list', type: "list",
description: '', description: "",
required: false, required: false,
}, },
], ],
@ -115,17 +115,17 @@ const setupOtherRoutes = (feature: string) => {
}; };
const userHasPermissions = (permissions: Array<IPermission>) => { const userHasPermissions = (permissions: Array<IPermission>) => {
testServerRoute(server, 'api/admin/user', { testServerRoute(server, "api/admin/user", {
user: { user: {
isAPI: false, isAPI: false,
id: 2, id: 2,
name: 'Test', name: "Test",
email: 'test@getunleash.ai', email: "test@getunleash.ai",
imageUrl: imageUrl:
'https://gravatar.com/avatar/e55646b526ff342ff8b43721f0cbdd8e?size=42&default=retro', "https://gravatar.com/avatar/e55646b526ff342ff8b43721f0cbdd8e?size=42&default=retro",
seenAt: '2022-11-29T08:21:52.581Z', seenAt: "2022-11-29T08:21:52.581Z",
loginAttempts: 0, loginAttempts: 0,
createdAt: '2022-11-21T10:10:33.074Z', createdAt: "2022-11-21T10:10:33.074Z",
}, },
permissions, permissions,
feedback: [], feedback: [],
@ -136,21 +136,21 @@ const userIsMemberOfProjects = (projects: string[]) => {
userHasPermissions( userHasPermissions(
projects.map((project) => ({ projects.map((project) => ({
project, project,
environment: 'irrelevant', environment: "irrelevant",
permission: 'irrelevant', permission: "irrelevant",
})), }))
); );
}; };
const featureEnvironments = ( const featureEnvironments = (
feature: string, feature: string,
environments: Array<{ name: string; strategies: Array<string> }>, environments: Array<{ name: string; strategies: Array<string> }>
) => { ) => {
testServerRoute(server, `/api/admin/projects/default/features/${feature}`, { testServerRoute(server, `/api/admin/projects/default/features/${feature}`, {
environments: environments.map((env) => ({ environments: environments.map((env) => ({
name: env.name, name: env.name,
enabled: false, enabled: false,
type: 'production', type: "production",
sortOrder: 1, sortOrder: 1,
strategies: env.strategies.map((strategy) => ({ strategies: env.strategies.map((strategy) => ({
name: strategy, name: strategy,
@ -162,13 +162,13 @@ const featureEnvironments = (
})), })),
name: feature, name: feature,
impressionData: false, impressionData: false,
description: '', description: "",
project: 'default', project: "default",
stale: false, stale: false,
variants: [], variants: [],
createdAt: '2022-11-14T08:16:33.338Z', createdAt: "2022-11-14T08:16:33.338Z",
lastSeenAt: null, lastSeenAt: null,
type: 'release', type: "release",
archived: false, archived: false,
children: [], children: [],
dependencies: [], dependencies: [],
@ -199,7 +199,7 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({
const strategiesAreDisplayed = async ( const strategiesAreDisplayed = async (
firstStrategy: string, firstStrategy: string,
secondStrategy: string, secondStrategy: string
) => { ) => {
await screen.findByText(firstStrategy); await screen.findByText(firstStrategy);
await screen.findByText(secondStrategy); await screen.findByText(secondStrategy);
@ -213,10 +213,10 @@ const getDeleteButtons = async () => {
removeMenus.map(async (menu) => { removeMenus.map(async (menu) => {
menu.click(); menu.click();
const removeButton = screen.getAllByTestId( const removeButton = screen.getAllByTestId(
'STRATEGY_FORM_REMOVE_ID', "STRATEGY_FORM_REMOVE_ID"
); );
deleteButtons.push(...removeButton); deleteButtons.push(...removeButton);
}), })
); );
return deleteButtons; return deleteButtons;
}; };
@ -229,12 +229,12 @@ const deleteButtonsActiveInChangeRequestEnv = async () => {
await waitFor(() => { await waitFor(() => {
// production // production
const productionStrategyDeleteButton = deleteButtons[1]; const productionStrategyDeleteButton = deleteButtons[1];
expect(productionStrategyDeleteButton).not.toHaveClass('Mui-disabled'); expect(productionStrategyDeleteButton).not.toHaveClass("Mui-disabled");
}); });
await waitFor(() => { await waitFor(() => {
// custom env // custom env
const customEnvStrategyDeleteButton = deleteButtons[2]; const customEnvStrategyDeleteButton = deleteButtons[2];
expect(customEnvStrategyDeleteButton).toHaveClass('Mui-disabled'); expect(customEnvStrategyDeleteButton).toHaveClass("Mui-disabled");
}); });
}; };
@ -246,17 +246,17 @@ const deleteButtonsInactiveInChangeRequestEnv = async () => {
await waitFor(() => { await waitFor(() => {
// production // production
const productionStrategyDeleteButton = deleteButtons[1]; const productionStrategyDeleteButton = deleteButtons[1];
expect(productionStrategyDeleteButton).toHaveClass('Mui-disabled'); expect(productionStrategyDeleteButton).toHaveClass("Mui-disabled");
}); });
await waitFor(() => { await waitFor(() => {
// custom env // custom env
const customEnvStrategyDeleteButton = deleteButtons[2]; const customEnvStrategyDeleteButton = deleteButtons[2];
expect(customEnvStrategyDeleteButton).toHaveClass('Mui-disabled'); expect(customEnvStrategyDeleteButton).toHaveClass("Mui-disabled");
}); });
}; };
const copyButtonsActiveInOtherEnv = async () => { const copyButtonsActiveInOtherEnv = async () => {
const copyButtons = screen.getAllByTestId('STRATEGY_FORM_COPY_ID'); const copyButtons = screen.getAllByTestId("STRATEGY_FORM_COPY_ID");
expect(copyButtons.length).toBe(2); expect(copyButtons.length).toBe(2);
// production // production
@ -274,92 +274,92 @@ const openEnvironments = async (envNames: string[]) => {
} }
}; };
test('open mode + non-project member can perform basic change request actions', async () => { test("open mode + non-project member can perform basic change request actions", async () => {
const project = 'default'; const project = "default";
const featureName = 'test'; const featureName = "test";
featureEnvironments(featureName, [ featureEnvironments(featureName, [
{ name: 'development', strategies: [] }, { name: "development", strategies: [] },
{ name: 'production', strategies: ['userWithId'] }, { name: "production", strategies: ["userWithId"] },
{ name: 'custom', strategies: ['default'] }, { name: "custom", strategies: ["default"] },
]); ]);
userIsMemberOfProjects([]); userIsMemberOfProjects([]);
changeRequestsEnabledIn('production'); changeRequestsEnabledIn("production");
projectWithCollaborationMode('open'); projectWithCollaborationMode("open");
uiConfigForEnterprise(); uiConfigForEnterprise();
setupOtherRoutes(featureName); setupOtherRoutes(featureName);
render( render(
<UnleashUiSetup <UnleashUiSetup
pathTemplate='/projects/:projectId/features/:featureId/*' pathTemplate="/projects/:projectId/features/:featureId/*"
path={`/projects/${project}/features/${featureName}`} path={`/projects/${project}/features/${featureName}`}
> >
<FeatureView /> <FeatureView />
</UnleashUiSetup>, </UnleashUiSetup>
); );
await openEnvironments(['development', 'production', 'custom']); await openEnvironments(["development", "production", "custom"]);
await strategiesAreDisplayed('UserIDs', 'Standard'); await strategiesAreDisplayed("UserIDs", "Standard");
await deleteButtonsActiveInChangeRequestEnv(); await deleteButtonsActiveInChangeRequestEnv();
await copyButtonsActiveInOtherEnv(); await copyButtonsActiveInOtherEnv();
}); });
test('protected mode + project member can perform basic change request actions', async () => { test("protected mode + project member can perform basic change request actions", async () => {
const project = 'default'; const project = "default";
const featureName = 'test'; const featureName = "test";
featureEnvironments(featureName, [ featureEnvironments(featureName, [
{ name: 'development', strategies: [] }, { name: "development", strategies: [] },
{ name: 'production', strategies: ['userWithId'] }, { name: "production", strategies: ["userWithId"] },
{ name: 'custom', strategies: ['default'] }, { name: "custom", strategies: ["default"] },
]); ]);
userIsMemberOfProjects([project]); userIsMemberOfProjects([project]);
changeRequestsEnabledIn('production'); changeRequestsEnabledIn("production");
projectWithCollaborationMode('protected'); projectWithCollaborationMode("protected");
uiConfigForEnterprise(); uiConfigForEnterprise();
setupOtherRoutes(featureName); setupOtherRoutes(featureName);
render( render(
<UnleashUiSetup <UnleashUiSetup
pathTemplate='/projects/:projectId/features/:featureId/*' pathTemplate="/projects/:projectId/features/:featureId/*"
path={`/projects/${project}/features/${featureName}`} path={`/projects/${project}/features/${featureName}`}
> >
<FeatureView /> <FeatureView />
</UnleashUiSetup>, </UnleashUiSetup>
); );
await openEnvironments(['development', 'production', 'custom']); await openEnvironments(["development", "production", "custom"]);
await strategiesAreDisplayed('UserIDs', 'Standard'); await strategiesAreDisplayed("UserIDs", "Standard");
await deleteButtonsActiveInChangeRequestEnv(); await deleteButtonsActiveInChangeRequestEnv();
await copyButtonsActiveInOtherEnv(); await copyButtonsActiveInOtherEnv();
}); });
test('protected mode + non-project member cannot perform basic change request actions', async () => { test("protected mode + non-project member cannot perform basic change request actions", async () => {
const project = 'default'; const project = "default";
const featureName = 'test'; const featureName = "test";
featureEnvironments(featureName, [ featureEnvironments(featureName, [
{ name: 'development', strategies: [] }, { name: "development", strategies: [] },
{ name: 'production', strategies: ['userWithId'] }, { name: "production", strategies: ["userWithId"] },
{ name: 'custom', strategies: ['default'] }, { name: "custom", strategies: ["default"] },
]); ]);
userIsMemberOfProjects([]); userIsMemberOfProjects([]);
changeRequestsEnabledIn('production'); changeRequestsEnabledIn("production");
projectWithCollaborationMode('protected'); projectWithCollaborationMode("protected");
uiConfigForEnterprise(); uiConfigForEnterprise();
setupOtherRoutes(featureName); setupOtherRoutes(featureName);
render( render(
<UnleashUiSetup <UnleashUiSetup
pathTemplate='/projects/:projectId/features/:featureId/*' pathTemplate="/projects/:projectId/features/:featureId/*"
path={`/projects/${project}/features/${featureName}`} path={`/projects/${project}/features/${featureName}`}
> >
<FeatureView /> <FeatureView />
</UnleashUiSetup>, </UnleashUiSetup>
); );
await openEnvironments(['development', 'production', 'custom']); await openEnvironments(["development", "production", "custom"]);
await strategiesAreDisplayed('UserIDs', 'Standard'); await strategiesAreDisplayed("UserIDs", "Standard");
await deleteButtonsInactiveInChangeRequestEnv(); await deleteButtonsInactiveInChangeRequestEnv();
await copyButtonsActiveInOtherEnv(); await copyButtonsActiveInOtherEnv();
}); });

View File

@ -1,5 +1,5 @@
import MenuBookIcon from '@mui/icons-material/MenuBook'; import MenuBookIcon from "@mui/icons-material/MenuBook";
import Codebox from '../Codebox/Codebox'; import Codebox from "../Codebox/Codebox";
import { import {
Collapse, Collapse,
IconButton, IconButton,
@ -7,16 +7,16 @@ import {
Tooltip, Tooltip,
Divider, Divider,
styled, styled,
} from '@mui/material'; } from "@mui/material";
import { FileCopy, Info } from '@mui/icons-material'; import { FileCopy, Info } from "@mui/icons-material";
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender";
import Loader from '../Loader/Loader'; import Loader from "../Loader/Loader";
import copy from 'copy-to-clipboard'; import copy from "copy-to-clipboard";
import useToast from 'hooks/useToast'; import useToast from "hooks/useToast";
import React, { ReactNode, useState } from 'react'; import React, { ReactNode, useState } from "react";
import { ReactComponent as MobileGuidanceBG } from 'assets/img/mobileGuidanceBg.svg'; import { ReactComponent as MobileGuidanceBG } from "assets/img/mobileGuidanceBg.svg";
import { formTemplateSidebarWidth } from './FormTemplate.styles'; import { formTemplateSidebarWidth } from "./FormTemplate.styles";
import { relative } from 'themes/themeStyles'; import { relative } from "themes/themeStyles";
interface ICreateProps { interface ICreateProps {
title?: ReactNode; title?: ReactNode;
@ -26,61 +26,74 @@ interface ICreateProps {
loading?: boolean; loading?: boolean;
modal?: boolean; modal?: boolean;
disablePadding?: boolean; disablePadding?: boolean;
compactPadding?: boolean;
showDescription?: boolean;
showLink?: boolean;
formatApiCode?: () => string; formatApiCode?: () => string;
footer?: ReactNode; footer?: ReactNode;
compact?: boolean;
} }
const StyledContainer = styled('section', { const StyledContainer = styled("section", {
shouldForwardProp: (prop) => prop !== 'modal', shouldForwardProp: (prop) =>
})<{ modal?: boolean }>(({ theme, modal }) => ({ !["modal", "compact"].includes(prop.toString()),
minHeight: modal ? '100vh' : '80vh', })<{ modal?: boolean; compact?: boolean }>(({ theme, modal, compact }) => ({
minHeight: modal ? "100vh" : compact ? 0 : "80vh",
borderRadius: modal ? 0 : theme.spacing(2), borderRadius: modal ? 0 : theme.spacing(2),
width: '100%', width: "100%",
display: 'flex', display: "flex",
margin: '0 auto', margin: "0 auto",
overflow: modal ? 'unset' : 'hidden', overflow: modal ? "unset" : "hidden",
[theme.breakpoints.down(1100)]: { [theme.breakpoints.down(1100)]: {
flexDirection: 'column', flexDirection: "column",
minHeight: 0, minHeight: 0,
}, },
})); }));
const StyledRelativeDiv = styled('div')(({ theme }) => relative); const StyledRelativeDiv = styled("div")(({ theme }) => relative);
const StyledMain = styled('div')(({ theme }) => ({ const StyledMain = styled("div")(({ theme }) => ({
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
flexGrow: 1, flexGrow: 1,
flexShrink: 1, flexShrink: 1,
width: '100%', width: "100%",
[theme.breakpoints.down(1100)]: { [theme.breakpoints.down(1100)]: {
width: '100%', width: "100%",
}, },
})); }));
const StyledFormContent = styled('div', { const StyledFormContent = styled("div", {
shouldForwardProp: (prop) => prop !== 'disablePadding', shouldForwardProp: (prop) => {
})<{ disablePadding?: boolean }>(({ theme, disablePadding }) => ({ return !["disablePadding", "compactPadding"].includes(prop.toString());
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),
}, },
[theme.breakpoints.down(1100)]: { })<{ disablePadding?: boolean; compactPadding?: boolean }>(
width: '100%', ({ theme, disablePadding, compactPadding }) => ({
}, backgroundColor: theme.palette.background.paper,
[theme.breakpoints.down(500)]: { display: "flex",
padding: disablePadding ? 0 : theme.spacing(4, 2), 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, backgroundColor: theme.palette.background.paper,
padding: theme.spacing(4, 6), padding: theme.spacing(4, 6),
[theme.breakpoints.down('lg')]: { [theme.breakpoints.down("lg")]: {
padding: theme.spacing(4), padding: theme.spacing(4),
}, },
[theme.breakpoints.down(500)]: { [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, marginBottom: theme.fontSizes.mainHeader,
fontWeight: 'normal', fontWeight: "normal",
})); }));
const StyledSidebarDivider = styled(Divider)(({ theme }) => ({ const StyledSidebarDivider = styled(Divider)(({ theme }) => ({
@ -98,12 +111,12 @@ const StyledSidebarDivider = styled(Divider)(({ theme }) => ({
marginBottom: theme.spacing(0.5), marginBottom: theme.spacing(0.5),
})); }));
const StyledSubtitle = styled('h2')(({ theme }) => ({ const StyledSubtitle = styled("h2")(({ theme }) => ({
color: theme.palette.common.white, color: theme.palette.common.white,
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.bodySize, fontSize: theme.fontSizes.bodySize,
})); }));
@ -112,20 +125,20 @@ const StyledIcon = styled(FileCopy)(({ theme }) => ({
fill: theme.palette.primary.contrastText, fill: theme.palette.primary.contrastText,
})); }));
const StyledMobileGuidanceContainer = styled('div')(() => ({ const StyledMobileGuidanceContainer = styled("div")(() => ({
zIndex: 1, zIndex: 1,
position: 'absolute', position: "absolute",
right: -3, right: -3,
top: -3, top: -3,
})); }));
const StyledMobileGuidanceBackground = styled(MobileGuidanceBG)(() => ({ const StyledMobileGuidanceBackground = styled(MobileGuidanceBG)(() => ({
width: '75px', width: "75px",
height: '75px', height: "75px",
})); }));
const StyledMobileGuidanceButton = styled(IconButton)(() => ({ const StyledMobileGuidanceButton = styled(IconButton)(() => ({
position: 'absolute', position: "absolute",
zIndex: 400, zIndex: 400,
right: 0, right: 0,
})); }));
@ -134,31 +147,31 @@ const StyledInfoIcon = styled(Info)(({ theme }) => ({
fill: theme.palette.primary.contrastText, fill: theme.palette.primary.contrastText,
})); }));
const StyledSidebar = styled('aside')(({ theme }) => ({ const StyledSidebar = styled("aside")(({ theme }) => ({
backgroundColor: theme.palette.background.sidebar, backgroundColor: theme.palette.background.sidebar,
padding: theme.spacing(4), padding: theme.spacing(4),
flexGrow: 0, flexGrow: 0,
flexShrink: 0, flexShrink: 0,
width: formTemplateSidebarWidth, width: formTemplateSidebarWidth,
[theme.breakpoints.down(1100)]: { [theme.breakpoints.down(1100)]: {
width: '100%', width: "100%",
color: 'red', color: "red",
}, },
[theme.breakpoints.down(500)]: { [theme.breakpoints.down(500)]: {
padding: theme.spacing(4, 2), padding: theme.spacing(4, 2),
}, },
})); }));
const StyledDescription = styled('p')(({ theme }) => ({ const StyledDescription = styled("p")(({ theme }) => ({
color: theme.palette.common.white, color: theme.palette.common.white,
zIndex: 1, zIndex: 1,
position: 'relative', position: "relative",
})); }));
const StyledLinkContainer = styled('div')(({ theme }) => ({ const StyledLinkContainer = styled("div")(({ theme }) => ({
margin: theme.spacing(3, 0), margin: theme.spacing(3, 0),
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
})); }));
const StyledLinkIcon = styled(MenuBookIcon)(({ theme }) => ({ const StyledLinkIcon = styled(MenuBookIcon)(({ theme }) => ({
@ -166,11 +179,11 @@ const StyledLinkIcon = styled(MenuBookIcon)(({ theme }) => ({
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
})); }));
const StyledDocumentationLink = styled('a')(({ theme }) => ({ const StyledDocumentationLink = styled("a")(({ theme }) => ({
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
display: 'block', display: "block",
'&:hover': { "&:hover": {
textDecoration: 'none', textDecoration: "none",
}, },
})); }));
@ -184,7 +197,11 @@ const FormTemplate: React.FC<ICreateProps> = ({
modal, modal,
formatApiCode, formatApiCode,
disablePadding, disablePadding,
compactPadding = false,
showDescription = true,
showLink = true,
footer, footer,
compact,
}) => { }) => {
const { setToastData } = useToast(); const { setToastData } = useToast();
const smallScreen = useMediaQuery(`(max-width:${1099}px)`); const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
@ -192,45 +209,48 @@ const FormTemplate: React.FC<ICreateProps> = ({
if (formatApiCode !== undefined) { if (formatApiCode !== undefined) {
if (copy(formatApiCode())) { if (copy(formatApiCode())) {
setToastData({ setToastData({
title: 'Successfully copied the command', title: "Successfully copied the command",
text: 'The command should now be automatically copied to your clipboard', text: "The command should now be automatically copied to your clipboard",
autoHideDuration: 6000, autoHideDuration: 6000,
type: 'success', type: "success",
show: true, show: true,
}); });
} else { } else {
setToastData({ setToastData({
title: 'Could not copy the command', title: "Could not copy the command",
text: 'Sorry, but we could not copy the command.', text: "Sorry, but we could not copy the command.",
autoHideDuration: 6000, autoHideDuration: 6000,
type: 'error', type: "error",
show: true, show: true,
}); });
} }
} }
}; };
const renderApiInfo = (apiDisabled: boolean) => { const renderApiInfo = (apiDisabled: boolean, dividerDisabled = false) => {
if (!apiDisabled) { if (!apiDisabled) {
return ( return (
<> <>
<StyledSidebarDivider /> <ConditionallyRender
condition={!dividerDisabled}
show={<StyledSidebarDivider />}
/>
<StyledSubtitle> <StyledSubtitle>
API Command{' '} API Command{" "}
<Tooltip title='Copy command' arrow> <Tooltip title="Copy command" arrow>
<IconButton onClick={copyCommand} size='large'> <IconButton onClick={copyCommand} size="large">
<StyledIcon /> <StyledIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</StyledSubtitle> </StyledSubtitle>
<Codebox text={formatApiCode!()} />{' '} <Codebox text={formatApiCode!()} />{" "}
</> </>
); );
} }
}; };
return ( return (
<StyledContainer modal={modal}> <StyledContainer modal={modal} compact={compact}>
<ConditionallyRender <ConditionallyRender
condition={smallScreen} condition={smallScreen}
show={ show={
@ -244,7 +264,10 @@ const FormTemplate: React.FC<ICreateProps> = ({
} }
/> />
<StyledMain> <StyledMain>
<StyledFormContent disablePadding={disablePadding}> <StyledFormContent
disablePadding={disablePadding}
compactPadding={compactPadding}
>
<ConditionallyRender <ConditionallyRender
condition={loading || false} condition={loading || false}
show={<Loader />} show={<Loader />}
@ -276,8 +299,13 @@ const FormTemplate: React.FC<ICreateProps> = ({
description={description} description={description}
documentationLink={documentationLink} documentationLink={documentationLink}
documentationLinkLabel={documentationLinkLabel} documentationLinkLabel={documentationLinkLabel}
showDescription={showDescription}
showLink={showLink}
> >
{renderApiInfo(formatApiCode === undefined)} {renderApiInfo(
formatApiCode === undefined,
!(showDescription || showLink)
)}
</Guidance> </Guidance>
} }
/> />
@ -303,10 +331,10 @@ const MobileGuidance = ({
<StyledMobileGuidanceContainer> <StyledMobileGuidanceContainer>
<StyledMobileGuidanceBackground /> <StyledMobileGuidanceBackground />
</StyledMobileGuidanceContainer> </StyledMobileGuidanceContainer>
<Tooltip title='Toggle help' arrow> <Tooltip title="Toggle help" arrow>
<StyledMobileGuidanceButton <StyledMobileGuidanceButton
onClick={() => setOpen((prev) => !prev)} onClick={() => setOpen((prev) => !prev)}
size='large' size="large"
> >
<StyledInfoIcon /> <StyledInfoIcon />
</StyledMobileGuidanceButton> </StyledMobileGuidanceButton>
@ -326,28 +354,40 @@ interface IGuidanceProps {
description: string; description: string;
documentationLink: string; documentationLink: string;
documentationLinkLabel?: string; documentationLinkLabel?: string;
showDescription?: boolean;
showLink?: boolean;
} }
const Guidance: React.FC<IGuidanceProps> = ({ const Guidance: React.FC<IGuidanceProps> = ({
description, description,
children, children,
documentationLink, documentationLink,
documentationLinkLabel = 'Learn more', documentationLinkLabel = "Learn more",
showDescription = true,
showLink = true,
}) => { }) => {
return ( return (
<StyledSidebar> <StyledSidebar>
<StyledDescription>{description}</StyledDescription> <ConditionallyRender
condition={showDescription}
show={<StyledDescription>{description}</StyledDescription>}
/>
<StyledLinkContainer> <ConditionallyRender
<StyledLinkIcon /> condition={showLink}
<StyledDocumentationLink show={
href={documentationLink} <StyledLinkContainer>
rel='noopener noreferrer' <StyledLinkIcon />
target='_blank' <StyledDocumentationLink
> href={documentationLink}
{documentationLinkLabel} rel="noopener noreferrer"
</StyledDocumentationLink> target="_blank"
</StyledLinkContainer> >
{documentationLinkLabel}
</StyledDocumentationLink>
</StyledLinkContainer>
}
/>
{children} {children}
</StyledSidebar> </StyledSidebar>

View File

@ -26,15 +26,6 @@ exports[`returns all baseRoutes 1`] = `
"title": "Create", "title": "Create",
"type": "protected", "type": "protected",
}, },
{
"component": [Function],
"enterprise": true,
"menu": {},
"parent": "/projects",
"path": "/projects/:projectId/edit",
"title": ":projectId",
"type": "protected",
},
{ {
"component": [Function], "component": [Function],
"menu": {}, "menu": {},

View File

@ -15,7 +15,6 @@ import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironm
import { EditContext } from 'component/context/EditContext/EditContext'; import { EditContext } from 'component/context/EditContext/EditContext';
import EditTagType from 'component/tags/EditTagType/EditTagType'; import EditTagType from 'component/tags/EditTagType/EditTagType';
import CreateTagType from 'component/tags/CreateTagType/CreateTagType'; import CreateTagType from 'component/tags/CreateTagType/CreateTagType';
import EditProject from 'component/project/Project/EditProject/EditProject';
import CreateFeature from 'component/feature/CreateFeature/CreateFeature'; import CreateFeature from 'component/feature/CreateFeature/CreateFeature';
import EditFeature from 'component/feature/EditFeature/EditFeature'; import EditFeature from 'component/feature/EditFeature/EditFeature';
import { ApplicationEdit } from 'component/application/ApplicationEdit/ApplicationEdit'; import { ApplicationEdit } from 'component/application/ApplicationEdit/ApplicationEdit';
@ -68,15 +67,6 @@ export const routes: IRoute[] = [
enterprise: true, enterprise: true,
menu: {}, menu: {},
}, },
{
path: '/projects/:projectId/edit',
parent: '/projects',
title: ':projectId',
component: EditProject,
type: 'protected',
enterprise: true,
menu: {},
},
{ {
path: '/projects/:projectId/archived', path: '/projects/:projectId/archived',
title: ':projectId', title: ':projectId',

View File

@ -1,21 +1,21 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import ProjectForm from '../ProjectForm/ProjectForm'; import ProjectForm from "../ProjectForm/ProjectForm";
import useProjectForm, { import useProjectForm, {
DEFAULT_PROJECT_STICKINESS, DEFAULT_PROJECT_STICKINESS,
} from '../hooks/useProjectForm'; } from "../hooks/useProjectForm";
import { CreateButton } from 'component/common/CreateButton/CreateButton'; import { CreateButton } from "component/common/CreateButton/CreateButton";
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from "component/common/FormTemplate/FormTemplate";
import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions'; import { CREATE_PROJECT } from "component/providers/AccessProvider/permissions";
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import useProjectApi from "hooks/api/actions/useProjectApi/useProjectApi";
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; import { useAuthUser } from "hooks/api/getters/useAuth/useAuthUser";
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig";
import useToast from 'hooks/useToast'; import useToast from "hooks/useToast";
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from "utils/formatUnknownError";
import { GO_BACK } from 'constants/navigate'; import { GO_BACK } from "constants/navigate";
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from "hooks/usePlausibleTracker";
import { Button, styled } from '@mui/material'; 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 }) => ({ const StyledButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3), marginLeft: theme.spacing(3),
@ -30,25 +30,17 @@ const CreateProject = () => {
const { const {
projectId, projectId,
projectName, projectName,
projectMode,
projectDesc, projectDesc,
featureLimit, projectMode,
featureNamingPattern, setProjectMode,
featureNamingExample,
featureNamingDescription,
setFeatureNamingExample,
setFeatureNamingPattern,
setFeatureNamingDescription,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
getProjectPayload, getCreateProjectPayload,
clearErrors, clearErrors,
validateProjectId, validateProjectId,
validateName, validateName,
setProjectStickiness, setProjectStickiness,
setFeatureLimit,
setProjectMode,
projectStickiness, projectStickiness,
errors, errors,
} = useProjectForm(); } = useProjectForm();
@ -62,21 +54,24 @@ const CreateProject = () => {
const validId = await validateProjectId(); const validId = await validateProjectId();
if (validName && validId) { if (validName && validId) {
const payload = getProjectPayload(); const payload = getCreateProjectPayload();
try { try {
await createProject(payload); await createProject(payload);
refetchUser(); refetchUser();
navigate(`/projects/${projectId}`); navigate(`/projects/${projectId}`);
setToastData({ setToastData({
title: 'Project created', title: "Project created",
text: 'Now you can add toggles to this project', text: "Now you can add toggles to this project",
confetti: true, confetti: true,
type: 'success', type: "success",
}); });
if (projectStickiness !== DEFAULT_PROJECT_STICKINESS) { if (projectStickiness !== DEFAULT_PROJECT_STICKINESS) {
trackEvent('project_stickiness_set'); trackEvent("project_stickiness_set");
} }
trackEvent("project-mode", {
props: { mode: projectMode, action: "added" },
});
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} }
@ -84,10 +79,12 @@ const CreateProject = () => {
}; };
const formatApiCode = () => { 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 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\ --header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`; --data-raw '${JSON.stringify(getCreateProjectPayload(), undefined, 2)}'`;
}; };
const handleCancel = () => { const handleCancel = () => {
@ -97,10 +94,10 @@ const CreateProject = () => {
return ( return (
<FormTemplate <FormTemplate
loading={loading} loading={loading}
title='Create project' title="Create project"
description='Projects allows you to group feature toggles together in the management UI.' description="Projects allows you to group feature toggles together in the management UI."
documentationLink='https://docs.getunleash.io/reference/projects' documentationLink="https://docs.getunleash.io/reference/projects"
documentationLinkLabel='Projects documentation' documentationLinkLabel="Projects documentation"
formatApiCode={formatApiCode} formatApiCode={formatApiCode}
> >
<ProjectForm <ProjectForm
@ -109,27 +106,19 @@ const CreateProject = () => {
projectId={projectId} projectId={projectId}
setProjectId={setProjectId} setProjectId={setProjectId}
projectName={projectName} projectName={projectName}
projectMode={projectMode}
projectStickiness={projectStickiness} projectStickiness={projectStickiness}
featureLimit={featureLimit} projectMode={projectMode}
featureNamingExample={featureNamingExample}
featureNamingPattern={featureNamingPattern}
setFeatureNamingPattern={setFeatureNamingPattern}
featureNamingDescription={featureNamingDescription}
setFeatureNamingDescription={setFeatureNamingDescription}
setFeatureNamingExample={setFeatureNamingExample}
setProjectStickiness={setProjectStickiness}
setFeatureLimit={setFeatureLimit}
setProjectMode={setProjectMode} setProjectMode={setProjectMode}
setProjectStickiness={setProjectStickiness}
setProjectName={setProjectName} setProjectName={setProjectName}
projectDesc={projectDesc} projectDesc={projectDesc}
setProjectDesc={setProjectDesc} setProjectDesc={setProjectDesc}
mode='Create' mode="Create"
clearErrors={clearErrors} clearErrors={clearErrors}
validateProjectId={validateProjectId} validateProjectId={validateProjectId}
> >
<CreateButton <CreateButton
name='project' name="project"
permission={CREATE_PROJECT} permission={CREATE_PROJECT}
data-testid={CREATE_PROJECT_BTN} data-testid={CREATE_PROJECT_BTN}
/> />

View File

@ -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;

View File

@ -1,20 +1,20 @@
import { Box, styled, Typography } from '@mui/material'; import { Box, styled, Typography } from "@mui/material";
import { FC } from 'react'; import { FC } from "react";
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; import { HelpIcon } from "component/common/HelpIcon/HelpIcon";
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from "hooks/useUiFlag";
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender";
const StyledTitle = styled(Typography)(({ theme }) => ({ const StyledTitle = styled(Typography)(({ theme }) => ({
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
display: ' inline', display: "inline",
})); }));
const StyledDescription = styled(Typography)(({ theme }) => ({ const StyledDescription = styled(Typography)(({ theme }) => ({
display: ' inline', display: "inline",
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
})); }));
export const CollaborationModeTooltip: FC = () => { export const CollaborationModeTooltip: FC = () => {
const privateProjects = useUiFlag('privateProjects'); const privateProjects = useUiFlag("privateProjects");
return ( return (
<HelpIcon <HelpIcon
htmlTooltip htmlTooltip

View File

@ -1,6 +1,6 @@
import { Box } from '@mui/material'; import { Box } from "@mui/material";
import { FC } from 'react'; import { FC } from "react";
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; import { HelpIcon } from "component/common/HelpIcon/HelpIcon";
export const FeatureFlagNamingTooltip: FC = () => { export const FeatureFlagNamingTooltip: FC = () => {
return ( return (
@ -9,8 +9,8 @@ export const FeatureFlagNamingTooltip: FC = () => {
tooltip={ tooltip={
<Box> <Box>
<p> <p>
For example, the pattern{' '} For example, the pattern{" "}
<code>{'[a-z0-9]{2}\\.[a-z]{4,12}'}</code> matches <code>{"[a-z0-9]{2}\\.[a-z]{4,12}"}</code> matches
'a1.project', but not 'a1.project.feature-1'. 'a1.project', but not 'a1.project.feature-1'.
</p> </p>
</Box> </Box>

View File

@ -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 dont 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;

View File

@ -1,4 +1,4 @@
import { validateFeatureNamingExample } from './ProjectForm'; import { validateFeatureNamingExample } from './ProjectEnterpriseSettingsForm';
describe('validateFeatureNaming', () => { describe('validateFeatureNaming', () => {
test.each(['+', 'valid regex$'])( test.each(['+', 'valid regex$'])(

View File

@ -1,61 +1,58 @@
import React, { useEffect } from 'react'; import React from "react";
import { trim } from 'component/common/util'; import { trim } from "component/common/util";
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { StickinessSelect } from "component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect";
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender";
import Select from 'component/common/select'; import { Box, styled, TextField } from "@mui/material";
import { ProjectMode } from '../hooks/useProjectForm'; import Input from "component/common/Input/Input";
import { Box, InputAdornment, styled, TextField } from '@mui/material'; import { FeatureTogglesLimitTooltip } from "./FeatureTogglesLimitTooltip";
import { CollaborationModeTooltip } from './CollaborationModeTooltip'; import { ProjectMode } from "../hooks/useProjectEnterpriseSettingsForm";
import Input from 'component/common/Input/Input'; import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig";
import { FeatureTogglesLimitTooltip } from './FeatureTogglesLimitTooltip'; import { CollaborationModeTooltip } from "../ProjectEnterpriseSettingsForm/CollaborationModeTooltip";
import { FeatureFlagNamingTooltip } from './FeatureFlagNamingTooltip'; import Select from "component/common/select";
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useUiFlag } from "hooks/useUiFlag";
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUiFlag } from 'hooks/useUiFlag';
interface IProjectForm { interface IProjectForm {
projectId: string; projectId: string;
projectName: string; projectName: string;
projectDesc: string; projectDesc: string;
projectStickiness?: string; projectStickiness?: string;
projectMode?: string; featureLimit?: string;
featureLimit: string;
featureCount?: number; featureCount?: number;
featureNamingPattern?: string; projectMode?: string;
featureNamingExample?: string;
featureNamingDescription?: string;
setFeatureNamingPattern?: React.Dispatch<React.SetStateAction<string>>;
setFeatureNamingExample?: React.Dispatch<React.SetStateAction<string>>;
setFeatureNamingDescription?: React.Dispatch<React.SetStateAction<string>>;
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>; setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>;
setProjectId: React.Dispatch<React.SetStateAction<string>>; setProjectId: React.Dispatch<React.SetStateAction<string>>;
setProjectName: React.Dispatch<React.SetStateAction<string>>; setProjectName: React.Dispatch<React.SetStateAction<string>>;
setProjectDesc: 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; handleSubmit: (e: any) => void;
errors: { [key: string]: string }; errors: { [key: string]: string };
mode: 'Create' | 'Edit'; mode: "Create" | "Edit";
clearErrors: () => void; clearErrors: () => void;
validateProjectId: () => void; validateProjectId: () => void;
} }
const PROJECT_STICKINESS_SELECT = 'PROJECT_STICKINESS_SELECT'; const PROJECT_STICKINESS_SELECT = "PROJECT_STICKINESS_SELECT";
const PROJECT_ID_INPUT = 'PROJECT_ID_INPUT'; const PROJECT_ID_INPUT = "PROJECT_ID_INPUT";
const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT'; const PROJECT_NAME_INPUT = "PROJECT_NAME_INPUT";
const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_INPUT'; const PROJECT_DESCRIPTION_INPUT = "PROJECT_DESCRIPTION_INPUT";
const StyledForm = styled('form')(({ theme }) => ({ const StyledForm = styled("form")(({ theme }) => ({
height: '100%', height: "100%",
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(1),
})); }));
const StyledDescription = styled('p')(({ theme }) => ({ const StyledDescription = styled("p")(({ theme }) => ({
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
marginRight: 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, color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody, fontSize: theme.fontSizes.smallerBody,
lineHeight: 1.25, lineHeight: 1.25,
@ -63,93 +60,27 @@ const StyledSubtitle = styled('div')(({ theme }) => ({
})); }));
const StyledInput = styled(Input)(({ theme }) => ({ const StyledInput = styled(Input)(({ theme }) => ({
width: '100%', width: "100%",
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
paddingRight: theme.spacing(1), paddingRight: theme.spacing(1),
})); }));
const StyledTextField = styled(TextField)(({ theme }) => ({ const StyledTextField = styled(TextField)(({ theme }) => ({
width: '100%', width: "100%",
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
})); }));
const StyledFieldset = styled('fieldset')(() => ({ const StyledButtonContainer = styled("div")(() => ({
padding: 0, marginTop: "auto",
border: 'none', display: "flex",
justifyContent: "flex-end",
})); }));
const StyledSelect = styled(Select)(({ theme }) => ({ const StyledInputContainer = styled("div")(() => ({
marginBottom: theme.spacing(2), display: "flex",
minWidth: '200px', 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> = ({ const ProjectForm: React.FC<IProjectForm> = ({
children, children,
handleSubmit, handleSubmit,
@ -157,133 +88,50 @@ const ProjectForm: React.FC<IProjectForm> = ({
projectName, projectName,
projectDesc, projectDesc,
projectStickiness, projectStickiness,
projectMode,
featureLimit, featureLimit,
featureCount, featureCount,
featureNamingExample, projectMode,
featureNamingPattern, setProjectMode,
featureNamingDescription,
setFeatureNamingExample,
setFeatureNamingPattern,
setFeatureNamingDescription,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
setProjectStickiness, setProjectStickiness,
setProjectMode,
setFeatureLimit, setFeatureLimit,
errors, errors,
mode, mode,
validateProjectId, validateProjectId,
clearErrors, clearErrors,
}) => { }) => {
const { uiConfig } = useUiConfig(); const { isEnterprise } = useUiConfig();
const shouldShowFlagNaming = uiConfig.flags.featureNamingPattern; const privateProjects = useUiFlag("privateProjects");
const { setPreviousPattern, trackPattern } =
useFeatureNamePatternTracking();
const privateProjects = useUiFlag('privateProjects');
const projectModeOptions = privateProjects const projectModeOptions = privateProjects
? [ ? [
{ key: 'open', label: 'open' }, { key: "open", label: "open" },
{ key: 'protected', label: 'protected' }, { key: "protected", label: "protected" },
{ key: 'private', label: 'private' }, { key: "private", label: "private" },
] ]
: [ : [
{ key: 'open', label: 'open' }, { key: "open", label: "open" },
{ key: 'protected', label: 'protected' }, { 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 ( return (
<StyledForm <StyledForm
onSubmit={(submitEvent) => { onSubmit={(submitEvent) => {
handleSubmit(submitEvent); handleSubmit(submitEvent);
trackPattern(featureNamingPattern);
}} }}
> >
<StyledDescription>What is your project Id?</StyledDescription> <StyledDescription>What is your project Id?</StyledDescription>
<StyledInput <StyledInput
label='Project Id' label="Project Id"
value={projectId} value={projectId}
onChange={(e) => setProjectId(trim(e.target.value))} onChange={(e) => setProjectId(trim(e.target.value))}
error={Boolean(errors.id)} error={Boolean(errors.id)}
errorText={errors.id} errorText={errors.id}
onFocus={() => clearErrors()} onFocus={() => clearErrors()}
onBlur={validateProjectId} onBlur={validateProjectId}
disabled={mode === 'Edit'} disabled={mode === "Edit"}
data-testid={PROJECT_ID_INPUT} data-testid={PROJECT_ID_INPUT}
autoFocus autoFocus
required required
@ -291,7 +139,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
<StyledDescription>What is your project name?</StyledDescription> <StyledDescription>What is your project name?</StyledDescription>
<StyledInput <StyledInput
label='Project name' label="Project name"
value={projectName} value={projectName}
onChange={(e) => setProjectName(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
error={Boolean(errors.name)} error={Boolean(errors.name)}
@ -307,8 +155,8 @@ const ProjectForm: React.FC<IProjectForm> = ({
What is your project description? What is your project description?
</StyledDescription> </StyledDescription>
<StyledTextField <StyledTextField
label='Project description' label="Project description"
variant='outlined' variant="outlined"
multiline multiline
maxRows={4} maxRows={4}
value={projectDesc} value={projectDesc}
@ -324,7 +172,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
What is the default stickiness for the project? What is the default stickiness for the project?
</StyledDescription> </StyledDescription>
<StickinessSelect <StickinessSelect
label='Stickiness' label="Stickiness"
value={projectStickiness} value={projectStickiness}
data-testid={PROJECT_STICKINESS_SELECT} data-testid={PROJECT_STICKINESS_SELECT}
onChange={(e) => 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 dont 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 <ConditionallyRender
condition={Boolean(shouldShowFlagNaming)} condition={mode === "Edit" && Boolean(setFeatureLimit)}
show={ show={
<StyledFieldset> <>
<Box <Box
sx={{ sx={{
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
marginBottom: 1, marginBottom: 1,
gap: 1, gap: 1,
}} }}
> >
<legend>Feature flag naming pattern?</legend> <p>Feature flag limit?</p>
<FeatureFlagNamingTooltip /> <FeatureTogglesLimitTooltip />
</Box> </Box>
<StyledSubtitle> <StyledSubtitle>
<StyledPatternNamingExplanation id='pattern-naming-description'> Leave it empty if you dont want to add a limit
<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 dont want to add a
naming pattern.
</p>
</StyledPatternNamingExplanation>
</StyledSubtitle> </StyledSubtitle>
<StyledFlagNamingContainer> <StyledInputContainer>
<StyledInput {featureLimit && setFeatureLimit && (
label={'Naming Pattern'} <StyledInput
name='feature flag naming pattern' label={"Limit"}
aria-describedby='pattern-naming-description' name="value"
placeholder='[A-Za-z]+.[A-Za-z]+.[A-Za-z0-9-]+' type={"number"}
InputProps={{ value={featureLimit}
startAdornment: ( onChange={(e) =>
<InputAdornment position='start'> setFeatureLimit(e.target.value)
^ }
</InputAdornment> />
), )}
endAdornment: ( <ConditionallyRender
<InputAdornment position='end'> condition={
$ featureCount !== undefined &&
</InputAdornment> Boolean(featureLimit)
), }
}} show={
type={'text'} <Box>
value={featureNamingPattern || ''} ({featureCount} of {featureLimit} used)
error={Boolean(errors.featureNamingPattern)} </Box>
errorText={errors.featureNamingPattern}
onChange={(e) =>
onSetFeatureNamingPattern(e.target.value)
} }
/> />
<StyledSubtitle> </StyledInputContainer>
<p id='pattern-additional-description'> </>
The example and description will be shown to }
users when they create a new feature flag in />
this project. <ConditionallyRender
</p> condition={mode === "Create" && isEnterprise()}
</StyledSubtitle> show={
<>
<StyledInput <Box
label={'Naming Example'} sx={{
name='feature flag naming example' display: "flex",
type={'text'} alignItems: "center",
aria-describedby='pattern-additional-description' marginBottom: 1,
value={featureNamingExample || ''} gap: 1,
placeholder='dx.feature1.1-135' }}
error={Boolean(errors.namingExample)} >
errorText={errors.namingExample} <p>What is your project collaboration mode?</p>
onChange={(e) => <CollaborationModeTooltip />
onSetFeatureNamingExample(e.target.value) </Box>
} <StyledSelect
/> id="project-mode"
<StyledTextField value={projectMode}
label={'Naming pattern description'} label="Project collaboration mode"
name='feature flag naming description' name="Project collaboration mode"
type={'text'} onChange={(e) => {
aria-describedby='pattern-additional-description' setProjectMode?.(e.target.value as ProjectMode);
placeholder={`<project>.<featureName>.<ticket> }}
options={projectModeOptions}
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> <StyledButtonContainer>{children}</StyledButtonContainer>

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -7,7 +7,7 @@ import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject'; 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 { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';

View File

@ -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;

View File

@ -1,41 +1,29 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { formatUnknownError } from 'utils/formatUnknownError'; 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'; export const DEFAULT_PROJECT_STICKINESS = 'default';
const useProjectForm = ( const useProjectForm = (
initialProjectId = '', initialProjectId = '',
initialProjectName = '', initialProjectName = '',
initialProjectDesc = '', initialProjectDesc = '',
initialProjectStickiness = DEFAULT_PROJECT_STICKINESS, initialProjectStickiness = DEFAULT_PROJECT_STICKINESS,
initialProjectMode: ProjectMode = 'open',
initialFeatureLimit = '', initialFeatureLimit = '',
initialFeatureNamingPattern = '', initialProjectMode: ProjectMode = 'open',
initialFeatureNamingExample = '',
initialFeatureNamingDescription = '',
) => { ) => {
const { isEnterprise } = useUiConfig();
const [projectId, setProjectId] = useState(initialProjectId); const [projectId, setProjectId] = useState(initialProjectId);
const [projectMode, setProjectMode] =
useState<ProjectMode>(initialProjectMode);
const [projectName, setProjectName] = useState(initialProjectName); const [projectName, setProjectName] = useState(initialProjectName);
const [projectDesc, setProjectDesc] = useState(initialProjectDesc); const [projectDesc, setProjectDesc] = useState(initialProjectDesc);
const [projectStickiness, setProjectStickiness] = useState<string>( const [projectStickiness, setProjectStickiness] = useState<string>(
initialProjectStickiness, initialProjectStickiness,
); );
const [projectMode, setProjectMode] =
useState<ProjectMode>(initialProjectMode);
const [featureLimit, setFeatureLimit] = const [featureLimit, setFeatureLimit] =
useState<string>(initialFeatureLimit); useState<string>(initialFeatureLimit);
const [featureNamingPattern, setFeatureNamingPattern] = useState(
initialFeatureNamingPattern,
);
const [featureNamingExample, setFeatureNamingExample] = useState(
initialFeatureNamingExample,
);
const [featureNamingDescription, setFeatureNamingDescription] = useState(
initialFeatureNamingDescription,
);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
@ -53,43 +41,42 @@ const useProjectForm = (
setProjectDesc(initialProjectDesc); setProjectDesc(initialProjectDesc);
}, [initialProjectDesc]); }, [initialProjectDesc]);
useEffect(() => {
setProjectMode(initialProjectMode);
}, [initialProjectMode]);
useEffect(() => { useEffect(() => {
setFeatureLimit(initialFeatureLimit); setFeatureLimit(initialFeatureLimit);
}, [initialFeatureLimit]); }, [initialFeatureLimit]);
useEffect(() => {
setFeatureNamingPattern(initialFeatureNamingPattern);
}, [initialFeatureNamingPattern]);
useEffect(() => {
setFeatureNamingExample(initialFeatureNamingExample);
}, [initialFeatureNamingExample]);
useEffect(() => {
setFeatureNamingDescription(initialFeatureNamingDescription);
}, [initialFeatureNamingDescription]);
useEffect(() => { useEffect(() => {
setProjectStickiness(initialProjectStickiness); setProjectStickiness(initialProjectStickiness);
}, [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 { return {
id: projectId, id: projectId,
name: projectName, name: projectName,
description: projectDesc, description: projectDesc,
defaultStickiness: projectStickiness, defaultStickiness: projectStickiness,
featureLimit: getFeatureLimitAsNumber(), featureLimit: getFeatureLimitAsNumber(),
mode: projectMode,
featureNaming: {
pattern: featureNamingPattern,
example: featureNamingExample,
description: featureNamingDescription,
},
}; };
}; };
@ -106,7 +93,7 @@ const useProjectForm = (
return false; return false;
} }
try { try {
await validateId(getProjectPayload().id); await validateId(getCreateProjectPayload().id);
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
setErrors((prev) => ({ ...prev, id: formatUnknownError(error) })); setErrors((prev) => ({ ...prev, id: formatUnknownError(error) }));
@ -131,22 +118,17 @@ const useProjectForm = (
projectId, projectId,
projectName, projectName,
projectDesc, projectDesc,
projectStickiness,
projectMode, projectMode,
projectStickiness,
featureLimit, featureLimit,
featureNamingPattern,
featureNamingExample,
featureNamingDescription,
setFeatureNamingPattern,
setFeatureNamingExample,
setFeatureNamingDescription,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
setProjectStickiness, setProjectStickiness,
setProjectMode,
setFeatureLimit, setFeatureLimit,
getProjectPayload, setProjectMode,
getCreateProjectPayload,
getEditProjectPayload,
validateName, validateName,
validateProjectId, validateProjectId,
clearErrors, clearErrors,

View File

@ -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 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 { interface IAccessPayload {
roles: number[]; roles: number[];
@ -21,41 +18,63 @@ const useProjectApi = () => {
propagateErrors: true, propagateErrors: true,
}); });
const createProject = async (payload: ICreatePayload) => { const createProject = async (payload: CreateProjectSchema) => {
const path = `api/admin/projects`; const path = `api/admin/projects`;
const req = createRequest(path, { const req = createRequest(path, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), 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 path = `api/admin/projects/validate`;
const req = createRequest(path, { const req = createRequest(path, {
method: 'POST', method: 'POST',
body: JSON.stringify({ id }), 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 path = `api/admin/projects/${id}`;
const req = createRequest(path, { const req = createRequest(path, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(payload), 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 deleteProject = async (projectId: string) => {
const path = `api/admin/projects/${projectId}`; const path = `api/admin/projects/${projectId}`;
const req = createRequest(path, { method: 'DELETE' }); 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 ( const addEnvironmentToProject = async (
@ -68,7 +87,9 @@ const useProjectApi = () => {
body: JSON.stringify({ environment }), body: JSON.stringify({ environment }),
}); });
return makeRequest(req.caller, req.id); const res = await makeRequest(req.caller, req.id);
return res;
}; };
const removeEnvironmentFromProject = async ( const removeEnvironmentFromProject = async (
@ -78,7 +99,9 @@ const useProjectApi = () => {
const path = `api/admin/projects/${projectId}/environments/${environment}`; const path = `api/admin/projects/${projectId}/environments/${environment}`;
const req = createRequest(path, { method: 'DELETE' }); 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 ( const addAccessToProject = async (
@ -91,21 +114,21 @@ const useProjectApi = () => {
body: JSON.stringify(payload), 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 removeUserAccess = async (projectId: string, userId: number) => {
const path = `api/admin/projects/${projectId}/users/${userId}/roles`; const path = `api/admin/projects/${projectId}/users/${userId}/roles`;
const req = createRequest(path, { method: 'DELETE' }); 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 removeGroupAccess = async (projectId: string, groupId: number) => {
const path = `api/admin/projects/${projectId}/groups/${groupId}/roles`; const path = `api/admin/projects/${projectId}/groups/${groupId}/roles`;
const req = createRequest(path, { method: 'DELETE' }); const req = createRequest(path, { method: 'DELETE' });
return makeRequest(req.caller, req.id); return await makeRequest(req.caller, req.id);
}; };
const setUserRoles = ( const setUserRoles = (
@ -212,6 +235,7 @@ const useProjectApi = () => {
createProject, createProject,
validateId, validateId,
editProject, editProject,
editProjectSettings,
deleteProject, deleteProject,
addEnvironmentToProject, addEnvironmentToProject,
removeEnvironmentFromProject, removeEnvironmentFromProject,

View File

@ -47,7 +47,8 @@ export type CustomEvents =
| 'search-filter-suggestions' | 'search-filter-suggestions'
| 'project-metrics' | 'project-metrics'
| 'open-integration' | 'open-integration'
| 'feature-naming-pattern'; | 'feature-naming-pattern'
| 'project-mode';
export const usePlausibleTracker = () => { export const usePlausibleTracker = () => {
const plausible = useContext(PlausibleContext); const plausible = useContext(PlausibleContext);

View File

@ -1,7 +1,7 @@
import { ProjectStatsSchema } from 'openapi'; import { ProjectStatsSchema } from 'openapi';
import { IFeatureToggleListItem } from './featureToggle'; import { IFeatureToggleListItem } from './featureToggle';
import { ProjectEnvironmentType } from 'component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef'; import { ProjectEnvironmentType } from 'component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef';
import { ProjectMode } from 'component/project/Project/hooks/useProjectForm'; import { ProjectMode } from 'component/project/Project/hooks/useProjectEnterpriseSettingsForm';
export interface IProjectCard { export interface IProjectCard {
name: string; name: string;