diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx new file mode 100644 index 0000000000000..d426d97393cd6 --- /dev/null +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -0,0 +1,101 @@ +import { Story } from "@storybook/react" +import { AlertBanner, AlertBannerProps } from "./AlertBanner" +import Button from "@material-ui/core/Button" +import { makeMockApiError } from "testHelpers/entities" + +export default { + title: "components/AlertBanner", + component: AlertBanner, +} + +const ExampleAction = ( + +) + +const mockError = makeMockApiError({ + message: "Email or password was invalid", + detail: "Password is invalid", +}) + +const Template: Story = (args) => + +export const Warning = Template.bind({}) +Warning.args = { + text: "This is a warning", + severity: "warning", +} + +export const ErrorWithDefaultMessage = Template.bind({}) +ErrorWithDefaultMessage.args = { + text: "This is an error", + severity: "error", +} + +export const ErrorWithErrorMessage = Template.bind({}) +ErrorWithErrorMessage.args = { + error: mockError, + severity: "error", +} + +export const WarningWithDismiss = Template.bind({}) +WarningWithDismiss.args = { + text: "This is a warning", + dismissible: true, + severity: "warning", +} + +export const ErrorWithDismiss = Template.bind({}) +ErrorWithDismiss.args = { + error: mockError, + dismissible: true, + severity: "error", +} + +export const WarningWithAction = Template.bind({}) +WarningWithAction.args = { + text: "This is a warning", + actions: [ExampleAction], + severity: "warning", +} + +export const ErrorWithAction = Template.bind({}) +ErrorWithAction.args = { + error: mockError, + actions: [ExampleAction], + severity: "error", +} + +export const WarningWithActionAndDismiss = Template.bind({}) +WarningWithActionAndDismiss.args = { + text: "This is a warning", + actions: [ExampleAction], + dismissible: true, + severity: "warning", +} + +export const ErrorWithActionAndDismiss = Template.bind({}) +ErrorWithActionAndDismiss.args = { + error: mockError, + actions: [ExampleAction], + dismissible: true, + severity: "error", +} + +export const ErrorWithRetry = Template.bind({}) +ErrorWithRetry.args = { + error: mockError, + retry: () => null, + dismissible: true, + severity: "error", +} + +export const ErrorWithActionRetryAndDismiss = Template.bind({}) +ErrorWithActionRetryAndDismiss.args = { + error: mockError, + actions: [ExampleAction], + retry: () => null, + dismissible: true, + severity: "error", +} diff --git a/site/src/components/AlertBanner/AlertBanner.tsx b/site/src/components/AlertBanner/AlertBanner.tsx new file mode 100644 index 0000000000000..9df5a92ae161a --- /dev/null +++ b/site/src/components/AlertBanner/AlertBanner.tsx @@ -0,0 +1,93 @@ +import { useState, FC } from "react" +import Collapse from "@material-ui/core/Collapse" +import { Stack } from "components/Stack/Stack" +import { makeStyles, Theme } from "@material-ui/core/styles" +import { colors } from "theme/colors" +import { useTranslation } from "react-i18next" +import { getErrorDetail, getErrorMessage } from "api/errors" +import { Expander } from "components/Expander/Expander" +import { Severity, AlertBannerProps } from "./alertTypes" +import { severityConstants } from "./severityConstants" +import { AlertBannerCtas } from "./AlertBannerCtas" + +/** + * severity: the level of alert severity (see ./severityTypes.ts) + * text: default text to be displayed to the user; useful for warnings or as a fallback error message + * error: should be passed in if the severity is 'Error'; warnings can use 'text' instead + * actions: an array of CTAs passed in by the consumer + * dismissible: determines whether or not the banner should have a `Dismiss` CTA + * retry: a handler to retry the action that spawned the error + */ +export const AlertBanner: FC = ({ + severity, + text, + error, + actions = [], + retry, + dismissible = false, +}) => { + const { t } = useTranslation("common") + + const [open, setOpen] = useState(true) + + // if an error is passed in, display that error, otherwise + // display the text passed in, e.g. warning text + const alertMessage = getErrorMessage(error, text ?? t("warningsAndErrors.somethingWentWrong")) + + // if we have an error, check if there's detail to display + const detail = error ? getErrorDetail(error) : undefined + const classes = useStyles({ severity, hasDetail: Boolean(detail) }) + + const [showDetails, setShowDetails] = useState(false) + + return ( + + + + {severityConstants[severity].icon} + + {alertMessage} + {detail && ( + +
{detail}
+
+ )} +
+
+ + +
+
+ ) +} + +interface StyleProps { + severity: Severity + hasDetail: boolean +} + +const useStyles = makeStyles((theme) => ({ + alertContainer: (props) => ({ + borderColor: severityConstants[props.severity].color, + border: `1px solid ${colors.orange[7]}`, + borderRadius: theme.shape.borderRadius, + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + backgroundColor: `${colors.gray[16]}`, + + "& svg": { + marginTop: props.hasDetail ? `${theme.spacing(1)}px` : "inherit", + marginRight: `${theme.spacing(1)}px`, + }, + }), +})) diff --git a/site/src/components/AlertBanner/AlertBannerCtas.tsx b/site/src/components/AlertBanner/AlertBannerCtas.tsx new file mode 100644 index 0000000000000..4516bfac75c2f --- /dev/null +++ b/site/src/components/AlertBanner/AlertBannerCtas.tsx @@ -0,0 +1,42 @@ +import { FC } from "react" +import { AlertBannerProps } from "./alertTypes" +import { Stack } from "components/Stack/Stack" +import Button from "@material-ui/core/Button" +import RefreshIcon from "@material-ui/icons/Refresh" +import { useTranslation } from "react-i18next" + +type AlertBannerCtasProps = Pick & { + setOpen: (arg0: boolean) => void +} + +export const AlertBannerCtas: FC = ({ + actions = [], + dismissible, + retry, + setOpen, +}) => { + const { t } = useTranslation("common") + + return ( + + {/* CTAs passed in by the consumer */} + {actions.length > 0 && actions.map((action) =>
{action}
)} + + {/* retry CTA */} + {retry && ( +
+ +
+ )} + + {/* close CTA */} + {dismissible && ( + + )} +
+ ) +} diff --git a/site/src/components/AlertBanner/alertTypes.ts b/site/src/components/AlertBanner/alertTypes.ts new file mode 100644 index 0000000000000..154c2c9a2cdab --- /dev/null +++ b/site/src/components/AlertBanner/alertTypes.ts @@ -0,0 +1,13 @@ +import { ApiError } from "api/errors" +import { ReactElement } from "react" + +export type Severity = "warning" | "error" + +export interface AlertBannerProps { + severity: Severity + text?: string + error?: ApiError | Error | unknown + actions?: ReactElement[] + dismissible?: boolean + retry?: () => void +} diff --git a/site/src/components/AlertBanner/severityConstants.tsx b/site/src/components/AlertBanner/severityConstants.tsx new file mode 100644 index 0000000000000..832b851047487 --- /dev/null +++ b/site/src/components/AlertBanner/severityConstants.tsx @@ -0,0 +1,16 @@ +import ReportProblemOutlinedIcon from "@material-ui/icons/ReportProblemOutlined" +import ErrorOutlineOutlinedIcon from "@material-ui/icons/ErrorOutlineOutlined" +import { colors } from "theme/colors" +import { Severity } from "./alertTypes" +import { ReactElement } from "react" + +export const severityConstants: Record = { + warning: { + color: colors.orange[7], + icon: , + }, + error: { + color: colors.red[7], + icon: , + }, +} diff --git a/site/src/components/ErrorSummary/ErrorSummary.test.tsx b/site/src/components/ErrorSummary/ErrorSummary.test.tsx index a6d222e0e3860..1fda20d683fd9 100644 --- a/site/src/components/ErrorSummary/ErrorSummary.test.tsx +++ b/site/src/components/ErrorSummary/ErrorSummary.test.tsx @@ -1,5 +1,8 @@ import { fireEvent, render, screen } from "@testing-library/react" import { ErrorSummary } from "./ErrorSummary" +import { i18n } from "i18n" + +const { t } = i18n describe("ErrorSummary", () => { it("renders", async () => { @@ -26,7 +29,8 @@ describe("ErrorSummary", () => { render() // Then - fireEvent.click(screen.getByText("More")) + const expandText = t("ctas.expand", { ns: "common" }) + fireEvent.click(screen.getByText(expandText)) const element = await screen.findByText( "The resource you requested does not exist in the database.", { exact: false }, @@ -48,8 +52,11 @@ describe("ErrorSummary", () => { render() // Then - fireEvent.click(screen.getByText("More")) - fireEvent.click(screen.getByText("Less")) + const expandText = t("ctas.expand", { ns: "common" }) + const collapseText = t("ctas.collapse", { ns: "common" }) + + fireEvent.click(screen.getByText(expandText)) + fireEvent.click(screen.getByText(collapseText)) const element = await screen.findByText( "The resource you requested does not exist in the database.", { exact: false }, diff --git a/site/src/components/Expander/Expander.tsx b/site/src/components/Expander/Expander.tsx index c11a180088382..78dfdeaa97e73 100644 --- a/site/src/components/Expander/Expander.tsx +++ b/site/src/components/Expander/Expander.tsx @@ -1,45 +1,66 @@ import Link from "@material-ui/core/Link" import makeStyles from "@material-ui/core/styles/makeStyles" import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" - -const Language = { - expand: "More", - collapse: "Less", -} +import { PropsWithChildren, FC } from "react" +import Collapse from "@material-ui/core/Collapse" +import { useTranslation } from "react-i18next" +import { combineClasses } from "util/combineClasses" export interface ExpanderProps { expanded: boolean setExpanded: (val: boolean) => void } -export const Expander: React.FC = ({ expanded, setExpanded }) => { - const toggleExpanded = () => setExpanded(!expanded) +export const Expander: FC> = ({ + expanded, + setExpanded, + children, +}) => { const styles = useStyles() + const { t } = useTranslation("common") + + const toggleExpanded = () => setExpanded(!expanded) + return ( - - {expanded ? ( - - {Language.collapse} - {" "} - - ) : ( - - {Language.expand} - - + <> + {!expanded && ( + + + {t("ctas.expand")} + + + + )} + +
{children}
+
+ {expanded && ( + + + {t("ctas.collapse")} + + + )} - + ) } const useStyles = makeStyles((theme) => ({ expandLink: { cursor: "pointer", - color: theme.palette.text.primary, - display: "flex", + color: theme.palette.text.secondary, + }, + collapseLink: { + marginTop: `${theme.spacing(2)}px`, }, text: { display: "flex", alignItems: "center", + color: theme.palette.text.secondary, + fontSize: theme.typography.caption.fontSize, }, })) diff --git a/site/src/components/LicenseBanner/LicenseBannerView.tsx b/site/src/components/LicenseBanner/LicenseBannerView.tsx index 5d63d23bedb67..bb10cd2b85a0b 100644 --- a/site/src/components/LicenseBanner/LicenseBannerView.tsx +++ b/site/src/components/LicenseBanner/LicenseBannerView.tsx @@ -1,4 +1,3 @@ -import Collapse from "@material-ui/core/Collapse" import { makeStyles } from "@material-ui/core/styles" import { Expander } from "components/Expander/Expander" import { Pill } from "components/Pill/Pill" @@ -43,17 +42,16 @@ export const LicenseBannerView: React.FC = ({ warnings } {Language.upgrade} - + +
    + {warnings.map((warning) => ( +
  • + {warning} +
  • + ))} +
+
- -
    - {warnings.map((warning) => ( -
  • - {warning} -
  • - ))} -
-
) } diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index e68063fe9e94b..8671936670f2c 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -15,7 +15,7 @@ import { WorkspaceScheduleBanner } from "../WorkspaceScheduleBanner/WorkspaceSch import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" -import { WarningAlert } from "../WarningAlert/WarningAlert" +import { AlertBanner } from "../AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" export enum WorkspaceErrors { @@ -87,7 +87,11 @@ export const Workspace: FC> = ({ ) const workspaceRefreshWarning = Boolean(workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]) && ( - + ) return ( diff --git a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx index 63ca251c3a8b6..769f2dda72811 100644 --- a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx +++ b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx @@ -1,7 +1,7 @@ import Button from "@material-ui/core/Button" import { FC } from "react" -import * as TypesGen from "../../api/typesGenerated" -import { WarningAlert } from "components/WarningAlert/WarningAlert" +import * as TypesGen from "api/typesGenerated" +import { AlertBanner } from "components/AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" import { Maybe } from "components/Conditionals/Maybe" @@ -15,6 +15,7 @@ export const WorkspaceDeletedBanner: FC { const { t } = useTranslation("workspacePage") + const NewWorkspaceButton = (