commit
4d37a67543
24 changed files with 639 additions and 724 deletions
324
README.md
324
README.md
|
|
@ -1,322 +1,66 @@
|
||||||
<img align="right" width="360px" height="410px" src="./.github/assets/project-logo-vertical.png">
|
# Document Upload App with Next.js
|
||||||
|
|
||||||
# Next.js Enterprise Boilerplate </br> [![GitHub Actions Workflow Status][check-workflow-badge]][check-workflow-badge-link] [![GitHub License][github-license-badge]][github-license-badge-link] [![GitHub contributors][github-contributors-badge]][github-contributors-badge-link] [![Discord][discord-badge]][discord-badge-link] [![Blazity][made-by-blazity-badge]][made-by-blazity-badge-link]
|
This project is built on the robust foundation of the Next.js Enterprise Boilerplate by Blazity, tailored for a document upload application. It demonstrates advanced usage of Next.js, Formik for form management, Tailwind CSS for styling, and includes comprehensive testing with Jest and Playwright. This README guides you through the project setup, features, and deployment.
|
||||||
|
|
||||||
Welcome to the _Next.js Enterprise Boilerplate_, an open-source template for enterprise projects! It's loaded with features that'll help you build a high-performance, maintainable, and enjoyable app. We've done all the heavy lifting for you, so sit back, relax, and get ready to conquer the world with your incredible app! 🌍
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> **Blazity** is a group of Next.js/Headless experts. Contact us at [contact@blazity.com](https://blazity.com) if you’d like to talk about your project or just to have a chat with us
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
With this template, you get all the awesomeness you need:
|
- **Next.js**: Leveraging the latest features of Next.js for SSR and static generation.
|
||||||
|
- **Tailwind CSS**: Utilizing Tailwind for rapid and responsive UI development.
|
||||||
|
- **Formik**: Streamlined form handling with validation and custom input components.
|
||||||
|
- **Playwright**: End-to-end testing to ensure reliability and functionality.
|
||||||
|
- **Radix UI**: Utilizing Radix UI for accessible and customizable components.
|
||||||
|
- **Tailwind Merge**: Efficiently managing Tailwind CSS classes.
|
||||||
|
- **TypeScript**: Ensuring type safety and developer productivity.
|
||||||
|
|
||||||
- 🏎️ **[Next.js](https://nextjs.org/)** - Fast by default, with config optimized for performance (with **App Directory**)
|
## Getting Started
|
||||||
- 💅 **[Tailwind CSS](https://tailwindcss.com/)** - A utility-first CSS framework for rapid UI development
|
|
||||||
- ✨ **[ESlint](https://eslint.org/)** and **[Prettier](https://prettier.io/)** - For clean, consistent, and error-free code
|
|
||||||
- 🛠️ **[Extremely strict TypeScript](https://www.typescriptlang.org/)** - With [`ts-reset`](https://github.com/total-typescript/ts-reset) library for ultimate type safety
|
|
||||||
- 🚀 **[GitHub Actions](https://github.com/features/actions)** - Pre-configured actions for smooth workflows, including Bundle Size and performance stats
|
|
||||||
- 💯 **Perfect Lighthouse score** - Because performance matters
|
|
||||||
- **[Bundle analyzer plugin](https://www.npmjs.com/package/@next/bundle-analyzer)** - Keep an eye on your bundle size
|
|
||||||
- **[Jest](https://jestjs.io/)** and **[React Testing Library](https://testing-library.com/react)** - For rock-solid unit and integration tests
|
|
||||||
- **[Playwright](https://playwright.dev/)** - Write end-to-end tests like a pro
|
|
||||||
- **[Storybook](https://storybook.js.org/)** - Create, test, and showcase your components
|
|
||||||
- **Smoke Testing** and **Acceptance Tests** - For confidence in your deployments
|
|
||||||
- **[Conventional commits git hook](https://www.conventionalcommits.org/)** - Keep your commit history neat and tidy
|
|
||||||
- **[Observability](https://opentelemetry.io/)** - Open Telemetry integration for seamless monitoring
|
|
||||||
- **[Absolute imports](https://nextjs.org/docs/advanced-features/module-path-aliases)** - No more spaghetti imports
|
|
||||||
- **[Health checks](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/)** - Kubernetes-compatible for robust deployments
|
|
||||||
- **[Radix UI](https://www.radix-ui.com/)** - Headless UI components for endless customization
|
|
||||||
- **[CVA](http://cva.style/)** - Create a consistent, reusable, and atomic design system
|
|
||||||
- **[Renovate BOT](https://www.whitesourcesoftware.com/free-developer-tools/renovate)** - Auto-updating dependencies, so you can focus on coding
|
|
||||||
- **[Patch-package](https://www.npmjs.com/package/patch-package)** - Fix external dependencies without losing your mind
|
|
||||||
- **Components coupling and cohesion graph** - A tool for managing component relationships
|
|
||||||
- **[Automated ChatGPT Code Reviews](https://openai.com/chatgpt)** - **Stay on the cutting edge with AI-powered code reviews!**
|
|
||||||
- **[Semantic Release](https://github.com/semantic-release/semantic-release)** - for automatic changelog
|
|
||||||
- **[T3 Env](https://env.t3.gg/)** - Manage your environment variables with ease
|
|
||||||
|
|
||||||
## Table of Contents
|
To run this project locally, follow these steps:
|
||||||
|
|
||||||
- [Next.js Enterprise Boilerplate](#nextjs-enterprise-boilerplate)
|
1. Clone the repository:
|
||||||
- [Features](#features)
|
|
||||||
- [Table of Contents](#table-of-contents)
|
|
||||||
- [Getting Started](#-getting-started)
|
|
||||||
- [Deployment](#-deployment)
|
|
||||||
- [Scripts Overview](#-scripts-overview)
|
|
||||||
- [Coupling Graph](#-coupling-graph)
|
|
||||||
- [Testing](#-testing)
|
|
||||||
- [Running Tests](#running-tests)
|
|
||||||
- [Acceptance Tests](#acceptance-tests)
|
|
||||||
- [Smoke Testing](#smoke-testing)
|
|
||||||
- [Styling and Design System](#-styling-and-design-system)
|
|
||||||
- [CVA - A New Approach to Variants](#cva---a-new-approach-to-variants)
|
|
||||||
- [State Management](#-state-management)
|
|
||||||
- [Zustand](#zustand)
|
|
||||||
- [Jotai](#jotai)
|
|
||||||
- [Recoil](#recoil)
|
|
||||||
- [ChatGPT Code Review](#-chatgpt-code-review)
|
|
||||||
- [Environment Variables handling](#-environment-variables-handling)
|
|
||||||
- [Contribution](#-contribution)
|
|
||||||
- [Support](#support)
|
|
||||||
- [License](#-license)
|
|
||||||
- [Contributors](#contributors)
|
|
||||||
|
|
||||||
## 🎯 Getting Started
|
|
||||||
|
|
||||||
To get started with this boilerplate, follow these steps:
|
|
||||||
|
|
||||||
1. Fork & clone repository:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
## Don't forget to ⭐ star and fork it first :)
|
git clone https://github.com/vasquezjesus2710/next-enterprise
|
||||||
git clone https://github.com/<your_username)/next-enterprise.git
|
cd next-enterprise
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install the dependencies:
|
2. Install Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn install --frozen-lockfile
|
yarn install
|
||||||
```
|
```
|
||||||
|
3. Start the development server
|
||||||
3. Run the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Visit [http://localhost:3000](http://localhost:3000) to view the application.
|
||||||
|
|
||||||
5. This project uses a git hook to enforce [conventional commits](https://github.com/qoomon/git-conventional-commits). To install the git hook, run the following command in the root directory of the project:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
brew install pre-commit
|
|
||||||
pre-commit install -t commit-msg
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
## Project Structure
|
||||||
|
|
||||||
Easily deploy your Next.js app with [Vercel](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=github&utm_campaign=next-enterprise) by clicking the button below:
|
- **components/**: Reusable UI components, including custom Formik inputs.
|
||||||
|
- **pages/**: Application pages and the API endpoint for form submission.
|
||||||
|
- **public/**: Static assets like images and fonts.
|
||||||
|
- **styles/**: Global styles and Tailwind configuration.
|
||||||
|
- **tests/**: Jest unit tests and Playwright end-to-end tests.
|
||||||
|
- **utils/**: Utility functions and helpers.
|
||||||
|
|
||||||
[](https://vercel.com/new/git/external?repository-url=https://github.com/Blazity/next-enterprise)
|
**Testing**
|
||||||
|
|
||||||
## 📃 Scripts Overview
|
This project includes both unit and integration tests with Jest and end-to-end tests with Playwright:
|
||||||
|
|
||||||
The following scripts are available in the `package.json`:
|
- Run unit and integration tests:
|
||||||
|
`yarn test`
|
||||||
- `dev`: Starts the development server with colorized output
|
|
||||||
- `build`: Builds the app for production
|
|
||||||
- `start`: Starts the production server
|
|
||||||
- `lint`: Lints the code using ESLint
|
|
||||||
- `lint:fix`: Automatically fixes linting errors
|
|
||||||
- `prettier`: Checks the code for proper formatting
|
|
||||||
- `prettier:fix`: Automatically fixes formatting issues
|
|
||||||
- `analyze`: Analyzes the bundle sizes for Client, Server and Edge environments
|
|
||||||
- `storybook`: Starts the Storybook server
|
|
||||||
- `build-storybook`: Builds the Storybook for deployment
|
|
||||||
- `test`: Runs unit and integration tests
|
|
||||||
- `e2e:headless`: Runs end-to-end tests in headless mode
|
|
||||||
- `e2e:ui`: Runs end-to-end tests with UI
|
|
||||||
- `format`: Formats the code with Prettier
|
|
||||||
- `postinstall`: Applies patches to external dependencies
|
|
||||||
- `preinstall`: Ensures the project is installed with Yarn
|
|
||||||
- `coupling-graph`: **Generates a coupling and cohesion graph for the components**
|
|
||||||
|
|
||||||
## 🔗 Coupling Graph
|
|
||||||
|
|
||||||
The `coupling-graph` script is a useful tool that helps visualize the coupling and connections between your project's internal modules. It's built using the [Madge](https://github.com/pahen/madge) library. To generate the graph, simply run the following command:
|
|
||||||
|
|
||||||
|
- Run end-to-end tests:
|
||||||
```bash
|
```bash
|
||||||
yarn coupling-graph
|
yarn e2e:headless # For headless testing
|
||||||
|
yarn e2e:ui # For UI testing
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create a `graph.svg` file, which contains a graphical representation of the connections between your components. You can open the file with any SVG-compatible viewer.
|
|
||||||
|
|
||||||

|
## Acknowledgments
|
||||||
|
|
||||||
## 🧪 Testing
|
Built on the [Next.js Enterprise Boilerplate](https://github.com/Blazity/next-enterprise) by Blazity.
|
||||||
|
|
||||||
This boilerplate comes with various testing setups to ensure your application's reliability and robustness.
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
- **Unit and integration tests**: Run Jest tests using `yarn test`
|
|
||||||
- **End-to-end tests (headless mode)**: Run Playwright tests in headless mode with `yarn e2e:headless`
|
|
||||||
- **End-to-end tests (UI mode)**: Run Playwright tests with UI using `yarn e2e:ui`
|
|
||||||
|
|
||||||
<img width="1392" alt="image" src="https://user-images.githubusercontent.com/28964599/233666655-93b7d08b-2fd8-406a-b43c-44d4d96cf387.png">
|
|
||||||
|
|
||||||
### Acceptance Tests
|
|
||||||
|
|
||||||
To write acceptance tests, we leverage Storybook's [`play` function](https://storybook.js.org/docs/react/writing-stories/play-function#writing-stories-with-the-play-function). This allows you to interact with your components and test various user flows within Storybook.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
/*
|
|
||||||
* See https://storybook.js.org/docs/react/writing-stories/play-function#working-with-the-canvas
|
|
||||||
* to learn more about using the canvasElement to query the DOM
|
|
||||||
*/
|
|
||||||
export const FilledForm: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement)
|
|
||||||
|
|
||||||
const emailInput = canvas.getByLabelText("email", {
|
|
||||||
selector: "input",
|
|
||||||
})
|
|
||||||
|
|
||||||
await userEvent.type(emailInput, "example-email@email.com", {
|
|
||||||
delay: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
const passwordInput = canvas.getByLabelText("password", {
|
|
||||||
selector: "input",
|
|
||||||
})
|
|
||||||
|
|
||||||
await userEvent.type(passwordInput, "ExamplePassword", {
|
|
||||||
delay: 100,
|
|
||||||
})
|
|
||||||
// See https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
|
|
||||||
const submitButton = canvas.getByRole("button")
|
|
||||||
|
|
||||||
await userEvent.click(submitButton)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Smoke Testing
|
|
||||||
|
|
||||||
In this boilerplate, we use Storybook's out-of-the-box support for smoke testing to verify that components render correctly without any errors. Just run `yarn test-storybook` to perform smoke testing. Remember to write stories in JSX or TSX format only. Smoke testing and a lot of other functionalities dont work well with MDX stories.
|
|
||||||
|
|
||||||
## 🎨 Styling and Design System
|
|
||||||
|
|
||||||
This boilerplate uses Tailwind CSS for styling and CVA for creating a powerful, easy-to-use design system. If you want to learn more about the setup, check out this fantastic video by Vercel:
|
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=T-Zv73yZ_QI&ab_channel=Vercel)
|
|
||||||
|
|
||||||
### CVA - A New Approach to Variants
|
|
||||||
|
|
||||||
While CSS-in-TS libraries such as [Stitches](https://stitches.dev/) and [Vanilla Extract](https://vanilla-extract.style/) are great for building type-safe UI components, they might not be the perfect fit for everyone. You may prefer more control over your stylesheets, need to use a framework like Tailwind CSS, or simply enjoy writing your own CSS.
|
|
||||||
|
|
||||||
Creating variants using traditional CSS can be a tedious task, requiring you to manually match classes to props and add types. CVA is here to take that pain away, allowing you to focus on the enjoyable aspects of UI development. By providing an easy and type-safe way to create variants, CVA simplifies the process and helps you create powerful design systems without compromising on the flexibility and control of CSS.
|
|
||||||
|
|
||||||
## 💾 State Management
|
|
||||||
|
|
||||||
While this boilerplate doesn't include a specific state management library, we believe it's essential for you to choose the one that best suits your project's needs. Here are some libraries we recommend for state management:
|
|
||||||
|
|
||||||
### Zustand
|
|
||||||
|
|
||||||
[Zustand](https://github.com/pmndrs/zustand) is a small, fast, and scalable state management library. It's designed to be simple and intuitive, making it a great choice for small to medium-sized projects. It's also optimized for bundle size, ensuring minimal impact on your app's performance.
|
|
||||||
|
|
||||||
### Jotai
|
|
||||||
|
|
||||||
[Jotai](https://github.com/pmndrs/jotai) is an atom-based state management library for React that focuses on providing a minimal and straightforward API. Its atom-based approach allows you to manage your state in a granular way while still being highly optimized for bundle size.
|
|
||||||
|
|
||||||
### Recoil
|
|
||||||
|
|
||||||
[Recoil](https://recoiljs.org/) is a state management library developed by Facebook, specifically designed for React applications. By utilizing atoms and selectors, Recoil allows you to efficiently manage state and derived state. Its key benefit is the ability to update components only when the state they're subscribed to changes, reducing unnecessary re-renders and keeping your application fast and efficient. Recoil also offers great developer experience with built-in debugging tools.
|
|
||||||
|
|
||||||
Choose the library that best fits your requirements and project structure to ensure an efficient state management solution for your application.
|
|
||||||
|
|
||||||
## 🤖 ChatGPT Code Review
|
|
||||||
|
|
||||||
We've integrated the innovative [ChatGPT Code Review](https://github.com/anc95/ChatGPT-CodeReview) for AI-powered, automated code reviews. This feature provides real-time feedback on your code, helping improve code quality and catch potential issues.
|
|
||||||
|
|
||||||
To use ChatGPT Code Review, add an `OPENAI_API_KEY` environment variable with an appropriate key from the OpenAI platform. For setup details, refer to the [Using GitHub Actions](https://github.com/anc95/ChatGPT-CodeReview#using-github-actions) section in the documentation.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 💻 Environment Variables handling
|
|
||||||
|
|
||||||
[T3 Env](https://env.t3.gg/) is a library that provides environmental variables checking at build time, type validation and transforming. It ensures that your application is using the correct environment variables and their values are of the expected type. You’ll never again struggle with runtime errors caused by incorrect environment variable usage.
|
|
||||||
|
|
||||||
Config file is located at `env.mjs`. Simply set your client and server variables and import `env` from any file in your project.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const env = createEnv({
|
|
||||||
server: {
|
|
||||||
// Server variables
|
|
||||||
SECRET_KEY: z.string(),
|
|
||||||
},
|
|
||||||
client: {
|
|
||||||
// Client variables
|
|
||||||
API_URL: z.string().url(),
|
|
||||||
},
|
|
||||||
runtimeEnv: {
|
|
||||||
// Assign runtime variables
|
|
||||||
SECRET_KEY: process.env.SECRET_KEY,
|
|
||||||
API_URL: process.env.NEXT_PUBLIC_API_URL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
If the required environment variables are not set, you'll get an error message:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
❌ Invalid environment variables: { SECRET_KEY: [ 'Required' ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🤝 Contribution
|
|
||||||
|
|
||||||
Contributions are always welcome! To contribute, please follow these steps:
|
|
||||||
|
|
||||||
1. Fork the repository.
|
|
||||||
2. Create a new branch with a descriptive name.
|
|
||||||
3. Make your changes, and commit them using the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
|
||||||
4. Push your changes to the forked repository.
|
|
||||||
5. Create a pull request, and we'll review your changes.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you're looking for help or simply want to share your thoughts about the project, we encourage you to join our Discord community. Here's the link: [https://blazity.com/discord](https://blazity.com/discord). It's a space where we exchange ideas and help one another. Everyone's input is appreciated, and we look forward to welcoming you.
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<a href="https://discord.gg/fyWtyNKmfX" style="width: 100%; display: flex; justify-content: center;">
|
|
||||||
<img src="https://discordapp.com/api/guilds/1111676875782234175/widget.png?style=banner2" alt="Blazity Discord Banner"/>
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
|
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License. For more information, see the [LICENSE](./LICENSE) file.
|
|
||||||
|
|
||||||
## Contributors
|
|
||||||
|
|
||||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
|
||||||
<!-- prettier-ignore-start -->
|
|
||||||
<!-- markdownlint-disable -->
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://bstefanski.com/"><img src="https://avatars.githubusercontent.com/u/28964599?v=4?s=100" width="100px;" alt="Bart Stefanski"/><br /><sub><b>Bart Stefanski</b></sub></a><br /><a href="https://github.com/Blazity/next-enterprise/commits?author=bmstefanski" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jjablonski-it"><img src="https://avatars.githubusercontent.com/u/51968772?v=4?s=100" width="100px;" alt="Jakub Jabłoński"/><br /><sub><b>Jakub Jabłoński</b></sub></a><br /><a href="#infra-jjablonski-it" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://neg4n.dev/"><img src="https://avatars.githubusercontent.com/u/57688858?v=4?s=100" width="100px;" alt="Igor Klepacki"/><br /><sub><b>Igor Klepacki</b></sub></a><br /><a href="https://github.com/Blazity/next-enterprise/commits?author=neg4n" title="Documentation">📖</a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td align="center" size="13px" colspan="7">
|
|
||||||
<img src="https://raw.githubusercontent.com/all-contributors/all-contributors-cli/1b8533af435da9854653492b1327a23a4dbd0a10/assets/logo-small.svg">
|
|
||||||
<a href="https://all-contributors.js.org/docs/en/bot/usage">Add your contributions</a>
|
|
||||||
</img>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- markdownlint-restore -->
|
|
||||||
<!-- prettier-ignore-end -->
|
|
||||||
|
|
||||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
|
||||||
|
|
||||||
<!-- Badges and links -->
|
|
||||||
|
|
||||||
[check-workflow-badge]: https://img.shields.io/github/actions/workflow/status/blazity/next-enterprise/check.yml?label=check
|
|
||||||
[github-license-badge]: https://img.shields.io/github/license/blazity/next-enterprise?link=https%3A%2F%2Fgithub.com%2FBlazity%2Fnext-enterprise%2Fblob%2Fmain%2FLICENSE
|
|
||||||
[github-contributors-badge]: https://img.shields.io/github/contributors/blazity/next-enterprise?link=https%3A%2F%2Fgithub.com%2FBlazity%2Fnext-enterprise%2Fgraphs%2Fcontributors
|
|
||||||
[discord-badge]: https://img.shields.io/discord/1111676875782234175?color=7b8dcd&link=https%3A%2F%2Fblazity.com%2Fdiscord
|
|
||||||
[made-by-blazity-badge]: https://img.shields.io/badge/made_by-Blazity-blue?color=FF782B&link=https://blazity.com/
|
|
||||||
|
|
||||||
[check-workflow-badge-link]: https://github.com/Blazity/next-enterprise/actions/workflows/check.yml
|
|
||||||
[github-license-badge-link]: https://github.com/Blazity/next-enterprise/blob/main/LICENSE
|
|
||||||
[github-contributors-badge-link]: https://github.com/Blazity/next-enterprise/graphs/contributors
|
|
||||||
[discord-badge-link]: https://blazity.com/discord
|
|
||||||
[made-by-blazity-badge-link]: https://blazity.com/?utm_source=nextenterprise&utm_medium=github
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "styles/tailwind.css"
|
import "styles/tailwind.css"
|
||||||
|
import "styles/global.css"
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
18
app/page.tsx
18
app/page.tsx
|
|
@ -1,29 +1,21 @@
|
||||||
import { Metadata } from "next"
|
import { Metadata } from "next"
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
|
||||||
import { faXmark } from "@fortawesome/free-solid-svg-icons"
|
|
||||||
import Form from "components/Form"
|
import Form from "components/Form"
|
||||||
|
import Head from "next/head"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Form Components",
|
title: "Form Components",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Web() {
|
export default function Web() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Head>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@400;700&display=swap" rel="stylesheet" />
|
||||||
|
</Head>
|
||||||
<section className="flex min-h-full grow items-center justify-center bg-slate-200 p-4">
|
<section className="flex min-h-full grow items-center justify-center bg-slate-200 p-4">
|
||||||
<div className="max-w-screen-lg grow rounded-3xl bg-white p-8">
|
<div className="max-w-screen-lg grow rounded-3xl bg-white p-8">
|
||||||
{/* Close button */}
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-950 text-white">
|
|
||||||
<FontAwesomeIcon icon={faXmark} className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
{/* Title */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<h1 className="border-grey-300 w-fit border-b pb-4 text-3xl text-indigo-900">Document Upload</h1>
|
|
||||||
</div>
|
|
||||||
{/* Form layout */}
|
|
||||||
<div className="flex gap-14">
|
|
||||||
<Form />
|
<Form />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react"
|
|
||||||
import { Button } from "./Button"
|
|
||||||
|
|
||||||
const meta: Meta<typeof Button> = {
|
|
||||||
title: "Button",
|
|
||||||
component: Button,
|
|
||||||
args: {
|
|
||||||
intent: "primary",
|
|
||||||
underline: false,
|
|
||||||
children: "Button",
|
|
||||||
size: "lg",
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
intent: {
|
|
||||||
options: ["primary", "secondary"],
|
|
||||||
control: { type: "select" },
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
options: ["sm", "lg"],
|
|
||||||
control: { type: "select" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof Button>
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
render: (args) => <Button {...args} />,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
const button = cva(
|
|
||||||
[
|
|
||||||
"justify-center",
|
|
||||||
"inline-flex",
|
|
||||||
"items-center",
|
|
||||||
"rounded-xl",
|
|
||||||
"text-center",
|
|
||||||
"border",
|
|
||||||
"border-blue-400",
|
|
||||||
"transition-colors",
|
|
||||||
"delay-50",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
intent: {
|
|
||||||
primary: ["bg-blue-400", "text-white", "hover:enabled:bg-blue-700"],
|
|
||||||
secondary: ["bg-transparent", "text-blue-400", "hover:enabled:bg-blue-400", "hover:enabled:text-white"],
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
sm: ["min-w-20", "h-full", "min-h-10", "text-sm", "py-1.5", "px-4"],
|
|
||||||
lg: ["min-w-32", "h-full", "min-h-12", "text-lg", "py-2.5", "px-6"],
|
|
||||||
},
|
|
||||||
underline: { true: ["underline"], false: [] },
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
intent: "primary",
|
|
||||||
size: "lg",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLAnchorElement>, VariantProps<typeof button> {
|
|
||||||
underline?: boolean
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button({ className, intent, size, underline, ...props }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<a className={twMerge(button({ intent, size, className, underline }))} {...props}>
|
|
||||||
{props.children}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
50
components/Form/Button.tsx
Normal file
50
components/Form/Button.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Button component that supports primary and secondary variants, as well as size customization.
|
||||||
|
* Utilizes 'class-variance-authority' for managing class variants and 'tailwind-merge' for class merging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure base and variant classes using 'class-variance-authority'
|
||||||
|
* Variants:
|
||||||
|
* - 'primary': Blue background with white text, changes on hover.
|
||||||
|
* - 'secondary': Transparent background with amber border and text, changes on hover.
|
||||||
|
* Sizes:
|
||||||
|
* - 'sm': Small size button.
|
||||||
|
* - 'lg': Large size button (default).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const button = cva(
|
||||||
|
"flex items-center justify-center rounded-md py-3 px-4 md:px-9 text-center text-xs md:text-sm font-bold transition-all md:min-w-[240px]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: ["bg-blue-950 text-white", "hover:enabled:bg-blue-700"],
|
||||||
|
secondary: ["border-2 border-amber-500 text-amber-500", "hover:enabled:bg-amber-500 hover:enabled:text-white"],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: ["py-2 px-4", "text-xs"],
|
||||||
|
lg: ["py-3 px-9", "text-sm"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "primary",
|
||||||
|
size: "lg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ButtonProps extends standard button attributes with variant and size customization options.
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof button> {}
|
||||||
|
|
||||||
|
// Button component: Renders a button with customizable variants, sizes, and additional passed props.
|
||||||
|
export const Button: React.FC<ButtonProps> = ({ className, variant, size, ...props }) => {
|
||||||
|
return (
|
||||||
|
<button className={twMerge(button({ variant, size }), className)} {...props}>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,34 +1,46 @@
|
||||||
|
/**
|
||||||
|
* ClientField Component
|
||||||
|
*
|
||||||
|
* Renders a list of testing centers with associated client selection inputs.
|
||||||
|
* Each testing center allows for the selection of a client from a provided list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { faClock } from "@fortawesome/free-regular-svg-icons"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import SelectInput from "./SelectInput"
|
import SelectInput from "./SelectInput"
|
||||||
import { Utils } from "./Utils"
|
import { Utils } from "./Utils"
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
|
||||||
import { faClock } from "@fortawesome/free-regular-svg-icons"
|
|
||||||
|
|
||||||
// Define a type for your props to improve type-checking
|
/**
|
||||||
|
* Props for ClientField component.
|
||||||
|
*
|
||||||
|
* @param testingCenters Array of testing center names to be displayed.
|
||||||
|
* @param clients Array of client names available for selection.
|
||||||
|
*/
|
||||||
|
|
||||||
type ClientFieldProps = {
|
type ClientFieldProps = {
|
||||||
testingCenters: string[]
|
testingCenters: string[]
|
||||||
clients: string[]
|
clients: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destructure the props in the function parameter
|
|
||||||
const ClientField: React.FC<ClientFieldProps> = ({ testingCenters, clients }) => {
|
const ClientField: React.FC<ClientFieldProps> = ({ testingCenters, clients }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 text-sm leading-tight">
|
||||||
|
{/* Render a selection input for each testing center, allowing a client to be chosen for each. */}
|
||||||
{testingCenters.map((testingCenter) => (
|
{testingCenters.map((testingCenter) => (
|
||||||
<div key={Utils.camelize(testingCenter)} className="flex gap-14 items-center">
|
<div key={Utils.camelize(testingCenter)} className="flex items-center gap-4 md:gap-14">
|
||||||
<span>{testingCenter}</span>
|
<span>{testingCenter}</span>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex items-center gap-2">
|
||||||
<SelectInput name={Utils.camelize(testingCenter)}>
|
<SelectInput name={Utils.camelize(testingCenter)}>
|
||||||
<option value="">Select Client</option>
|
<option value="">Select Client</option>
|
||||||
{clients.map((option: string) => (
|
{clients.map((option: string) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
<FontAwesomeIcon icon={faClock}/>
|
<FontAwesomeIcon icon={faClock} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
|
/**
|
||||||
|
* ErrorMessage Component
|
||||||
|
*
|
||||||
|
* Displays an error message if a form field has been touched and an error exists.
|
||||||
|
*/
|
||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
const ErrorMessage = ({ touched, error }: { touched: boolean; error?: string }) => {
|
const ErrorMessage = ({ touched, error }: { touched: boolean; error?: string }) => {
|
||||||
if (touched && error) {
|
if (touched && error) {
|
||||||
return <p className="mt-1">{error}</p>
|
return <p className="text-xs text-red-500" role="alert">{error}</p>
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,146 @@
|
||||||
|
/**
|
||||||
|
* FileInput Component
|
||||||
|
*
|
||||||
|
* Renders a file input with drag-and-drop support. Displays the selected file's name and size,
|
||||||
|
* and simulates file upload progress. Integrates with Formik for form state management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { faFile } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useField, useFormikContext } from "formik";
|
import { useField, useFormikContext } from "formik";
|
||||||
import React, { useRef } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import ErrorMessage from "./ErrorMessage";
|
import ErrorMessage from "./ErrorMessage";
|
||||||
import RequiredAsterisk from "./RequiredAsterisk";
|
import RequiredAsterisk from "./RequiredAsterisk";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import Separator from "./Separator";
|
||||||
import { faFile } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
|
|
||||||
interface FileInputProps {
|
interface FileInputProps {
|
||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
name:string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileInput: React.FC<FileInputProps> = ({ label, ...props }) => {
|
const FileInput: React.FC<FileInputProps> = ({ label, ...props }) => {
|
||||||
const { required } = props;
|
const { required } = props;
|
||||||
const [field, meta] = useField(props);
|
const [field, meta] = useField(props);
|
||||||
const { setFieldValue } = useFormikContext();
|
const { setFieldValue } = useFormikContext();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// State to manage drag-over styling
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
// State to store information about the selected file
|
||||||
|
const [fileInfo, setFileInfo] = useState<{ name: string; size: number } | null>(null);
|
||||||
|
|
||||||
|
// State to simulate and display upload progress
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
|
// Simulates file upload progress
|
||||||
|
const simulateUpload = () => {
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += 10;
|
||||||
|
setUploadProgress(progress);
|
||||||
|
if (progress >= 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 20); // Adjust time interval to control speed of the animation
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handles file selection via input change event
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { files } = event.target;
|
const { files } = event.target;
|
||||||
const file = files ? files[0] : null;
|
const file = files ? files[0] : null;
|
||||||
setFieldValue(props.name, file); // Directly use setFieldValue to update Formik's state
|
setFieldValue(props.name, file); // Update Formik's state
|
||||||
|
if (file) {
|
||||||
|
setFileInfo({ name: file.name, size: file.size });
|
||||||
|
setUploadProgress(0); // Reset progress
|
||||||
|
simulateUpload(); // Simulate upload process
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Opens the file input dialog
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click();
|
fileInputRef.current.click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handles file drop
|
||||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
setDragging(false); // Reset drag state
|
||||||
const files = event.dataTransfer.files;
|
const files = event.dataTransfer.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
setFieldValue(props.name, file);
|
if (file) {
|
||||||
|
setFileInfo({ name: file.name, size: file.size });
|
||||||
|
setFieldValue(props.name, file);
|
||||||
|
}
|
||||||
|
setUploadProgress(0); // Reset progress
|
||||||
|
simulateUpload(); // Simulate upload process
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manages drag-over state for styling
|
||||||
|
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dragging) {
|
||||||
|
setDragging(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resets dragging state when dragging leaves the drop area
|
||||||
|
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="flex flex-col gap-4">
|
||||||
<label className="text-md" htmlFor={props.name}>
|
{/* Title */}
|
||||||
{label}
|
<div className="flex flex-col gap-4">
|
||||||
<RequiredAsterisk required={required} />
|
<Separator/>
|
||||||
</label>
|
<label className="font-bold text-sm leading-tight text-indigo-900" htmlFor={props.name}>
|
||||||
<div className="drop-area" onClick={handleClick} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
{label}
|
||||||
<div>
|
<RequiredAsterisk required={required} />
|
||||||
<FontAwesomeIcon icon={faFile} />
|
</label>
|
||||||
<h4>Drag & Drop Here or Browse</h4>
|
|
||||||
</div>
|
|
||||||
<button type="button">Upload Manifest</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="file"
|
{/* File drop area */}
|
||||||
className="hidden"
|
<div
|
||||||
ref={fileInputRef}
|
className={`border border-grey-500 rounded-md p-4 flex flex-col items-center gap-4 ${dragging ? "bg-blue-100" : "bg-white"} cursor-pointer`}
|
||||||
onChange={handleChange}
|
onClick={handleClick}
|
||||||
/>
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
>
|
||||||
|
<div className="border border-grey-500 border-dashed flex flex-col gap-4 items-center justify-center rounded-md py-9 w-full">
|
||||||
|
<FontAwesomeIcon icon={faFile} className="text-amber-500 h-5 w-5" />
|
||||||
|
<h4 className="text-sm leading-tight">Drag & Drop Here or <span className="font-bold">Browse</span></h4>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="text-white px-12 py-2 rounded-md bg-blue-950 w-fit">Upload Manifest</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden input */}
|
||||||
|
<input type="file" className="hidden" ref={fileInputRef} onChange={handleChange} />
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
{fileInfo && (
|
||||||
|
<div className="file-info flex gap-4 p-4 items-center border-t border-grey-500">
|
||||||
|
<FontAwesomeIcon icon={faFile} className="text-amber-500 h-5 w-5" />
|
||||||
|
<div className="grow">
|
||||||
|
<div className="flex justify-between text-sm text-slate-500">
|
||||||
|
<p className="text-sm">{fileInfo.name}</p>
|
||||||
|
<p className="text-xs">{Math.round(fileInfo.size / 1024)} KB</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1 dark:bg-gray-700">
|
||||||
|
<div className="bg-amber-500 h-1 rounded-full" style={{ width: `${uploadProgress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ErrorMessage touched={meta.touched} error={meta.error} />
|
<ErrorMessage touched={meta.touched} error={meta.error} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,63 @@
|
||||||
|
/**
|
||||||
|
* RadioGroupField Component
|
||||||
|
*
|
||||||
|
* Renders a group of radio buttons using Radix UI with Formik integration.
|
||||||
|
* Supports custom options, labels, and an optional separator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group"
|
||||||
import { useField } from "formik"
|
import { useField } from "formik"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import ErrorMessage from "./ErrorMessage"
|
import ErrorMessage from "./ErrorMessage"
|
||||||
import RequiredAsterisk from "./RequiredAsterisk"
|
import RequiredAsterisk from "./RequiredAsterisk"
|
||||||
import * as RadioGroup from "@radix-ui/react-radio-group"
|
import Separator from "./Separator"
|
||||||
|
|
||||||
const RadioGroupField = ({
|
/**
|
||||||
|
* Props for RadioGroupField.
|
||||||
|
*
|
||||||
|
* @param name The name attribute for the radio group, used for form submission.
|
||||||
|
* @param label Display label for the radio group.
|
||||||
|
* @param separator Optional boolean to render a visual separator above the radio group.
|
||||||
|
* @param options Array of option strings that the radio group will display.
|
||||||
|
* @param props Additional props passed to the radio group, including `required`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RadioGroupFieldProps {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
separator?: boolean;
|
||||||
|
options: string[];
|
||||||
|
[key: string]: any; // This allows for additional props not explicitly defined in the interface.
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioGroupField: React.FC<RadioGroupFieldProps> = ({
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
|
separator,
|
||||||
options,
|
options,
|
||||||
...props
|
...props
|
||||||
}: {
|
|
||||||
name: string
|
|
||||||
label: string
|
|
||||||
options: string[]
|
|
||||||
[x: string]: any // Allow any other props
|
|
||||||
}) => {
|
}) => {
|
||||||
const { required, ...restProps } = props
|
const { required, ...restProps } = props
|
||||||
|
|
||||||
|
// State and helpers from Formik for managing radio group value
|
||||||
const [field, meta, helpers] = useField({ ...restProps, name })
|
const [field, meta, helpers] = useField({ ...restProps, name })
|
||||||
|
|
||||||
|
// Handles radio option change, updating Formik's state
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
helpers.setValue(value)
|
helpers.setValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="text-md" htmlFor={label}>
|
<div className="flex flex-col gap-4">
|
||||||
{label}
|
{separator && <Separator/>}
|
||||||
</label>
|
<label className="font-bold text-sm leading-tight text-indigo-900" htmlFor={label}>
|
||||||
<RequiredAsterisk required={required} />
|
{label}
|
||||||
<RadioGroup.Root
|
<RequiredAsterisk required={required} />
|
||||||
className="flex gap-3"
|
</label>
|
||||||
value={field.value}
|
</div>
|
||||||
onValueChange={handleChange}
|
|
||||||
aria-label={label}
|
<RadioGroup.Root className="flex gap-3" value={field.value} onValueChange={handleChange} aria-label={label}>
|
||||||
>
|
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<div className="flex items-center" key={option}>
|
<div className="flex items-center" key={option}>
|
||||||
<RadioGroup.Item
|
<RadioGroup.Item
|
||||||
|
|
@ -41,7 +65,7 @@ const RadioGroupField = ({
|
||||||
value={option}
|
value={option}
|
||||||
id={`${name}-${option}`}
|
id={`${name}-${option}`}
|
||||||
>
|
>
|
||||||
<RadioGroup.Indicator className="relative flex h-full w-full items-center justify-center after:block after:h-[11px] after:w-[11px] after:rounded-[50%] after:bg-violet-400 after:content-['']" />
|
<RadioGroup.Indicator className="relative flex h-full w-full items-center justify-center after:block after:h-[11px] after:w-[11px] after:rounded-[50%] after:bg-blue-950 after:content-['']" />
|
||||||
</RadioGroup.Item>
|
</RadioGroup.Item>
|
||||||
<label className="pl-[15px] text-[15px] leading-none" htmlFor={`${name}-${option}`}>
|
<label className="pl-[15px] text-[15px] leading-none" htmlFor={`${name}-${option}`}>
|
||||||
{option}
|
{option}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
const RequiredAsterisk = ({ required }: { required?: boolean }) => {
|
const RequiredAsterisk = ({ required }: { required?: boolean }) => {
|
||||||
return required ? <span className="text-sm"> *</span> : null
|
return required ? <span className="text-red-500 text-xs"> *</span> : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RequiredAsterisk
|
export default RequiredAsterisk
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,25 @@ import ErrorMessage from "./ErrorMessage"
|
||||||
import RequiredAsterisk from "./RequiredAsterisk"
|
import RequiredAsterisk from "./RequiredAsterisk"
|
||||||
|
|
||||||
const SelectInput = ({ label, ...props }: { label?: string; name: string; [x: string]: any }) => {
|
const SelectInput = ({ label, ...props }: { label?: string; name: string; [x: string]: any }) => {
|
||||||
|
// Since `useField` only needs `name`, and optionally `type` and `value`, we destruct those from props
|
||||||
const { required, ...restProps } = props
|
const { required, ...restProps } = props
|
||||||
const [field, meta] = useField(restProps)
|
const [field, meta] = useField(restProps)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="flex flex-col gap-3">
|
||||||
{label && (
|
{label && (
|
||||||
<label className="text-md" htmlFor={props.id || props.name}>
|
<label className="text-md" htmlFor={props.id || props.name}>
|
||||||
{label}
|
{label}
|
||||||
|
<RequiredAsterisk required={required} />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<RequiredAsterisk required={required} />
|
|
||||||
<select className="h-10 w-full rounded-lg border px-3 text-base shadow-sm" {...field} {...restProps} />
|
<div className="flex flex-col">
|
||||||
<ErrorMessage touched={meta.touched} error={meta.error} />
|
<select className="h-10 w-full rounded-lg border border-grey-500 px-4 text-sm" {...field} {...restProps} />
|
||||||
|
<ErrorMessage touched={meta.touched} error={meta.error} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
components/Form/Separator.tsx
Normal file
9
components/Form/Separator.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const Separator = () => {
|
||||||
|
return (
|
||||||
|
<hr className='w-[285px]'/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Separator
|
||||||
|
|
@ -1,18 +1,29 @@
|
||||||
import React from "react"
|
/**
|
||||||
|
* StatusField Component
|
||||||
|
*
|
||||||
|
* Displays a status label with a customizable color. This component is typically used
|
||||||
|
* to indicate the status of a process or entity (e.g., success, error) within the application.
|
||||||
|
* Includes a visual separator and supports dynamic text and color customization through props.
|
||||||
|
*/
|
||||||
|
|
||||||
const StatusField = ({
|
import React from "react"
|
||||||
label,
|
import Separator from "./Separator"
|
||||||
statusLabel,
|
|
||||||
statusColor,
|
interface StatusFieldProps {
|
||||||
}: {
|
label: string;
|
||||||
label: string
|
statusLabel: string;
|
||||||
statusLabel: string
|
statusColor: string;
|
||||||
statusColor: string
|
}
|
||||||
}) => {
|
|
||||||
|
const StatusField: React.FC<StatusFieldProps> = ({ label, statusLabel, statusColor }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
<h4>{label}</h4>
|
<div className="flex flex-col gap-4">
|
||||||
<span className={statusColor}>{statusLabel}</span>
|
<Separator/>
|
||||||
|
<h4 className="font-bold text-sm leading-tight text-indigo-900">{label}</h4>
|
||||||
|
</div>
|
||||||
|
{/* Dynamically styled based on status */}
|
||||||
|
<span className={`text-sm leading-tight ${statusColor}`}>{statusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,75 @@
|
||||||
import { useField } from "formik";
|
/**
|
||||||
import React, { useState } from "react";
|
* ToggleField Component
|
||||||
import ErrorMessage from "./ErrorMessage";
|
*
|
||||||
import RequiredAsterisk from "./RequiredAsterisk";
|
* Renders a toggle switch using Radix UI, integrated with Formik for form state management.
|
||||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
* Supports custom child content and displays an error message if validation fails.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
import { useField } from "formik"
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import ErrorMessage from "./ErrorMessage"
|
||||||
|
import RequiredAsterisk from "./RequiredAsterisk"
|
||||||
|
import Separator from "./Separator"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||||
|
import { faClock } from "@fortawesome/free-regular-svg-icons"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for ToggleField.
|
||||||
|
*
|
||||||
|
* @param children ReactNode - The content to be displayed alongside the toggle switch.
|
||||||
|
* @extends React.InputHTMLAttributes<HTMLInputElement> - Inherits standard input attributes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
const ToggleField = ({
|
const ToggleField = ({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
} & React.InputHTMLAttributes<HTMLInputElement>) => {
|
} & React.InputHTMLAttributes<HTMLInputElement>) => {
|
||||||
const { required, ...restProps } = props;
|
const { required, ...restProps } = props
|
||||||
const [field, meta] = useField({ ...(restProps as { name: string }), type: "checkbox" });
|
|
||||||
const [checked, setChecked] = useState(false);
|
// Formik hook for managing form state and validation
|
||||||
|
const [field, meta] = useField({ ...(restProps as { name: string }), type: "checkbox" })
|
||||||
|
|
||||||
|
// Local state to manage the checked status of the switch
|
||||||
|
const [checked, setChecked] = useState(false)
|
||||||
|
|
||||||
|
// Handles checked state change, updates Formik's state accordingly
|
||||||
|
const handleCheckedChange = (isChecked: boolean) => {
|
||||||
|
setChecked(isChecked);
|
||||||
|
field.onChange({ target: { name: field.name, value: isChecked } });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="flex flex-col gap-4">
|
||||||
<label className="flex items-center gap-2">
|
<div className="flex flex-col gap-4">
|
||||||
{children}
|
<Separator/>
|
||||||
<RequiredAsterisk required={required} />
|
<label className="flex items-center gap-2 font-bold text-sm leading-tight text-indigo-900">
|
||||||
</label>
|
{children}
|
||||||
<SwitchPrimitive.Root
|
<RequiredAsterisk required={required} />
|
||||||
className="relative h-[25px] w-[42px] cursor-default rounded-full bg-black shadow-[0_2px_10px] outline-none focus:shadow-[0_0_0_2px] focus:shadow-black"
|
</label>
|
||||||
checked={checked}
|
</div>
|
||||||
onCheckedChange={(isChecked) => {
|
<div className="flex gap-3 itens-center">
|
||||||
setChecked(isChecked);
|
<SwitchPrimitive.Root
|
||||||
field.onChange({ target: { name: field.name, value: isChecked } });
|
className="toggle-switch relative h-[25px] w-[42px] cursor-default rounded-full bg-indigo-900 outline-none"
|
||||||
}}
|
onCheckedChange={(isChecked) => handleCheckedChange(isChecked)}
|
||||||
>
|
>
|
||||||
<SwitchPrimitive.Thumb className="block h-[21px] w-[21px] translate-x-0.5 rounded-full bg-white shadow-[0_2px_2px] shadow-black transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
<SwitchPrimitive.Thumb className="block h-[21px] w-[21px] translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
</SwitchPrimitive.Root>
|
</SwitchPrimitive.Root>
|
||||||
|
<div className="flex gap-2 md:gap-4 items-center text-xs md:text-sm leading-tight">
|
||||||
|
<span>Toggle ON</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span className="flex gap-2 items-center"><FontAwesomeIcon icon={faClock} />Select Tolerance Level</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<ErrorMessage touched={meta.touched} error={meta.error} />
|
<ErrorMessage touched={meta.touched} error={meta.error} />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ToggleField;
|
export default ToggleField
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const data = {
|
||||||
|
importNames: ["Import ABC", "Import DEF", "Import GHI"],
|
||||||
|
splitScheduleOptions: ["Yes", "No"],
|
||||||
|
clientOptions: ["Single", "Multiple"],
|
||||||
|
testingCenterOptions: ["Testing Center 1", "Testing Center 2", "Testing Center 3", "Testing Center 4"],
|
||||||
|
clientsOptions: ["Client 1", "Client 2", "Client 3"],
|
||||||
|
}
|
||||||
24
components/Form/formSchemas.ts
Normal file
24
components/Form/formSchemas.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as Yup from "yup"
|
||||||
|
import { FormValues } from "./types"
|
||||||
|
|
||||||
|
export const initialValues: FormValues = {
|
||||||
|
importName: "",
|
||||||
|
manifestFile: null,
|
||||||
|
splitSchedule: "",
|
||||||
|
client: "",
|
||||||
|
testingCenter1: "",
|
||||||
|
testingCenter2: "",
|
||||||
|
testingCenter3: "",
|
||||||
|
testingCenter4: "",
|
||||||
|
toleranceWindow: false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validationSchema = Yup.object({
|
||||||
|
importName: Yup.string()
|
||||||
|
.required("Import name is required")
|
||||||
|
.oneOf(["Import ABC", "Import DEF", "Import GHI"], "Invalid Import Name"),
|
||||||
|
manifestFile: Yup.mixed().required("A file is required").nullable(), // Allows for null values
|
||||||
|
splitSchedule: Yup.string().required("This field is required").oneOf(["Yes", "No"], "Invalid Option"),
|
||||||
|
client: Yup.string().required("Client selection is required").oneOf(["Single", "Multiple"], "Invalid Client Type"),
|
||||||
|
toleranceWindow: Yup.boolean().required("Tolerance window selection is required"), // Assuming this is a boolean represented by a toggle
|
||||||
|
})
|
||||||
|
|
@ -1,172 +1,157 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
// React and Formik imports
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { Form, Formik } from "formik";
|
||||||
//Dependencies
|
|
||||||
import { Formik, Form } from "formik";
|
|
||||||
import * as Yup from "yup";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
//Input fields
|
// UI components and utilities
|
||||||
import SelectInput from "./SelectInput";
|
import { Button } from "./Button";
|
||||||
import RadioGroup from "./RadioGroup";
|
|
||||||
import FileInput from "./FileInput";
|
|
||||||
import ToggleField from "./ToggleField";
|
|
||||||
import StatusField from "./StatusField";
|
|
||||||
import ClientField from "./ClientField";
|
import ClientField from "./ClientField";
|
||||||
|
import FileInput from "./FileInput";
|
||||||
|
import RadioGroup from "./RadioGroup";
|
||||||
|
import SelectInput from "./SelectInput";
|
||||||
|
import StatusField from "./StatusField";
|
||||||
|
import ToggleField from "./ToggleField";
|
||||||
|
|
||||||
interface FormValues {
|
// Data and schema and types
|
||||||
importName: string;
|
import { FormValues } from "./types";
|
||||||
manifestFile: File | null;
|
import { data } from "./data";
|
||||||
splitSchedule: string;
|
import { initialValues, validationSchema } from "./formSchemas";
|
||||||
client: string;
|
|
||||||
testingCenter: string;
|
|
||||||
toleranceWindow: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialValues: FormValues = {
|
// Icons
|
||||||
importName: '',
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
manifestFile: null,
|
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
splitSchedule: '',
|
|
||||||
client: '',
|
|
||||||
testingCenter: '',
|
|
||||||
toleranceWindow: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationSchema = Yup.object({
|
/**
|
||||||
importName: Yup.string()
|
* FormComponent: Main form component for document upload.
|
||||||
.required('Import name is required')
|
*
|
||||||
.oneOf(["Import ABC", "Import DEF", "Import GHI"], "Invalid Import Name"),
|
* This component handles the document upload form, including input fields for document details,
|
||||||
manifestFile: Yup.mixed()
|
* file upload, and form submission. It uses Formik for form state management and validation,
|
||||||
.required('A file is required')
|
* and Axios for submission to a backend endpoint.
|
||||||
.nullable(), // Allows for null values, adjust based on your logic for file handling
|
*/
|
||||||
splitSchedule: Yup.string()
|
|
||||||
.required('This field is required')
|
|
||||||
.oneOf(["Yes", "No"], "Invalid Option"),
|
|
||||||
client: Yup.string()
|
|
||||||
.required('Client selection is required')
|
|
||||||
.oneOf(["Single", "Multiple"], "Invalid Client Type"),
|
|
||||||
testingCenter: Yup.string()
|
|
||||||
.required('Testing center selection is required'), // Assuming a selection is required
|
|
||||||
toleranceWindow: Yup.boolean()
|
|
||||||
.required('Tolerance window selection is required'), // Assuming this is a boolean represented by a toggle
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
importNames: ["Import ABC", "Import DEF", "Import GHI"],
|
|
||||||
splitScheduleOptions: ["Yes", "No"],
|
|
||||||
clientOptions: ["Single", "Multiple"],
|
|
||||||
testingCenterOptions: ["testing center 1", "testing center 2", "testing center 3", "testing center 4"],
|
|
||||||
clientsOptions: ["client 1", "client 2", "client 3"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const FormComponent: React.FC = () => {
|
const FormComponent: React.FC = () => {
|
||||||
const [formSubmitted, setFormSubmitted] = useState(false);
|
// State to track if the form has been successfully submitted
|
||||||
|
const [formSubmitted, setFormSubmitted] = useState(false);
|
||||||
|
|
||||||
const submitForm = (values: FormValues) => {
|
// Function to handle form submission
|
||||||
return axios.post(
|
const submitForm = (values: FormValues) => {
|
||||||
`/api/submitForm`,
|
//TODO implement form submission
|
||||||
{
|
return axios.post(
|
||||||
submissionData: values,
|
`/api/submitForm`,
|
||||||
},
|
{
|
||||||
{
|
submissionData: values,
|
||||||
// options
|
},
|
||||||
headers: {
|
{
|
||||||
"Content-Type": "application/json"
|
// options
|
||||||
}
|
headers: {
|
||||||
}
|
"Content-Type": "application/json",
|
||||||
);
|
},
|
||||||
};
|
}
|
||||||
|
)
|
||||||
return (
|
}
|
||||||
<Formik
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Formik setup for handling form state, validation, and submission */}
|
||||||
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={async (values, { setSubmitting }) => {
|
onSubmit={async (values, { setSubmitting }) => {
|
||||||
try {
|
//console.log("Form values:", values); // Log form values on submit
|
||||||
console.log(values);
|
try {
|
||||||
await submitForm(values);
|
//await submitForm(values)
|
||||||
setFormSubmitted(true);
|
setFormSubmitted(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting form:", error);
|
console.error("Error submitting form:", error)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formik => (
|
{(formik) => {
|
||||||
|
//console.log("Form errors:", formik.errors); // Log form errors on every render
|
||||||
|
//console.log("Form values:", formik.values); // Log current form values on every render
|
||||||
|
|
||||||
|
return (
|
||||||
<Form>
|
<Form>
|
||||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-16">
|
{/* Close button - allows resetting the form to initial state */}
|
||||||
<div className="flex flex-col grow lg:max-w-[500px]">
|
<button type="button" className="hidden md:flex h-10 w-10 items-center justify-center rounded-lg bg-blue-950 text-white" onClick={() => formik.resetForm()}>
|
||||||
<SelectInput
|
<FontAwesomeIcon icon={faXmark} className="h-6 w-6" />
|
||||||
key="importName"
|
</button>
|
||||||
name="importName"
|
{/* Title - provides context for the form's purpose */}
|
||||||
label="Select Import Name:"
|
<div className="flex justify-center mb-12">
|
||||||
required
|
<h1 className="border-grey-300 w-fit border-b pb-4 font-bold text-3xl text-indigo-900">Document Upload</h1>
|
||||||
>
|
</div>
|
||||||
<option value="">Select Import Name:</option>
|
|
||||||
{data.importNames.map(option => (
|
<div className="flex flex-col gap-12">
|
||||||
<option key={option} value={option}>
|
|
||||||
{option}
|
{/* Form layout - organizes the form fields into logical sections */}
|
||||||
</option>
|
<div className="flex flex-col gap-4 lg:flex-row lg:gap-16">
|
||||||
))}
|
{/* Left Column */}
|
||||||
</SelectInput>
|
<div className="flex w-full flex-col lg:w-[500px] gap-5">
|
||||||
<FileInput
|
<SelectInput key="importName" name="importName" required>
|
||||||
label="Select a manifest that you'd like to import"
|
<option value="">Select Import Name:</option>
|
||||||
name="manifestFile"
|
{data.importNames.map((option) => (
|
||||||
required
|
<option key={option} value={option}>
|
||||||
/>
|
{option}
|
||||||
<StatusField
|
</option>
|
||||||
label="Elapse Data Checking:"
|
))}
|
||||||
statusLabel="No Elapsed Dates!"
|
</SelectInput>
|
||||||
statusColor="text-green-500"
|
|
||||||
/>
|
<FileInput label="Select a manifest that you'd like to import" name="manifestFile" required />
|
||||||
<ToggleField
|
|
||||||
key="toleranceWindow"
|
<StatusField label="Elapse Data Checking:" statusLabel="No Elapsed Dates!" statusColor="text-green-500" />
|
||||||
name="toleranceWindow"
|
<ToggleField key="toleranceWindow" name="toleranceWindow" required={true}>
|
||||||
required={true}
|
Tolerance Window:
|
||||||
>Tolerance Window:</ToggleField>
|
</ToggleField>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col grow lg:max-w-[350px]">
|
</div>
|
||||||
<RadioGroup
|
{/* Right Column */}
|
||||||
key="splitSchedule"
|
<div className="flex w-full flex-col gap-5 lg:w-[350px]">
|
||||||
name="splitSchedule"
|
<RadioGroup
|
||||||
label="Split schedule using social distancing?"
|
key="splitSchedule"
|
||||||
options={data.splitScheduleOptions}
|
name="splitSchedule"
|
||||||
required={true}
|
label="Split schedule using social distancing?"
|
||||||
/>
|
options={data.splitScheduleOptions}
|
||||||
<StatusField
|
required={true}
|
||||||
label="Location Checking:"
|
/>
|
||||||
statusLabel="All Available!"
|
<StatusField label="Location Checking:" statusLabel="All Available!" statusColor="text-green-500" />
|
||||||
statusColor="text-green-500"
|
<RadioGroup key="client" name="client" label="Client" options={data.clientOptions} required={true} separator />
|
||||||
/>
|
<ClientField testingCenters={data.testingCenterOptions} clients={data.clientsOptions} />
|
||||||
<RadioGroup
|
</div>
|
||||||
key="client"
|
|
||||||
name="client"
|
|
||||||
label="Client"
|
|
||||||
options={data.clientOptions}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
<ClientField
|
|
||||||
testingCenters={data.testingCenterOptions}
|
|
||||||
clients={data.clientsOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="mx-auto mt-4"
|
{/* Form Footer - contains actions for form submission and cancellation */}
|
||||||
type="submit"
|
<div className="flex flex-col gap-6">
|
||||||
disabled={formik.isSubmitting}
|
<p className="text-lg leading-tight text-center">Data in the import file is correct. Please press Continue to import.</p>
|
||||||
>
|
<div className="flex gap-3 mdgap-6 justify-center">
|
||||||
Submit
|
<Button
|
||||||
</button>
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!formik.isValid || !formik.dirty || formik.isSubmitting} >
|
||||||
|
Continue Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => formik.resetForm()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* Success message - Display a success message if the form has been submitted successfully */}
|
||||||
|
{formSubmitted && <div className="success-message text-center text-green-500">Success</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)
|
||||||
</Formik>
|
}
|
||||||
|
}
|
||||||
)
|
</Formik>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FormComponent;
|
export default FormComponent
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface FormValues {
|
||||||
|
importName: string;
|
||||||
|
manifestFile: File | null;
|
||||||
|
splitSchedule: string;
|
||||||
|
client: string;
|
||||||
|
testingCenter1: string;
|
||||||
|
testingCenter2: string;
|
||||||
|
testingCenter3: string;
|
||||||
|
testingCenter4: string;
|
||||||
|
toleranceWindow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import * as RadixTooltip from "@radix-ui/react-tooltip"
|
|
||||||
import { cva, VariantProps } from "class-variance-authority"
|
|
||||||
import React from "react"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
const tooltipContent = cva([], {
|
|
||||||
variants: {
|
|
||||||
intent: {
|
|
||||||
primary: ["rounded-0.5md", "bg-zinc-700", "font-open-sans", "text-white"],
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
md: ["px-4", "py-2.5", "text-2xs"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
intent: "primary",
|
|
||||||
size: "md",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const tooltipArrow = cva([], {
|
|
||||||
variants: {
|
|
||||||
intent: {
|
|
||||||
primary: ["fill-zinc-700"],
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
md: ["w-4", "h-2"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
intent: "primary",
|
|
||||||
size: "md",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface TooltipProps extends VariantProps<typeof tooltipContent>, RadixTooltip.TooltipProps {
|
|
||||||
explainer: React.ReactElement | string
|
|
||||||
children: React.ReactElement
|
|
||||||
className?: string
|
|
||||||
withArrow?: boolean
|
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tooltip({
|
|
||||||
children,
|
|
||||||
explainer,
|
|
||||||
open,
|
|
||||||
defaultOpen,
|
|
||||||
onOpenChange,
|
|
||||||
intent,
|
|
||||||
size,
|
|
||||||
side = "top",
|
|
||||||
className,
|
|
||||||
withArrow,
|
|
||||||
}: TooltipProps) {
|
|
||||||
return (
|
|
||||||
<RadixTooltip.Provider>
|
|
||||||
<RadixTooltip.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} delayDuration={200}>
|
|
||||||
<RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
|
|
||||||
<RadixTooltip.Portal>
|
|
||||||
<RadixTooltip.Content
|
|
||||||
side={side}
|
|
||||||
sideOffset={5}
|
|
||||||
className={twMerge(tooltipContent({ intent, size, className }))}
|
|
||||||
>
|
|
||||||
{explainer}
|
|
||||||
{withArrow ? <RadixTooltip.Arrow className={twMerge(tooltipArrow({ intent, size, className }))} /> : null}
|
|
||||||
</RadixTooltip.Content>
|
|
||||||
</RadixTooltip.Portal>
|
|
||||||
</RadixTooltip.Root>
|
|
||||||
</RadixTooltip.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { expect, test } from "@playwright/test"
|
|
||||||
|
|
||||||
test("has title", async ({ page }) => {
|
|
||||||
await page.goto("./")
|
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Next.js Enterprise Boilerplate/)
|
|
||||||
})
|
|
||||||
67
e2e/form.spec.ts
Normal file
67
e2e/form.spec.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
//TODO Install playwright
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
|
||||||
|
//Form load and initial state validation
|
||||||
|
test("form loads correctly with initial state", async ({ page }) => {
|
||||||
|
await page.goto("/"); //Home URL
|
||||||
|
|
||||||
|
// Check if specific fields are present and the default selected option is the placeholder value
|
||||||
|
await expect(page.locator('select[name="importName"] option').first()).toHaveText('Select Import Name:');
|
||||||
|
|
||||||
|
// Check if the file upload field indicates that no file is selected
|
||||||
|
await expect(page.locator('.file-info')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify that no option is selected initially for "splitSchedule"
|
||||||
|
await expect(page.locator('input[name="splitSchedule"]:checked')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Verify that no option is selected initially for "client"
|
||||||
|
await expect(page.locator('input[name="client"]:checked')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Check for the presence of the submit button and that it's initially disabled
|
||||||
|
const submitButton = page.locator('button', { hasText: 'Continue Import' });
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
//File upload validation
|
||||||
|
test("handles file upload correctly", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Simulate file upload
|
||||||
|
await page.setInputFiles('input[type="file"]', '/e2e/testFile.txt');
|
||||||
|
|
||||||
|
// Check if the file name is displayed correctly
|
||||||
|
await expect(page.locator('.file-info')).toHaveText(/testFile.txt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
//Toggle and submit interactions
|
||||||
|
test("toggles a switch and submits the form", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Toggle a switch
|
||||||
|
await page.locator('.toggle-switch').click();
|
||||||
|
// Wait for the 'aria-checked' attribute to become 'true'
|
||||||
|
await expect(page.locator('.toggle-switch')).toHaveAttribute('aria-checked', 'true', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Fill in other required fields
|
||||||
|
// Select an option from "importName" dropdown
|
||||||
|
await page.locator('select[name="importName"]').selectOption({ label: "Import ABC" });
|
||||||
|
|
||||||
|
// Upload a file for "manifestFile"
|
||||||
|
await page.locator('input[type="file"]').setInputFiles('/e2e/testFile.txt');
|
||||||
|
|
||||||
|
// Select an option for "splitSchedule"
|
||||||
|
await page.locator('button[role="radio"][value="Yes"]').click();
|
||||||
|
|
||||||
|
// Select an option for "client"
|
||||||
|
await page.locator('button[role="radio"][value="Single"]').click();
|
||||||
|
|
||||||
|
// Enable and click the submit button
|
||||||
|
const submitButton = page.locator('button', { hasText: 'Continue Import' });
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Verify submission was successful
|
||||||
|
await expect(page.locator('.success-message')).toHaveText(/Success/);
|
||||||
|
});
|
||||||
|
|
||||||
1
e2e/testFile.txt
Normal file
1
e2e/testFile.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
File to be used to test the file upload field
|
||||||
5
styles/global.css
Normal file
5
styles/global.css
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Raleway', sans-serif;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue