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:
parent
78cf9d03aa
commit
2262ca1be6
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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