diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts index 1f6391e7f4..59ea9178a2 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts +++ b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts @@ -1 +1 @@ -export const formTemplateSidebarWidth = '27.5rem'; +export const formTemplateSidebarWidth = '36%'; diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx index bd82aabe72..4f97820f95 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -19,12 +19,13 @@ import { formTemplateSidebarWidth } from './FormTemplate.styles'; import { relative } from 'themes/themeStyles'; interface ICreateProps { - title: string; + title?: string; description: string; documentationLink: string; documentationLinkLabel: string; loading?: boolean; modal?: boolean; + disablePadding?: boolean; formatApiCode: () => string; } @@ -45,20 +46,22 @@ const StyledContainer = styled('section', { const StyledRelativeDiv = styled('div')(({ theme }) => relative); -const StyledFormContent = styled('div')(({ theme }) => ({ +const StyledFormContent = styled('div', { + shouldForwardProp: prop => prop !== 'disablePadding', +})<{ disablePadding?: boolean }>(({ theme, disablePadding }) => ({ backgroundColor: theme.palette.background.paper, display: 'flex', flexDirection: 'column', - padding: theme.spacing(6), flexGrow: 1, + padding: disablePadding ? 0 : theme.spacing(6), [theme.breakpoints.down('lg')]: { - padding: theme.spacing(4), + padding: disablePadding ? 0 : theme.spacing(4), }, [theme.breakpoints.down(1100)]: { width: '100%', }, [theme.breakpoints.down(500)]: { - padding: theme.spacing(4, 2), + padding: disablePadding ? 0 : theme.spacing(4, 2), }, })); @@ -157,6 +160,7 @@ const FormTemplate: React.FC = ({ loading, modal, formatApiCode, + disablePadding, }) => { const { setToastData } = useToast(); const smallScreen = useMediaQuery(`(max-width:${1099}px)`); @@ -194,13 +198,16 @@ const FormTemplate: React.FC = ({ } /> - + } elseShow={ <> - {title} + {title}} + /> {children} } diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx index af8f2d92fb..03398aec40 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -13,9 +13,14 @@ 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 StyledButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + const CreateProject = () => { const { setToastData, setToastApiError } = useToast(); const { refetchUser } = useAuthUser(); @@ -95,7 +100,6 @@ const CreateProject = () => { { permission={CREATE_PROJECT} data-testid={CREATE_PROJECT_BTN} /> + Cancel ); diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx index 6540eb27c8..c3055e4692 100644 --- a/frontend/src/component/project/Project/EditProject/EditProject.tsx +++ b/frontend/src/component/project/Project/EditProject/EditProject.tsx @@ -14,13 +14,17 @@ 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 { 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(); @@ -114,7 +118,6 @@ const EditProject = () => { { permission={UPDATE_PROJECT} projectId={projectId} data-testid={EDIT_PROJECT_BTN} - /> + />{' '} + Cancel ); diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 5536338b6c..f2b3704992 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -165,7 +165,10 @@ export const Project = () => { } /> { } /> ({ - display: 'flex', - flexDirection: 'column', - height: '100%', -})); - -export const StyledContainer = styled('div')(() => ({ - maxWidth: '400px', -})); - -export const StyledDescription = styled('p')(({ theme }) => ({ - marginBottom: theme.spacing(1), - marginRight: theme.spacing(1), -})); - -export const StyledInput = styled(Input)(({ theme }) => ({ - width: '100%', - marginBottom: theme.spacing(2), -})); - -export const StyledTextField = styled(TextField)(({ theme }) => ({ - width: '100%', - marginBottom: theme.spacing(2), -})); - -export const StyledButtonContainer = styled('div')(() => ({ - marginTop: 'auto', - display: 'flex', - justifyContent: 'flex-end', -})); - -export const StyledButton = styled(Button)(({ theme }) => ({ - marginLeft: theme.spacing(3), -})); diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index e9a1f764ac..73b21b55d9 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -1,20 +1,12 @@ import React from 'react'; import { trim } from 'component/common/util'; -import { - StyledButton, - StyledButtonContainer, - StyledContainer, - StyledDescription, - StyledForm, - StyledInput, - StyledTextField, -} from './ProjectForm.styles'; 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 } from '@mui/material'; +import { Box, styled, TextField } from '@mui/material'; import { CollaborationModeTooltip } from './CollaborationModeTooltip'; +import Input from 'component/common/Input/Input'; interface IProjectForm { projectId: string; @@ -28,7 +20,6 @@ interface IProjectForm { setProjectName: React.Dispatch>; setProjectDesc: React.Dispatch>; handleSubmit: (e: any) => void; - handleCancel: () => void; errors: { [key: string]: string }; mode: 'Create' | 'Edit'; clearErrors: () => void; @@ -40,10 +31,41 @@ 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 }) => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', + paddingBottom: theme.spacing(4), +})); + +const StyledContainer = styled('div')(() => ({ + maxWidth: '400px', +})); + +const StyledDescription = styled('p')(({ theme }) => ({ + marginBottom: theme.spacing(1), + marginRight: theme.spacing(1), +})); + +const StyledInput = styled(Input)(({ theme }) => ({ + width: '100%', + marginBottom: theme.spacing(2), +})); + +const StyledTextField = styled(TextField)(({ theme }) => ({ + width: '100%', + marginBottom: theme.spacing(2), +})); + +const StyledButtonContainer = styled('div')(() => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +})); + const ProjectForm: React.FC = ({ children, handleSubmit, - handleCancel, projectId, projectName, projectDesc, @@ -153,10 +175,7 @@ const ProjectForm: React.FC = ({ - - {children} - Cancel - + {children} ); }; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx index c545a315c0..a484abb09a 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx @@ -12,12 +12,23 @@ import { ChangeRequestConfiguration } from './ChangeRequestConfiguration/ChangeR import { ProjectApiAccess } from 'component/project/Project/ProjectSettings/ProjectApiAccess/ProjectApiAccess'; import { ProjectSegments } from './ProjectSegments/ProjectSegments'; import { ProjectDefaultStrategySettings } from './ProjectDefaultStrategySettings/ProjectDefaultStrategySettings'; +import { Settings } from './Settings/Settings'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; export const ProjectSettings = () => { const location = useLocation(); + const { uiConfig } = useUiConfig(); const navigate = useNavigate(); const tabs: ITab[] = [ + ...(uiConfig.flags.newProjectLayout + ? [ + { + id: '', + label: 'Settings', + }, + ] + : []), { id: 'environments', label: 'Environments', @@ -59,6 +70,9 @@ export const ProjectSettings = () => { onChange={onChange} > + {uiConfig.flags.newProjectLayout ? ( + } /> + ) : null} } diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/DeleteProject.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/DeleteProject.tsx new file mode 100644 index 0000000000..6fa9aebc9f --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/DeleteProject.tsx @@ -0,0 +1,79 @@ +import { styled } from '@mui/material'; +import { DELETE_PROJECT } from 'component/providers/AccessProvider/permissions'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { DeleteProjectDialogue } from '../../DeleteProject/DeleteProjectDialogue'; +import { useState } from 'react'; +import { useNavigate } from 'react-router'; + +const StyledContainer = styled('div')(({ theme }) => ({ + borderTop: `1px solid ${theme.palette.divider}`, +})); + +const StyledTitle = styled('div')(({ theme }) => ({ + paddingTop: theme.spacing(4), + lineHeight: 2, +})); + +const StyledCounter = styled('div')(({ theme }) => ({ + paddingTop: theme.spacing(3), +})); + +const StyledButtonContainer = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-end', + paddingTop: theme.spacing(3), +})); + +interface IDeleteProjectProps { + projectId: string; + featureCount: number; +} + +export const DeleteProject = ({ + projectId, + featureCount, +}: IDeleteProjectProps) => { + const [showDelDialog, setShowDelDialog] = useState(false); + const navigate = useNavigate(); + return ( + + Delete project +
+ Before you can delete a project, you must first archive all the + feature toggles associated with it. Keep in mind that deleting a + project will permanently remove all the archived feature + toggles, and they cannot be recovered once deleted. +
+ + Currently there are{' '} + {featureCount} feature toggles active + + + 0} + projectId={projectId} + onClick={() => { + setShowDelDialog(true); + }} + tooltipProps={{ + title: 'Delete project', + }} + data-loading + > + Delete project + + + { + setShowDelDialog(false); + }} + onSuccess={() => { + navigate('/projects'); + }} + /> +
+ ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx new file mode 100644 index 0000000000..ceb50575e2 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx @@ -0,0 +1,143 @@ +import { useNavigate } from 'react-router-dom'; +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 { GO_BACK } from 'constants/navigate'; +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 navigate = useNavigate(); + const { trackEvent } = usePlausibleTracker(); + + const { + projectId, + projectName, + projectDesc, + projectStickiness, + projectMode, + setProjectId, + setProjectName, + setProjectDesc, + setProjectStickiness, + setProjectMode, + getProjectPayload, + clearErrors, + validateProjectId, + validateName, + errors, + } = useProjectForm( + id, + project.name, + project.description, + defaultStickiness, + project.mode + ); + + 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) && ( + + You do not have the required permissions to edit this project. + + ); + + return ( + + {accessDeniedAlert} + }> + + + Save changes + + + + + + ); +}; + +export default EditProject; diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/Settings.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/Settings.tsx new file mode 100644 index 0000000000..ef03772234 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/Settings.tsx @@ -0,0 +1,29 @@ +import { useContext } from 'react'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { Alert } from '@mui/material'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import AccessContext from 'contexts/AccessContext'; +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'; + +export const Settings = () => { + const projectId = useRequiredPathParam('projectId'); + const projectName = useProjectNameOrId(projectId); + const { hasAccess } = useContext(AccessContext); + usePageTitle(`Project configuration – ${projectName}`); + + if (!hasAccess(UPDATE_PROJECT, projectId)) { + return ( + }> + + You need project owner permissions to access this section. + + + ); + } + + return ; +}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccess.styles.ts b/frontend/src/component/project/ProjectAccess/ProjectAccess.styles.ts deleted file mode 100644 index 9f2bee17f8..0000000000 --- a/frontend/src/component/project/ProjectAccess/ProjectAccess.styles.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()(theme => ({ - pageContent: { - minHeight: '200px', - }, - divider: { - height: '1px', - position: 'relative', - left: 0, - right: 0, - backgroundColor: theme.palette.divider, - margin: theme.spacing(4, -4, 3), - }, - inputLabel: { - backgroundColor: theme.palette.background.paper, - }, - roleName: { - fontWeight: 'bold', - padding: '5px 0px', - }, - menuItem: { - width: '340px', - whiteSpace: 'normal', - }, - projectRoleSelect: { - minWidth: '150px', - }, -})); diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.tsx b/frontend/src/component/project/ProjectCard/ProjectCard.tsx index cbc0b39e2b..2304b9d248 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCard.tsx +++ b/frontend/src/component/project/ProjectCard/ProjectCard.tsx @@ -49,7 +49,7 @@ export const ProjectCard = ({ isFavorite = false, }: IProjectCardProps) => { const { hasAccess } = useContext(AccessContext); - const { isOss } = useUiConfig(); + const { isOss, uiConfig } = useUiConfig(); const [anchorEl, setAnchorEl] = useState(null); const [showDelDialog, setShowDelDialog] = useState(false); const navigate = useNavigate(); @@ -117,24 +117,34 @@ export const ProjectCard = ({ { e.preventDefault(); - navigate(getProjectEditPath(id)); + navigate( + getProjectEditPath( + id, + Boolean(uiConfig.flags.newProjectLayout) + ) + ); }} > Edit project - { - e.preventDefault(); - setShowDelDialog(true); - }} - disabled={!canDeleteProject} - > - - {id === DEFAULT_PROJECT_ID && !canDeleteProject - ? "You can't delete the default project" - : 'Delete project'} - + { + e.preventDefault(); + setShowDelDialog(true); + }} + disabled={!canDeleteProject} + > + + {id === DEFAULT_PROJECT_ID && !canDeleteProject + ? "You can't delete the default project" + : 'Delete project'} + + } + />
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 62954e09ce..40fab365fe 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -53,6 +53,7 @@ export interface IFlags { advancedPlayground?: boolean; customRootRoles?: boolean; strategySplittedButton?: boolean; + newProjectLayout?: boolean; } export interface IVersionInfo { diff --git a/frontend/src/utils/routePathHelpers.ts b/frontend/src/utils/routePathHelpers.ts index cbbd6170d8..76c989ec0f 100644 --- a/frontend/src/utils/routePathHelpers.ts +++ b/frontend/src/utils/routePathHelpers.ts @@ -1,3 +1,5 @@ +import useUiConfig from '../hooks/api/getters/useUiConfig/useUiConfig'; + export const getTogglePath = (projectId: string, featureToggleName: string) => { return `/projects/${projectId}/features/${featureToggleName}`; }; @@ -23,6 +25,11 @@ export const getCreateTogglePath = ( return path; }; -export const getProjectEditPath = (projectId: string) => { - return `/projects/${projectId}/edit`; +export const getProjectEditPath = ( + projectId: string, + newProjectPath: boolean +) => { + return newProjectPath + ? `/projects/${projectId}/settings` + : `/projects/${projectId}/edit`; }; diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 691d0652b2..a9ee577166 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -89,6 +89,7 @@ exports[`should create default config 1`] = ` }, }, "migrationLock": false, + "newProjectLayout": false, "personalAccessTokensKillSwitch": false, "proPlanAutoCharge": false, "responseTimeWithAppNameKillSwitch": false, @@ -121,6 +122,7 @@ exports[`should create default config 1`] = ` }, }, "migrationLock": false, + "newProjectLayout": false, "personalAccessTokensKillSwitch": false, "proPlanAutoCharge": false, "responseTimeWithAppNameKillSwitch": false, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 48a44ba88d..8238f3710c 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -23,7 +23,8 @@ export type IFlagKey = | 'disableNotifications' | 'advancedPlayground' | 'customRootRoles' - | 'strategySplittedButton'; + | 'strategySplittedButton' + | 'newProjectLayout'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -108,6 +109,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES, false, ), + newProjectLayout: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_NEW_PROJECT_LAYOUT, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/server-dev.ts b/src/server-dev.ts index 27aa53725c..a880dfa077 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -40,6 +40,7 @@ process.nextTick(async () => { segmentContextFieldUsage: true, advancedPlayground: true, strategySplittedButton: true, + newProjectLayout: true, }, }, authentication: {