mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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