mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
Feat/add enterprise badge to change req settings (#2585)
<!-- Thanks for creating a PR! To make it easier for reviewers and everyone else to understand what your changes relate to, please add some relevant content to the headings below. Feel free to ignore or delete sections that you don't think are relevant. Thank you! ❤️ --> Disable change requests for Pro and oss ## About the changes <!-- Describe the changes introduced. What are they and why are they being introduced? Feel free to also add screenshots or steps to view the changes if they're visual. --> <!-- Does it close an issue? Multiple? --> Closes # <!-- (For internal contributors): Does it relate to an issue on public roadmap? --> <!-- Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: # --> ### Important files <!-- PRs can contain a lot of changes, but not all changes are equally important. Where should a reviewer start looking to get an overview of the changes? Are any files particularly important? --> ## Discussion points <!-- Anything about the PR you'd like to discuss before it gets merged? Got any questions or doubts? --> Signed-off-by: andreas-unleash <andreas@getunleash.ai> Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
parent
cfc347f1b6
commit
7af155fe64
@ -0,0 +1,86 @@
|
||||
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
|
||||
import { Box, Link, styled, Typography } from '@mui/material';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
|
||||
const PremiumFeatureWrapper = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(1, 0.5),
|
||||
}));
|
||||
|
||||
const StyledTitle = styled(Typography)(({ theme }) => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
gap: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledBody = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
margin: theme.spacing(1, 0),
|
||||
}));
|
||||
|
||||
const StyledLink = styled(Link)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
width: 'fit-content',
|
||||
}));
|
||||
|
||||
enum FeatureLevelTitle {
|
||||
PRO = 'Pro & Enterprise feature',
|
||||
ENTERPRISE = 'Enterprise feature',
|
||||
}
|
||||
|
||||
export enum PlausibleOrigin {
|
||||
PROJECT = 'Projects',
|
||||
ACCESS = 'Access',
|
||||
CHANGE_REQUEST = 'Change Request',
|
||||
}
|
||||
|
||||
export interface PremiumFeatureProps {
|
||||
children: React.ReactNode;
|
||||
origin?: PlausibleOrigin;
|
||||
center?: boolean;
|
||||
enterpriseOnly?: boolean;
|
||||
}
|
||||
|
||||
export const PremiumFeature = ({
|
||||
children,
|
||||
origin,
|
||||
center,
|
||||
enterpriseOnly = false,
|
||||
}: PremiumFeatureProps) => {
|
||||
const tracker = usePlausibleTracker();
|
||||
|
||||
const handleClick = () => {
|
||||
if (origin) {
|
||||
tracker.trackEvent('upgrade_plan_clicked', {
|
||||
props: { origin },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PremiumFeatureWrapper
|
||||
sx={{
|
||||
alignItems: center ? 'center' : 'start',
|
||||
textAlign: center ? 'center' : 'left',
|
||||
}}
|
||||
>
|
||||
<StyledTitle>
|
||||
<ProPlanIcon />
|
||||
{enterpriseOnly
|
||||
? FeatureLevelTitle.ENTERPRISE
|
||||
: FeatureLevelTitle.PRO}
|
||||
</StyledTitle>
|
||||
<StyledBody>{children}</StyledBody>
|
||||
<StyledLink
|
||||
href={'https://www.getunleash.io/plans'}
|
||||
target="_blank"
|
||||
onClick={handleClick}
|
||||
>
|
||||
Upgrade now
|
||||
</StyledLink>
|
||||
</PremiumFeatureWrapper>
|
||||
);
|
||||
};
|
@ -1,48 +0,0 @@
|
||||
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
|
||||
import { Box, Link, styled, Typography } from '@mui/material';
|
||||
|
||||
export interface ProFeatureTooltipProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ProFeatureTooltipWrapper = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(1, 0.5),
|
||||
}));
|
||||
|
||||
const StyledTitle = styled(Typography)(({ theme }) => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
gap: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledBody = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
margin: theme.spacing(1, 0),
|
||||
}));
|
||||
|
||||
const StyledLink = styled(Link)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
width: 'fit-content',
|
||||
}));
|
||||
|
||||
export const ProFeatureTooltip = ({ children }: ProFeatureTooltipProps) => {
|
||||
return (
|
||||
<ProFeatureTooltipWrapper>
|
||||
<StyledTitle>
|
||||
<ProPlanIcon />
|
||||
Pro & Enterprise feature
|
||||
</StyledTitle>
|
||||
<StyledBody>{children}</StyledBody>
|
||||
<StyledLink
|
||||
href={'https://www.getunleash.io/plans'}
|
||||
target="_blank"
|
||||
>
|
||||
Upgrade now
|
||||
</StyledLink>
|
||||
</ProFeatureTooltipWrapper>
|
||||
);
|
||||
};
|
@ -1,25 +1,36 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { Button, styled } from '@mui/material';
|
||||
|
||||
const StyledTab = styled('button')<{ selected: boolean }>(
|
||||
const StyledTab = styled(Button)<{ selected: boolean }>(
|
||||
({ theme, selected }) => ({
|
||||
cursor: 'pointer',
|
||||
border: 0,
|
||||
backgroundColor: selected
|
||||
? theme.palette.background.paper
|
||||
: 'transparent',
|
||||
borderLeft: `${theme.spacing(1)} solid ${
|
||||
selected ? theme.palette.primary.main : 'transparent'
|
||||
}`,
|
||||
borderRadius: theme.shape.borderRadiusMedium,
|
||||
padding: theme.spacing(2, 4),
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: theme.fontSizes.bodySize,
|
||||
fontWeight: selected ? theme.fontWeight.bold : theme.fontWeight.medium,
|
||||
textAlign: 'left',
|
||||
transition: 'background-color 0.2s ease',
|
||||
'&.MuiButton-root': {
|
||||
cursor: 'pointer',
|
||||
height: theme.spacing(6.5),
|
||||
border: 0,
|
||||
backgroundColor: selected
|
||||
? theme.palette.background.paper
|
||||
: 'transparent',
|
||||
borderLeft: `${theme.spacing(1)} solid ${
|
||||
selected ? theme.palette.primary.main : 'transparent'
|
||||
}`,
|
||||
borderRadius: theme.shape.borderRadiusMedium,
|
||||
justifyContent: 'start',
|
||||
transition: 'background-color 0.2s ease',
|
||||
color: theme.palette.text.primary,
|
||||
textAlign: 'left',
|
||||
padding: theme.spacing(2, 4),
|
||||
fontSize: theme.fontSizes.bodySize,
|
||||
fontWeight: selected
|
||||
? theme.fontWeight.bold
|
||||
: theme.fontWeight.medium,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.neutral.light,
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
justifyContent: 'space-between',
|
||||
})
|
||||
);
|
||||
|
||||
@ -34,7 +45,15 @@ export const VerticalTab = ({
|
||||
selected,
|
||||
onClick,
|
||||
}: IVerticalTabProps) => (
|
||||
<StyledTab selected={Boolean(selected)} onClick={onClick}>
|
||||
<StyledTab
|
||||
selected={Boolean(selected)}
|
||||
onClick={onClick}
|
||||
disableRipple
|
||||
disableElevation
|
||||
disableFocusRipple
|
||||
disableTouchRipple
|
||||
fullWidth
|
||||
>
|
||||
{label}
|
||||
</StyledTab>
|
||||
);
|
||||
|
@ -1,313 +1,70 @@
|
||||
import React, { useContext, useMemo, useState, VFC } from 'react';
|
||||
import { HeaderGroup, useGlobalFilter, useTable } from 'react-table';
|
||||
import { Alert, Box, styled, Typography } from '@mui/material';
|
||||
import {
|
||||
SortableTableHeader,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from 'component/common/Table';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { useContext } from 'react';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { Alert, Link, styled } from '@mui/material';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useChangeRequestConfig } from 'hooks/api/getters/useChangeRequestConfig/useChangeRequestConfig';
|
||||
import {
|
||||
IChangeRequestConfig,
|
||||
useChangeRequestApi,
|
||||
} from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||
import { UPDATE_PROJECT } from '@server/types/permissions';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { ChangeRequestProcessHelp } from './ChangeRequestProcessHelp/ChangeRequestProcessHelp';
|
||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||
import { KeyboardArrowDownOutlined } from '@mui/icons-material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
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 { ChangeRequestTable } from './ChangeRequestTable';
|
||||
import {
|
||||
PlausibleOrigin,
|
||||
PremiumFeature,
|
||||
} from 'component/common/PremiumFeature/PremiumFeature';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(1),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
},
|
||||
const StyledLink = styled(Link)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
width: 'fit-content',
|
||||
}));
|
||||
|
||||
export const ChangeRequestConfiguration: VFC = () => {
|
||||
const { trackEvent } = usePlausibleTracker();
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
isOpen: boolean;
|
||||
enableEnvironment: string;
|
||||
isEnabled: boolean;
|
||||
requiredApprovals: number;
|
||||
}>({
|
||||
isOpen: false,
|
||||
enableEnvironment: '',
|
||||
isEnabled: false,
|
||||
requiredApprovals: 1,
|
||||
});
|
||||
|
||||
const theme = useTheme();
|
||||
export const ChangeRequestConfiguration = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const { data, loading, refetchChangeRequestConfig } =
|
||||
useChangeRequestConfig(projectId);
|
||||
const { updateChangeRequestEnvironmentConfig } = useChangeRequestApi();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
|
||||
const onRowChange =
|
||||
(
|
||||
enableEnvironment: string,
|
||||
isEnabled: boolean,
|
||||
requiredApprovals: number
|
||||
) =>
|
||||
() => {
|
||||
setDialogState({
|
||||
isOpen: true,
|
||||
enableEnvironment,
|
||||
isEnabled,
|
||||
requiredApprovals,
|
||||
});
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (dialogState.enableEnvironment) {
|
||||
await updateConfiguration();
|
||||
}
|
||||
setDialogState(state => ({ ...state, isOpen: false }));
|
||||
};
|
||||
|
||||
async function updateConfiguration(config?: IChangeRequestConfig) {
|
||||
try {
|
||||
await updateChangeRequestEnvironmentConfig(
|
||||
config || {
|
||||
project: projectId,
|
||||
environment: dialogState.enableEnvironment,
|
||||
enabled: !dialogState.isEnabled,
|
||||
requiredApprovals: dialogState.requiredApprovals,
|
||||
}
|
||||
);
|
||||
setToastData({
|
||||
type: 'success',
|
||||
title: 'Updated change request status',
|
||||
text: 'Successfully updated change request status.',
|
||||
});
|
||||
await refetchChangeRequestConfig();
|
||||
} catch (error) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
}
|
||||
|
||||
const approvalOptions = Array.from(Array(10).keys())
|
||||
.map(key => String(key + 1))
|
||||
.map(key => {
|
||||
const labelText = key === '1' ? 'approval' : 'approvals';
|
||||
return {
|
||||
key,
|
||||
label: `${key} ${labelText}`,
|
||||
sx: { 'font-size': theme.fontSizes.smallBody },
|
||||
};
|
||||
});
|
||||
|
||||
function onRequiredApprovalsChange(original: any, approvals: string) {
|
||||
updateConfiguration({
|
||||
project: projectId,
|
||||
environment: original.environment,
|
||||
enabled: original.changeRequestEnabled,
|
||||
requiredApprovals: Number(approvals),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Environment',
|
||||
accessor: 'environment',
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'type',
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: 'Required approvals',
|
||||
align: 'center',
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
return (
|
||||
<ConditionallyRender
|
||||
condition={original.changeRequestEnabled}
|
||||
show={
|
||||
<StyledBox data-loading>
|
||||
<GeneralSelect
|
||||
options={approvalOptions}
|
||||
value={original.requiredApprovals || 1}
|
||||
onChange={approvals => {
|
||||
onRequiredApprovalsChange(
|
||||
original,
|
||||
approvals
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
!hasAccess(
|
||||
UPDATE_PROJECT,
|
||||
projectId
|
||||
)
|
||||
}
|
||||
IconComponent={
|
||||
KeyboardArrowDownOutlined
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledBox>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
width: 100,
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: 'Status',
|
||||
accessor: 'changeRequestEnabled',
|
||||
id: 'changeRequestEnabled',
|
||||
align: 'center',
|
||||
|
||||
Cell: ({ value, row: { original } }: any) => (
|
||||
<StyledBox data-loading>
|
||||
<PermissionSwitch
|
||||
checked={value}
|
||||
projectId={projectId}
|
||||
permission={UPDATE_PROJECT}
|
||||
inputProps={{ 'aria-label': original.environment }}
|
||||
onClick={onRowChange(
|
||||
original.environment,
|
||||
original.changeRequestEnabled,
|
||||
original.requiredApprovals
|
||||
)}
|
||||
/>
|
||||
</StyledBox>
|
||||
),
|
||||
width: 100,
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
],
|
||||
[]
|
||||
const projectName = useProjectNameOrId(projectId);
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { isOss, uiConfig } = useUiConfig();
|
||||
const isPro = !(
|
||||
Boolean(uiConfig.versionInfo?.current.oss) ||
|
||||
Boolean(uiConfig.versionInfo?.current.enterprise)
|
||||
);
|
||||
usePageTitle(`Project change request – ${projectName}`);
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
useTable(
|
||||
{
|
||||
// @ts-ignore
|
||||
columns,
|
||||
data,
|
||||
sortTypes,
|
||||
autoResetGlobalFilter: false,
|
||||
disableSortRemove: true,
|
||||
defaultColumn: {
|
||||
Cell: TextCell,
|
||||
},
|
||||
},
|
||||
useGlobalFilter
|
||||
);
|
||||
return (
|
||||
<PageContent
|
||||
header={
|
||||
<PageHeader
|
||||
titleElement="Change request configuration"
|
||||
actions={<ChangeRequestProcessHelp />}
|
||||
/>
|
||||
}
|
||||
isLoading={loading}
|
||||
>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
If change request is enabled for an environment, then any change
|
||||
in that environment needs to be approved before it will be
|
||||
applied
|
||||
</Alert>
|
||||
|
||||
<Table {...getTableProps()}>
|
||||
<SortableTableHeader
|
||||
headerGroups={headerGroups as HeaderGroup<object>[]}
|
||||
/>
|
||||
<TableBody {...getTableBodyProps()}>
|
||||
{rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<TableRow hover {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<TableCell {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialogue
|
||||
onClick={() => {
|
||||
trackEvent('change_request', {
|
||||
props: {
|
||||
eventType: `change request ${
|
||||
!dialogState.isEnabled ? 'enabled' : 'disabled'
|
||||
}`,
|
||||
},
|
||||
});
|
||||
|
||||
onConfirm();
|
||||
}}
|
||||
open={dialogState.isOpen}
|
||||
onClose={() =>
|
||||
setDialogState(state => ({ ...state, isOpen: false }))
|
||||
}
|
||||
primaryButtonText={dialogState.isEnabled ? 'Disable' : 'Enable'}
|
||||
secondaryButtonText="Cancel"
|
||||
title={`${
|
||||
dialogState.isEnabled ? 'Disable' : 'Enable'
|
||||
} change requests`}
|
||||
if (isOss() || isPro) {
|
||||
return (
|
||||
<PageContent
|
||||
header={<PageHeader title="Change request configuration" />}
|
||||
sx={{ justifyContent: 'center' }}
|
||||
>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
You are about to{' '}
|
||||
{dialogState.isEnabled ? 'disable' : 'enable'} “Change
|
||||
request”
|
||||
<ConditionallyRender
|
||||
condition={Boolean(dialogState.enableEnvironment)}
|
||||
show={
|
||||
<>
|
||||
{' '}
|
||||
for{' '}
|
||||
<strong>{dialogState.enableEnvironment}</strong>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
.
|
||||
</Typography>
|
||||
<ConditionallyRender
|
||||
condition={!dialogState.isEnabled}
|
||||
show={
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
When enabling change request for an environment, you
|
||||
need to be sure that your Unleash Admin already have
|
||||
created the custom project roles in your Unleash
|
||||
instance so you can assign your project members from
|
||||
the project access page.
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Dialogue>
|
||||
</PageContent>
|
||||
);
|
||||
<PremiumFeature
|
||||
origin={PlausibleOrigin.CHANGE_REQUEST}
|
||||
enterpriseOnly
|
||||
center
|
||||
>
|
||||
<>
|
||||
If you want to use{' '}
|
||||
<StyledLink
|
||||
href={'https://www.getunleash.io/plans'} // TODO: Add link to change request docs when available
|
||||
target="_blank"
|
||||
>
|
||||
"Change Requests"
|
||||
</StyledLink>{' '}
|
||||
you will need to upgrade to Enterprise plan
|
||||
</>
|
||||
</PremiumFeature>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasAccess(UPDATE_PROJECT, projectId)) {
|
||||
return (
|
||||
<PageContent header={<PageHeader title="Project access" />}>
|
||||
<Alert severity="error">
|
||||
You need project owner permissions to access this section.
|
||||
</Alert>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
return <ChangeRequestTable />;
|
||||
};
|
||||
|
@ -0,0 +1,313 @@
|
||||
import React, { useContext, useMemo, useState, VFC } from 'react';
|
||||
import { HeaderGroup, useGlobalFilter, useTable } from 'react-table';
|
||||
import { Alert, Box, styled, Typography } from '@mui/material';
|
||||
import {
|
||||
SortableTableHeader,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from 'component/common/Table';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useChangeRequestConfig } from 'hooks/api/getters/useChangeRequestConfig/useChangeRequestConfig';
|
||||
import {
|
||||
IChangeRequestConfig,
|
||||
useChangeRequestApi,
|
||||
} from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||
import { UPDATE_PROJECT } from '@server/types/permissions';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { ChangeRequestProcessHelp } from './ChangeRequestProcessHelp/ChangeRequestProcessHelp';
|
||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||
import { KeyboardArrowDownOutlined } from '@mui/icons-material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(1),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
},
|
||||
}));
|
||||
|
||||
export const ChangeRequestTable: VFC = () => {
|
||||
const { trackEvent } = usePlausibleTracker();
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
isOpen: boolean;
|
||||
enableEnvironment: string;
|
||||
isEnabled: boolean;
|
||||
requiredApprovals: number;
|
||||
}>({
|
||||
isOpen: false,
|
||||
enableEnvironment: '',
|
||||
isEnabled: false,
|
||||
requiredApprovals: 1,
|
||||
});
|
||||
|
||||
const theme = useTheme();
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const { data, loading, refetchChangeRequestConfig } =
|
||||
useChangeRequestConfig(projectId);
|
||||
const { updateChangeRequestEnvironmentConfig } = useChangeRequestApi();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
|
||||
const onRowChange =
|
||||
(
|
||||
enableEnvironment: string,
|
||||
isEnabled: boolean,
|
||||
requiredApprovals: number
|
||||
) =>
|
||||
() => {
|
||||
setDialogState({
|
||||
isOpen: true,
|
||||
enableEnvironment,
|
||||
isEnabled,
|
||||
requiredApprovals,
|
||||
});
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (dialogState.enableEnvironment) {
|
||||
await updateConfiguration();
|
||||
}
|
||||
setDialogState(state => ({ ...state, isOpen: false }));
|
||||
};
|
||||
|
||||
async function updateConfiguration(config?: IChangeRequestConfig) {
|
||||
try {
|
||||
await updateChangeRequestEnvironmentConfig(
|
||||
config || {
|
||||
project: projectId,
|
||||
environment: dialogState.enableEnvironment,
|
||||
enabled: !dialogState.isEnabled,
|
||||
requiredApprovals: dialogState.requiredApprovals,
|
||||
}
|
||||
);
|
||||
setToastData({
|
||||
type: 'success',
|
||||
title: 'Updated change request status',
|
||||
text: 'Successfully updated change request status.',
|
||||
});
|
||||
await refetchChangeRequestConfig();
|
||||
} catch (error) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
}
|
||||
|
||||
const approvalOptions = Array.from(Array(10).keys())
|
||||
.map(key => String(key + 1))
|
||||
.map(key => {
|
||||
const labelText = key === '1' ? 'approval' : 'approvals';
|
||||
return {
|
||||
key,
|
||||
label: `${key} ${labelText}`,
|
||||
sx: { 'font-size': theme.fontSizes.smallBody },
|
||||
};
|
||||
});
|
||||
|
||||
function onRequiredApprovalsChange(original: any, approvals: string) {
|
||||
updateConfiguration({
|
||||
project: projectId,
|
||||
environment: original.environment,
|
||||
enabled: original.changeRequestEnabled,
|
||||
requiredApprovals: Number(approvals),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Environment',
|
||||
accessor: 'environment',
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'type',
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: 'Required approvals',
|
||||
align: 'center',
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
return (
|
||||
<ConditionallyRender
|
||||
condition={original.changeRequestEnabled}
|
||||
show={
|
||||
<StyledBox data-loading>
|
||||
<GeneralSelect
|
||||
options={approvalOptions}
|
||||
value={original.requiredApprovals || 1}
|
||||
onChange={approvals => {
|
||||
onRequiredApprovalsChange(
|
||||
original,
|
||||
approvals
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
!hasAccess(
|
||||
UPDATE_PROJECT,
|
||||
projectId
|
||||
)
|
||||
}
|
||||
IconComponent={
|
||||
KeyboardArrowDownOutlined
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledBox>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
width: 100,
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: 'Status',
|
||||
accessor: 'changeRequestEnabled',
|
||||
id: 'changeRequestEnabled',
|
||||
align: 'center',
|
||||
|
||||
Cell: ({ value, row: { original } }: any) => (
|
||||
<StyledBox data-loading>
|
||||
<PermissionSwitch
|
||||
checked={value}
|
||||
projectId={projectId}
|
||||
permission={UPDATE_PROJECT}
|
||||
inputProps={{ 'aria-label': original.environment }}
|
||||
onClick={onRowChange(
|
||||
original.environment,
|
||||
original.changeRequestEnabled,
|
||||
original.requiredApprovals
|
||||
)}
|
||||
/>
|
||||
</StyledBox>
|
||||
),
|
||||
width: 100,
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
useTable(
|
||||
{
|
||||
// @ts-ignore
|
||||
columns,
|
||||
data,
|
||||
sortTypes,
|
||||
autoResetGlobalFilter: false,
|
||||
disableSortRemove: true,
|
||||
defaultColumn: {
|
||||
Cell: TextCell,
|
||||
},
|
||||
},
|
||||
useGlobalFilter
|
||||
);
|
||||
return (
|
||||
<PageContent
|
||||
header={
|
||||
<PageHeader
|
||||
titleElement="Change request configuration"
|
||||
actions={<ChangeRequestProcessHelp />}
|
||||
/>
|
||||
}
|
||||
isLoading={loading}
|
||||
>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
If change request is enabled for an environment, then any change
|
||||
in that environment needs to be approved before it will be
|
||||
applied
|
||||
</Alert>
|
||||
|
||||
<Table {...getTableProps()}>
|
||||
<SortableTableHeader
|
||||
headerGroups={headerGroups as HeaderGroup<object>[]}
|
||||
/>
|
||||
<TableBody {...getTableBodyProps()}>
|
||||
{rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<TableRow hover {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<TableCell {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialogue
|
||||
onClick={() => {
|
||||
trackEvent('change_request', {
|
||||
props: {
|
||||
eventType: `change request ${
|
||||
!dialogState.isEnabled ? 'enabled' : 'disabled'
|
||||
}`,
|
||||
},
|
||||
});
|
||||
|
||||
onConfirm();
|
||||
}}
|
||||
open={dialogState.isOpen}
|
||||
onClose={() =>
|
||||
setDialogState(state => ({ ...state, isOpen: false }))
|
||||
}
|
||||
primaryButtonText={dialogState.isEnabled ? 'Disable' : 'Enable'}
|
||||
secondaryButtonText="Cancel"
|
||||
title={`${
|
||||
dialogState.isEnabled ? 'Disable' : 'Enable'
|
||||
} change requests`}
|
||||
>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
You are about to{' '}
|
||||
{dialogState.isEnabled ? 'disable' : 'enable'} “Change
|
||||
request”
|
||||
<ConditionallyRender
|
||||
condition={Boolean(dialogState.enableEnvironment)}
|
||||
show={
|
||||
<>
|
||||
{' '}
|
||||
for{' '}
|
||||
<strong>{dialogState.enableEnvironment}</strong>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
.
|
||||
</Typography>
|
||||
<ConditionallyRender
|
||||
condition={!dialogState.isEnabled}
|
||||
show={
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
When enabling change request for an environment, you
|
||||
need to be sure that your Unleash Admin already have
|
||||
created the custom project roles in your Unleash
|
||||
instance so you can assign your project members from
|
||||
the project access page.
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Dialogue>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -1,9 +1,9 @@
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
import { ITab, VerticalTabs } from 'component/common/VerticalTabs/VerticalTabs';
|
||||
import { ProjectAccess } from 'component/project/ProjectAccess/ProjectAccess';
|
||||
@ -14,10 +14,19 @@ export const ProjectSettings = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tabs = [
|
||||
{ id: 'access', label: 'Access' },
|
||||
{ id: 'environments', label: 'Environments' },
|
||||
{ id: 'change-requests', label: 'Change request configuration' },
|
||||
const tabs: ITab[] = [
|
||||
{
|
||||
id: 'environments',
|
||||
label: 'Environments',
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
label: 'Access',
|
||||
},
|
||||
{
|
||||
id: 'change-requests',
|
||||
label: 'Change request configuration',
|
||||
},
|
||||
];
|
||||
|
||||
const onChange = (tab: ITab) => {
|
||||
@ -35,13 +44,13 @@ export const ProjectSettings = () => {
|
||||
onChange={onChange}
|
||||
>
|
||||
<Routes>
|
||||
<Route path={`${tabs[0].id}/*`} element={<ProjectAccess />} />
|
||||
<Route
|
||||
path={`${tabs[1].id}/*`}
|
||||
path="environments/*"
|
||||
element={<ProjectEnvironmentList />}
|
||||
/>
|
||||
<Route path="access/*" element={<ProjectAccess />} />
|
||||
<Route
|
||||
path={`${tabs[2].id}/*`}
|
||||
path="change-requests/*"
|
||||
element={<ChangeRequestConfiguration />}
|
||||
/>
|
||||
<Route
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { Alert } from '@mui/material';
|
||||
import { Alert, Box, Link, styled } from '@mui/material';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||
@ -9,6 +9,15 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { ProjectAccessTable } from 'component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable';
|
||||
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
|
||||
import {
|
||||
PlausibleOrigin,
|
||||
PremiumFeature,
|
||||
} from 'component/common/PremiumFeature/PremiumFeature';
|
||||
|
||||
const StyledLink = styled(Link)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
width: 'fit-content',
|
||||
}));
|
||||
|
||||
export const ProjectAccess = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
@ -20,18 +29,29 @@ export const ProjectAccess = () => {
|
||||
if (isOss()) {
|
||||
return (
|
||||
<PageContent header={<PageHeader title="Project access" />}>
|
||||
<Alert severity="error">
|
||||
Controlling access to projects requires a paid version of
|
||||
Unleash. Check out{' '}
|
||||
<a
|
||||
href="https://www.getunleash.io"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
getunleash.io
|
||||
</a>{' '}
|
||||
to find out more.
|
||||
</Alert>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
maxWidth: '50%',
|
||||
margin: '0 25%',
|
||||
}}
|
||||
alignSelf={'center'}
|
||||
>
|
||||
<PremiumFeature origin={PlausibleOrigin.ACCESS} center>
|
||||
<>
|
||||
Controlling access to projects requires a paid
|
||||
version of Unleash. Check out{' '}
|
||||
<StyledLink
|
||||
href="https://www.getunleash.io"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
getunleash.io
|
||||
</StyledLink>{' '}
|
||||
to find out more.
|
||||
</>
|
||||
</PremiumFeature>
|
||||
</Box>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import { ProjectCard } from '../ProjectCard/ProjectCard';
|
||||
import { useStyles } from './ProjectList.styles';
|
||||
import { IProjectCard } from 'interfaces/project';
|
||||
import loadingData from './loadingData';
|
||||
import useLoading from 'hooks/useLoading';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
@ -21,7 +20,10 @@ import { TablePlaceholder } from 'component/common/Table';
|
||||
import { useMediaQuery } from '@mui/material';
|
||||
import theme from 'themes/theme';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { ProFeatureTooltip } from 'component/common/ProFeatureTooltip/ProFeatureTooltip';
|
||||
import {
|
||||
PlausibleOrigin,
|
||||
PremiumFeature,
|
||||
} from 'component/common/PremiumFeature/PremiumFeature';
|
||||
import { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver';
|
||||
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
|
||||
|
||||
@ -46,10 +48,10 @@ function resolveCreateButtonData(
|
||||
disabled: true,
|
||||
tooltip: {
|
||||
titleComponent: (
|
||||
<ProFeatureTooltip>
|
||||
<PremiumFeature origin={PlausibleOrigin.PROJECT}>
|
||||
To be able to add more projects you need to upgrade to
|
||||
Pro or Enterprise plan
|
||||
</ProFeatureTooltip>
|
||||
</PremiumFeature>
|
||||
),
|
||||
sx: { maxWidth: '320px' },
|
||||
variant: 'custom',
|
||||
|
Loading…
Reference in New Issue
Block a user