diff --git a/package.json b/package.json index 0a6346bb..7e0c02a5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packages/*" ], "scripts": { - "ci": "yarn prettier && yarn lint && yarn sbuild && yarn dbuild && yarn test", + "ci": "yarn prettier && yarn lint && yarn dbuild && yarn sbuild && yarn test", "clean": "rimraf node_modules -g 'packages/*/.eslintcache' 'packages/*/*.tsbuildinfo' 'packages/*/dist' 'packages/*/.rollup.cache' 'packages/*/types' 'packages/*/coverage'", "dbuild": "npm-run-all 'dbuild:*'", "dbuild:utils": "yarn workspace @dolthub/web-utils dbuild", diff --git a/packages/components/.eslintignore b/packages/components/.eslintignore index 40100c3a..37757305 100644 --- a/packages/components/.eslintignore +++ b/packages/components/.eslintignore @@ -3,3 +3,4 @@ node_modules .eslintrc.js ./*.js storybook-static +coverage diff --git a/packages/components/.storybook/preview.tsx b/packages/components/.storybook/preview.tsx index baf21e15..973c10b3 100644 --- a/packages/components/.storybook/preview.tsx +++ b/packages/components/.storybook/preview.tsx @@ -1,6 +1,6 @@ import { Preview } from "@storybook/react"; import React from "react"; -import "../src/main.css"; +import "../src/styles/global.css"; import ThemeProvider from "../src/tailwind/context"; const preview: Preview = { diff --git a/packages/components/.stylelintrc.json b/packages/components/.stylelintrc.json new file mode 100644 index 00000000..06bb7e28 --- /dev/null +++ b/packages/components/.stylelintrc.json @@ -0,0 +1,13 @@ +{ + "extends": "stylelint-config-recommended", + "ignoreFiles": "coverage/**", + "rules": { + "at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "screen"] }], + "selector-pseudo-class-no-unknown": [ + true, + { + "ignorePseudoClasses": ["global"] + } + ] + } +} diff --git a/packages/components/package.json b/packages/components/package.json index 3d0645d9..8d97a978 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -2,7 +2,7 @@ "name": "@dolthub/react-components", "author": "DoltHub", "description": "A collection of React components for common tasks", - "version": "0.1.7", + "version": "0.1.8", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "types": "dist/index.d.ts", @@ -21,7 +21,9 @@ "compile": "tsc -b", "build": "rollup -c --bundleConfigAsCjs", "dbuild": "yarn compile && yarn build", - "lint": "eslint --cache --ext .ts,.js,.tsx,.jsx src", + "lint": "yarn lint-js-errors && yarn lint-css", + "lint-js-errors": "eslint --cache --ext .ts,.js,.tsx,.jsx src", + "lint-css": "stylelint \"**/*.css\"", "prettier": "prettier --check 'src/**/*.{js,ts,tsx}'", "prettier-fix": "prettier --write 'src/**/*.{js,ts,tsx}'", "npm:publish": "yarn dbuild && npm publish", @@ -30,18 +32,20 @@ "yalc:push": "yarn dbuild && yalc push", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "clean": "rm -rf dist && rm -rf .rollup.cache && rm -rf types && rm -rf tsconfig.tsbuildinfo" + "clean": "rm -rf dist && rm -rf .rollup.cache && rm -rf types && rm -rf tsconfig.tsbuildinfo && rm -rf .eslintcache" }, "peerDependencies": { "react": "^18", "react-dom": "^18" }, "dependencies": { + "@dolthub/react-hooks": "^0.1.7", "@dolthub/web-utils": "^0.1.3", "@react-icons/all-files": "^4.1.0", "classnames": "^2.5.1", "deepmerge": "^4.3.1", "github-markdown-css": "^5.5.1", + "react-copy-to-clipboard": "^5.1.0", "react-loader": "^2.4.7", "react-markdown": "^9.0.1", "reactjs-popup": "^2.0.6", @@ -75,6 +79,7 @@ "@types/jest": "^29.5.11", "@types/prop-types": "^15", "@types/react": "^18", + "@types/react-copy-to-clipboard": "^5", "@types/react-dom": "^18", "@types/react-loader": "^2", "@types/rollup-plugin-peer-deps-external": "^2", @@ -102,6 +107,8 @@ "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-terser": "^7.0.2", "storybook": "^7.6.17", + "stylelint": "^16.2.1", + "stylelint-config-recommended": "^14.0.0", "tslib": "^2.6.2", "typescript": "^5.3.3", "yalc": "^1.0.0-pre.53" diff --git a/packages/components/src/Button/index.module.css b/packages/components/src/Button/index.module.css index b96dfcc9..0da81453 100644 --- a/packages/components/src/Button/index.module.css +++ b/packages/components/src/Button/index.module.css @@ -45,6 +45,10 @@ @apply text-link-2; } + &:focus { + @apply outline-none widget-shadow-lightblue; + } + &:disabled { @apply text-ld-darkgrey; @@ -52,10 +56,6 @@ @apply text-ld-darkgrey; } } - - &:focus { - @apply outline-none widget-shadow-lightblue; - } } .dark { diff --git a/packages/components/src/ButtonWithPopup/index.module.css b/packages/components/src/ButtonWithPopup/index.module.css new file mode 100644 index 00000000..3102e1d7 --- /dev/null +++ b/packages/components/src/ButtonWithPopup/index.module.css @@ -0,0 +1,19 @@ +.triggerButton { + @apply rounded px-3 py-1 text-sm font-semibold border border-primary whitespace-nowrap bg-transparent flex items-center justify-center; + + &:focus { + @apply outline-none widget-shadow-lightblue; + } +} + +.withoutText { + @apply border-0 p-0 rounded-none inline-block; +} + +.caret { + @apply pl-2 text-lg; +} + +.caretWithoutText { + @apply pl-0 inline-block; +} diff --git a/packages/components/src/ButtonWithPopup/index.tsx b/packages/components/src/ButtonWithPopup/index.tsx new file mode 100644 index 00000000..c17eee65 --- /dev/null +++ b/packages/components/src/ButtonWithPopup/index.tsx @@ -0,0 +1,72 @@ +import { FaCaretDown } from "@react-icons/all-files/fa/FaCaretDown"; +import { FaCaretUp } from "@react-icons/all-files/fa/FaCaretUp"; +import cx from "classnames"; +import React, { ReactNode } from "react"; +import Popup, { PopupProps } from "../Popup"; +import css from "./index.module.css"; + +export type Props = Partial & { + children: ReactNode; + isOpen?: boolean; + setIsOpen?: (o: boolean) => void; + triggerText?: string; + buttonClassName?: string; + ["data-cy"]?: string; +}; + +export default function ButtonWithPopup({ + children, + isOpen, + setIsOpen, + triggerText, + ...props +}: Props) { + const openProps: Partial = + isOpen !== undefined && setIsOpen !== undefined + ? { + open: isOpen, + onOpen: () => setIsOpen(true), + onClose: () => setIsOpen(false), + } + : {}; + return ( + ( + + )} + // props must come last to override default props above + {...props} + > + {children} + + ); +} diff --git a/packages/components/src/CellDropdown/index.module.css b/packages/components/src/CellDropdown/index.module.css new file mode 100644 index 00000000..c286c627 --- /dev/null +++ b/packages/components/src/CellDropdown/index.module.css @@ -0,0 +1,39 @@ +.cellDropdown { + @apply hidden; + + @screen md { + @apply block; + } +} + +.button { + @apply bg-white rounded-full; + + &:focus { + @apply outline-none widget-shadow-lightblue; + } +} + +.rowButton { + @apply bg-ld-lightgrey; +} + +.icon { + @apply p-1 w-5 h-5 text-ld-darkgrey; + + &:hover { + @apply text-primary; + } +} + +.rowIcon { + @apply text-ld-darkergrey; +} + +.dropdown { + @apply absolute bg-white widget-shadow-hover z-100 rounded-b top-8 px-3 py-1 border-b border-x border-ld-lightgrey max-w-36 right-0 flex flex-col; +} + +.rowDropdown { + @apply -right-20 top-9; +} diff --git a/packages/components/src/CellDropdown/index.tsx b/packages/components/src/CellDropdown/index.tsx new file mode 100644 index 00000000..f720399a --- /dev/null +++ b/packages/components/src/CellDropdown/index.tsx @@ -0,0 +1,55 @@ +import { useOnClickOutside } from "@dolthub/react-hooks"; +import { RiMenu5Line } from "@react-icons/all-files/ri/RiMenu5Line"; +import cx from "classnames"; +import React, { ReactNode, useRef } from "react"; +import css from "./index.module.css"; + +type Props = { + children: ReactNode; + showDropdown: boolean; + setShowDropdown: (s: boolean) => void; + buttonClassName: string; + dropdownClassName?: string; + forRow?: boolean; + ["data-cy"]?: string; +}; + +export default function CellDropdown({ + setShowDropdown, + forRow = false, + ...props +}: Props) { + const toggle = () => setShowDropdown(!props.showDropdown); + const dropdownRef = useRef(null); + useOnClickOutside(dropdownRef, () => setShowDropdown(false)); + + return ( +
+ + {props.showDropdown && ( +
+ {props.children} +
+ )} +
+ ); +} diff --git a/packages/components/src/CodeBlock/index.module.css b/packages/components/src/CodeBlock/index.module.css new file mode 100644 index 00000000..48ff5f87 --- /dev/null +++ b/packages/components/src/CodeBlock/index.module.css @@ -0,0 +1,47 @@ +.code { + @apply text-left p-6 mb-4 rounded-lg bg-code-background; + + code { + @apply text-white text-sm leading-loose; + } +} + +.disabled { + @apply bg-ld-darkgrey; +} + +.clipboard { + @apply p-2 mx-2 rounded h-10 min-w-[37px] text-white flex items-center justify-center; + + &:hover { + @apply bg-ld-darkgrey; + } +} + +.withCopy { + @apply py-1 pr-0 mt-3; + + > div { + @apply flex justify-between items-center; + } + + pre { + @apply overflow-x-auto mt-[0.4rem] mb-[0.3rem]; + /* Hide scrollbar for IE, Edge, and Firefox */ + -ms-overflow-style: none; + scrollbar-width: none; + } + + /* Hide scrollbar for Chrome, Safari and Opera */ + pre::-webkit-scrollbar { + @apply hidden; + } +} + +.smallCopy { + @apply py-0.5 pl-4; + + .clipboard { + @apply p-1 h-7 min-w-[28px]; + } +} diff --git a/packages/components/src/CodeBlock/index.tsx b/packages/components/src/CodeBlock/index.tsx new file mode 100644 index 00000000..ab4e8c67 --- /dev/null +++ b/packages/components/src/CodeBlock/index.tsx @@ -0,0 +1,63 @@ +import { useDelay } from "@dolthub/react-hooks"; +import { FaRegClone } from "@react-icons/all-files/fa/FaRegClone"; +import cx from "classnames"; +import React, { ReactNode } from "react"; +import CopyToClipboard from "react-copy-to-clipboard"; +import Btn from "../Btn"; +import css from "./index.module.css"; + +type Props = { + children: ReactNode; + className?: string; + disabled?: boolean; +}; + +const CodeBlock = ({ children, className, disabled = false }: Props) => ( +
+ {children} +
+); + +type CopyProps = { + textToCopy: string; + children?: ReactNode; + className?: string; + ["data-cy"]?: string; + disabled?: boolean; + small?: boolean; +}; + +const WithCopyButton = (props: CopyProps) => { + const copySuccess = useDelay(); + return ( + +
+
+          
+            {copySuccess.active
+              ? "Copied to clipboard"
+              : props.children ?? props.textToCopy}
+          
+        
+ {!props.disabled && ( + + + + + + )} +
+
+ ); +}; + +CodeBlock.WithCopyButton = WithCopyButton; + +export default CodeBlock; diff --git a/packages/components/src/Markdown/index.tsx b/packages/components/src/Markdown/index.tsx index fa8ced74..68a63e9d 100644 --- a/packages/components/src/Markdown/index.tsx +++ b/packages/components/src/Markdown/index.tsx @@ -11,7 +11,6 @@ type Props = { ["data-cy"]?: string; forModal?: boolean; baseTextSize?: boolean; - isDoc?: boolean; }; export default function Markdown({ @@ -26,7 +25,6 @@ export default function Markdown({ "markdown-body", css.preview, { - "markdown-doc": props.isDoc, [css.forModal]: forModal, [css.baseText]: baseTextSize, }, diff --git a/packages/components/src/Radio/index.module.css b/packages/components/src/Radio/index.module.css index c8e9bef0..4e4db13d 100644 --- a/packages/components/src/Radio/index.module.css +++ b/packages/components/src/Radio/index.module.css @@ -19,12 +19,9 @@ } .container input:checked ~ .radio { - @apply border-button-2; + @apply border-button-2 bg-white; } -.container input:checked ~ .radio { - @apply bg-white; -} .container input:focus ~ .radio { @apply widget-shadow-lightblue; } diff --git a/packages/components/src/SmallLoader/index.tsx b/packages/components/src/SmallLoader/index.tsx index a4b80d05..610928da 100644 --- a/packages/components/src/SmallLoader/index.tsx +++ b/packages/components/src/SmallLoader/index.tsx @@ -32,30 +32,28 @@ type Props = { children?: ReactNode; }; -export default function SmallLoader(props: Props) { - return ( -
- -
- ); -} +const SmallLoader = (props: Props) => ( +
+ +
+); type WithTextProps = { text: string; outerClassName?: string; } & Props; -function WithText(props: WithTextProps) { - return ( -
- - {!props.loaded && {props.text}} -
- ); -} +const WithText = (props: WithTextProps) => ( +
+ + {!props.loaded && {props.text}} +
+); SmallLoader.WithText = WithText; + +export default SmallLoader; diff --git a/packages/components/src/__stories__/Button.stories.tsx b/packages/components/src/__stories__/Button.stories.tsx index 942603db..b312cab4 100644 --- a/packages/components/src/__stories__/Button.stories.tsx +++ b/packages/components/src/__stories__/Button.stories.tsx @@ -47,6 +47,9 @@ export const White: Story = { white: true, pill: true, }, + parameters: { + backgrounds: { default: "dark" }, + }, }; export const Gradient: Story = { diff --git a/packages/components/src/__stories__/ButtonWithPopup.stories.tsx b/packages/components/src/__stories__/ButtonWithPopup.stories.tsx new file mode 100644 index 00000000..ab709063 --- /dev/null +++ b/packages/components/src/__stories__/ButtonWithPopup.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import ButtonWithPopup from "../ButtonWithPopup"; + +const meta: Meta = { + title: "ButtonWithPopup", + component: ButtonWithPopup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + children:
Items
, + triggerText: "Action", + }, +}; + +export const NoText: Story = { + args: { + children:
Items
, + }, +}; diff --git a/packages/components/src/__stories__/CellDropdown.stories.tsx b/packages/components/src/__stories__/CellDropdown.stories.tsx new file mode 100644 index 00000000..b88bc20a --- /dev/null +++ b/packages/components/src/__stories__/CellDropdown.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import cx from "classnames"; +import React from "react"; +import CellDropdown from "../CellDropdown"; + +const meta: Meta = { + title: "CellDropdown", + component: CellDropdown, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const cellClassName = + "align-top relative min-w-[130px] pr-10 pl-2 border-b border-ld-lightgrey text-primary font-mono leading-8 text-sm"; +const buttonClassName = "absolute bg-white right-2 top-[0.35rem]"; + +const Cell = (props: { children: React.ReactNode; className?: string }) => ( + {props.children} +); + +const HeadCell = (props: { children: React.ReactNode; className?: string }) => ( + {props.children} +); + +const CellWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + Header 1 + Header 2 + + + + + + Cell 1 + {children} + + Cell 2 + + + Cell 1 + Cell 2 + + +
+); + +const HeadCellWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + Header 1{children} + Header 2 + + + + + Cell 1 + Cell 2 + + + Cell 1 + Cell 2 + + +
+); + +const RowWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + + + + + Cell 1 + Cell 2 + + + + +
+ Header 1 + Header 2 +
{children}
+ Cell 1 + Cell 2 +
+); + +const children = ( +
    +
  • First item
  • +
  • Second item
  • +
+); + +export const NoShow: Story = { + args: { + children, + showDropdown: false, + setShowDropdown: () => {}, + buttonClassName, + }, + decorators: [story => {story()}], +}; + +export const ShowCell: Story = { + args: { + children, + showDropdown: true, + setShowDropdown: () => {}, + buttonClassName, + }, + decorators: [story => {story()}], +}; + +export const ShowHeadCell: Story = { + args: { + children, + showDropdown: true, + setShowDropdown: () => {}, + buttonClassName, + }, + decorators: [story => {story()}], +}; + +export const ShowRow: Story = { + args: { + children, + showDropdown: true, + setShowDropdown: () => {}, + forRow: true, + buttonClassName: "text-ld-darkergrey mx-2 text-xl flex", + }, + decorators: [story => {story()}], +}; diff --git a/packages/components/src/__stories__/CodeBlock.stories.tsx b/packages/components/src/__stories__/CodeBlock.stories.tsx new file mode 100644 index 00000000..6a8c5abc --- /dev/null +++ b/packages/components/src/__stories__/CodeBlock.stories.tsx @@ -0,0 +1,49 @@ +import { nTimesWithIndex } from "@dolthub/web-utils"; +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import CodeBlock from "../CodeBlock"; + +const meta: Meta = { + title: "CodeBlock", + component: CodeBlock, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: const a = 1 + b;, + }, +}; + +export const Disabled: Story = { + args: { + children: const a = 1 + b;, + disabled: true, + }, +}; + +export const MultiLine: Story = { + args: { + children: ( + + const a = 1 + b; +
+ const c = 2 + d; +
+ ), + }, +}; + +export const Long: Story = { + args: { + children: ( + + {nTimesWithIndex(10, i => `const a${i} = ${i} + b;`).join(" ")} + + ), + }, +}; diff --git a/packages/components/src/__stories__/CodeBlockWithCopyButton.stories.tsx b/packages/components/src/__stories__/CodeBlockWithCopyButton.stories.tsx new file mode 100644 index 00000000..9617d9ad --- /dev/null +++ b/packages/components/src/__stories__/CodeBlockWithCopyButton.stories.tsx @@ -0,0 +1,53 @@ +import { nTimesWithIndex } from "@dolthub/web-utils"; +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import CodeBlock from "../CodeBlock"; + +const meta: Meta = { + title: "CodeBlock.WithCopyButton", + component: CodeBlock.WithCopyButton, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + textToCopy: "const a = 1 + b;", + }, +}; + +export const WithChildren: Story = { + args: { + children: const a = 1 + b;, + }, +}; + +export const Disabled: Story = { + args: { + textToCopy: "const a = 1 + b;", + disabled: true, + }, +}; + +export const MultiLine: Story = { + args: { + textToCopy: `const a = 1 + b; +const b = 1 + a;`, + }, +}; + +export const Long: Story = { + args: { + textToCopy: nTimesWithIndex(10, i => `const a${i} = ${i} + b;`).join(" "), + }, +}; + +export const Small: Story = { + args: { + textToCopy: "const a = 1 + b;", + small: true, + }, +}; diff --git a/packages/components/src/__stories__/Markdown.stories.tsx b/packages/components/src/__stories__/Markdown.stories.tsx index 748ddddc..6b46f384 100644 --- a/packages/components/src/__stories__/Markdown.stories.tsx +++ b/packages/components/src/__stories__/Markdown.stories.tsx @@ -45,10 +45,3 @@ export const BaseText: Story = { baseTextSize: true, }, }; - -export const ForDoc: Story = { - args: { - value: markdown, - isDoc: true, - }, -}; diff --git a/packages/components/src/__stories__/Popup.stories.tsx b/packages/components/src/__stories__/Popup.stories.tsx index c4c64be2..1a3eadbc 100644 --- a/packages/components/src/__stories__/Popup.stories.tsx +++ b/packages/components/src/__stories__/Popup.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; +import Button from "../Button"; import Popup from "../Popup"; const meta: Meta = { @@ -18,14 +19,22 @@ type Story = StoryObj; export const Basic: Story = { args: { children: Show me on click, - trigger: , + trigger: ( +
+ Click me +
+ ), }, }; export const Hover: Story = { args: { children: Show me on hover, - trigger: , + trigger: ( +
+ Hover over me +
+ ), on: "hover", }, }; diff --git a/packages/components/src/__stories__/SmallLoader.stories.tsx b/packages/components/src/__stories__/SmallLoader.stories.tsx new file mode 100644 index 00000000..6d6fcbb0 --- /dev/null +++ b/packages/components/src/__stories__/SmallLoader.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import SmallLoader from "../SmallLoader"; + +const meta: Meta = { + title: "SmallLoader", + component: SmallLoader, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const NotLoadedNoChild: Story = { + args: { + loaded: false, + }, +}; + +export const NotLoadedWithChild: Story = { + args: { + loaded: false, + children: Loaded, + }, +}; + +export const LoadedWithChild: Story = { + args: { + loaded: true, + children: Loaded, + }, +}; + +export const LoadedNoChild: Story = { + args: { + loaded: true, + }, +}; + +export const NotLoadedWithStyles: Story = { + args: { + loaded: false, + options: { + color: "red", + lines: 4, + radius: 4, + shadow: true, + speed: 0.5, + opacity: 0.2, + }, + }, +}; diff --git a/packages/components/src/__stories__/SmallLoaderWithText.stories.tsx b/packages/components/src/__stories__/SmallLoaderWithText.stories.tsx new file mode 100644 index 00000000..13cb33cc --- /dev/null +++ b/packages/components/src/__stories__/SmallLoaderWithText.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import SmallLoader from "../SmallLoader"; + +const meta: Meta = { + title: "SmallLoader.WithText", + component: SmallLoader.WithText, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const NotLoadedNoChild: Story = { + args: { + loaded: false, + text: "Loading...", + }, +}; + +export const NotLoadedWithChild: Story = { + args: { + loaded: false, + children: Loaded, + text: "Loading...", + }, +}; + +export const LoadedWithChild: Story = { + args: { + loaded: true, + children: Loaded, + text: "Loading...", + }, +}; + +export const LoadedNoChild: Story = { + args: { + loaded: true, + text: "Loading...", + }, +}; + +export const NotLoadedWithStyles: Story = { + args: { + loaded: false, + text: "Loading...", + options: { + color: "red", + lines: 4, + radius: 4, + shadow: true, + speed: 0.5, + opacity: 0.2, + }, + }, +}; diff --git a/packages/components/src/__tests__/Button.test.tsx b/packages/components/src/__tests__/Button.test.tsx index b19b537c..f3538677 100644 --- a/packages/components/src/__tests__/Button.test.tsx +++ b/packages/components/src/__tests__/Button.test.tsx @@ -116,6 +116,15 @@ describe("test Button", () => { expect(button).toHaveClass(/underlined/); }); + it("renders an outlined button", () => { + render(Button Name); + + const button = screen.getByText("Button Name"); + expect(button).toHaveTextContent("Button Name"); + expect(button).toHaveAttribute("type", "button"); + expect(button).toHaveClass(/outlined/); + }); + it("renders a Button Group", () => { render( diff --git a/packages/components/src/__tests__/ButtonWithPopup.test.tsx b/packages/components/src/__tests__/ButtonWithPopup.test.tsx new file mode 100644 index 00000000..18bba0ac --- /dev/null +++ b/packages/components/src/__tests__/ButtonWithPopup.test.tsx @@ -0,0 +1,242 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import ButtonWithPopup, { Props } from "../ButtonWithPopup"; + +// Taken from https://github.com/yjose/reactjs-popup/blob/master/__test__/index.test.tsx + +const SimplePopup = ({ ...props }: Partial) => ( + + popup Content + +); +const popupContentShouldntExist = () => { + expect(screen.queryByText(/popup Content/)).toBeNull(); +}; +const popupContentShouldExist = () => { + expect(screen.getByText(/popup Content/)).toBeInTheDocument(); +}; + +describe("test ButtonWithPopup ", () => { + test("should render trigger correctly", () => { + render(); + expect(screen.getByText(/trigger/)).toBeInTheDocument(); + }); + + test("should be a tooltip by default", () => { + render(); + fireEvent.click(screen.getByText("trigger")); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + }); + + test("should use open props to open", async () => { + const setIsOpen = jest.fn(); + render(); + fireEvent.click(screen.getByText("trigger")); + await waitFor(() => expect(setIsOpen).toHaveBeenCalledWith(true)); + }); + + test("should use open props to close", async () => { + const setIsOpen = jest.fn(); + render(); + fireEvent.click(screen.getByText("trigger")); + await waitFor(() => expect(setIsOpen).toHaveBeenCalledWith(false)); + }); + + test("no Arrow for modal", () => { + render(); + fireEvent.click(screen.getByText("trigger")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.queryByTestId("arrow")).toBeNull(); + }); + + test("no Arrow on arrow= false", () => { + render(); + fireEvent.click(screen.getByText("trigger")); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + expect(screen.queryByTestId("arrow")).toBeNull(); + }); + + test("should render a Modal on modal=true", () => { + render(); + fireEvent.click(screen.getByText("trigger")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + test("it should be closed on disabled = true ", () => { + render(); + popupContentShouldntExist(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + }); + test("should be open by default on defaultOpen= true ", () => { + render(); + popupContentShouldExist(); + }); + + test("should call onOpen & onClose functions ", async () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + render(); + + fireEvent.click(screen.getByText("trigger")); + await waitFor(() => { + expect(onOpen).toHaveBeenCalled(); + const [event] = onOpen.mock.calls[0]; + expect("target" in event).toBe(true); + }); + fireEvent.click(screen.getByText("trigger")); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + const [event] = onClose.mock.calls[0]; + expect("target" in event).toBe(true); + }); + // expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + test("should be closed on Escape", async () => { + render(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.keyUp(document, { key: "Escape", code: "Escape" }); + popupContentShouldntExist(); + }); + test("shouldnt close on Escape if closeOnEscape=false", async () => { + render(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.keyUp(document, { key: "Escape", code: "Escape" }); + popupContentShouldExist(); + }); + + test("should be closed on ClickOutside ", async () => { + render(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.mouseDown(document); + popupContentShouldntExist(); + }); + test("shouldnt close on ClickOutside if closeOnDocumentClick=false", async () => { + render(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.mouseDown(document); + popupContentShouldExist(); + }); + + test("should lock Document Scroll on lockScroll=true", async () => { + render(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + expect(document.body).toHaveStyle(`overflow: hidden`); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + expect(document.body).toHaveStyle(`overflow: auto`); + }); +}); + +// test for "on" props status +describe('Popup Component with "on" Prop ', () => { + test("it should be opened only on Click as default value ", () => { + render(); + popupContentShouldntExist(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + }); + + test('it should be opened only on Click where on="click" ', () => { + render(); + popupContentShouldntExist(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + }); + test('it should be opened only on Right-Click where on="right-click" ', () => { + render(); + popupContentShouldntExist(); + fireEvent.contextMenu(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.contextMenu(screen.getByText("trigger")); + popupContentShouldntExist(); + }); + test('it should be opened only on Hover where on="hover" ', async () => { + render(); + popupContentShouldntExist(); + fireEvent.mouseOver(screen.getByText("trigger")); + await waitFor( + () => popupContentShouldExist(), + { timeout: 120 }, // default delay = "100" + ); + fireEvent.mouseLeave(screen.getByText("trigger")); + + await waitFor( + () => expect(screen.queryByText(/popup Content/)).toBeNull(), + { timeout: 120 }, + ); + // should not show on click + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + }); + test('it should be opened only on Focus where on="focus" ', async () => { + render(); + popupContentShouldntExist(); + fireEvent.focus(screen.getByText("trigger")); + await waitFor( + () => popupContentShouldExist(), + { timeout: 120 }, // default delay = "100" + ); + fireEvent.blur(screen.getByText("trigger")); + await waitFor(() => popupContentShouldntExist(), { timeout: 120 }); + // should not show content on click + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + }); + test('it should be opened on Focus & click & focus where on=["focus","click","hover"] ', async () => { + render(); + popupContentShouldntExist(); + // on focus + fireEvent.focus(screen.getByText("trigger")); + await waitFor( + () => popupContentShouldExist(), + { timeout: 120 }, // default delay = "100" + ); + fireEvent.blur(screen.getByText("trigger")); + await waitFor(() => popupContentShouldntExist(), { timeout: 120 }); + // on click + fireEvent.click(screen.getByText("trigger")); + popupContentShouldExist(); + fireEvent.click(screen.getByText("trigger")); + popupContentShouldntExist(); + + // on Hover + fireEvent.mouseOver(screen.getByText("trigger")); + await waitFor( + () => popupContentShouldExist(), + { timeout: 120 }, // default delay = "100" + ); + fireEvent.mouseLeave(screen.getByText("trigger")); + + await waitFor( + () => expect(screen.queryByText(/popup Content/)).toBeNull(), + { timeout: 120 }, + ); + }); + + test("should respect mouseEnterDelay mouseLeaveDelay on Hover ", async () => { + render( + , + ); + popupContentShouldntExist(); + fireEvent.mouseOver(screen.getByText("trigger")); + await waitFor(() => popupContentShouldntExist(), { timeout: 120 }); + await waitFor(() => popupContentShouldExist(), { timeout: 1000 }); + fireEvent.mouseLeave(screen.getByText("trigger")); + + await waitFor(() => popupContentShouldExist(), { timeout: 120 }); + await waitFor(() => popupContentShouldntExist(), { timeout: 1000 }); + }); +}); diff --git a/packages/components/src/__tests__/CellDropdown.test.tsx b/packages/components/src/__tests__/CellDropdown.test.tsx new file mode 100644 index 00000000..8bb687f5 --- /dev/null +++ b/packages/components/src/__tests__/CellDropdown.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import CellDropdown from "../CellDropdown"; +import { setup } from "./testUtils.test"; + +describe("test CellDropdown", () => { + it("renders without crashing", () => { + render( + {}} + buttonClassName="" + > +
Dropdown Content
+
, + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("toggles dropdown visibility on button click", async () => { + const setShowDropdown = jest.fn(); + const { user } = setup( + +
Dropdown Content
+
, + ); + + const button = screen.getByRole("button"); + await user.click(button); + expect(setShowDropdown).toHaveBeenCalledWith(true); + }); + + it("closes dropdown when clicking outside", async () => { + const setShowDropdown = jest.fn(); + const { user } = setup( +
+ +
Dropdown Content
+
+
Outside Area
+
, + ); + + const outsideArea = screen.getByTestId("outside"); + await user.click(outsideArea); + expect(setShowDropdown).toHaveBeenCalledWith(false); + }); + + it("applies correct class based on `forRow` prop", () => { + const { rerender } = render( + {}} + buttonClassName="" + forRow={false} + > +
Dropdown Content
+
, + ); + + expect(screen.getByRole("button")).not.toHaveClass(/rowButton/); + + rerender( + {}} + buttonClassName="" + forRow + > +
Dropdown Content
+
, + ); + + expect(screen.getByRole("button")).toHaveClass(/rowButton/); + }); +}); diff --git a/packages/components/src/__tests__/Checkbox.test.tsx b/packages/components/src/__tests__/Checkbox.test.tsx index c0e2e131..50a8bb2c 100644 --- a/packages/components/src/__tests__/Checkbox.test.tsx +++ b/packages/components/src/__tests__/Checkbox.test.tsx @@ -7,7 +7,7 @@ describe("test Checkbox", () => { const mocks = [ { name: "one", label: "one-label" }, { name: "two", label: "two-label" }, - { name: "three", label: "three-label" }, + { name: "three", label: "three-label", description: "description" }, ]; mocks.forEach((mock, ind) => { @@ -23,6 +23,7 @@ describe("test Checkbox", () => { checked={checked} className="classname" label={mock.label} + description={mock.description} />, ); @@ -36,6 +37,10 @@ describe("test Checkbox", () => { expect(input).not.toBeChecked(); } + if (mock.description) { + expect(screen.getByText(mock.description)).toBeVisible(); + } + await user.click(screen.getByLabelText(mock.label)); expect(onChangeValue).toHaveBeenCalled(); }); diff --git a/packages/components/src/__tests__/CodeBlock.test.tsx b/packages/components/src/__tests__/CodeBlock.test.tsx new file mode 100644 index 00000000..1cc75fcb --- /dev/null +++ b/packages/components/src/__tests__/CodeBlock.test.tsx @@ -0,0 +1,20 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import CodeBlock from "../CodeBlock"; + +describe("test CodeBlock", () => { + it("displays the provided children", () => { + render( + + Some test code + , + ); + expect(screen.getByTestId("code-test")).toHaveTextContent("Some test code"); + }); + + it("shows Copied to clipboard after clicking on the copy button", async () => { + render(); + fireEvent.click(screen.getByRole("button")); + await screen.findByText("Copied to clipboard"); + }); +}); diff --git a/packages/components/src/__tests__/FormInput.test.tsx b/packages/components/src/__tests__/FormInput.test.tsx index 1c81817d..200be40d 100644 --- a/packages/components/src/__tests__/FormInput.test.tsx +++ b/packages/components/src/__tests__/FormInput.test.tsx @@ -29,20 +29,43 @@ describe("test FormInput", () => { expect(input).toHaveValue("new name"); }); - it("renders form input with value", () => { - render( + it("renders form input with onChangeString", async () => { + const onChangeString = jest.fn(); + const { user } = setup( , + ); + + const input = screen.getByPlaceholderText("Placeholder text"); + expect(input).toBeVisible(); + expect(input).toHaveAttribute("type", "text"); + + await user.type(input, "new name"); + expect(onChangeString).toHaveBeenCalledWith("new name"); + expect(input).toHaveValue("new name"); + }); + + it("renders form input with value and description", () => { + render( + , ); - const input = screen.getByPlaceholderText("Placeholder text"); + const input = screen.getByRole("textbox"); expect(input).toBeVisible(); expect(input).toHaveAttribute("type", "email"); expect(input).toHaveValue("email@email.com"); + expect(input).toHaveAttribute("placeholder", ""); + + expect(screen.getByText("description")).toBeVisible(); }); }); diff --git a/packages/components/src/__tests__/Radio.test.tsx b/packages/components/src/__tests__/Radio.test.tsx index b76c7f9f..e7116253 100644 --- a/packages/components/src/__tests__/Radio.test.tsx +++ b/packages/components/src/__tests__/Radio.test.tsx @@ -7,7 +7,7 @@ describe("test Radio", () => { const mocks = [ { name: "one", label: "one-label" }, { name: "two", label: "two-label" }, - { name: "three", label: "three-label" }, + { name: "three", label: "three-label", description: "description" }, ]; mocks.forEach((mock, ind) => { @@ -23,10 +23,17 @@ describe("test Radio", () => { checked={checked} className="classname" disabled={disabled} + description={mock.description} />, ); + const content = screen.getByLabelText(mock.label); expect(content).toBeVisible(); + + if (mock.description) { + expect(screen.getByText(mock.description)).toBeVisible(); + } + if (!disabled) { const input = screen.getByRole("radio"); if (checked) { diff --git a/packages/components/src/__tests__/Textarea.test.tsx b/packages/components/src/__tests__/Textarea.test.tsx index 5e0ee46c..fcb82537 100644 --- a/packages/components/src/__tests__/Textarea.test.tsx +++ b/packages/components/src/__tests__/Textarea.test.tsx @@ -29,20 +29,42 @@ describe("test Textarea", () => { expect(input).toHaveValue("new name"); }); + it("renders textarea with onChangeString", async () => { + const onChangeString = jest.fn(); + const { user } = setup( +