Skip to content

Commit

Permalink
Merge branch 'main' into feat/verifyAccessToCreatOrgFrontend
Browse files Browse the repository at this point in the history
  • Loading branch information
framitdavid authored Jan 24, 2025
2 parents 6804cde + b44b5c5 commit 20a7a05
Showing 43 changed files with 895 additions and 131 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.confirmUndeployButton {
margin-top: var(--fds-spacing-3);
}

.errorContainer {
margin-top: var(--fds-spacing-2);
}

.errorMessage {
display: inline;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { renderWithProviders } from '../../../../test/testUtils';
import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants';
import { app, org } from '@studio/testing/testids';
import React from 'react';
import { textMock } from '@studio/testing/mocks/i18nMock';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfirmUndeployDialog } from './ConfirmUndeployDialog';
import { useUndeployMutation } from '../../../../hooks/mutations/useUndeployMutation';

jest.mock('../../../../hooks/mutations/useUndeployMutation');

describe('ConfirmUndeployDialog', () => {
it('should provide a input field to confirm the app to undeploy and button is disabled', async () => {
renderConfirmUndeployDialog();
await openDialog();

const confirmTextField = getConfirmTextField();
const undeployButton = getUndeployButton();

expect(confirmTextField).toBeInTheDocument();
expect(undeployButton).toBeDisabled();
});

it('should enable undeploy button when confirm text field matches the app name', async () => {
const user = userEvent.setup();
renderConfirmUndeployDialog();
await openDialog();

const confirmTextField = getConfirmTextField();
await user.type(confirmTextField, app);
expect(confirmTextField).toHaveValue(app);

const undeployButton = getUndeployButton();
expect(undeployButton).toBeEnabled();
});

it('should not be case-sensitive when confirming the app-name', async () => {
const user = userEvent.setup();
renderConfirmUndeployDialog();
await openDialog();

const appNameInUpperCase = app.toUpperCase();

const confirmTextField = getConfirmTextField();
await user.type(confirmTextField, appNameInUpperCase);
expect(confirmTextField).toHaveValue(appNameInUpperCase);

const undeployButton = getUndeployButton();
expect(undeployButton).toBeEnabled();
});

it('should trigger undeploy when undeploy button is clicked', async () => {
const user = userEvent.setup();
renderConfirmUndeployDialog();
await openDialog();

const mutateFunctionMock = jest.fn();
(useUndeployMutation as jest.Mock).mockReturnValue({
mutate: mutateFunctionMock,
});

const confirmTextField = getConfirmTextField();
await user.type(confirmTextField, app);
await user.click(getUndeployButton());

expect(mutateFunctionMock).toBeCalledTimes(1);
expect(mutateFunctionMock).toHaveBeenCalledWith(
expect.objectContaining({ environment: 'unit-test-env' }),
expect.anything(),
);
});

it('should display an error alert when the undeploy mutation fails', async () => {
const user = userEvent.setup();
renderConfirmUndeployDialog();
await openDialog();

const errorMessageKey = 'app_deployment.error_unknown.message';
const mutateFunctionMock = jest.fn((_, { onError }) => onError());

(useUndeployMutation as jest.Mock).mockReturnValue({
mutate: mutateFunctionMock,
});

const confirmTextField = getConfirmTextField();
await user.type(confirmTextField, app);

const undeployButton = getUndeployButton();
await user.click(undeployButton);

expect(mutateFunctionMock).toBeCalledTimes(1);
expect(mutateFunctionMock).toHaveBeenCalledWith(
expect.objectContaining({ environment: 'unit-test-env' }),
expect.anything(),
);

const alertMessage = screen.getByText(textMock(errorMessageKey));
expect(alertMessage).toBeInTheDocument();
});
});

async function openDialog(): Promise<void> {
const user = userEvent.setup();
const button = screen.getByRole('button', { name: textMock('app_deployment.undeploy_button') });
await user.click(button);
}

function getConfirmTextField(): HTMLInputElement | null {
return screen.getByLabelText(textMock('app_deployment.undeploy_confirmation_input_label'));
}

function getUndeployButton(): HTMLButtonElement | null {
return screen.getByRole('button', {
name: textMock('app_deployment.undeploy_confirmation_button'),
});
}

function renderConfirmUndeployDialog(environment: string = 'unit-test-env'): void {
renderWithProviders(<ConfirmUndeployDialog environment={environment} />, {
startUrl: `${APP_DEVELOPMENT_BASENAME}/${org}/${app}/deploy`,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { ReactElement } from 'react';
import React, { useRef, useState } from 'react';
import {
StudioButton,
StudioModal,
StudioTextfield,
StudioParagraph,
StudioAlert,
StudioLink,
} from '@studio/components';
import { Trans, useTranslation } from 'react-i18next';
import classes from './ConfirmUndeployDialog.module.css';
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
import { useUndeployMutation } from '../../../../hooks/mutations/useUndeployMutation';

type ConfirmUndeployDialogProps = {
environment: string;
};
export const ConfirmUndeployDialog = ({
environment,
}: ConfirmUndeployDialogProps): ReactElement => {
const { t } = useTranslation();
const { org, app: appName } = useStudioEnvironmentParams();
const dialogRef = useRef<HTMLDialogElement>();
const [isAppNameConfirmed, setIsAppNameConfirmed] = useState<boolean>(false);
const [undeployError, setUndeployError] = useState<string | null>(null);
const mutation = useUndeployMutation(org, appName);
const onAppNameInputChange = (event: React.FormEvent<HTMLInputElement>): void => {
setIsAppNameConfirmed(isAppNameConfirmedForDelete(event.currentTarget.value, appName));
};

const openDialog = () => dialogRef.current.showModal();
const closeDialog = () => dialogRef.current.close();

const onUndeployClicked = (): void => {
mutation.mutate(
{
environment,
},
{
onSuccess: (): void => {
setUndeployError(null);
closeDialog();
},
onError: (): void => {
setUndeployError('app_deployment.error_unknown.message');
},
},
);
};

return (
<>
<StudioButton size='sm' onClick={openDialog} variant='primary'>
{t('app_deployment.undeploy_button')}
</StudioButton>
<StudioModal.Dialog
closeButtonTitle={t('sync_header.close_local_changes_button')}
heading={t('app_deployment.undeploy_confirmation_dialog_title')}
ref={dialogRef}
>
<StudioParagraph spacing>
{t('app_deployment.undeploy_confirmation_dialog_description')}
</StudioParagraph>
<StudioTextfield
size='sm'
label={t('app_deployment.undeploy_confirmation_input_label')}
description={t('app_deployment.undeploy_confirmation_input_description', {
appName,
})}
onChange={onAppNameInputChange}
/>
{undeployError && (
<StudioAlert severity='danger' className={classes.errorContainer}>
<StudioParagraph size='sm'>
<Trans
i18nKey={undeployError}
components={{
a: <StudioLink href='/contact'> </StudioLink>,
}}
/>
</StudioParagraph>
</StudioAlert>
)}
<StudioButton
disabled={!isAppNameConfirmed}
color='danger'
size='sm'
className={classes.confirmUndeployButton}
onClick={onUndeployClicked}
>
{t('app_deployment.undeploy_confirmation_button')}
</StudioButton>
</StudioModal.Dialog>
</>
);
};

function isAppNameConfirmedForDelete(userInputAppName: string, appNameToMatch: string): boolean {
return userInputAppName.toLowerCase().includes(appNameToMatch.toLowerCase());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ConfirmUndeployDialog } from './ConfirmUndeployDialog';
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.listContainer {
padding: 0;
list-style: none;
}

.content {
padding: 0;
}

.itemButton {
justify-content: flex-start;
}

.trigger {
position: absolute;
top: var(--fds-spacing-3);
right: var(--fds-spacing-3);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeployMoreOptionsMenu } from './DeployMoreOptionsMenu';
import { textMock } from '@studio/testing/mocks/i18nMock';

describe('DeployMoreOptionsMenu', () => {
const linkToEnv = 'https://unit-test';

it('should display two options, undeploy app and link to app', async () => {
renderMenu(linkToEnv);
await openMenu();

const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(2);

expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(textMock('app_deployment.more_options_menu'))).toBeInTheDocument();
});

it('should open list of list-items when menu trigger is clicked', async () => {
renderMenu(linkToEnv);
await openMenu();

const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(2);
});

it('should open dialog if undeploy is clicked', async () => {
renderMenu(linkToEnv);
await openMenu();

const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
});

it('should have a link to app within the env', async () => {
renderMenu(linkToEnv);
await openMenu();

const linkButton = screen.getByRole('link', {
name: textMock('app_deployment.more_options_menu'),
});
expect(linkButton).toHaveAttribute('href', linkToEnv);
expect(linkButton).toHaveAttribute('rel', 'noopener noreferrer');
});
});

function renderMenu(linkToEnv: string): void {
render(<DeployMoreOptionsMenu linkToEnv={linkToEnv} environment='unit-test-env' />);
}

async function openMenu(): Promise<void> {
const user = userEvent.setup();
return user.click(
screen.getByRole('button', {
name: textMock('app_deployment.deploy_more_options_menu_label'),
}),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { type ReactElement } from 'react';
import { StudioPopover, StudioButton } from '@studio/components';
import { ExternalLinkIcon, MenuElipsisVerticalIcon } from '@studio/icons';
import { UndeployConsequenceDialog } from '../UndeployConsequenceDialog/UndeployConsequenceDialog';
import classes from './DeployMoreOptionsMenu.module.css';
import { useTranslation } from 'react-i18next';

type DeployMoreOptionsMenuProps = {
environment: string;
linkToEnv: string;
};

export const DeployMoreOptionsMenu = ({
linkToEnv,
environment,
}: DeployMoreOptionsMenuProps): ReactElement => {
const { t } = useTranslation();
return (
<StudioPopover>
<StudioPopover.Trigger
size='sm'
variant='secondary'
className={classes.trigger}
aria-label={t('app_deployment.deploy_more_options_menu_label')}
>
<MenuElipsisVerticalIcon />
</StudioPopover.Trigger>
<StudioPopover.Content className={classes.content}>
<ul className={classes.listContainer}>
<li>
<UndeployConsequenceDialog environment={environment} />
</li>
<li>
<StudioButton
className={classes.itemButton}
as='a'
fullWidth
href={linkToEnv}
icon={<ExternalLinkIcon />}
rel='noopener noreferrer'
size='sm'
variant='tertiary'
>
{t('app_deployment.more_options_menu')}
</StudioButton>
</li>
</ul>
</StudioPopover.Content>
</StudioPopover>
);
};
Original file line number Diff line number Diff line change
@@ -2,12 +2,10 @@ import React from 'react';
import classes from './DeploymentEnvironment.module.css';
import { DeploymentEnvironmentStatus } from './DeploymentEnvironmentStatus';
import { Deploy } from './Deploy';
import { UnDeploy } from './UnDeploy';
import { DeploymentEnvironmentLogList } from './DeploymentEnvironmentLogList';
import type { PipelineDeployment } from 'app-shared/types/api/PipelineDeployment';
import type { KubernetesDeployment } from 'app-shared/types/api/KubernetesDeployment';
import { BuildResult } from 'app-shared/types/Build';
import { FeatureFlag, shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils';

export interface DeploymentEnvironmentProps {
pipelineDeploymentList: PipelineDeployment[];
@@ -52,7 +50,6 @@ export const DeploymentEnvironment = ({
isProduction={isProduction}
orgName={orgName}
/>
{shouldDisplayFeature(FeatureFlag.Undeploy) && <UnDeploy />}
<DeploymentEnvironmentLogList
envName={envName}
isProduction={isProduction}
Loading

0 comments on commit 20a7a05

Please sign in to comment.