Skip to content

Commit

Permalink
Merge pull request #98 from dolthub/taylor/error-modal
Browse files Browse the repository at this point in the history
components: ErrorMsg, ButtonsWithError, Modal
  • Loading branch information
tbantle22 authored Apr 4, 2024
2 parents fcd2709 + 134a124 commit 34cd5d3
Show file tree
Hide file tree
Showing 20 changed files with 796 additions and 4 deletions.
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "DoltHub"
},
"description": "A collection of React components for common tasks",
"version": "0.1.12",
"version": "0.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/dolthub/react-library.git"
Expand Down
26 changes: 26 additions & 0 deletions packages/components/src/ButtonsWithError/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.stackedButton {
@apply flex flex-col;
button {
@apply mb-4 h-10;
}
}

.error {
@apply max-w-lg text-center;
}

.group {
@apply text-center mt-4;
}

.cancel {
@apply px-2;
}

.left {
@apply text-left ml-0;
}

.right {
@apply text-right mr-0;
}
59 changes: 59 additions & 0 deletions packages/components/src/ButtonsWithError/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import cx from "classnames";
import React, { ReactNode } from "react";
import Button from "../Button";
import ErrorMsg from "../ErrorMsg";
import css from "./index.module.css";

type Props = {
onCancel?: () => void;
error?: Error;
children: ReactNode;
className?: string;
left?: boolean;
stackedButton?: boolean;
right?: boolean;
["data-cy"]?: string;
cancelText?: string;
};

export default function ButtonsWithError({
children,
onCancel,
error,
className,
left = false,
stackedButton = false,
right = false,
...props
}: Props) {
return (
<div className={className} aria-label="buttons-with-error">
<Button.Group
className={cx(css.group, {
[css.left]: left,
[css.stackedButton]: stackedButton,
[css.right]: right,
})}
data-cy={props["data-cy"]}
>
{children}
{!!onCancel && (
<Button.Link
onClick={onCancel}
data-cy="cancel-button"
className={css.cancel}
>
{props.cancelText ?? "cancel"}
</Button.Link>
)}
</Button.Group>
<ErrorMsg
className={cx(css.error, {
[css.left]: left,
[css.right]: right,
})}
err={error}
/>
</div>
);
}
65 changes: 65 additions & 0 deletions packages/components/src/ErrorMsg/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, { createContext, useCallback, useContext, useMemo } from "react";

type Props = {
children: React.ReactNode;
improveErrorMsgFn?: (m: string) => string;
renderDifferentComp?: (m: string) => JSX.Element | null;
};

type ErrorMsgContextType = {
improveErrorMsg: (m: string) => string;
renderDifferentComp?: (m: string) => JSX.Element | null;
};

const ErrorMsgContext = createContext<ErrorMsgContextType>({
improveErrorMsg: (m: string) => m,
renderDifferentComp: () => null,
});

// ErrorMsgProvider sets improveErrorMsg and renderDifferentComp functions in
// context. Should wrap all pages that use ErrorMsg.
export default function ErrorMsgProvider({
children,
improveErrorMsgFn,
renderDifferentComp,
}: Props) {
const improveErrorMsg = useCallback(
(msg: string): string => {
if (isTimeoutError(msg)) {
return "Request timed out. Please try again.";
}
return improveErrorMsgFn ? improveErrorMsgFn(msg) : msg;
},
[improveErrorMsgFn],
);

const value = useMemo(() => {
return { improveErrorMsg, renderDifferentComp };
}, [improveErrorMsg, renderDifferentComp]);

return (
<ErrorMsgContext.Provider value={value}>
{children}
</ErrorMsgContext.Provider>
);
}

export function useErrorContext(): ErrorMsgContextType {
return useContext(ErrorMsgContext);
}

export function isTimeoutError(err: string): boolean {
return (
err.includes("upstream request timeout") ||
err.includes("query error: timeout") ||
err.includes("Unexpected token 'u'") ||
// Chrome and Edge
err.includes("Unexpected token u in JSON at position 0") ||
// Safari
err.includes(`Unexpected identifier "upstream"`) ||
// Firefox
err.includes("unexpected character at line 1 column 1 of the JSON data") ||
err.includes("Failed to fetch") ||
err.includes("Received status code 504")
);
}
3 changes: 3 additions & 0 deletions packages/components/src/ErrorMsg/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.errorMsg {
@apply text-acc-red font-semibold mx-auto pt-6;
}
37 changes: 37 additions & 0 deletions packages/components/src/ErrorMsg/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import cx from "classnames";
import React from "react";
import { useErrorContext } from "./context";
import css from "./index.module.css";

type Props = {
err?: Error;
errString?: string;
className?: string;
};

export default function ErrorMsg({ err, errString, className }: Props) {
const { improveErrorMsg, renderDifferentComp } = useErrorContext();

const msg = (() => {
if (err) return improveErrorMsg(err.message);
if (errString) return improveErrorMsg(errString);
return null;
})();

if (!msg) return null;
const customComp = renderDifferentComp ? renderDifferentComp(msg) : null;
if (customComp !== null) {
return React.cloneElement(customComp, {
className: cx(css.errorMsg, className),
});
}

const splitMsg = msg.split("\n").filter(Boolean);
return (
<div className={cx(css.errorMsg, className)} aria-label="error-msg">
{splitMsg.map(m => (
<div key={m}>{m}</div>
))}
</div>
);
}
38 changes: 38 additions & 0 deletions packages/components/src/Modal/ForForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { SyntheticEvent } from "react";
import { ModalButtons, ModalInner, ModalOuter, OuterProps } from ".";
import Button from "../Button";

type Props = OuterProps & {
onSubmit: (e: SyntheticEvent) => void | Promise<void>;
err?: Error;

// Button props
btnText: string;
buttonDataCy?: string;
pill?: boolean;
red?: boolean;
disabled?: boolean;
gradient?: boolean;
};

export default function FormModal({ children, ...props }: Props) {
return (
<ModalOuter {...props}>
<form onSubmit={props.onSubmit}>
<ModalInner>{children}</ModalInner>
<ModalButtons onRequestClose={props.onRequestClose} err={props.err}>
<Button
type="submit"
pill={props.pill}
red={props.red}
gradient={props.gradient}
disabled={props.disabled}
data-cy={props.buttonDataCy}
>
{props.btnText}
</Button>
</ModalButtons>
</form>
</ModalOuter>
);
}
44 changes: 44 additions & 0 deletions packages/components/src/Modal/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.hide {
@apply hidden;
}

.overlay {
@apply fixed top-0 left-0 w-full h-full bg-acc-darkgrey/70;
z-index: 1000;
}

.modal {
@apply fixed bg-white h-auto top-1/2 left-1/2 rounded-lg -translate-x-1/2 -translate-y-1/2 w-[325px] md:w-[500px];
}

.top {
@apply flex justify-between items-center py-4 px-6 border-b;
}

.inner {
@apply p-6 bg-ld-grey;
p {
@apply mb-6;
}
form {
@apply text-left;
}
}

.buttons {
@apply flex justify-end items-center py-4 px-6 border-t;
}

.close {
svg {
@apply text-lg;
}
}

.error {
@apply pt-0 max-w-[275px] pr-4 ml-0 text-sm;
}

.cancel {
@apply pr-4;
}
90 changes: 90 additions & 0 deletions packages/components/src/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useOnClickOutside } from "@dolthub/react-hooks";
import { IoMdClose } from "@react-icons/all-files/io/IoMdClose";
import cx from "classnames";
import React, { ReactNode, useRef } from "react";
import Btn from "../Btn";
import Button from "../Button";
import ErrorMsg from "../ErrorMsg";
import css from "./index.module.css";

type ButtonProps = {
onRequestClose: () => void | Promise<void>;
children?: ReactNode;
err?: Error;
};

export type OuterProps = {
onRequestClose: () => void | Promise<void>;
children: ReactNode;
title: string;
className?: string;
overlayClassName?: string;
isOpen: boolean;
};

type Props = OuterProps & {
button?: ReactNode;
err?: Error;
};

function Inner(props: Omit<OuterProps, "isOpen">) {
const modalRef = useRef<HTMLDivElement>(null);
useOnClickOutside(modalRef, props.onRequestClose);

return (
<div className={cx(css.overlay, props.overlayClassName)}>
<div
className={cx(css.modal, props.className)}
ref={modalRef}
role="dialog"
>
<div className={css.top}>
<h3 data-cy="modal-title">{props.title}</h3>
<Btn
className={css.close}
onClick={props.onRequestClose}
data-cy="close-modal"
>
<span aria-label="close">
<IoMdClose />
</span>
</Btn>
</div>
<div>{props.children}</div>
</div>
</div>
);
}

export function ModalOuter(props: OuterProps) {
if (!props.isOpen) return null;
return <Inner {...props} />;
}

export function ModalInner(props: { children: ReactNode; className?: string }) {
return <div className={cx(css.inner, props.className)}>{props.children}</div>;
}

export function ModalButtons(props: ButtonProps) {
return (
<div className={css.buttons} data-cy="modal-buttons">
<ErrorMsg className={css.error} err={props.err} />
<Button.Group>
<Button.Link className={css.cancel} onClick={props.onRequestClose}>
Cancel
</Button.Link>
{props.children ?? <Button onClick={props.onRequestClose}>OK</Button>}
</Button.Group>
</div>
);
}

export default function Modal({ children, ...props }: Props) {
if (!props.isOpen) return null;
return (
<ModalOuter {...props}>
<ModalInner>{children}</ModalInner>
<ModalButtons {...props}>{props.button}</ModalButtons>
</ModalOuter>
);
}
Loading

0 comments on commit 34cd5d3

Please sign in to comment.