From 2262ca1be6c2316b8f4fcbe7c13acc25ff06d8df Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Mon, 6 Nov 2023 11:13:50 +0200 Subject: [PATCH] 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) Screenshot 2023-11-03 at 14 43 17 Screenshot 2023-11-03 at 14 43 28 UI e2e tests will be in a follow up PR --------- Signed-off-by: andreas-unleash --- .../ChangeRequestOverview.tsx | 90 +++++++++++++++---- .../ChangeRequestReviewStatus.tsx | 19 +--- .../ChangeRequestReviewStatus/utils.test.ts | 58 ++++++++++++ .../ChangeRequestReviewStatus/utils.ts | 17 ++++ .../ChangeRequestScheduledDialogue.tsx | 59 ++++++++++++ .../changeRequestScheduledDialogs.tsx | 65 ++++++++++++++ 6 files changed, 273 insertions(+), 35 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.test.ts create mode 100644 frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.ts create mode 100644 frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/ChangeRequestScheduledDialogue.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/changeRequestScheduledDialogs.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx index 7a0ef34dfb..c1bd1a266d 100644 --- a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx @@ -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={ { - 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={ - + + setShowRejectScheduledDialog( + true, + ) + } + > + Reject changes + + } + elseShow={ + + Cancel changes + + } + /> } /> @@ -389,6 +417,32 @@ export const ChangeRequestOverview: FC = () => { onConfirm={onReject} onClose={onCancelReject} /> + + + + + } + /> ); diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/ChangeRequestReviewStatus.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/ChangeRequestReviewStatus.tsx index 96864362f3..7c653989f9 100644 --- a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/ChangeRequestReviewStatus.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/ChangeRequestReviewStatus.tsx @@ -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 ( <> diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.test.ts b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.test.ts new file mode 100644 index 0000000000..c362c484a2 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.ts b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.ts new file mode 100644 index 0000000000..5b39595de8 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewStatus/utils.ts @@ -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}`; +}; diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/ChangeRequestScheduledDialogue.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/ChangeRequestScheduledDialogue.tsx new file mode 100644 index 0000000000..8d20991c3b --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/ChangeRequestScheduledDialogue.tsx @@ -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 ( + onConfirm()} + fullWidth + > + + There is a scheduled time to apply these changes set for{' '} + +
+ {`${new Date(scheduledTime).toLocaleString()}`} +
+
+ {message} +
+ ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/changeRequestScheduledDialogs.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/changeRequestScheduledDialogs.tsx new file mode 100644 index 0000000000..3cfd98bb73 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestScheduledDialogs/changeRequestScheduledDialogs.tsx @@ -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 ( + onConfirm()} + projectId={projectId} + permission={APPLY_CHANGE_REQUEST} + environmentId={environment} + disabled={disabled} + > + Apply changes now + + } + {...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 ( + + ); +};