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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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', () => {
test.each(['+', 'valid regex$'])(

View File

@ -1,61 +1,58 @@
import React, { useEffect } from 'react';
import { trim } from 'component/common/util';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Select from 'component/common/select';
import { ProjectMode } from '../hooks/useProjectForm';
import { Box, InputAdornment, styled, TextField } from '@mui/material';
import { CollaborationModeTooltip } from './CollaborationModeTooltip';
import Input from 'component/common/Input/Input';
import { FeatureTogglesLimitTooltip } from './FeatureTogglesLimitTooltip';
import { FeatureFlagNamingTooltip } from './FeatureFlagNamingTooltip';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUiFlag } from 'hooks/useUiFlag';
import React from "react";
import { trim } from "component/common/util";
import { StickinessSelect } from "component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect";
import { ConditionallyRender } from "component/common/ConditionallyRender/ConditionallyRender";
import { Box, styled, TextField } from "@mui/material";
import Input from "component/common/Input/Input";
import { FeatureTogglesLimitTooltip } from "./FeatureTogglesLimitTooltip";
import { ProjectMode } from "../hooks/useProjectEnterpriseSettingsForm";
import useUiConfig from "hooks/api/getters/useUiConfig/useUiConfig";
import { CollaborationModeTooltip } from "../ProjectEnterpriseSettingsForm/CollaborationModeTooltip";
import Select from "component/common/select";
import { useUiFlag } from "hooks/useUiFlag";
interface IProjectForm {
projectId: string;
projectName: string;
projectDesc: string;
projectStickiness?: string;
projectMode?: string;
featureLimit: string;
featureLimit?: string;
featureCount?: number;
featureNamingPattern?: string;
featureNamingExample?: string;
featureNamingDescription?: string;
setFeatureNamingPattern?: React.Dispatch<React.SetStateAction<string>>;
setFeatureNamingExample?: React.Dispatch<React.SetStateAction<string>>;
setFeatureNamingDescription?: React.Dispatch<React.SetStateAction<string>>;
projectMode?: string;
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>;
setProjectId: React.Dispatch<React.SetStateAction<string>>;
setProjectName: React.Dispatch<React.SetStateAction<string>>;
setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
setFeatureLimit: React.Dispatch<React.SetStateAction<string>>;
setFeatureLimit?: React.Dispatch<React.SetStateAction<string>>;
setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>;
handleSubmit: (e: any) => void;
errors: { [key: string]: string };
mode: 'Create' | 'Edit';
mode: "Create" | "Edit";
clearErrors: () => void;
validateProjectId: () => void;
}
const PROJECT_STICKINESS_SELECT = 'PROJECT_STICKINESS_SELECT';
const PROJECT_ID_INPUT = 'PROJECT_ID_INPUT';
const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT';
const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_INPUT';
const PROJECT_STICKINESS_SELECT = "PROJECT_STICKINESS_SELECT";
const PROJECT_ID_INPUT = "PROJECT_ID_INPUT";
const PROJECT_NAME_INPUT = "PROJECT_NAME_INPUT";
const PROJECT_DESCRIPTION_INPUT = "PROJECT_DESCRIPTION_INPUT";
const StyledForm = styled('form')(({ theme }) => ({
height: '100%',
paddingBottom: theme.spacing(4),
const StyledForm = styled("form")(({ theme }) => ({
height: "100%",
paddingBottom: theme.spacing(1),
}));
const StyledDescription = styled('p')(({ theme }) => ({
const StyledDescription = styled("p")(({ theme }) => ({
marginBottom: theme.spacing(1),
marginRight: theme.spacing(1),
}));
const StyledSubtitle = styled('div')(({ theme }) => ({
const StyledSelect = styled(Select)(({ theme }) => ({
marginBottom: theme.spacing(2),
minWidth: "200px",
}));
const StyledSubtitle = styled("div")(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody,
lineHeight: 1.25,
@ -63,93 +60,27 @@ const StyledSubtitle = styled('div')(({ theme }) => ({
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
width: "100%",
marginBottom: theme.spacing(2),
paddingRight: theme.spacing(1),
}));
const StyledTextField = styled(TextField)(({ theme }) => ({
width: '100%',
width: "100%",
marginBottom: theme.spacing(2),
}));
const StyledFieldset = styled('fieldset')(() => ({
padding: 0,
border: 'none',
const StyledButtonContainer = styled("div")(() => ({
marginTop: "auto",
display: "flex",
justifyContent: "flex-end",
}));
const StyledSelect = styled(Select)(({ theme }) => ({
marginBottom: theme.spacing(2),
minWidth: '200px',
const StyledInputContainer = styled("div")(() => ({
display: "flex",
alignItems: "center",
}));
const StyledButtonContainer = styled('div')(() => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
}));
const StyledInputContainer = styled('div')(() => ({
display: 'flex',
alignItems: 'center',
}));
const StyledFlagNamingContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
'& > *': { width: '100%' },
}));
const StyledPatternNamingExplanation = styled('div')(({ theme }) => ({
'p + p': { marginTop: theme.spacing(1) },
}));
export const validateFeatureNamingExample = ({
pattern,
example,
featureNamingPatternError,
}: {
pattern: string;
example: string;
featureNamingPatternError: string | undefined;
}): { state: 'valid' } | { state: 'invalid'; reason: string } => {
if (featureNamingPatternError || !example || !pattern) {
return { state: 'valid' };
} else if (example && pattern) {
const regex = new RegExp(`^${pattern}$`);
const matches = regex.test(example);
if (!matches) {
return { state: 'invalid', reason: 'Example does not match regex' };
} else {
return { state: 'valid' };
}
}
return { state: 'valid' };
};
const useFeatureNamePatternTracking = () => {
const [previousPattern, setPreviousPattern] = React.useState<string>('');
const { trackEvent } = usePlausibleTracker();
const eventName = 'feature-naming-pattern' as const;
const trackPattern = (pattern: string = '') => {
if (pattern === previousPattern) {
// do nothing; they've probably updated something else in the
// project.
} else if (pattern === '' && previousPattern !== '') {
trackEvent(eventName, { props: { action: 'removed' } });
} else if (pattern !== '' && previousPattern === '') {
trackEvent(eventName, { props: { action: 'added' } });
} else if (pattern !== '' && previousPattern !== '') {
trackEvent(eventName, { props: { action: 'edited' } });
}
};
return { trackPattern, setPreviousPattern };
};
const ProjectForm: React.FC<IProjectForm> = ({
children,
handleSubmit,
@ -157,133 +88,50 @@ const ProjectForm: React.FC<IProjectForm> = ({
projectName,
projectDesc,
projectStickiness,
projectMode,
featureLimit,
featureCount,
featureNamingExample,
featureNamingPattern,
featureNamingDescription,
setFeatureNamingExample,
setFeatureNamingPattern,
setFeatureNamingDescription,
projectMode,
setProjectMode,
setProjectId,
setProjectName,
setProjectDesc,
setProjectStickiness,
setProjectMode,
setFeatureLimit,
errors,
mode,
validateProjectId,
clearErrors,
}) => {
const { uiConfig } = useUiConfig();
const shouldShowFlagNaming = uiConfig.flags.featureNamingPattern;
const { setPreviousPattern, trackPattern } =
useFeatureNamePatternTracking();
const privateProjects = useUiFlag('privateProjects');
const { isEnterprise } = useUiConfig();
const privateProjects = useUiFlag("privateProjects");
const projectModeOptions = privateProjects
? [
{ key: 'open', label: 'open' },
{ key: 'protected', label: 'protected' },
{ key: 'private', label: 'private' },
{ key: "open", label: "open" },
{ key: "protected", label: "protected" },
{ key: "private", label: "private" },
]
: [
{ key: 'open', label: 'open' },
{ key: 'protected', label: 'protected' },
{ key: "open", label: "open" },
{ key: "protected", label: "protected" },
];
useEffect(() => {
setPreviousPattern(featureNamingPattern || '');
}, [projectId]);
const updateNamingExampleError = ({
example,
pattern,
}: {
example: string;
pattern: string;
}) => {
const validationResult = validateFeatureNamingExample({
pattern,
example,
featureNamingPatternError: errors.featureNamingPattern,
});
switch (validationResult.state) {
case 'invalid':
errors.namingExample = validationResult.reason;
break;
case 'valid':
delete errors.namingExample;
break;
}
};
const onSetFeatureNamingPattern = (regex: string) => {
const disallowedStrings = [
' ',
'\\t',
'\\s',
'\\n',
'\\r',
'\\f',
'\\v',
];
if (
disallowedStrings.some((blockedString) =>
regex.includes(blockedString),
)
) {
errors.featureNamingPattern =
'Whitespace is not allowed in the expression';
} else {
try {
new RegExp(regex);
delete errors.featureNamingPattern;
} catch (e) {
errors.featureNamingPattern = 'Invalid regular expression';
}
}
setFeatureNamingPattern?.(regex);
updateNamingExampleError({
pattern: regex,
example: featureNamingExample || '',
});
};
const onSetFeatureNamingExample = (example: string) => {
setFeatureNamingExample?.(example);
updateNamingExampleError({
pattern: featureNamingPattern || '',
example,
});
};
const onSetFeatureNamingDescription = (description: string) => {
setFeatureNamingDescription?.(description);
};
return (
<StyledForm
onSubmit={(submitEvent) => {
handleSubmit(submitEvent);
trackPattern(featureNamingPattern);
}}
>
<StyledDescription>What is your project Id?</StyledDescription>
<StyledInput
label='Project Id'
label="Project Id"
value={projectId}
onChange={(e) => setProjectId(trim(e.target.value))}
error={Boolean(errors.id)}
errorText={errors.id}
onFocus={() => clearErrors()}
onBlur={validateProjectId}
disabled={mode === 'Edit'}
disabled={mode === "Edit"}
data-testid={PROJECT_ID_INPUT}
autoFocus
required
@ -291,7 +139,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
<StyledDescription>What is your project name?</StyledDescription>
<StyledInput
label='Project name'
label="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
error={Boolean(errors.name)}
@ -307,8 +155,8 @@ const ProjectForm: React.FC<IProjectForm> = ({
What is your project description?
</StyledDescription>
<StyledTextField
label='Project description'
variant='outlined'
label="Project description"
variant="outlined"
multiline
maxRows={4}
value={projectDesc}
@ -324,7 +172,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
What is the default stickiness for the project?
</StyledDescription>
<StickinessSelect
label='Stickiness'
label="Stickiness"
value={projectStickiness}
data-testid={PROJECT_STICKINESS_SELECT}
onChange={(e) =>
@ -335,167 +183,77 @@ const ProjectForm: React.FC<IProjectForm> = ({
</>
}
/>
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
marginBottom: 1,
gap: 1,
}}
>
<p>What is your project collaboration mode?</p>
<CollaborationModeTooltip />
</Box>
<StyledSelect
id='project-mode'
value={projectMode}
label='Project collaboration mode'
name='Project collaboration mode'
onChange={(e) => {
setProjectMode?.(e.target.value as ProjectMode);
}}
options={projectModeOptions}
/>
</>
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
marginBottom: 1,
gap: 1,
}}
>
<p>Feature flag limit?</p>
<FeatureTogglesLimitTooltip />
</Box>
<StyledSubtitle>
Leave it empty if you 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
condition={Boolean(shouldShowFlagNaming)}
condition={mode === "Edit" && Boolean(setFeatureLimit)}
show={
<StyledFieldset>
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
marginBottom: 1,
gap: 1,
}}
>
<legend>Feature flag naming pattern?</legend>
<FeatureFlagNamingTooltip />
<p>Feature flag limit?</p>
<FeatureTogglesLimitTooltip />
</Box>
<StyledSubtitle>
<StyledPatternNamingExplanation id='pattern-naming-description'>
<p>
Define a{' '}
<a
href={`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Cheatsheet`}
target='_blank'
rel='noreferrer'
>
JavaScript RegEx
</a>{' '}
used to enforce feature flag names within
this project. The regex will be surrounded
by a leading <code>^</code> and a trailing{' '}
<code>$</code>.
</p>
<p>
Leave it empty if you dont want to add a
naming pattern.
</p>
</StyledPatternNamingExplanation>
Leave it empty if you dont want to add a limit
</StyledSubtitle>
<StyledFlagNamingContainer>
<StyledInput
label={'Naming Pattern'}
name='feature flag naming pattern'
aria-describedby='pattern-naming-description'
placeholder='[A-Za-z]+.[A-Za-z]+.[A-Za-z0-9-]+'
InputProps={{
startAdornment: (
<InputAdornment position='start'>
^
</InputAdornment>
),
endAdornment: (
<InputAdornment position='end'>
$
</InputAdornment>
),
}}
type={'text'}
value={featureNamingPattern || ''}
error={Boolean(errors.featureNamingPattern)}
errorText={errors.featureNamingPattern}
onChange={(e) =>
onSetFeatureNamingPattern(e.target.value)
<StyledInputContainer>
{featureLimit && setFeatureLimit && (
<StyledInput
label={"Limit"}
name="value"
type={"number"}
value={featureLimit}
onChange={(e) =>
setFeatureLimit(e.target.value)
}
/>
)}
<ConditionallyRender
condition={
featureCount !== undefined &&
Boolean(featureLimit)
}
show={
<Box>
({featureCount} of {featureLimit} used)
</Box>
}
/>
<StyledSubtitle>
<p id='pattern-additional-description'>
The example and description will be shown to
users when they create a new feature flag in
this project.
</p>
</StyledSubtitle>
<StyledInput
label={'Naming Example'}
name='feature flag naming example'
type={'text'}
aria-describedby='pattern-additional-description'
value={featureNamingExample || ''}
placeholder='dx.feature1.1-135'
error={Boolean(errors.namingExample)}
errorText={errors.namingExample}
onChange={(e) =>
onSetFeatureNamingExample(e.target.value)
}
/>
<StyledTextField
label={'Naming pattern description'}
name='feature flag naming description'
type={'text'}
aria-describedby='pattern-additional-description'
placeholder={`<project>.<featureName>.<ticket>
The flag name should contain the project name, the feature name, and the ticket number, each separated by a dot.`}
multiline
minRows={5}
value={featureNamingDescription || ''}
onChange={(e) =>
onSetFeatureNamingDescription(
e.target.value,
)
}
/>
</StyledFlagNamingContainer>
</StyledFieldset>
</StyledInputContainer>
</>
}
/>
<ConditionallyRender
condition={mode === "Create" && isEnterprise()}
show={
<>
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: 1,
gap: 1,
}}
>
<p>What is your project collaboration mode?</p>
<CollaborationModeTooltip />
</Box>
<StyledSelect
id="project-mode"
value={projectMode}
label="Project collaboration mode"
name="Project collaboration mode"
onChange={(e) => {
setProjectMode?.(e.target.value as ProjectMode);
}}
options={projectModeOptions}
/>
</>
}
/>
<StyledButtonContainer>{children}</StyledButtonContainer>

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 { usePageTitle } from 'hooks/usePageTitle';
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
import EditProject from './EditProject';
import EditProject from './EditProject/EditProject';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';

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

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

View File

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

View File

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