Skip to content

chore: soften error message styling for invalid/authenticating tokens #102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
import React, { FormEvent } from 'react';
import React, { type FormEvent, useState } from 'react';
import { useId } from '../../hooks/hookPolyfills';
import {
type CoderAuthStatus,
useCoderAppConfig,
useCoderAuth,
} from '../CoderProvider';

import { Theme, makeStyles } from '@material-ui/core';
import TextField from '@material-ui/core/TextField';
import { CoderLogo } from '../CoderLogo';
import { Link, LinkButton } from '@backstage/core-components';
import { VisuallyHidden } from '../VisuallyHidden';
import { makeStyles } from '@material-ui/core';
import TextField from '@material-ui/core/TextField';
import ErrorIcon from '@material-ui/icons/ErrorOutline';
import SyncIcon from '@material-ui/icons/Sync';

type UseStyleInput = Readonly<{ status: CoderAuthStatus }>;
type StyleKeys =
| 'formContainer'
| 'authInputFieldset'
| 'coderLogo'
| 'authButton'
| 'warningBanner'
| 'warningBannerContainer';

const useStyles = makeStyles<Theme, UseStyleInput, StyleKeys>(theme => ({
const useStyles = makeStyles(theme => ({
formContainer: {
maxWidth: '30em',
marginLeft: 'auto',
Expand Down Expand Up @@ -50,41 +43,13 @@ const useStyles = makeStyles<Theme, UseStyleInput, StyleKeys>(theme => ({
marginLeft: 'auto',
marginRight: 'auto',
},

warningBannerContainer: {
paddingTop: theme.spacing(4),
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
},

warningBanner: ({ status }) => {
let color: string;
let backgroundColor: string;

if (status === 'invalid') {
color = theme.palette.error.contrastText;
backgroundColor = theme.palette.banner.error;
} else {
color = theme.palette.text.primary;
backgroundColor = theme.palette.background.default;
}

return {
color,
backgroundColor,
borderRadius: theme.shape.borderRadius,
textAlign: 'center',
paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
};
},
}));

export const CoderAuthInputForm = () => {
const hookId = useId();
const styles = useStyles();
const appConfig = useCoderAppConfig();
const { status, registerNewToken } = useCoderAuth();
const styles = useStyles({ status });

const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -161,13 +126,122 @@ export const CoderAuthInputForm = () => {
</fieldset>

{(status === 'invalid' || status === 'authenticating') && (
<div className={styles.warningBannerContainer}>
<div id={warningBannerId} className={styles.warningBanner}>
{status === 'invalid' && 'Invalid token'}
{status === 'authenticating' && <>Authenticating&hellip;</>}
</div>
</div>
<InvalidStatusNotifier authStatus={status} bannerId={warningBannerId} />
)}
</form>
);
};

const useInvalidStatusStyles = makeStyles(theme => ({
warningBannerSpacer: {
paddingTop: theme.spacing(2),
},

warningBanner: {
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.default,
borderRadius: theme.shape.borderRadius,
border: `1.5px solid ${theme.palette.background.default}`,
padding: 0,
},

errorContent: {
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
columnGap: theme.spacing(1),
marginRight: 'auto',

paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
paddingLeft: theme.spacing(2),
paddingRight: 0,
},

icon: {
fontSize: '16px',
},

syncIcon: {
color: theme.palette.text.primary,
opacity: 0.6,
},

errorIcon: {
color: theme.palette.error.main,
fontSize: '16px',
},

dismissButton: {
border: 'none',
alignSelf: 'stretch',
padding: `0 ${theme.spacing(1.5)}px 0 ${theme.spacing(2)}px`,
color: theme.palette.text.primary,
backgroundColor: 'inherit',
lineHeight: 1,
cursor: 'pointer',

'&:hover': {
backgroundColor: theme.palette.action.hover,
},
},

'@keyframes spin': {
'100%': {
transform: 'rotate(360deg)',
},
},
}));

type InvalidStatusProps = Readonly<{
authStatus: CoderAuthStatus;
bannerId: string;
}>;

function InvalidStatusNotifier({ authStatus, bannerId }: InvalidStatusProps) {
const [showNotification, setShowNotification] = useState(true);
const styles = useInvalidStatusStyles();

if (!showNotification) {
return null;
}

return (
<div className={styles.warningBannerSpacer}>
<div id={bannerId} className={styles.warningBanner}>
<span className={styles.errorContent}>
{authStatus === 'authenticating' && (
<>
<SyncIcon
className={`${styles.icon} ${styles.syncIcon}`}
// Needed to make MUI v4 icons respect sizing values
fontSize="inherit"
/>
Authenticating&hellip;
</>
)}

{authStatus === 'invalid' && (
<>
<ErrorIcon
className={`${styles.icon} ${styles.errorIcon}`}
fontSize="inherit"
/>
Invalid token
</>
)}
</span>

<button
className={styles.dismissButton}
onClick={() => setShowNotification(false)}
>
Dismiss
</button>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CoderProviderWithMockAuth } from '../../testHelpers/setup';
import type { CoderAuth, CoderAuthStatus } from '../CoderProvider';
Expand All @@ -12,13 +12,13 @@ import { CoderAuthWrapper } from './CoderAuthWrapper';
import { renderInTestApp } from '@backstage/test-utils';

type RenderInputs = Readonly<{
childButtonText: string;
authStatus: CoderAuthStatus;
childButtonText?: string;
}>;

async function renderAuthWrapper({
authStatus,
childButtonText,
childButtonText = 'Default button text',
}: RenderInputs) {
const ejectToken = jest.fn();
const registerNewToken = jest.fn();
Expand Down Expand Up @@ -108,7 +108,6 @@ describe(`${CoderAuthWrapper.name}`, () => {
it('Lets the user eject the current token', async () => {
const { ejectToken } = await renderAuthWrapper({
authStatus: 'distrusted',
childButtonText: "I don't matter",
});

const user = userEvent.setup();
Expand Down Expand Up @@ -174,7 +173,6 @@ describe(`${CoderAuthWrapper.name}`, () => {
it('Lets the user submit a new token', async () => {
const { registerNewToken } = await renderAuthWrapper({
authStatus: 'tokenMissing',
childButtonText: "I don't matter",
});

/**
Expand All @@ -194,5 +192,24 @@ describe(`${CoderAuthWrapper.name}`, () => {

expect(registerNewToken).toHaveBeenCalledWith(mockCoderAuthToken);
});

it('Lets the user dismiss any notifications for invalid/authenticating states', async () => {
const authStatuses: readonly CoderAuthStatus[] = [
'invalid',
'authenticating',
];

const user = userEvent.setup();
for (const authStatus of authStatuses) {
const { unmount } = await renderAuthWrapper({ authStatus });
const dismissButton = await screen.findByRole('button', {
name: 'Dismiss',
});

await user.click(dismissButton);
await waitFor(() => expect(dismissButton).not.toBeInTheDocument());
unmount();
}
});
});
});