mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
Feat: scheduled change request dialogs (#5267)
Creates the Apply and Reject scheduled change request dialogs Closes # [1-1584](https://linear.app/unleash/issue/1-1584/add-modal-for-apply-now) Closes # [1-1586](https://linear.app/unleash/issue/1-1586/reject-changes-dialog) <img width="1669" alt="Screenshot 2023-11-03 at 14 43 17" src="https://github.com/Unleash/unleash/assets/104830839/832edb8e-1da1-4d96-a5c3-4fa0cd336fea"> <img width="1669" alt="Screenshot 2023-11-03 at 14 43 28" src="https://github.com/Unleash/unleash/assets/104830839/f9028671-e5e1-441c-886b-1e562c83f214"> UI e2e tests will be in a follow up PR --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
parent
78cf9d03aa
commit
2262ca1be6
@ -26,6 +26,10 @@ import { ChangeRequestReviewers } from './ChangeRequestReviewers/ChangeRequestRe
|
|||||||
import { ChangeRequestRejectDialogue } from './ChangeRequestRejectDialog/ChangeRequestRejectDialog';
|
import { ChangeRequestRejectDialogue } from './ChangeRequestRejectDialog/ChangeRequestRejectDialog';
|
||||||
import { ApplyButton } from './ApplyButton/ApplyButton';
|
import { ApplyButton } from './ApplyButton/ApplyButton';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
import {
|
||||||
|
ChangeRequestApplyScheduledDialogue,
|
||||||
|
ChangeRequestRejectScheduledDialogue,
|
||||||
|
} from './ChangeRequestScheduledDialogs/changeRequestScheduledDialogs';
|
||||||
|
|
||||||
const StyledAsideBox = styled(Box)(({ theme }) => ({
|
const StyledAsideBox = styled(Box)(({ theme }) => ({
|
||||||
width: '30%',
|
width: '30%',
|
||||||
@ -58,6 +62,10 @@ const StyledInnerContainer = styled(Box)(({ theme }) => ({
|
|||||||
padding: theme.spacing(2),
|
padding: theme.spacing(2),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StyledButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
const ChangeRequestBody = styled(Box)(({ theme }) => ({
|
const ChangeRequestBody = styled(Box)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
[theme.breakpoints.down('sm')]: {
|
[theme.breakpoints.down('sm')]: {
|
||||||
@ -69,6 +77,10 @@ export const ChangeRequestOverview: FC = () => {
|
|||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||||
|
const [showApplyScheduledDialog, setShowApplyScheduledDialog] =
|
||||||
|
useState(false);
|
||||||
|
const [showRejectScheduledDialog, setShowRejectScheduledDialog] =
|
||||||
|
useState(false);
|
||||||
const { user } = useAuthUser();
|
const { user } = useAuthUser();
|
||||||
const { isAdmin } = useContext(AccessContext);
|
const { isAdmin } = useContext(AccessContext);
|
||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
@ -183,6 +195,8 @@ export const ChangeRequestOverview: FC = () => {
|
|||||||
const onCancel = () => setShowCancelDialog(true);
|
const onCancel = () => setShowCancelDialog(true);
|
||||||
const onCancelAbort = () => setShowCancelDialog(false);
|
const onCancelAbort = () => setShowCancelDialog(false);
|
||||||
const onCancelReject = () => setShowRejectDialog(false);
|
const onCancelReject = () => setShowRejectDialog(false);
|
||||||
|
const onApplyScheduledAbort = () => setShowApplyScheduledDialog(false);
|
||||||
|
const onRejectScheduledAbort = () => setShowRejectScheduledDialog(false);
|
||||||
|
|
||||||
const isSelfReview =
|
const isSelfReview =
|
||||||
changeRequest?.createdBy.id === user?.id &&
|
changeRequest?.createdBy.id === user?.id &&
|
||||||
@ -318,11 +332,9 @@ export const ChangeRequestOverview: FC = () => {
|
|||||||
}
|
}
|
||||||
show={
|
show={
|
||||||
<ApplyButton
|
<ApplyButton
|
||||||
onApply={() => {
|
onApply={() =>
|
||||||
console.log(
|
setShowApplyScheduledDialog(true)
|
||||||
'I would show the apply now dialog',
|
}
|
||||||
);
|
|
||||||
}}
|
|
||||||
disabled={
|
disabled={
|
||||||
!allowChangeRequestActions ||
|
!allowChangeRequestActions ||
|
||||||
loading
|
loading
|
||||||
@ -348,19 +360,35 @@ export const ChangeRequestOverview: FC = () => {
|
|||||||
isAdmin)
|
isAdmin)
|
||||||
}
|
}
|
||||||
show={
|
show={
|
||||||
<Button
|
<ConditionallyRender
|
||||||
sx={{
|
condition={
|
||||||
marginLeft: (theme) =>
|
scheduleChangeRequests &&
|
||||||
theme.spacing(2),
|
Boolean(
|
||||||
}}
|
changeRequest.schedule
|
||||||
variant='outlined'
|
?.scheduledAt,
|
||||||
onClick={onCancel}
|
)
|
||||||
>
|
}
|
||||||
{changeRequest.schedule
|
show={
|
||||||
? 'Reject'
|
<StyledButton
|
||||||
: 'Cancel'}{' '}
|
variant='outlined'
|
||||||
changes
|
onClick={() =>
|
||||||
</Button>
|
setShowRejectScheduledDialog(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reject changes
|
||||||
|
</StyledButton>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StyledButton
|
||||||
|
variant='outlined'
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel changes
|
||||||
|
</StyledButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</StyledButtonBox>
|
</StyledButtonBox>
|
||||||
@ -389,6 +417,32 @@ export const ChangeRequestOverview: FC = () => {
|
|||||||
onConfirm={onReject}
|
onConfirm={onReject}
|
||||||
onClose={onCancelReject}
|
onClose={onCancelReject}
|
||||||
/>
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={scheduleChangeRequests}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<ChangeRequestApplyScheduledDialogue
|
||||||
|
open={showApplyScheduledDialog}
|
||||||
|
onConfirm={onApplyChanges}
|
||||||
|
onClose={onApplyScheduledAbort}
|
||||||
|
scheduledTime={
|
||||||
|
changeRequest?.schedule?.scheduledAt
|
||||||
|
}
|
||||||
|
disabled={!allowChangeRequestActions || loading}
|
||||||
|
projectId={projectId}
|
||||||
|
environment={changeRequest.environment}
|
||||||
|
/>
|
||||||
|
<ChangeRequestRejectScheduledDialogue
|
||||||
|
open={showRejectScheduledDialog}
|
||||||
|
onConfirm={onCancelChanges}
|
||||||
|
onClose={onRejectScheduledAbort}
|
||||||
|
scheduledTime={
|
||||||
|
changeRequest?.schedule?.scheduledAt
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ChangeRequestBody>
|
</ChangeRequestBody>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -19,6 +19,7 @@ import {
|
|||||||
ChangeRequestState,
|
ChangeRequestState,
|
||||||
IChangeRequest,
|
IChangeRequest,
|
||||||
} from 'component/changeRequest/changeRequest.types';
|
} from 'component/changeRequest/changeRequest.types';
|
||||||
|
import { getBrowserTimezoneInHumanReadableUTCOffset } from './utils';
|
||||||
|
|
||||||
interface ISuggestChangeReviewsStatusProps {
|
interface ISuggestChangeReviewsStatusProps {
|
||||||
changeRequest: IChangeRequest;
|
changeRequest: IChangeRequest;
|
||||||
@ -213,23 +214,7 @@ const Scheduled = ({ scheduledDate }: IScheduledProps) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBrowserTimezone = (): string => {
|
const timezone = getBrowserTimezoneInHumanReadableUTCOffset();
|
||||||
const offset = -new Date().getTimezoneOffset();
|
|
||||||
const hours = Math.floor(Math.abs(offset) / 60);
|
|
||||||
const minutes = Math.abs(offset) % 60;
|
|
||||||
let sign = '+';
|
|
||||||
if (offset < 0) {
|
|
||||||
sign = '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure that hours and minutes are two digits
|
|
||||||
const zeroPaddedHours = hours.toString().padStart(2, '0');
|
|
||||||
const zeroPaddedMinutes = minutes.toString().padStart(2, '0');
|
|
||||||
|
|
||||||
return `UTC${sign}${zeroPaddedHours}:${zeroPaddedMinutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const timezone = getBrowserTimezone();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -0,0 +1,58 @@
|
|||||||
|
import { getBrowserTimezoneInHumanReadableUTCOffset } from './utils';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
describe('getBrowserTimezoneInHumanReadableUTCOffset', () => {
|
||||||
|
// Test for the current timezone offset
|
||||||
|
test('should return the correct UTC offset for the current timezone', () => {
|
||||||
|
const date = new Date('2023-01-01T00:00:00Z'); // fixed date in UTC
|
||||||
|
const expectedOffset = new Date(
|
||||||
|
'2023-01-01T00:00:00Z',
|
||||||
|
).getTimezoneOffset();
|
||||||
|
const sign = expectedOffset > 0 ? '-' : '+';
|
||||||
|
const expectedHours = Math.floor(Math.abs(expectedOffset) / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0');
|
||||||
|
const expectedMinutes = (Math.abs(expectedOffset) % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0');
|
||||||
|
const expected = `UTC${sign}${expectedHours}:${expectedMinutes}`;
|
||||||
|
|
||||||
|
const result = getBrowserTimezoneInHumanReadableUTCOffset(date);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test for known timezones
|
||||||
|
const timezones = [
|
||||||
|
{ offset: 0, expected: 'UTC+00:00' },
|
||||||
|
{ offset: -330, expected: 'UTC+05:30' },
|
||||||
|
{ offset: -120, expected: 'UTC+02:00' },
|
||||||
|
{ offset: 420, expected: 'UTC-07:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
timezones.forEach(({ offset, expected }) => {
|
||||||
|
test(`should return '${expected}' for offset ${offset} minutes`, () => {
|
||||||
|
// Mock the getTimezoneOffset function to return a fixed offset
|
||||||
|
Date.prototype.getTimezoneOffset = vi.fn(() => offset);
|
||||||
|
const result = getBrowserTimezoneInHumanReadableUTCOffset();
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
test('should handle the edge case for zero offset', () => {
|
||||||
|
Date.prototype.getTimezoneOffset = vi.fn(() => 0);
|
||||||
|
const result = getBrowserTimezoneInHumanReadableUTCOffset();
|
||||||
|
expect(result).toBe('UTC+00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle offsets that are not on the hour', () => {
|
||||||
|
Date.prototype.getTimezoneOffset = vi.fn(() => -45);
|
||||||
|
const result = getBrowserTimezoneInHumanReadableUTCOffset();
|
||||||
|
expect(result).toBe('UTC+00:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset mock after all tests are done
|
||||||
|
afterAll(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,17 @@
|
|||||||
|
export const getBrowserTimezoneInHumanReadableUTCOffset = (
|
||||||
|
date = new Date(),
|
||||||
|
): string => {
|
||||||
|
const offset = -date.getTimezoneOffset();
|
||||||
|
const hours = Math.floor(Math.abs(offset) / 60);
|
||||||
|
const minutes = Math.abs(offset) % 60;
|
||||||
|
let sign = '+';
|
||||||
|
if (offset < 0) {
|
||||||
|
sign = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that hours and minutes are two digits
|
||||||
|
const zeroPaddedHours = hours.toString().padStart(2, '0');
|
||||||
|
const zeroPaddedMinutes = minutes.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `UTC${sign}${zeroPaddedHours}:${zeroPaddedMinutes}`;
|
||||||
|
};
|
@ -0,0 +1,59 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { Alert, styled, Typography } from '@mui/material';
|
||||||
|
import { Dialogue } from '../../../common/Dialogue/Dialogue';
|
||||||
|
|
||||||
|
export interface ChangeRequestScheduleDialogueProps {
|
||||||
|
title: string;
|
||||||
|
primaryButtonText: string;
|
||||||
|
open: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
scheduledTime?: string;
|
||||||
|
message: string;
|
||||||
|
permissionButton?: ReactElement;
|
||||||
|
disabled?: boolean;
|
||||||
|
projectId?: string;
|
||||||
|
environment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
backgroundColor: `${theme.palette.neutral.light}!important`,
|
||||||
|
color: `${theme.palette.text.primary}!important`,
|
||||||
|
borderColor: `${theme.palette.neutral.light}!important`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ChangeRequestScheduledDialogue: FC<
|
||||||
|
ChangeRequestScheduleDialogueProps
|
||||||
|
> = ({
|
||||||
|
open,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
primaryButtonText,
|
||||||
|
message,
|
||||||
|
scheduledTime,
|
||||||
|
}) => {
|
||||||
|
if (!scheduledTime) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
title={title}
|
||||||
|
primaryButtonText={primaryButtonText}
|
||||||
|
secondaryButtonText='Cancel'
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onClick={() => onConfirm()}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<StyledAlert icon={false}>
|
||||||
|
There is a scheduled time to apply these changes set for{' '}
|
||||||
|
<strong>
|
||||||
|
<br />
|
||||||
|
{`${new Date(scheduledTime).toLocaleString()}`}
|
||||||
|
</strong>
|
||||||
|
</StyledAlert>
|
||||||
|
<Typography variant={'body1'}>{message}</Typography>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,65 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { TextField, Box, Alert, styled, Typography } from '@mui/material';
|
||||||
|
import { Dialogue } from '../../../common/Dialogue/Dialogue';
|
||||||
|
import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { APPLY_CHANGE_REQUEST } from '../../../providers/AccessProvider/permissions';
|
||||||
|
import PermissionButton from '../../../common/PermissionButton/PermissionButton';
|
||||||
|
import {
|
||||||
|
ChangeRequestScheduledDialogue,
|
||||||
|
ChangeRequestScheduleDialogueProps,
|
||||||
|
} from './ChangeRequestScheduledDialogue';
|
||||||
|
|
||||||
|
export const ChangeRequestApplyScheduledDialogue: FC<
|
||||||
|
Omit<
|
||||||
|
ChangeRequestScheduleDialogueProps,
|
||||||
|
'message' | 'title' | 'primaryButtonText' | 'permissionButton'
|
||||||
|
>
|
||||||
|
> = ({ projectId, environment, disabled, onConfirm, ...rest }) => {
|
||||||
|
const message =
|
||||||
|
'Applying the changes now means the scheduled time will be ignored';
|
||||||
|
const title = 'Apply changes';
|
||||||
|
const primaryButtonText = 'Apply changes now';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChangeRequestScheduledDialogue
|
||||||
|
message={message}
|
||||||
|
title={title}
|
||||||
|
primaryButtonText={primaryButtonText}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
permissionButton={
|
||||||
|
<PermissionButton
|
||||||
|
variant='contained'
|
||||||
|
onClick={() => onConfirm()}
|
||||||
|
projectId={projectId}
|
||||||
|
permission={APPLY_CHANGE_REQUEST}
|
||||||
|
environmentId={environment}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Apply changes now
|
||||||
|
</PermissionButton>
|
||||||
|
}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangeRequestRejectScheduledDialogue: FC<
|
||||||
|
Omit<
|
||||||
|
ChangeRequestScheduleDialogueProps,
|
||||||
|
'message' | 'title' | 'primaryButtonText'
|
||||||
|
>
|
||||||
|
> = ({ ...rest }) => {
|
||||||
|
const message =
|
||||||
|
'Rejecting the changes now means the scheduled time will be ignored';
|
||||||
|
const title = 'Reject changes';
|
||||||
|
const primaryButtonText = 'Reject changes';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChangeRequestScheduledDialogue
|
||||||
|
message={message}
|
||||||
|
title={title}
|
||||||
|
primaryButtonText={primaryButtonText}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user