1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01: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:
andreas-unleash 2023-11-06 11:13:50 +02:00 committed by GitHub
parent 78cf9d03aa
commit 2262ca1be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 273 additions and 35 deletions

View File

@ -26,6 +26,10 @@ import { ChangeRequestReviewers } from './ChangeRequestReviewers/ChangeRequestRe
import { ChangeRequestRejectDialogue } from './ChangeRequestRejectDialog/ChangeRequestRejectDialog';
import { ApplyButton } from './ApplyButton/ApplyButton';
import { useUiFlag } from 'hooks/useUiFlag';
import {
ChangeRequestApplyScheduledDialogue,
ChangeRequestRejectScheduledDialogue,
} from './ChangeRequestScheduledDialogs/changeRequestScheduledDialogs';
const StyledAsideBox = styled(Box)(({ theme }) => ({
width: '30%',
@ -58,6 +62,10 @@ const StyledInnerContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
}));
const StyledButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(2),
}));
const ChangeRequestBody = styled(Box)(({ theme }) => ({
display: 'flex',
[theme.breakpoints.down('sm')]: {
@ -69,6 +77,10 @@ export const ChangeRequestOverview: FC = () => {
const projectId = useRequiredPathParam('projectId');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [showApplyScheduledDialog, setShowApplyScheduledDialog] =
useState(false);
const [showRejectScheduledDialog, setShowRejectScheduledDialog] =
useState(false);
const { user } = useAuthUser();
const { isAdmin } = useContext(AccessContext);
const [commentText, setCommentText] = useState('');
@ -183,6 +195,8 @@ export const ChangeRequestOverview: FC = () => {
const onCancel = () => setShowCancelDialog(true);
const onCancelAbort = () => setShowCancelDialog(false);
const onCancelReject = () => setShowRejectDialog(false);
const onApplyScheduledAbort = () => setShowApplyScheduledDialog(false);
const onRejectScheduledAbort = () => setShowRejectScheduledDialog(false);
const isSelfReview =
changeRequest?.createdBy.id === user?.id &&
@ -318,11 +332,9 @@ export const ChangeRequestOverview: FC = () => {
}
show={
<ApplyButton
onApply={() => {
console.log(
'I would show the apply now dialog',
);
}}
onApply={() =>
setShowApplyScheduledDialog(true)
}
disabled={
!allowChangeRequestActions ||
loading
@ -348,19 +360,35 @@ export const ChangeRequestOverview: FC = () => {
isAdmin)
}
show={
<Button
sx={{
marginLeft: (theme) =>
theme.spacing(2),
}}
variant='outlined'
onClick={onCancel}
>
{changeRequest.schedule
? 'Reject'
: 'Cancel'}{' '}
changes
</Button>
<ConditionallyRender
condition={
scheduleChangeRequests &&
Boolean(
changeRequest.schedule
?.scheduledAt,
)
}
show={
<StyledButton
variant='outlined'
onClick={() =>
setShowRejectScheduledDialog(
true,
)
}
>
Reject changes
</StyledButton>
}
elseShow={
<StyledButton
variant='outlined'
onClick={onCancel}
>
Cancel changes
</StyledButton>
}
/>
}
/>
</StyledButtonBox>
@ -389,6 +417,32 @@ export const ChangeRequestOverview: FC = () => {
onConfirm={onReject}
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>
</>
);

View File

@ -19,6 +19,7 @@ import {
ChangeRequestState,
IChangeRequest,
} from 'component/changeRequest/changeRequest.types';
import { getBrowserTimezoneInHumanReadableUTCOffset } from './utils';
interface ISuggestChangeReviewsStatusProps {
changeRequest: IChangeRequest;
@ -213,23 +214,7 @@ const Scheduled = ({ scheduledDate }: IScheduledProps) => {
return null;
}
const getBrowserTimezone = (): string => {
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();
const timezone = getBrowserTimezoneInHumanReadableUTCOffset();
return (
<>

View File

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

View File

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

View File

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

View File

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