1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

feat: project UI rework, move edit and delete buttons deeper (#4195)

This commit is contained in:
Jaanus Sellin 2023-07-11 09:47:38 +03:00 committed by GitHub
parent ec2978b133
commit a2b06e4222
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 379 additions and 113 deletions

View File

@ -1 +1 @@
export const formTemplateSidebarWidth = '27.5rem';
export const formTemplateSidebarWidth = '36%';

View File

@ -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<ICreateProps> = ({
loading,
modal,
formatApiCode,
disablePadding,
}) => {
const { setToastData } = useToast();
const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
@ -194,13 +198,16 @@ const FormTemplate: React.FC<ICreateProps> = ({
</StyledRelativeDiv>
}
/>
<StyledFormContent>
<StyledFormContent disablePadding={disablePadding}>
<ConditionallyRender
condition={loading || false}
show={<Loader />}
elseShow={
<>
<StyledTitle>{title}</StyledTitle>
<ConditionallyRender
condition={title !== undefined}
show={<StyledTitle>{title}</StyledTitle>}
/>
{children}
</>
}

View File

@ -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 = () => {
<ProjectForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
projectId={projectId}
setProjectId={setProjectId}
projectName={projectName}
@ -115,6 +119,7 @@ const CreateProject = () => {
permission={CREATE_PROJECT}
data-testid={CREATE_PROJECT_BTN}
/>
<StyledButton onClick={handleCancel}>Cancel</StyledButton>
</ProjectForm>
</FormTemplate>
);

View File

@ -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 = () => {
<ProjectForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
projectId={projectId}
setProjectId={setProjectId}
projectName={projectName}
@ -133,7 +136,8 @@ const EditProject = () => {
permission={UPDATE_PROJECT}
projectId={projectId}
data-testid={EDIT_PROJECT_BTN}
/>
/>{' '}
<StyledButton onClick={handleCancel}>Cancel</StyledButton>
</ProjectForm>
</FormTemplate>
);

View File

@ -165,7 +165,10 @@ export const Project = () => {
}
/>
<ConditionallyRender
condition={!isOss()}
condition={
!isOss() &&
!Boolean(uiConfig.flags.newProjectLayout)
}
show={
<PermissionIconButton
permission={UPDATE_PROJECT}
@ -184,7 +187,10 @@ export const Project = () => {
}
/>
<ConditionallyRender
condition={!isOss()}
condition={
!isOss() &&
!Boolean(uiConfig.flags.newProjectLayout)
}
show={
<PermissionIconButton
permission={DELETE_PROJECT}

View File

@ -1,37 +0,0 @@
import Input from 'component/common/Input/Input';
import { TextField, Button, styled } from '@mui/material';
export const StyledForm = styled('form')(() => ({
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),
}));

View File

@ -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<React.SetStateAction<string>>;
setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
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<IProjectForm> = ({
children,
handleSubmit,
handleCancel,
projectId,
projectName,
projectDesc,
@ -153,10 +175,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
</>
</StyledContainer>
<StyledButtonContainer>
{children}
<StyledButton onClick={handleCancel}>Cancel</StyledButton>
</StyledButtonContainer>
<StyledButtonContainer>{children}</StyledButtonContainer>
</StyledForm>
);
};

View File

@ -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}
>
<Routes>
{uiConfig.flags.newProjectLayout ? (
<Route path="/*" element={<Settings />} />
) : null}
<Route
path="environments/*"
element={<ProjectEnvironmentList />}

View File

@ -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 (
<StyledContainer>
<StyledTitle>Delete project</StyledTitle>
<div>
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.
</div>
<StyledCounter>
Currently there are{' '}
<strong>{featureCount} feature toggles active</strong>
</StyledCounter>
<StyledButtonContainer>
<PermissionButton
permission={DELETE_PROJECT}
disabled={featureCount > 0}
projectId={projectId}
onClick={() => {
setShowDelDialog(true);
}}
tooltipProps={{
title: 'Delete project',
}}
data-loading
>
Delete project
</PermissionButton>
</StyledButtonContainer>
<DeleteProjectDialogue
project={projectId}
open={showDelDialog}
onClose={() => {
setShowDelDialog(false);
}}
onSuccess={() => {
navigate('/projects');
}}
/>
</StyledContainer>
);
};

View File

@ -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) && (
<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}
setProjectName={setProjectName}
projectStickiness={projectStickiness}
setProjectStickiness={setProjectStickiness}
setProjectMode={setProjectMode}
projectDesc={projectDesc}
mode="Edit"
setProjectDesc={setProjectDesc}
clearErrors={clearErrors}
validateProjectId={validateProjectId}
>
<PermissionButton
type="submit"
permission={UPDATE_PROJECT}
projectId={projectId}
>
Save changes
</PermissionButton>
</ProjectForm>
<DeleteProject
projectId={projectId}
featureCount={project.features.length}
/>
</PageContent>
</FormTemplate>
);
};
export default EditProject;

View File

@ -0,0 +1,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 (
<PageContent header={<PageHeader title="Access" />}>
<Alert severity="error">
You need project owner permissions to access this section.
</Alert>
</PageContent>
);
}
return <EditProject />;
};

View File

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

View File

@ -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<Element | null>(null);
const [showDelDialog, setShowDelDialog] = useState(false);
const navigate = useNavigate();
@ -117,24 +117,34 @@ export const ProjectCard = ({
<MenuItem
onClick={e => {
e.preventDefault();
navigate(getProjectEditPath(id));
navigate(
getProjectEditPath(
id,
Boolean(uiConfig.flags.newProjectLayout)
)
);
}}
>
<StyledEditIcon />
Edit project
</MenuItem>
<MenuItem
onClick={e => {
e.preventDefault();
setShowDelDialog(true);
}}
disabled={!canDeleteProject}
>
<StyledDeleteIcon />
{id === DEFAULT_PROJECT_ID && !canDeleteProject
? "You can't delete the default project"
: 'Delete project'}
</MenuItem>
<ConditionallyRender
condition={!Boolean(uiConfig.flags.newProjectLayout)}
show={
<MenuItem
onClick={e => {
e.preventDefault();
setShowDelDialog(true);
}}
disabled={!canDeleteProject}
>
<StyledDeleteIcon />
{id === DEFAULT_PROJECT_ID && !canDeleteProject
? "You can't delete the default project"
: 'Delete project'}
</MenuItem>
}
/>
</Menu>
</StyledDivHeader>
<div data-loading>

View File

@ -53,6 +53,7 @@ export interface IFlags {
advancedPlayground?: boolean;
customRootRoles?: boolean;
strategySplittedButton?: boolean;
newProjectLayout?: boolean;
}
export interface IVersionInfo {

View File

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

View File

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

View File

@ -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 = {

View File

@ -40,6 +40,7 @@ process.nextTick(async () => {
segmentContextFieldUsage: true,
advancedPlayground: true,
strategySplittedButton: true,
newProjectLayout: true,
},
},
authentication: {