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