From ee1d999e33c504b9bd683e39ac5d79e21800ab00 Mon Sep 17 00:00:00 2001 From: Wesley B <62723358+wesleyboar@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:12:56 -0600 Subject: [PATCH 1/7] feat!: blind copy from tup-ui Well, the .gitignore update is not blind. --- .eslintrc.json | 18 ++ .gitignore | 4 +- .storybook/main.ts | 20 ++ .storybook/preview.global.css | 4 + .storybook/preview.ts | 1 + README.md | 153 +++++++++---- package-lock.json | 17 ++ package.json | 58 ++++- project.json | 66 ++++++ src/index.ts | 38 ++++ src/lib/Button/Button.module.css | 9 + src/lib/Button/Button.stories.module.css | 3 + src/lib/Button/Button.stories.tsx | 44 ++++ src/lib/Button/Button.test.jsx | 109 ++++++++++ src/lib/Button/Button.tsx | 116 ++++++++++ src/lib/Button/index.ts | 3 + src/lib/Checkbox/Checkbox.jsx | 53 +++++ src/lib/Checkbox/Checkbox.module.css | 23 ++ src/lib/Checkbox/Checkbox.test.jsx | 27 +++ src/lib/Checkbox/index.js | 3 + src/lib/Collapse/Collapse.module.css | 21 ++ src/lib/Collapse/Collapse.tsx | 61 ++++++ src/lib/Collapse/index.ts | 3 + src/lib/DescriptionList/DescriptionList.jsx | 77 +++++++ .../DescriptionList.module.css | 54 +++++ .../DescriptionList/DescriptionList.test.jsx | 73 +++++++ src/lib/DescriptionList/index.js | 3 + src/lib/DropdownSelector/DropdownSelector.jsx | 48 ++++ .../DropdownSelector.module.css | 68 ++++++ .../DropdownSelector.test.jsx | 20 ++ src/lib/DropdownSelector/index.js | 3 + .../FieldWrapperFormik.global.css | 12 + .../FieldWrapperFormik/FieldWrapperFormik.tsx | 45 ++++ .../fields/FormikCheck.module.css | 4 + .../FieldWrapperFormik/fields/FormikCheck.tsx | 36 +++ .../FormikFileInput/FileDropzone.module.css | 57 +++++ .../FormikFileInput/FileDropzone.test.tsx | 46 ++++ .../fields/FormikFileInput/FileDropzone.tsx | 120 ++++++++++ .../FormikFileInput/FormikFileInput.tsx | 61 ++++++ .../FieldWrapperFormik/fields/FormikInput.tsx | 29 +++ .../fields/FormikSelect.tsx | 32 +++ .../fields/FormikTextarea.tsx | 29 +++ .../FieldWrapperFormik/fields/formikPatch.ts | 82 +++++++ src/lib/FieldWrapperFormik/fields/index.ts | 31 +++ src/lib/FieldWrapperFormik/fields/styles.css | 4 + src/lib/FieldWrapperFormik/index.ts | 8 + src/lib/Form/FormField.global.css | 14 ++ src/lib/Form/FormField.jsx | 168 ++++++++++++++ src/lib/Form/index.js | 1 + src/lib/HistoryBadge/HistoryBadge.jsx | 25 +++ src/lib/HistoryBadge/HistoryBadge.module.css | 17 ++ src/lib/HistoryBadge/HistoryBadge.test.jsx | 17 ++ src/lib/HistoryBadge/index.js | 1 + src/lib/Icon/Icon.test.jsx | 32 +++ src/lib/Icon/Icon.tsx | 36 +++ src/lib/Icon/index.ts | 3 + .../InfiniteScrollTable.css | 79 +++++++ .../InfiniteScrollTable.jsx | 137 ++++++++++++ .../InfiniteScrollTable.module.css | 3 + .../InfiniteScrollTable.test.jsx | 51 +++++ src/lib/InfiniteScrollTable/index.js | 3 + src/lib/InlineMessage/InlineMessage.jsx | 28 +++ src/lib/InlineMessage/index.js | 3 + .../LoadingSpinner/LoadingSpinner.global.css | 104 +++++++++ .../LoadingSpinner/LoadingSpinner.test.jsx | 11 + src/lib/LoadingSpinner/LoadingSpinner.tsx | 22 ++ src/lib/LoadingSpinner/index.ts | 3 + src/lib/Message/Message.jsx | 205 ++++++++++++++++++ src/lib/Message/Message.module.css | 149 +++++++++++++ src/lib/Message/Message.test.jsx | 146 +++++++++++++ src/lib/Message/index.ts | 3 + src/lib/Navbar/Navbar.module.css | 59 +++++ src/lib/Navbar/Navbar.tsx | 112 ++++++++++ src/lib/Navbar/index.ts | 2 + src/lib/Paginator/Paginator.jsx | 104 +++++++++ src/lib/Paginator/Paginator.module.css | 25 +++ src/lib/Paginator/Paginator.test.jsx | 26 +++ src/lib/Paginator/index.js | 3 + src/lib/Pill/Pill.jsx | 31 +++ src/lib/Pill/Pill.module.css | 46 ++++ src/lib/Pill/Pill.test.jsx | 11 + src/lib/Pill/index.js | 3 + src/lib/QueryWrapper/QueryWrapper.tsx | 36 +++ src/lib/QueryWrapper/index.ts | 3 + src/lib/Section/Section.jsx | 200 +++++++++++++++++ src/lib/Section/Section.module.css | 7 + src/lib/Section/Section.test.tsx | 56 +++++ src/lib/Section/index.js | 1 + src/lib/SectionContent/SectionContent.jsx | 92 ++++++++ .../SectionContent.layouts.module.css | 87 ++++++++ .../SectionContent/SectionContent.module.css | 19 ++ .../SectionContent/SectionContent.test.jsx | 75 +++++++ src/lib/SectionContent/index.js | 6 + src/lib/SectionHeader/SectionHeader.jsx | 93 ++++++++ .../SectionHeader/SectionHeader.module.css | 40 ++++ src/lib/SectionHeader/SectionHeader.test.jsx | 64 ++++++ src/lib/SectionHeader/index.js | 1 + src/lib/SectionMessage/SectionMessage.jsx | 50 +++++ .../SectionMessage/SectionMessage.test.jsx | 24 ++ src/lib/SectionMessage/index.js | 3 + .../SectionTableWrapper.jsx | 199 +++++++++++++++++ .../SectionTableWrapper.module.css | 33 +++ .../SectionTableWrapper.test.jsx | 79 +++++++ src/lib/SectionTableWrapper/index.js | 1 + src/lib/ShowMore/ShowMore.jsx | 52 +++++ src/lib/ShowMore/ShowMore.module.css | 9 + src/lib/ShowMore/index.js | 3 + src/lib/Sidebar/Sidebar.jsx | 85 ++++++++ src/lib/Sidebar/Sidebar.module.css | 49 +++++ src/lib/Sidebar/Sidebar.test.jsx | 34 +++ src/lib/Sidebar/index.js | 3 + .../SubmitWrapper/SubmitWrapper.module.css | 18 ++ src/lib/SubmitWrapper/SubmitWrapper.tsx | 47 ++++ src/lib/SubmitWrapper/index.ts | 3 + .../TextCopyField/TextCopyField.module.css | 18 ++ src/lib/TextCopyField/TextCopyField.tsx | 75 +++++++ src/lib/TextCopyField/index.ts | 3 + src/lib/Wizard/Wizard.module.css | 45 ++++ src/lib/Wizard/Wizard.tsx | 194 +++++++++++++++++ src/lib/Wizard/index.ts | 15 ++ src/utils/withBuilder.test.tsx | 17 ++ src/utils/withBuilder.tsx | 70 ++++++ tsconfig.json | 26 +++ tsconfig.lib.json | 26 +++ tsconfig.spec.json | 19 ++ tsconfig.storybook.json | 35 +++ vite.config.ts | 83 +++++++ 127 files changed, 5515 insertions(+), 62 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .storybook/main.ts create mode 100644 .storybook/preview.global.css create mode 100644 .storybook/preview.ts create mode 100644 package-lock.json create mode 100644 project.json create mode 100644 src/index.ts create mode 100644 src/lib/Button/Button.module.css create mode 100644 src/lib/Button/Button.stories.module.css create mode 100644 src/lib/Button/Button.stories.tsx create mode 100644 src/lib/Button/Button.test.jsx create mode 100644 src/lib/Button/Button.tsx create mode 100644 src/lib/Button/index.ts create mode 100644 src/lib/Checkbox/Checkbox.jsx create mode 100644 src/lib/Checkbox/Checkbox.module.css create mode 100644 src/lib/Checkbox/Checkbox.test.jsx create mode 100644 src/lib/Checkbox/index.js create mode 100644 src/lib/Collapse/Collapse.module.css create mode 100644 src/lib/Collapse/Collapse.tsx create mode 100644 src/lib/Collapse/index.ts create mode 100644 src/lib/DescriptionList/DescriptionList.jsx create mode 100644 src/lib/DescriptionList/DescriptionList.module.css create mode 100644 src/lib/DescriptionList/DescriptionList.test.jsx create mode 100644 src/lib/DescriptionList/index.js create mode 100644 src/lib/DropdownSelector/DropdownSelector.jsx create mode 100644 src/lib/DropdownSelector/DropdownSelector.module.css create mode 100644 src/lib/DropdownSelector/DropdownSelector.test.jsx create mode 100644 src/lib/DropdownSelector/index.js create mode 100644 src/lib/FieldWrapperFormik/FieldWrapperFormik.global.css create mode 100644 src/lib/FieldWrapperFormik/FieldWrapperFormik.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/FormikCheck.module.css create mode 100644 src/lib/FieldWrapperFormik/fields/FormikCheck.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.module.css create mode 100644 src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.test.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/FormikFileInput/FormikFileInput.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/FormikInput.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/FormikSelect.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/FormikTextarea.tsx create mode 100644 src/lib/FieldWrapperFormik/fields/formikPatch.ts create mode 100644 src/lib/FieldWrapperFormik/fields/index.ts create mode 100644 src/lib/FieldWrapperFormik/fields/styles.css create mode 100644 src/lib/FieldWrapperFormik/index.ts create mode 100644 src/lib/Form/FormField.global.css create mode 100644 src/lib/Form/FormField.jsx create mode 100644 src/lib/Form/index.js create mode 100644 src/lib/HistoryBadge/HistoryBadge.jsx create mode 100644 src/lib/HistoryBadge/HistoryBadge.module.css create mode 100644 src/lib/HistoryBadge/HistoryBadge.test.jsx create mode 100644 src/lib/HistoryBadge/index.js create mode 100644 src/lib/Icon/Icon.test.jsx create mode 100644 src/lib/Icon/Icon.tsx create mode 100644 src/lib/Icon/index.ts create mode 100644 src/lib/InfiniteScrollTable/InfiniteScrollTable.css create mode 100644 src/lib/InfiniteScrollTable/InfiniteScrollTable.jsx create mode 100644 src/lib/InfiniteScrollTable/InfiniteScrollTable.module.css create mode 100644 src/lib/InfiniteScrollTable/InfiniteScrollTable.test.jsx create mode 100644 src/lib/InfiniteScrollTable/index.js create mode 100644 src/lib/InlineMessage/InlineMessage.jsx create mode 100644 src/lib/InlineMessage/index.js create mode 100644 src/lib/LoadingSpinner/LoadingSpinner.global.css create mode 100644 src/lib/LoadingSpinner/LoadingSpinner.test.jsx create mode 100644 src/lib/LoadingSpinner/LoadingSpinner.tsx create mode 100644 src/lib/LoadingSpinner/index.ts create mode 100644 src/lib/Message/Message.jsx create mode 100644 src/lib/Message/Message.module.css create mode 100644 src/lib/Message/Message.test.jsx create mode 100644 src/lib/Message/index.ts create mode 100644 src/lib/Navbar/Navbar.module.css create mode 100644 src/lib/Navbar/Navbar.tsx create mode 100644 src/lib/Navbar/index.ts create mode 100644 src/lib/Paginator/Paginator.jsx create mode 100644 src/lib/Paginator/Paginator.module.css create mode 100644 src/lib/Paginator/Paginator.test.jsx create mode 100644 src/lib/Paginator/index.js create mode 100644 src/lib/Pill/Pill.jsx create mode 100644 src/lib/Pill/Pill.module.css create mode 100644 src/lib/Pill/Pill.test.jsx create mode 100644 src/lib/Pill/index.js create mode 100644 src/lib/QueryWrapper/QueryWrapper.tsx create mode 100644 src/lib/QueryWrapper/index.ts create mode 100644 src/lib/Section/Section.jsx create mode 100644 src/lib/Section/Section.module.css create mode 100644 src/lib/Section/Section.test.tsx create mode 100644 src/lib/Section/index.js create mode 100644 src/lib/SectionContent/SectionContent.jsx create mode 100644 src/lib/SectionContent/SectionContent.layouts.module.css create mode 100644 src/lib/SectionContent/SectionContent.module.css create mode 100644 src/lib/SectionContent/SectionContent.test.jsx create mode 100644 src/lib/SectionContent/index.js create mode 100644 src/lib/SectionHeader/SectionHeader.jsx create mode 100644 src/lib/SectionHeader/SectionHeader.module.css create mode 100644 src/lib/SectionHeader/SectionHeader.test.jsx create mode 100644 src/lib/SectionHeader/index.js create mode 100644 src/lib/SectionMessage/SectionMessage.jsx create mode 100644 src/lib/SectionMessage/SectionMessage.test.jsx create mode 100644 src/lib/SectionMessage/index.js create mode 100644 src/lib/SectionTableWrapper/SectionTableWrapper.jsx create mode 100644 src/lib/SectionTableWrapper/SectionTableWrapper.module.css create mode 100644 src/lib/SectionTableWrapper/SectionTableWrapper.test.jsx create mode 100644 src/lib/SectionTableWrapper/index.js create mode 100644 src/lib/ShowMore/ShowMore.jsx create mode 100644 src/lib/ShowMore/ShowMore.module.css create mode 100644 src/lib/ShowMore/index.js create mode 100644 src/lib/Sidebar/Sidebar.jsx create mode 100644 src/lib/Sidebar/Sidebar.module.css create mode 100644 src/lib/Sidebar/Sidebar.test.jsx create mode 100644 src/lib/Sidebar/index.js create mode 100644 src/lib/SubmitWrapper/SubmitWrapper.module.css create mode 100644 src/lib/SubmitWrapper/SubmitWrapper.tsx create mode 100644 src/lib/SubmitWrapper/index.ts create mode 100644 src/lib/TextCopyField/TextCopyField.module.css create mode 100644 src/lib/TextCopyField/TextCopyField.tsx create mode 100644 src/lib/TextCopyField/index.ts create mode 100644 src/lib/Wizard/Wizard.module.css create mode 100644 src/lib/Wizard/Wizard.tsx create mode 100644 src/lib/Wizard/index.ts create mode 100644 src/utils/withBuilder.test.tsx create mode 100644 src/utils/withBuilder.tsx create mode 100644 tsconfig.json create mode 100644 tsconfig.lib.json create mode 100644 tsconfig.spec.json create mode 100644 tsconfig.storybook.json create mode 100644 vite.config.ts diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..a39ac5d --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/.gitignore b/.gitignore index c9c4e7c..829a615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ # Project node_modules -.postcssrc.yml -dist/_tests.css -_version.css +dist # IDE .vscode diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..6de5be1 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,20 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials'], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: 'libs/core-components/vite.config.ts', + }, + }, + }, +}; + +export default config; + +// To customize your Vite configuration you can use the viteFinal field. +// Check https://storybook.js.org/docs/react/builders/vite#configuration +// and https://nx.dev/recipes/storybook/custom-builder-configs diff --git a/.storybook/preview.global.css b/.storybook/preview.global.css new file mode 100644 index 0000000..e07fb8d --- /dev/null +++ b/.storybook/preview.global.css @@ -0,0 +1,4 @@ +/* https://tacc-main.atlassian.net/wiki/x/hRlv */ +@import url('https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css') layer(foundation); +@import url('@tacc/core-styles/dist/core-styles.base.css') layer(base); +@import url('@tacc/core-styles/dist/core-styles.portal.css') layer(base); diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..204ab3b --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1 @@ +import './preview.global.css'; diff --git a/README.md b/README.md index 5c799b1..3a99c18 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,146 @@ -# TACC Core Components +# TACC Core-Components The shared components for TACC WMA Workspace Portals & Websites -> __Notice__: This codebase is __not__ usable, yet. To test it, you may try the [`develop` branch](https://github.com/wesleyboar/Core-Components/tree/develop). +## Known Clients +- [TUP UI], the client code for TACC User Portal +- [Hazmapper], a TACC application for geospatial data + +[tup ui]: https://github.com/TACC/tup-ui +[hazmapper]: https://github.com/TACC-Cloud/hazmapper + +## Table of Contents + +- [Related Repositories](#related-repositories) +- [Project Architecture](#project-architecture) +- [Prerequisites](#prerequisites) +- [Getting Started](#getting-started) +- [Developing](#developing) +- [Contributing](#contributing) +- [Testing](#testing) ## Related Repositories -- [Core Portal], the base Portal code for TACC WMA Portals & Websites -- [Core Styles], the custom UI pattern code for TACC WMA Portals & Websites +- [Core Styles], the shared UI pattern code for TACC WMA CMS Websites +## Project Architecture -## External Project Usage +| directory | contents | +| --------- | ---------------------------- | +| `src/lib` | components, tests, [stories] | -1. Install the package with any package manager e.g.: - - `npm install TACC/Core-Styles` - - `yarn add TACC/Core-Styles` +## Prerequisites -2. Import components of either type: - - pre-transpiled, from `/dist` - - source files, from `/source` +- [Node.js] -## Local Development Setup +## Getting Started -### Prequisites for Building the Components +1. Install with any package manager e.g. -* Nodejs 17.x -* Typescript 4.x + - `npm install @tacc/core-styles` + - `yarn add @tacc/core-styles` -### Code Configuration +2. Import component(s) e.g. -Code configuration happens in repos that use these styles. + ```ts + import { Button } from '@tacc/core-components'; + ``` -### Source Files + ```ts + import { + FormikInput, + FormikTextarea, + FormikCheck, + } from '@tacc/core-components'; + ``` -If you changes files in a `source/` directory, you may need to follow some of these steps. +3. Use component(s)… -#### Quick Start + > **Sorry.** Examples are limited and incomplete: + > + > - [TACC-Cloud/hazmapper#239](https://github.com/TACC-Cloud/hazmapper/pull/239/files) + > - [TACC/tup-ui#465](https://github.com/TACC/tup-ui/pull/465/files) + > - [TACC/tup-ui@ee5e73b:`/.../Button.stories.tsx`](https://github.com/TACC/tup-ui/blob/ee5e73b/libs/core-components/src/lib/Button/Button.stories.tsx#L26-L37) -1. _(optional)_ Make changes to `/source` files. -2. Transpile the styles: `npm run build` -3. _(to debug)_ Review respective `/dist` files' content. +## Developing -## Testing +The components are [React components](https://react.dev/learn) that should be [written in TypeScript](https://react.dev/learn/typescript#typescript-with-react-components). -Plugin testing is done manually. Run `npm run build` from root folder in this project, then review output in `/dist/_tests.css`, to ensure plugins are working correctly. +### Setup -Style testing is done manually. Run `npm start` from root folder in this project, then review output at web interface, to ensure styles are rendering correctly. +0. [Clone this Repository.](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) +1. Enter the Repository Clone: -### Production Deployment + ```sh + cd tup-ui + ``` -The Core Components are not deployed alone. +2. Install Dependencies: -_For now_, the stylesheets are acquired or accessed by other repositories. + ```sh + npm install --include=optional --workspace=libs/core-components + ``` -| Repo | Usage | -| - | - | -| __[Core CMS]__ | [via CLI installed on test branch](https://github.com/TACC/Core-CMS/compare/test/core-styles) | +3. Start demo: + ```sh + npx nx serve core-components + ``` + +For more commands, see [Commands](#commands). ## Contributing -### Development Workflow +### to Components + +#### Minimum Viable Product + +0. Create or Improve a common component in a TACC repository e.g. + + - https://github.com/TACC/tup-ui + - https://github.com/TACC/Core-Portal + - https://github.com/TACC-Cloud/hazmapper + +1. Put your work in a branch in this repository. +2. Open a [Pull Request](https://github.com/TACC/tup-ui/pulls). +3. [Test your work in a client repository.](#end-to-end-tests) + +#### Complete Contribution + +4. [Create a story](https://storybook.js.org/docs/writing-stories) to demo the component. +5. Create [unit tests](#unit-tests). + +### in the Demo + +| task | reference | +| ------------------ | ------------------------------------------------- | +| add/edit component | https://storybook.js.org/docs/writing-stories | +| change interaction | https://storybook.js.org/docs/essentials/controls | + +## Testing + +### Unit Tests -We use a modifed version of [GitFlow](https://datasift.github.io/gitflow/IntroducingGitFlow.html) as our development workflow. Our [development site](https://dev.cep.tacc.utexas.edu) (accessible behind the TACC Network) is always up-to-date with `main`, while the [production site](https://prod.cep.tacc.utexas.edu) is built to a hashed commit tag. -- Feature branches contain major updates, bug fixes, and hot fixes with respective branch prefixes: - - `task/` for features and updates - - `bug/` for bugfixes - - `fix/` for hotfixes +Run `nx test core-components` to execute the unit tests via [Vitest](https://vitest.dev/). -### Best Practices +### End-to-End Tests -Sign your commits ([see this link](https://help.github.com/en/github/authenticating-to-github/managing-commit-signature-verification) for help) +Perform manually by installing and testing the components in a separate respository. See [different approaches to testing your own packages](https://dev.to/one-beyond/different-approaches-to-testing-your-own-packages-1kdg). -### Resources +## Resources -* [Learn Markdown](https://bitbucket.org/tutorials/markdowndemo) +### Commands +| command | task | service | +| ---------------------------------------- | ------------------ | -------------------------------------- | +| `npx nx serve core-components` | start demo | [Storybook](https://storybook.js.org/) | +| `npx nx build core-components` | build components | [Vite](https://vitejs.dev/) | +| `npx nx build-storybook core-components` | build demo | [Storybook](https://storybook.js.org/) | +| `npx nx test core-components` | execute unit tests | [Vitest](https://vitest.dev/) | -[Core Portal Deployments]: https://github.com/TACC/Core-Portal-Deployments -[Camino]: https://github.com/TACC/Camino -[Core CMS]: https://github.com/TACC/Core-CMS -[Core Portal]: https://github.com/TACC/Core-Portal -[Core Styles]: https://github.com/TACC/Core-Styles +[core styles]: https://github.com/TACC/Core-Styles +[node.js]: https://nodejs.org/ +[stories]: https://storybook.js.org/docs/get-started/whats-a-story diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..382bfb4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "@tacc/core-components", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@tacc/core-components", + "version": "0.0.0", + "license": "MIT", + "engines": { + "node": ">=17.x", + "npm": ">=8.x" + } + } + } +} diff --git a/package.json b/package.json index e577865..67b3232 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,55 @@ { "name": "@tacc/core-components", - "version": "0.0.0", + "version": "0.0.3-beta.0", "license": "MIT", - "author": "TACC ACI WMA ", - "description": "UI components for TACC Core-Portal and TAPIS UI.", + "author": "TACC ACI WMA ", + "contributors": [ + "TACC COA CMD " + ], + "description": "React component library for TACC applications.", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "dist", - "source" + "dist" ], + "homepage": "https://github.com/TACC/tup-ui/libs/core-components", "scripts": { - "build": "echo 'No build command yet'" + "build": "vite build --outDir dist" + }, + "dependencies": { + "uuid": "^8 || ^9" }, - "engines": { - "node": ">=17.x", - "npm": ">=8.x" + "peerDependencies": { + "formik": "^2.2.9", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", + "react-resize-detector": "^7.1.2", + "react-router-dom": "^6.11.2", + "react-step-wizard": "^5.3.11", + "react-table": "^7.8.0", + "reactstrap": "^9.1.5" }, - "homepage": "https://github.com/TACC/Core-Components", - "repository": "git@github.com:TACC/Core-Components.git", - "dependencies": {} + "devDependencies": { + "@nx/react": "^17.2.8", + "@nx/vite": "^17.2.8", + "@tacc/core-styles": "^2.25.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.2", + "@vitejs/plugin-react-swc": "^3.5.0", + "vite": "^5.0.11", + "vite-plugin-dts": "^2.3.0", + "vite-plugin-lib-inject-css": "^2.1.1", + "vite-tsconfig-paths": "^4.2.0" + }, + "sideEffects": [ + "libs/core-componets/**/*.css" + ], + "optionalDependencies": { + "@nx/storybook": "^17.2.8", + "@storybook/addon-essentials": "^7.6.19", + "@storybook/core-server": "^7.6.19", + "@storybook/react-vite": "^7.6.19" + } } diff --git a/project.json b/project.json new file mode 100644 index 0000000..9a3d48a --- /dev/null +++ b/project.json @@ -0,0 +1,66 @@ +{ + "name": "core-components", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/core-components/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "libs/core-components/dist" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/libs/core-components"], + "options": { + "passWithNoTests": true, + "reportsDirectory": "../../coverage/libs/core-components" + } + }, + "serve": { + "executor": "@nx/storybook:storybook", + "options": { + "port": 4400, + "configDir": "libs/core-components/.storybook" + }, + "configurations": { + "ci": { + "quiet": true + } + } + }, + "storybook": { + "executor": "@nx/storybook:storybook", + "options": { + "port": 4400, + "configDir": "libs/core-components/.storybook" + }, + "configurations": { + "ci": { + "quiet": true + } + } + }, + "build-storybook": { + "executor": "@nx/storybook:build", + "outputs": ["{options.outputDir}"], + "options": { + "outputDir": "dist/storybook/core-components", + "configDir": "libs/core-components/.storybook" + }, + "configurations": { + "ci": { + "quiet": true + } + } + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e396bb4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +import { WizardStep as WizardStepType } from './lib/Wizard'; + +export { default as Button } from './lib/Button'; +export { default as Icon } from './lib/Icon'; +export { default as Section } from './lib/Section'; +export { default as SectionHeader } from './lib/SectionHeader'; +export { default as InlineMessage } from './lib/InlineMessage'; +export { default as SectionMessage } from './lib/SectionMessage'; +export { default as LoadingSpinner } from './lib/LoadingSpinner'; +export { default as DescriptionList } from './lib/DescriptionList'; +export { default as Message } from './lib/Message'; +export { default as Paginator } from './lib/Paginator'; +export { default as Pill } from './lib/Pill'; +export { default as DropdownSelector } from './lib/DropdownSelector'; +export { default as ShowMore } from './lib/ShowMore'; +export { default as SectionTableWrapper } from './lib/SectionTableWrapper'; +export { default as InfiniteScrollTable } from './lib/InfiniteScrollTable'; +export { default as Sidebar } from './lib/Sidebar'; +export { default as HistoryBadge } from './lib/HistoryBadge'; +export { default as Collapse } from './lib/Collapse'; +export { default as TextCopyField } from './lib/TextCopyField'; +export * from './lib/Form'; + +export { Navbar, NavItem, QueryNavItem, AnchorNavItem } from './lib/Navbar'; +export { default as QueryWrapper } from './lib/QueryWrapper'; +export { default as SubmitWrapper } from './lib/SubmitWrapper'; +export { default as Wizard, useWizard, WizardNavigation } from './lib/Wizard'; +export type WizardStep = WizardStepType; +export { + FieldWrapperFormik, + FormikInput, + FormikSelect, + FormikCheck, + FormikTextarea, + FormikFileInput, +} from './lib/FieldWrapperFormik'; + +export { default as withBuilder } from './utils/withBuilder'; diff --git a/src/lib/Button/Button.module.css b/src/lib/Button/Button.module.css new file mode 100644 index 0000000..8218b85 --- /dev/null +++ b/src/lib/Button/Button.module.css @@ -0,0 +1,9 @@ +.root { + position: relative; +} +.root .loading-over-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/lib/Button/Button.stories.module.css b/src/lib/Button/Button.stories.module.css new file mode 100644 index 0000000..e5e1d86 --- /dev/null +++ b/src/lib/Button/Button.stories.module.css @@ -0,0 +1,3 @@ +.button + .button { + margin-left: 1em; +} diff --git a/src/lib/Button/Button.stories.tsx b/src/lib/Button/Button.stories.tsx new file mode 100644 index 0000000..57d6a08 --- /dev/null +++ b/src/lib/Button/Button.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Button, { SIZE_MAP } from './Button'; + +import styles from './Button.stories.module.css'; + +const meta: Meta = { + component: Button, + argTypes: { + size: { + options: Object.keys(SIZE_MAP), + control: { type: 'inline-radio' }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Types: Story = { + render: (args) => { + const { size, ...argsSansSize } = args; + + return ( + <> + + + + + + ); + }, + args: { + className: styles['button'] + ' ', + }, +}; diff --git a/src/lib/Button/Button.test.jsx b/src/lib/Button/Button.test.jsx new file mode 100644 index 0000000..194f899 --- /dev/null +++ b/src/lib/Button/Button.test.jsx @@ -0,0 +1,109 @@ +// WARNING: Relies on `Icon` because of `getByRole('img')` +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import Button, * as BTN from './Button'; + +const TEST_TEXT = '…'; +const TEST_TYPE = 'primary'; +const TEST_SIZE = 'medium'; + +function testClassnamesByType(type, size, getByRole, getByTestId) { + const root = getByRole('button'); + const typeClassName = BTN.TYPE_MAP[type]; + const sizeClassName = BTN.SIZE_MAP[size]; + expect(root.className).toMatch('root'); + expect(root.className).toMatch(new RegExp(typeClassName)); + expect(root.className).toMatch(new RegExp(sizeClassName)); + expect(root.textContent).toMatch(TEST_TEXT); +} + +function isPropertyLimitation(type, size) { + let isLimited = false; + + if ( + (type === 'primary' && size === 'small') || + (type !== 'link' && !size) || + (type === 'link' && size) + ) + isLimited = true; + + return isLimited; +} + +describe('Button', () => { + it('uses given text', () => { + const { getByRole } = render(); + expect(getByRole('button').textContent).toEqual(TEST_TEXT); + }); + + describe('icons exist as expected when', () => { + test('only `iconNameBefore` is given', () => { + const { queryByTestId } = render( + + ); + expect(queryByTestId('icon-before')).toBeInTheDocument(); + expect(queryByTestId('icon-after')).not.toBeInTheDocument(); + }); + test('only `iconNameAfter` is given', () => { + const { queryByTestId } = render( + + ); + expect(queryByTestId('icon-before')).not.toBeInTheDocument(); + expect(queryByTestId('icon-after')).toBeInTheDocument(); + }); + test('both `iconNameAfter` and `iconNameBefore` are given', () => { + const { queryByTestId } = render( + + ); + expect(queryByTestId('icon-before')).toBeInTheDocument(); + expect(queryByTestId('icon-after')).toBeInTheDocument(); + }); + }); + + describe('all type & size combinations render accurately', () => { + it.each(BTN.TYPES)('type is "%s"', (type) => { + if (isPropertyLimitation(type, TEST_SIZE)) { + return Promise.resolve(); + } + const { getByRole, getByTestId } = render( + + ); + + testClassnamesByType(type, TEST_SIZE, getByRole, getByTestId); + }); + it.each(BTN.SIZES)('size is "%s"', (size) => { + if (isPropertyLimitation(TEST_TYPE, size)) { + return Promise.resolve(); + } + const { getByRole, getByTestId } = render( + + ); + + testClassnamesByType(TEST_TYPE, size, getByRole, getByTestId); + }); + }); + + describe('loading', () => { + it('does not render button without text', () => { + const { queryByTestId } = render( + + ); + const el = queryByTestId('no button here'); + expect(el).toBeNull(); + }); + it('disables button when in loading state', () => { + const { queryByText } = render( + + ); + const el = queryByText('Loading Button').closest('button'); + expect(el).toBeDisabled(); + }); + }); +}); diff --git a/src/lib/Button/Button.tsx b/src/lib/Button/Button.tsx new file mode 100644 index 0000000..b6faae0 --- /dev/null +++ b/src/lib/Button/Button.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import Icon from '../Icon'; +import LoadingSpinner from '../LoadingSpinner'; +import styles from './Button.module.css'; + +export const TYPE_MAP = { + primary: 'primary', + secondary: 'secondary', + tertiary: 'tertiary', + active: 'is-active', + link: 'as-link', +}; + +export const SIZE_MAP = { + short: 'width-short', + medium: 'width-medium', + long: 'width-long', + small: 'size-small', +}; + +export const TYPES = [''].concat(Object.keys(TYPE_MAP)); + +export const SIZES = [''].concat(Object.keys(SIZE_MAP)); + +export const ATTRIBUTES = ['button', 'submit', 'reset']; + +type ButtonTypeLinkSize = { + type?: 'link'; + size?: never; +}; +type ButtonTypeOtherSize = { + type?: 'primary' | 'secondary' | 'tertiary' | 'active'; + size?: 'short' | 'medium' | 'long' | 'small'; +}; + +type ButtonProps = React.PropsWithChildren<{ + className?: string; + iconNameBefore?: string; + iconNameAfter?: string; + id?: string; + dataTestid?: string; + disabled?: boolean; + onClick?: (e: React.MouseEvent) => void; + attr?: 'button' | 'submit' | 'reset'; + isLoading?: boolean; +}> & + (ButtonTypeLinkSize | ButtonTypeOtherSize); + +const Button: React.FC = ({ + children, + className, + iconNameBefore, + iconNameAfter, + id, + type = 'secondary', + size = '', + dataTestid, + disabled, + onClick, + attr = 'button', + isLoading = false, +}) => { + function onclick(e: React.MouseEvent) { + if (disabled) { + e.preventDefault(); + return; + } + if (onClick) { + return onClick(e); + } + } + + return ( + + ); +}; + +export default Button; diff --git a/src/lib/Button/index.ts b/src/lib/Button/index.ts new file mode 100644 index 0000000..803f51f --- /dev/null +++ b/src/lib/Button/index.ts @@ -0,0 +1,3 @@ +import Button from './Button'; + +export default Button; diff --git a/src/lib/Checkbox/Checkbox.jsx b/src/lib/Checkbox/Checkbox.jsx new file mode 100644 index 0000000..6773a66 --- /dev/null +++ b/src/lib/Checkbox/Checkbox.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Icon from '../Icon'; + +import styles from './Checkbox.module.css'; + +// RFE: Use (and style) an actual checkbox… `` +// and still support `DataFilesListingCells`'s button usage (how?) +// (this would also resolve the aria/lint complications noted below) +const Checkbox = ({ className, isChecked, tabIndex, role, ...props }) => { + const rootStyleNames = [ + styles['root'], + isChecked ? styles['is-checked'] : '', + ].join(' '); + + return ( + + + + + ); +}; +Checkbox.propTypes = { + /** Additional className for the root element */ + className: PropTypes.string, + /** Whether box should be checked */ + isChecked: PropTypes.bool, + /** Standard HTML attribute [tabindex] */ + tabIndex: PropTypes.number, + /** Standard HTML attribute [role] */ + role: PropTypes.string, +}; +Checkbox.defaultProps = { + className: '', + isChecked: false, + tabIndex: 0, + role: 'checkbox', +}; + +export default Checkbox; diff --git a/src/lib/Checkbox/Checkbox.module.css b/src/lib/Checkbox/Checkbox.module.css new file mode 100644 index 0000000..b300794 --- /dev/null +++ b/src/lib/Checkbox/Checkbox.module.css @@ -0,0 +1,23 @@ +@import url('@tacc/core-styles/dist/settings/color--portal.css'); + +/* HACK: Only necessary because icon sizes are not managed by yet */ +.root, +.root > * { + font-size: 1rem !important; /* override `.icon, .icon-set` */ +} + +/* Children */ + +.check { + background-color: var(--global-color-accent--normal); + color: var(--global-color-primary--xx-light); + + clip-path: inset(0.075em); /* to hide internal padding around box shape */ +} +.root:not(.is-checked) .check { + display: none; +} + +.box { + color: var(--global-color-primary--x-dark); +} diff --git a/src/lib/Checkbox/Checkbox.test.jsx b/src/lib/Checkbox/Checkbox.test.jsx new file mode 100644 index 0000000..6ff12cc --- /dev/null +++ b/src/lib/Checkbox/Checkbox.test.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import Checkbox from './Checkbox'; + +describe('Icon', () => { + it('has correct `className` (when not passed `isChecked`)', () => { + const { getByRole } = render(); + const el = getByRole('checkbox'); + expect(el.className).not.toMatch(`is-checked`); + }); + it('has correct `className` (when passed `isChecked`)`', () => { + const { getByRole } = render(); + const el = getByRole('checkbox'); + expect(el.className).toMatch(`is-checked`); + }); + it('has correct `role` (when not passed `role`)', () => { + const { getByRole } = render(); + const el = getByRole('checkbox'); + expect(el).not.toEqual(null); + }); + it('has correct `role` (when passed `role`)', () => { + const { getByRole } = render(); + const el = getByRole('button'); + expect(el).not.toEqual(null); + }); +}); diff --git a/src/lib/Checkbox/index.js b/src/lib/Checkbox/index.js new file mode 100644 index 0000000..36fa16d --- /dev/null +++ b/src/lib/Checkbox/index.js @@ -0,0 +1,3 @@ +import Checkbox from './Checkbox'; + +export default Checkbox; diff --git a/src/lib/Collapse/Collapse.module.css b/src/lib/Collapse/Collapse.module.css new file mode 100644 index 0000000..e8334ed --- /dev/null +++ b/src/lib/Collapse/Collapse.module.css @@ -0,0 +1,21 @@ +.header { + border-bottom: 1px solid gray; + display: flex; + justify-content: space-between; + margin-bottom: 0.5em; + align-items: center; +} + +.controls { + display: flex; + color: gray; + align-items: center; +} + +.expand { + text-decoration: none !important; +} + +.expand:not:hover { + color: gray !important; +} \ No newline at end of file diff --git a/src/lib/Collapse/Collapse.tsx b/src/lib/Collapse/Collapse.tsx new file mode 100644 index 0000000..bfa82a5 --- /dev/null +++ b/src/lib/Collapse/Collapse.tsx @@ -0,0 +1,61 @@ +import React, { useState, useCallback } from 'react'; +import Button from '../Button'; +import { Badge } from 'reactstrap'; +import { Collapse as BootstrapCollapse } from 'reactstrap'; +import Icon from '../Icon'; +import styles from './Collapse.module.css'; + +type CollapseProperties = React.PropsWithChildren<{ + title: string; + note?: string; + open?: boolean; + requiredText?: string; + isCollapsable?: boolean; + className?: string; +}>; + +const Collapse: React.FC = ({ + title, + note, + open, + requiredText, + className, + children, + isCollapsable = true, +}) => { + const [isOpen, setIsOpen] = useState(open ?? false); + const toggle = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen, setIsOpen]); + + return ( +
+
+
+ {title} + {requiredText && ( + + {requiredText} + + )} +
+
+
{note ?? ''}
+ {isCollapsable && ( + + )} +
+
+ + {children} + +
+ ); +}; + +export default Collapse; diff --git a/src/lib/Collapse/index.ts b/src/lib/Collapse/index.ts new file mode 100644 index 0000000..bccdf23 --- /dev/null +++ b/src/lib/Collapse/index.ts @@ -0,0 +1,3 @@ +import { default as Collapse } from './Collapse'; + +export default Collapse; diff --git a/src/lib/DescriptionList/DescriptionList.jsx b/src/lib/DescriptionList/DescriptionList.jsx new file mode 100644 index 0000000..be35792 --- /dev/null +++ b/src/lib/DescriptionList/DescriptionList.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { v4 as uuidv4 } from 'uuid'; + +import styles from './DescriptionList.module.css'; + +export const DIRECTION_CLASS_MAP = { + vertical: 'is-vert', + horizontal: 'is-horz', +}; +export const DEFAULT_DIRECTION = 'vertical'; +export const DIRECTIONS = ['', ...Object.keys(DIRECTION_CLASS_MAP)]; + +export const DENSITY_CLASS_MAP = { + compact: 'is-narrow', + default: 'is-wide', +}; +export const DEFAULT_DENSITY = 'default'; +export const DENSITIES = ['', ...Object.keys(DENSITY_CLASS_MAP)]; + +const DescriptionList = ({ className, data, density, direction }) => { + const modifierClasses = []; + modifierClasses.push(DENSITY_CLASS_MAP[density || DEFAULT_DENSITY]); + modifierClasses.push(DIRECTION_CLASS_MAP[direction || DEFAULT_DIRECTION]); + const containerStyleNames = ['container', ...modifierClasses] + .map((s) => styles[s]) + .join(' '); + + const shouldTruncateValues = + (direction === 'vertical' && density === 'compact') || + (direction === 'horizontal' && density === 'default'); + const valueClassName = `${styles.value} ${ + shouldTruncateValues ? 'value-truncated' : '' + }`; + + return ( +
+ {Object.entries(data).map(([key, value]) => ( + +
+ {key} +
+ {Array.isArray(value) ? ( + value.map((val) => ( +
+ {val} +
+ )) + ) : ( +
+ {value} +
+ )} +
+ ))} +
+ ); +}; +DescriptionList.propTypes = { + /** Additional className for the root element */ + className: PropTypes.string, + /** Selector type */ + /* FAQ: We can support any values, even a component */ + // eslint-disable-next-line react/forbid-prop-types + data: PropTypes.object.isRequired, + /** Layout density */ + density: PropTypes.oneOf(DENSITIES), + /** Layout direction */ + direction: PropTypes.oneOf(DIRECTIONS), +}; +DescriptionList.defaultProps = { + className: '', + density: DEFAULT_DENSITY, + direction: DEFAULT_DIRECTION, +}; + +export default DescriptionList; diff --git a/src/lib/DescriptionList/DescriptionList.module.css b/src/lib/DescriptionList/DescriptionList.module.css new file mode 100644 index 0000000..394a54b --- /dev/null +++ b/src/lib/DescriptionList/DescriptionList.module.css @@ -0,0 +1,54 @@ +.container.is-horz, +.container.is-horz dd { + margin-bottom: 0; /* overwrite Bootstrap's `_reboot.scss` */ +} + +/* Children */ + +.key { + composes: x-truncate--one-line from '@tacc/core-styles/dist/tools/x-truncate.css'; +} +.key::after { + content: ':'; + display: inline; + padding-right: 0.25em; +} +.is-horz > .value { + white-space: nowrap; +} + +/* Types */ + +.is-horz { + display: flex; + flex-direction: row; +} +.is-horz > .key ~ .key::before { + content: '|'; + display: inline-block; +} + +.is-horz.is-narrow > .key ~ .key::before { + padding-left: 0.5em; + padding-right: 0.5em; +} +.is-horz.is-wide > .key ~ .key::before { + padding-left: 1em; + padding-right: 1em; +} + +/* Overwrite Bootstrap `_reboot.scss` */ +.is-vert > .value { + margin-left: 0; +} +.is-vert.is-narrow > .value { + padding-left: 0; +} +.is-vert.is-wide > .value { + padding-left: 2.5rem; +} /* 40px Firefox default margin */ + +/* Truncate specific edge cases */ +.value-truncated { + composes: x-truncate--one-line from '@tacc/core-styles/dist/tools/x-truncate.css'; +} diff --git a/src/lib/DescriptionList/DescriptionList.test.jsx b/src/lib/DescriptionList/DescriptionList.test.jsx new file mode 100644 index 0000000..e8735ea --- /dev/null +++ b/src/lib/DescriptionList/DescriptionList.test.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import DescriptionList, * as DL from './DescriptionList'; + +const DATA = { + Username: 'bobward500', + Prefix: 'Mr.', + Name: 'Bob Ward', + Suffix: 'The 5th', +}; + +describe('Description List', () => { + it('has accurate tags', async () => { + const { getByTestId, findAllByTestId } = render( + + ); + const list = getByTestId('list'); + const keys = await findAllByTestId('key'); + const values = await findAllByTestId('value'); + expect(list).toBeDefined(); + expect(list.tagName).toEqual('DL'); + keys.forEach((key) => { + expect(key.tagName).toEqual('DT'); + }); + values.forEach((value) => { + expect(value.tagName).toEqual('DD'); + }); + }); + it.each(DL.DIRECTIONS)( + 'has accurate className when direction is "%s"', + (direction) => { + const { getByTestId } = render( + + ); + const list = getByTestId('list'); + const className = + DL.DIRECTION_CLASS_MAP[direction || DL.DEFAULT_DIRECTION]; + expect(list).toBeDefined(); + expect(list.className).toMatch(className); + } + ); + it.each(DL.DENSITIES)( + 'has accurate className when density is "%s"', + (density) => { + const { getByTestId } = render( + + ); + const list = getByTestId('list'); + const className = DL.DENSITY_CLASS_MAP[density || DL.DEFAULT_DENSITY]; + expect(list).toBeDefined(); + expect(list.className).toMatch(className); + } + ); + + it('renders multiple
terms when value is an Array', async () => { + const dataWithArray = { + Hobbits: [ + 'Frodo Baggins', + 'Samwise Gamgee', + 'Meriadoc Brandybuck', + 'Peregrin Took', + ], + }; + const { findAllByTestId } = render( + + ); + const keys = await findAllByTestId('key'); + const values = await findAllByTestId('value'); + expect(keys.length).toEqual(1); + expect(values.length).toEqual(4); + }); +}); diff --git a/src/lib/DescriptionList/index.js b/src/lib/DescriptionList/index.js new file mode 100644 index 0000000..24ce8f7 --- /dev/null +++ b/src/lib/DescriptionList/index.js @@ -0,0 +1,3 @@ +import DescriptionList from './DescriptionList'; + +export default DescriptionList; diff --git a/src/lib/DropdownSelector/DropdownSelector.jsx b/src/lib/DropdownSelector/DropdownSelector.jsx new file mode 100644 index 0000000..8411a98 --- /dev/null +++ b/src/lib/DropdownSelector/DropdownSelector.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Input as BootstrapInput } from 'reactstrap'; + +import styles from './DropdownSelector.module.css'; + +export const TYPES = ['', 'single', 'multiple']; +export const DEFAULT_TYPE = 'single'; + +// RFE: Support `options` object prop and require either `options` or `children` prop: +// - https://stackoverflow.com/a/49682510/11817077 +// - https://stackoverflow.com/a/52661344/11817077 +// - https://www.npmjs.com/package/react-either-property +// - "customProp" at https://reactjs.org/docs/typechecking-with-proptypes.html#proptypes + +const DropdownSelector = ({ type, onChange, ...props }) => { + const canSelectMany = type === 'multiple'; + + return ( + ` is implicit (and depends on `` field (not dropdown) because of browser-limitations */ +/* CREDIT: https://github.com/filamentgroup/select-css/blob/8f91fe1/src/select-css.css */ +/* CAVEAT: Known Issues (across supported browsers): + 1. All: The menus have unique styles per browser and/or operating system. + 2. Safari: The element cascades certain styles (`color`, `font-…`) to menu texts, and they cannot be revert-ed nor initial-ed nor unset. + 3. Firefox: A `width: auto` on the field implies a min-width value equal to the longest `optgroup` (not `option`). + 4. Firefox: Extra horizontal space in field. Options are to hack-a-lot or use a plugin. +*/ +.container { + /* Load select-css after Bootstrap overrides, because Bootstrap is like our "base" */ + composes: form-control from '@tacc/core-styles/dist/components/bootstrap.form.css'; + + /* WARNING: "iOS Safari will [force-zoom site] if […] less than 16px" */ + /* SEE: https://github.com/filamentgroup/select-css#notes-on-the-css */ + width: auto; /* overwrite `.form-control` (from Bootstrap) */ + height: auto; /* overwrite `.form-control` (from Bootstrap) */ + + background-color: var(--global-color-primary--xx-light); + background-image: url("data:image/svg+xml,%3Csvg id='tacc-arrows' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 130.34 292.4'%3E%3Cdefs%3E%3Cstyle%3E.arrow%7Bfill:%23484848;%7D%3C/style%3E%3C/defs%3E%3Cg id='tacc-arrows——root'%3E%3Cpath id='Path_3088' data-name='Path 3088' class='arrow' d='M82.24,96.17,148.09,0l64.45,96.17Z' transform='translate(-82.2)'/%3E%3Cpath id='Path_3089' data-name='Path 3089' class='arrow' d='M212.5,196.23,146.65,292.4,82.2,196.23Z' transform='translate(-82.2)'/%3E%3C/g%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 6px top 50%; /* 5px design * 1.2 design-to-app ratio */ + background-size: auto 10px; /* ~8px design * 1.2 design-to-app ratio (rounded) */ + + color: var(--global-color-accent--normal); + font-style: italic; + font-weight: 500; + + appearance: none; +} +/* NOTE: CSS Modules does not support `.container.form-control`, but these overrides should be isolated */ +.container { + padding: 0 16px 0 6px; /* overwrite `.form-control` (from bootstrap.form.css) */ + /* 0 13.43px 0 5px design * 1.2 design-to-app ratio */ + + border-color: var( + --global-color-primary--dark + ); /* overwrite `.form-control` (from Bootstrap) */ +} + +.container:focus { + /* border-color: var(--global-color-primary--dark); */ + color: var(--global-color-accent--normal); +} +.container[multiple] { + background-image: none; +} + +/* Children */ + +/* Unset styles on children */ +.container option, +.container optgroup { + font-style: normal; + font-weight: normal; +} +.container optgroup { + color: var(--global-color-primary--dark); +} +.container option { + color: var(--global-color-primary--x-dark); +} + +/* FAQ: Ability to style selected option is browser-dependent */ +/* SEE: https://developer.mozilla.org/en-US/docs/Web/CSS/:checked */ +/* .container[multiple] option:checked {} */ diff --git a/src/lib/DropdownSelector/DropdownSelector.test.jsx b/src/lib/DropdownSelector/DropdownSelector.test.jsx new file mode 100644 index 0000000..7f7a361 --- /dev/null +++ b/src/lib/DropdownSelector/DropdownSelector.test.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import DropdownSelector, { TYPES } from './DropdownSelector'; + +describe('Select Dropdown Field', () => { + it.each(TYPES)( + 'has accurate tag and attributes when type is "%s"', + (type) => { + const { getByTestId } = render(); + const root = getByTestId('selector'); + expect(root).toBeDefined(); + expect(root.tagName).toEqual('SELECT'); + if (type === 'multiple') { + expect(root.getAttribute('multiple')).toBe(''); // i.e. true + } else { + expect(root.getAttribute('multiple')).toBe(null); // i.e. false + } + } + ); +}); diff --git a/src/lib/DropdownSelector/index.js b/src/lib/DropdownSelector/index.js new file mode 100644 index 0000000..2895b78 --- /dev/null +++ b/src/lib/DropdownSelector/index.js @@ -0,0 +1,3 @@ +import DropdownSelector from './DropdownSelector'; + +export default DropdownSelector; diff --git a/src/lib/FieldWrapperFormik/FieldWrapperFormik.global.css b/src/lib/FieldWrapperFormik/FieldWrapperFormik.global.css new file mode 100644 index 0000000..bf29a4f --- /dev/null +++ b/src/lib/FieldWrapperFormik/FieldWrapperFormik.global.css @@ -0,0 +1,12 @@ +/* To style "Required" flag on field */ +/* TODO: Remove this after making it moot: + - either use c-form__star instead of (like CMS) + - or add c-form__badge into TACC/Core-Styles +*/ +.c-form__field.has-required .badge { + color: white; + font-weight: var(--medium); + + margin-left: 0.5em; + vertical-align: top; +} diff --git a/src/lib/FieldWrapperFormik/FieldWrapperFormik.tsx b/src/lib/FieldWrapperFormik/FieldWrapperFormik.tsx new file mode 100644 index 0000000..a5c8ae4 --- /dev/null +++ b/src/lib/FieldWrapperFormik/FieldWrapperFormik.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { FieldProps } from 'formik'; +import { Badge } from 'reactstrap'; + +import './FieldWrapperFormik.global.css'; + +export type FieldWrapperProps = { + id?: string; + label: React.ReactNode; + required?: boolean; + className?: string; + description?: React.ReactNode; + formik: FieldProps; +}; + +const FieldWrapper: React.FC> = ({ + id, + label, + required, + description, + className, + children, + formik: { field, form }, +}) => { + return ( +
+ + {children} + {form.touched[field.name] && form.errors[field.name] ? ( +
    + {/* https://github.com/jaredpalmer/formik/issues/3683#issuecomment-1752751768 */} +
  • {String(form.errors[field.name])}
  • +
+ ) : null} + {description &&
{description}
} +
+ ); +}; + +export default FieldWrapper; diff --git a/src/lib/FieldWrapperFormik/fields/FormikCheck.module.css b/src/lib/FieldWrapperFormik/fields/FormikCheck.module.css new file mode 100644 index 0000000..5ac2e1d --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikCheck.module.css @@ -0,0 +1,4 @@ +.nospace { + padding-top: 0; + padding-bottom: 0; +} diff --git a/src/lib/FieldWrapperFormik/fields/FormikCheck.tsx b/src/lib/FieldWrapperFormik/fields/FormikCheck.tsx new file mode 100644 index 0000000..72788a7 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikCheck.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import FieldWrapper from '../FieldWrapperFormik'; +import { FormikInputProps } from '.'; + +const FormikCheck: React.FC = ({ + id, + name, + label, + required, + description, + field, + form, + meta, + ...props +}: FormikInputProps) => { + return ( + + + + ); +}; + +export default FormikCheck; diff --git a/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.module.css b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.module.css new file mode 100644 index 0000000..5576c47 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.module.css @@ -0,0 +1,57 @@ +.dropzone-area { + background-color: #f4f4f4; + height: 235px; + position: relative; + } + + .dropzone-area * { + font-size: 1.25rem; + } + + .no-attachment-view { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .no-attachment-view :global(.icon-upload) { + /* This rule needs the additional '.icon' in order for it to be picked up first both in dev and prod (see Core-Portal PR #667) */ + color: #70707026; + display: flex; + font-size: 190px; + position: absolute; + } + + .attachment-view { + height: 100%; + display: flex; + flex-direction: column; + padding: 10px; + } + + .attachment-view > div { + flex-grow: 1; + overflow-y: auto; + } + + .attachment-block { + display: flex; + justify-content: space-between; + align-items: center; + } + + .attachment-remove { + font-size: 0.75rem; + } + + .rejected-file-message { + text-align: center; + padding-left: 5px; + padding-right: 5px; + } + + .dropzone-select-more { + width: fit-content; + } diff --git a/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.test.tsx b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.test.tsx new file mode 100644 index 0000000..d187df3 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.test.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import FileDropzone from './FileDropzone'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const FileDropzoneWrapper: React.FC<{ maxSize: number }> = ({ maxSize }) => { + const [files, setFiles] = useState([]); + const onDrop = (files: File[]) => { + setFiles(files); + }; + const onRemoveFile = (index: number) => { + setFiles([]); + }; + return ( + + ); +}; + +describe('dropzone', () => { + it('Reacts to file upload.', async () => { + const smallFile = new File(['file'], 'myfile.json', { + type: 'application/json', + }); + render(); + const input = screen.getByTestId('dropzone-input'); + await userEvent.upload(input, smallFile); + expect(screen.getByText('myfile.json')).toBeDefined(); + expect(screen.getByText(/Remove/)).toBeDefined(); + }); + + it('Shows error when file is too big.', async () => { + const bigFile = new File(['hi'], 'myfile', { type: 'image/jpeg' }); + render(); + const input = screen.getByTestId('dropzone-input'); + await userEvent.upload(input, bigFile); + expect(screen.getByText(/Exceeds File Size Limit/)).toBeDefined(); + }); +}); diff --git a/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.tsx b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.tsx new file mode 100644 index 0000000..7ee0ea8 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FileDropzone.tsx @@ -0,0 +1,120 @@ +/* FP-993: Allow use by DataFilesUploadModal */ +import React, { useCallback, useState } from 'react'; +import { useDropzone, FileRejection } from 'react-dropzone'; +import { InlineMessage, Button } from '../../../..'; +import styles from './FileDropzone.module.css'; + +interface FileInputDropzoneProps { + id: string; + files: File[]; + onDrop: (files: File[]) => void; + onRemoveFile: (index: number) => void; + maxSize: number; + maxSizeMessage: string; +} + +/** + * FileInputDropZone is a component where users can select files via file browser or by + * drag/drop. an area to drop files. If `file` property is set then files are listed + * and user can manage (e.g. delete those files) directly in this component. + */ +const FileInputDropZone: React.FC = ({ + id, + files, + onDrop, + maxSize, + maxSizeMessage, + onRemoveFile, +}) => { + const [rejectedFiles, setRejectedFiles] = useState([]); + + const onDropRejected = useCallback( + (rejected: FileRejection[]) => { + const newRejectedFiles = rejected.filter( + (newFile) => + !rejectedFiles.some( + (prevFile) => prevFile.file.name === newFile.file.name + ) + ); + setRejectedFiles([...rejectedFiles, ...newRejectedFiles]); + }, + [rejectedFiles] + ); + + const { getRootProps, open, getInputProps } = useDropzone({ + noClick: true, + maxSize, + onDrop: (files) => { + onDrop(files); + setRejectedFiles([]); + }, + onDropRejected, + }); + + const removeFile = (fileIndex: number) => { + if (onRemoveFile) { + onRemoveFile(fileIndex); + } + }; + + const showFileList = files.length > 0 || rejectedFiles.length > 0; + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading +
+ + {!showFileList && ( +
+ +
+ + or + Drag and Drop +
+

{maxSizeMessage}

+
+ )} + {showFileList && ( +
+
+ {rejectedFiles && + rejectedFiles.map((f, i) => ( +
+ + {f.file.name} + + + Exceeds File Size Limit + +
+ ))} + {files.map((f, i) => ( +
+ {f.name} + +
+ ))} +
+ +
+ )} +
+ ); +}; + +export default FileInputDropZone; diff --git a/src/lib/FieldWrapperFormik/fields/FormikFileInput/FormikFileInput.tsx b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FormikFileInput.tsx new file mode 100644 index 0000000..14754e9 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikFileInput/FormikFileInput.tsx @@ -0,0 +1,61 @@ +/* FP-993: Allow use by DataFilesUploadModal */ +import React from 'react'; +import { useField, FieldProps } from 'formik'; +import FileInputDropZone from './FileDropzone'; +import FieldWrapper from '../../FieldWrapperFormik'; + +interface FormikFileInputProps extends FieldProps { + id: string; + name: string; + label: string; + required: boolean; + description: string; + maxSizeMessage: string; + maxSize: number; +} + +const FileInputDropZoneFormField: React.FC = ({ + id, + name, + label, + description, + maxSizeMessage, + maxSize, + required, + field, + form, + meta, +}) => { + const [, , helpers] = useField(name); + + const onSetFiles = (acceptedFiles: File[]) => { + const newAcceptedFiles = acceptedFiles.filter( + (newFile) => + !field.value.some((prevFile) => prevFile.name === newFile.name) + ); + helpers.setValue([...field.value, ...newAcceptedFiles]); + }; + const onRemoveFile = (fileIndex: number) => { + helpers.setValue(field.value.filter((_, i) => i !== fileIndex)); + }; + return ( + + + + ); +}; + +export default FileInputDropZoneFormField; diff --git a/src/lib/FieldWrapperFormik/fields/FormikInput.tsx b/src/lib/FieldWrapperFormik/fields/FormikInput.tsx new file mode 100644 index 0000000..8829b69 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikInput.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import FieldWrapper from '../FieldWrapperFormik'; +import { FormikInputProps } from '.'; + +const FormikInput: React.FC = ({ + id, + name, + label, + required, + description, + field, + form, + meta, + ...props +}: FormikInputProps) => { + return ( + + + + ); +}; + +export default FormikInput; diff --git a/src/lib/FieldWrapperFormik/fields/FormikSelect.tsx b/src/lib/FieldWrapperFormik/fields/FormikSelect.tsx new file mode 100644 index 0000000..a80cea1 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikSelect.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import FieldWrapper from '../FieldWrapperFormik'; +import { FormikSelectProps } from '.'; + +const FormikTextarea: React.FC = ({ + id, + name, + label, + required, + description, + children, + field, + form, + meta, + ...props +}: FormikSelectProps) => { + return ( + + + + ); +}; + +export default FormikTextarea; diff --git a/src/lib/FieldWrapperFormik/fields/FormikTextarea.tsx b/src/lib/FieldWrapperFormik/fields/FormikTextarea.tsx new file mode 100644 index 0000000..bef5665 --- /dev/null +++ b/src/lib/FieldWrapperFormik/fields/FormikTextarea.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import FieldWrapper from '../FieldWrapperFormik'; +import { FormikTextareaProps } from '.'; + +const FormikTextarea: React.FC = ({ + id, + name, + label, + required, + description, + field, + form, + meta, + ...props +}: FormikTextareaProps) => { + return ( + +