From 83898b17ccbe6a72644a4989999d6d28d872075d Mon Sep 17 00:00:00 2001 From: Serif Date: Fri, 26 Apr 2024 00:39:58 +0300 Subject: [PATCH] INFO (serif) : Shared Data Layer added. INFO (serif) : Redux and RTKQuery integration must be added on next article. --- .env.custom | 3 + .env.development | 3 + .env.production | 3 + SHADCN_UI_SETUP.md | 36 +- apps/container/module-federation.config.ts | 84 ++++ apps/container/project.json | 16 + apps/container/src/app/app.tsx | 55 +-- apps/container/src/bootstrap.tsx | 8 +- apps/container/src/pages/home/index.tsx | 6 + .../src/pages/login/hooks/use-login.ts | 49 +++ apps/container/src/pages/login/index.tsx | 65 +++ apps/container/src/routes/index.tsx | 23 ++ apps/container/webpack.config.custom.ts | 34 ++ apps/info/module-federation.config.ts | 84 ++++ apps/info/project.json | 16 + apps/info/src/app/app.tsx | 219 +++++++++- .../src/components/counter-actions/index.tsx | 21 + apps/info/webpack.config.custom.ts | 3 + package.json | 16 +- packages/data/.babelrc | 12 + packages/data/.eslintrc.json | 18 + packages/data/README.md | 7 + packages/data/package.json | 12 + packages/data/project.json | 32 ++ packages/data/src/apis/base.api.ts | 10 + packages/data/src/apis/index.ts | 3 + packages/data/src/apis/platzi.store.api.ts | 17 + packages/data/src/common/environments.ts | 41 ++ packages/data/src/common/index.ts | 5 + packages/data/src/common/paths.ts | 5 + .../data/src/features/counter/counterSlice.ts | 40 ++ .../data/src/helpers/environment.helpers.ts | 48 +++ packages/data/src/helpers/index.ts | 1 + packages/data/src/helpers/service.helpers.ts | 77 ++++ packages/data/src/hooks/base-query/index.ts | 44 ++ packages/data/src/hooks/index.ts | 4 + packages/data/src/hooks/use-counter/index.ts | 24 ++ .../src/hooks/use-platzi-store-auth/index.tsx | 70 ++++ .../hooks/use-platzi-store-products/index.ts | 163 ++++++++ packages/data/src/hooks/use-random/index.ts | 9 + packages/data/src/index.ts | 8 + packages/data/src/lib/api.interceptors.ts | 22 + packages/data/src/providers/index.tsx | 8 + packages/data/src/services/index.ts | 1 + .../data/src/services/platzi/auth/index.ts | 114 ++++++ .../data/src/services/platzi/auth/schemas.ts | 27 ++ .../data/src/services/platzi/auth/types.ts | 19 + packages/data/src/services/platzi/contants.ts | 17 + packages/data/src/services/platzi/index.ts | 4 + packages/data/src/services/platzi/methods.ts | 94 +++++ .../src/services/platzi/products/index.ts | 172 ++++++++ .../src/services/platzi/products/schemas.ts | 59 +++ .../src/services/platzi/products/types.ts | 21 + packages/data/src/store/index.ts | 19 + packages/data/src/types/index.ts | 15 + packages/data/tsconfig.json | 18 + packages/data/tsconfig.lib.json | 30 ++ packages/data/vite.config.ts | 44 ++ .../src/components/form-fields/date-field.tsx | 75 ++++ .../ui/src/components/form-fields/index.ts | 4 + .../components/form-fields/input-field.tsx | 43 ++ .../components/form-fields/select-field.tsx | 61 +++ .../form-fields/text-area-field.tsx | 43 ++ packages/ui/src/components/index.ts | 1 + packages/ui/src/components/ui/button.tsx | 112 ++++- packages/ui/src/components/ui/calendar.tsx | 66 +++ packages/ui/src/components/ui/card.tsx | 93 +++++ packages/ui/src/components/ui/carousel.tsx | 278 +++++++++++++ packages/ui/src/components/ui/form.tsx | 191 +++++++++ packages/ui/src/components/ui/index.ts | 10 + packages/ui/src/components/ui/input.tsx | 23 ++ packages/ui/src/components/ui/label.tsx | 25 ++ packages/ui/src/components/ui/popover.tsx | 29 ++ packages/ui/src/components/ui/select.tsx | 163 ++++++++ packages/ui/src/components/ui/textarea.tsx | 22 + packages/ui/src/components/ui/toast.tsx | 131 ++++++ packages/ui/src/components/ui/toaster.tsx | 29 ++ packages/ui/src/components/ui/tooltip.tsx | 34 ++ packages/ui/src/components/ui/use-toast.ts | 194 +++++++++ packages/ui/src/index.ts | 1 + packages/ui/src/utils/index.ts | 1 + packages/ui/src/utils/lazy.utils.tsx | 40 ++ pnpm-lock.yaml | 386 +++++++++++++++++- tsconfig.base.json | 1 + 84 files changed, 4030 insertions(+), 104 deletions(-) create mode 100644 .env.custom create mode 100644 .env.development create mode 100644 .env.production create mode 100644 apps/container/src/pages/login/hooks/use-login.ts create mode 100644 apps/container/src/pages/login/index.tsx create mode 100644 apps/container/src/routes/index.tsx create mode 100644 apps/container/webpack.config.custom.ts create mode 100644 apps/info/src/components/counter-actions/index.tsx create mode 100644 apps/info/webpack.config.custom.ts create mode 100644 packages/data/.babelrc create mode 100644 packages/data/.eslintrc.json create mode 100644 packages/data/README.md create mode 100644 packages/data/package.json create mode 100644 packages/data/project.json create mode 100644 packages/data/src/apis/base.api.ts create mode 100644 packages/data/src/apis/index.ts create mode 100644 packages/data/src/apis/platzi.store.api.ts create mode 100644 packages/data/src/common/environments.ts create mode 100644 packages/data/src/common/index.ts create mode 100644 packages/data/src/common/paths.ts create mode 100644 packages/data/src/features/counter/counterSlice.ts create mode 100644 packages/data/src/helpers/environment.helpers.ts create mode 100644 packages/data/src/helpers/index.ts create mode 100644 packages/data/src/helpers/service.helpers.ts create mode 100644 packages/data/src/hooks/base-query/index.ts create mode 100644 packages/data/src/hooks/index.ts create mode 100644 packages/data/src/hooks/use-counter/index.ts create mode 100644 packages/data/src/hooks/use-platzi-store-auth/index.tsx create mode 100644 packages/data/src/hooks/use-platzi-store-products/index.ts create mode 100644 packages/data/src/hooks/use-random/index.ts create mode 100644 packages/data/src/index.ts create mode 100644 packages/data/src/lib/api.interceptors.ts create mode 100644 packages/data/src/providers/index.tsx create mode 100644 packages/data/src/services/index.ts create mode 100644 packages/data/src/services/platzi/auth/index.ts create mode 100644 packages/data/src/services/platzi/auth/schemas.ts create mode 100644 packages/data/src/services/platzi/auth/types.ts create mode 100644 packages/data/src/services/platzi/contants.ts create mode 100644 packages/data/src/services/platzi/index.ts create mode 100644 packages/data/src/services/platzi/methods.ts create mode 100644 packages/data/src/services/platzi/products/index.ts create mode 100644 packages/data/src/services/platzi/products/schemas.ts create mode 100644 packages/data/src/services/platzi/products/types.ts create mode 100644 packages/data/src/store/index.ts create mode 100644 packages/data/src/types/index.ts create mode 100644 packages/data/tsconfig.json create mode 100644 packages/data/tsconfig.lib.json create mode 100644 packages/data/vite.config.ts create mode 100644 packages/ui/src/components/form-fields/date-field.tsx create mode 100644 packages/ui/src/components/form-fields/index.ts create mode 100644 packages/ui/src/components/form-fields/input-field.tsx create mode 100644 packages/ui/src/components/form-fields/select-field.tsx create mode 100644 packages/ui/src/components/form-fields/text-area-field.tsx create mode 100644 packages/ui/src/components/ui/calendar.tsx create mode 100644 packages/ui/src/components/ui/card.tsx create mode 100644 packages/ui/src/components/ui/carousel.tsx create mode 100644 packages/ui/src/components/ui/form.tsx create mode 100644 packages/ui/src/components/ui/input.tsx create mode 100644 packages/ui/src/components/ui/label.tsx create mode 100644 packages/ui/src/components/ui/popover.tsx create mode 100644 packages/ui/src/components/ui/select.tsx create mode 100644 packages/ui/src/components/ui/textarea.tsx create mode 100644 packages/ui/src/components/ui/toast.tsx create mode 100644 packages/ui/src/components/ui/toaster.tsx create mode 100644 packages/ui/src/components/ui/tooltip.tsx create mode 100644 packages/ui/src/components/ui/use-toast.ts create mode 100644 packages/ui/src/utils/index.ts create mode 100644 packages/ui/src/utils/lazy.utils.tsx diff --git a/.env.custom b/.env.custom new file mode 100644 index 0000000..594ee18 --- /dev/null +++ b/.env.custom @@ -0,0 +1,3 @@ +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..594ee18 --- /dev/null +++ b/.env.development @@ -0,0 +1,3 @@ +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..594ee18 --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken \ No newline at end of file diff --git a/SHADCN_UI_SETUP.md b/SHADCN_UI_SETUP.md index d0ed7e6..3924136 100644 --- a/SHADCN_UI_SETUP.md +++ b/SHADCN_UI_SETUP.md @@ -1,13 +1,12 @@ -# Shared UI Setup For Micro Frontend Application (Module Federation with React) with Nx Workspace +# Shared Data-Layer Setup For Micro Frontend Application with Nx Workspace -This tutorial will guide you through setting up a shared `UI library` for a `Micro Frontend Application` using Nx Workspace, React, and Tailwind CSS. We will use `Shadcn UI` for the UI components. +This tutorial will guide ## Link for Final Implementation The final implementation of the tutorial can be found in the following repository commits: -- [Add UI package with Shadcn components and use them on apps](https://github.com/serifcolakel/mfe-tutorial/commit/5704168095b2c83b8b51823ed585a1cdf3210dbc) -- [Add UI package with Button component and update dependencies](https://github.com/serifcolakel/mfe-tutorial/commit/cafa9a12f9c95a9a1536ff4e11c2a2008a3d89a5) +- > Live Demo: [Micro Frontend Application with Nx Workspace](https://relaxed-mochi-7581fa.netlify.app/) @@ -29,34 +28,37 @@ Before we begin, make sure you have the following things set up: ## Table of Contents -- [Create UI Library](#create-ui-library) -- [Add Tailwind CSS Setup](#add-tailwind-css-setup) -- [Shadcn UI Setup](#shadcn-ui-setup) - - [Add Button Component](#add-button-component) - - [Add Shadcn UI Hover Card](#add-shadcn-ui-hover-card) - - [Add Shadcn UI Badge](#add-shadcn-ui-badge) -- [Conclusion](#conclusion) +- [Create React Library](#create-react-library) -## Create UI Library +## Create React Library -First, we need to create a UI library using the Nx Workspace. We will use the `@nx/react:library` generator to create the UI library. +First, we need to create a React library using the Nx Workspace. We will use the `@nx/react:library` generator to create the React library. > With Script ```bash -pnpm exec nx generate @nx/react:library --name=ui --bundler=vite --directory=packages/ui --projectNameAndRootFormat=as-provided --no-interactive +pnpm exec nx generate @nx/react:library --name=data --bundler=vite --directory=apps/data --projectNameAndRootFormat=as-provided --no-interactive --dry-run ``` +The Scripts are explained below: + +- **--name** : The name of the library. In this case, we are naming it `data`. +- **--bundler** : The bundler to use for the library. In this case, we are using `vite`. +- **--directory** : The directory where the library will be created. In this case, we are creating it in the `apps/data` directory. +- **--projectNameAndRootFormat** : The format to use for the project name and root. In this case, we are using `as-provided`. +- **--no-interactive** : Disable interactive prompts. +- **--dry-run** : Show what will be generated without actually generating it. + > With Nx Console ![Nx Console](https://i.hizliresim.com/przb27y.png) -## Add Tailwind CSS Setup +## Add Services For Data Layer -Next, we need to add the Tailwind CSS setup to the UI library. We will use the `@nx/react:setup-tailwind` generator to add the Tailwind CSS setup. +Next, we need to add the services for the data layer in the `data` library. We will create a `data` service that fetches data from an API. ```bash -pnpm exec nx generate @nx/react:setup-tailwind --project=ui --no-interactive +pnpm add axios ``` - **Configure Tailwind Config** : Update the `packages/ui/tailwind.config.js` file with the following content: diff --git a/apps/container/module-federation.config.ts b/apps/container/module-federation.config.ts index 0c3d17e..c9c4ba6 100644 --- a/apps/container/module-federation.config.ts +++ b/apps/container/module-federation.config.ts @@ -3,6 +3,90 @@ import { ModuleFederationConfig } from '@nx/webpack'; const config: ModuleFederationConfig = { name: 'container', remotes: ['info'], + shared: (name, defaultConfig) => { + // "react-hook-form": "^7.51.3" + if (name.includes('react-hook-form')) { + return { + singleton: true, + eager: true, + requiredVersion: '^7.51.3', + }; + } + + // "@hookform/resolvers": "^3.3.4" + if (name.includes('@hookform/resolvers')) { + return { + ...defaultConfig, + strictVersion: false, + requiredVersion: '^3.3.4', + }; + } + + // "zod": "^3.22.5" + if (name.includes('zod')) { + return { + singleton: true, + eager: true, + requiredVersion: '^3.22.5', + }; + } + + // react 18.2.0 + if (name.includes('react')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // react-dom 18.2.0 + if (name.includes('react-dom')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // "react-redux": "^9.1.1", + if (name.includes('react-redux')) { + return { + singleton: true, + eager: true, + requiredVersion: '^9.1.1', + }; + } + + // "@reduxjs/toolkit": "^2.2.3", + if (name.includes('@reduxjs/toolkit')) { + return { + singleton: true, + eager: true, + requiredVersion: '^2.2.3', + }; + } + + // @radix-ui/react-toast (required ^1.1.5) + if (name.includes('@radix-ui/react-toast')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.1.5', + }; + } + + // @radix-ui/react-slot (required ^1.0.2) + if (name.includes('@radix-ui/react-slot')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.0.2', + }; + } + + return false; + }, }; export default config; diff --git a/apps/container/project.json b/apps/container/project.json index 8e4c082..377d902 100644 --- a/apps/container/project.json +++ b/apps/container/project.json @@ -46,6 +46,15 @@ "extractLicenses": true, "vendorChunk": false, "webpackConfig": "apps/container/webpack.config.prod.ts" + }, + "custom": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "webpackConfig": "apps/container/webpack.config.custom.ts" } } }, @@ -64,6 +73,10 @@ "production": { "buildTarget": "container:build:production", "hmr": false + }, + "custom": { + "buildTarget": "container:build:custom", + "hmr": false } } }, @@ -88,6 +101,9 @@ }, "production": { "buildTarget": "container:build:production" + }, + "custom": { + "buildTarget": "container:build:custom" } } }, diff --git a/apps/container/src/app/app.tsx b/apps/container/src/app/app.tsx index 2178f19..d3cdd0e 100644 --- a/apps/container/src/app/app.tsx +++ b/apps/container/src/app/app.tsx @@ -1,55 +1,14 @@ -import { Button } from '@mfe-tutorial/ui'; -import * as React from 'react'; -import { NavLink, Route, Routes } from 'react-router-dom'; +import { Loader } from 'lucide-react'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -const HomePage = React.lazy(() => import('../pages/home')); -const Info = React.lazy(() => import('info/InfoContainer')); +import { routes } from '../routes'; export function App() { return ( - - - - } path="/" /> - } path="/info" /> - - + } + router={createBrowserRouter(routes)} + /> ); } diff --git a/apps/container/src/bootstrap.tsx b/apps/container/src/bootstrap.tsx index 6043c7d..1a7f731 100644 --- a/apps/container/src/bootstrap.tsx +++ b/apps/container/src/bootstrap.tsx @@ -1,6 +1,7 @@ +import { DataLayerProviders } from '@mfe-tutorial/data'; +import { Toaster } from '@mfe-tutorial/ui'; import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; import App from './app/app'; @@ -14,8 +15,9 @@ const root = ReactDOM.createRoot(element); root.render( - + + - + ); diff --git a/apps/container/src/pages/home/index.tsx b/apps/container/src/pages/home/index.tsx index 5c99cfd..75db848 100644 --- a/apps/container/src/pages/home/index.tsx +++ b/apps/container/src/pages/home/index.tsx @@ -1,3 +1,6 @@ +import { paths } from '@mfe-tutorial/data'; +import { NavLink } from 'react-router-dom'; + import { HoverCardDemo } from '../../components/hover-card'; import SocialLinks from '../../components/social-links'; @@ -5,6 +8,9 @@ export default function HomePage() { return (

🌍

+ + Go to the Login App +

Welcome to the Container!

diff --git a/apps/container/src/pages/login/hooks/use-login.ts b/apps/container/src/pages/login/hooks/use-login.ts new file mode 100644 index 0000000..89f9fcb --- /dev/null +++ b/apps/container/src/pages/login/hooks/use-login.ts @@ -0,0 +1,49 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + LoginRequest, + loginRequestSchema, + paths, + usePlatziStoreAuth, +} from '@mfe-tutorial/data'; +import { useToast } from '@mfe-tutorial/ui'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +export default function useLogin() { + const navigate = useNavigate(); + const { toast } = useToast(); + const { error, handleLogin, loading, onResetError } = usePlatziStoreAuth(); + + const loginForm = useForm({ + defaultValues: { + email: 'john@mail.com', + password: 'changeme', + }, + resolver: zodResolver(loginRequestSchema), + }); + + async function onSubmit(data: LoginRequest) { + const result = await handleLogin(data); + + toast({ + title: result.title, + description: result.message, + variant: result.success ? 'default' : 'destructive', + }); + + if (result.success) { + navigate(paths.info); + } + } + + return { + loginForm, + loading: + loading || + loginForm.formState.isLoading || + loginForm.formState.isSubmitting, + error, + onSubmit, + onResetError, + }; +} diff --git a/apps/container/src/pages/login/index.tsx b/apps/container/src/pages/login/index.tsx new file mode 100644 index 0000000..ba739df --- /dev/null +++ b/apps/container/src/pages/login/index.tsx @@ -0,0 +1,65 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Form, + InputField, +} from '@mfe-tutorial/ui'; + +import useLogin from './hooks/use-login'; + +export default function LoginPage() { + const { loginForm, onSubmit, loading, error, onResetError } = useLogin(); + + if (error) { + return ( +
+

An error occurred!

+

{error}

+ +
+ ); + } + + return ( +
+ + + + Login + + Please enter your email and password to login. + + + + + + + + + + +
+ + ); +} diff --git a/apps/container/src/routes/index.tsx b/apps/container/src/routes/index.tsx new file mode 100644 index 0000000..7a04ad0 --- /dev/null +++ b/apps/container/src/routes/index.tsx @@ -0,0 +1,23 @@ +import { paths } from '@mfe-tutorial/data'; +import { withSuspense } from '@mfe-tutorial/ui'; +import { lazy } from 'react'; +import { RouteObject } from 'react-router-dom'; + +const HomePage = withSuspense(lazy(() => import('../pages/home'))); +const LoginPage = withSuspense(lazy(() => import('../pages/login'))); +const Info = withSuspense(lazy(() => import('info/InfoContainer'))); + +export const routes: RouteObject[] = [ + { + path: paths.home, + element: , + }, + { + path: paths.login, + element: , + }, + { + path: paths.info, + element: , + }, +]; diff --git a/apps/container/webpack.config.custom.ts b/apps/container/webpack.config.custom.ts new file mode 100644 index 0000000..eb1885b --- /dev/null +++ b/apps/container/webpack.config.custom.ts @@ -0,0 +1,34 @@ +import { withReact } from '@nx/react'; +import { withModuleFederation } from '@nx/react/module-federation'; +import { composePlugins, ModuleFederationConfig, withNx } from '@nx/webpack'; + +import baseConfig from './module-federation.config'; + +const prodConfig: ModuleFederationConfig = { + ...baseConfig, + /* + * Remote overrides for production. + * Each entry is a pair of a unique name and the URL where it is deployed. + * + * e.g. + * remotes: [ + * ['app1', 'http://app1.example.com'], + * ['app2', 'http://app2.example.com'], + * ] + * + * You can also use a full path to the remoteEntry.js file if desired. + * + * remotes: [ + * ['app1', 'http://example.com/path/to/app1/remoteEntry.js'], + * ['app2', 'http://example.com/path/to/app2/remoteEntry.js'], + * ] + */ + remotes: [['info', 'https://animated-lollipop-8d4ee8.netlify.app/']], +}; + +// Nx plugins for webpack to build config object from Nx options and context. +export default composePlugins( + withNx(), + withReact(), + withModuleFederation(prodConfig) +); diff --git a/apps/info/module-federation.config.ts b/apps/info/module-federation.config.ts index 955bd86..3ff020e 100644 --- a/apps/info/module-federation.config.ts +++ b/apps/info/module-federation.config.ts @@ -5,6 +5,90 @@ const config: ModuleFederationConfig = { exposes: { './InfoContainer': './src/app/app', }, + shared: (name, defaultConfig) => { + // "react-hook-form": "^7.51.3" + if (name.includes('react-hook-form')) { + return { + singleton: true, + eager: true, + requiredVersion: '^7.51.3', + }; + } + + // "@hookform/resolvers": "^3.3.4" + if (name.includes('@hookform/resolvers')) { + return { + ...defaultConfig, + strictVersion: false, + requiredVersion: '^3.3.4', + }; + } + + // "zod": "^3.22.5" + if (name.includes('zod')) { + return { + singleton: true, + eager: true, + requiredVersion: '^3.22.5', + }; + } + + // react 18.2.0 + if (name.includes('react')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // react-dom 18.2.0 + if (name.includes('react-dom')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // "react-redux": "^9.1.1", + if (name.includes('react-redux')) { + return { + singleton: true, + eager: true, + requiredVersion: '^9.1.1', + }; + } + + // "@reduxjs/toolkit": "^2.2.3", + if (name.includes('@reduxjs/toolkit')) { + return { + singleton: true, + eager: true, + requiredVersion: '^2.2.3', + }; + } + + // @radix-ui/react-toast (required ^1.1.5) + if (name.includes('@radix-ui/react-toast')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.1.5', + }; + } + + // @radix-ui/react-slot (required ^1.0.2) + if (name.includes('@radix-ui/react-slot')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.0.2', + }; + } + + return false; + }, }; export default config; diff --git a/apps/info/project.json b/apps/info/project.json index a48a235..b341af2 100644 --- a/apps/info/project.json +++ b/apps/info/project.json @@ -43,6 +43,15 @@ "extractLicenses": true, "vendorChunk": false, "webpackConfig": "apps/info/webpack.config.prod.ts" + }, + "custom": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "webpackConfig": "apps/info/webpack.config.custom.ts" } } }, @@ -61,6 +70,10 @@ "production": { "buildTarget": "info:build:production", "hmr": false + }, + "custom": { + "buildTarget": "info:build:custom", + "hmr": false } } }, @@ -85,6 +98,9 @@ }, "production": { "buildTarget": "info:build:production" + }, + "custom": { + "buildTarget": "info:build:custom" } } }, diff --git a/apps/info/src/app/app.tsx b/apps/info/src/app/app.tsx index 49a670a..5bbec27 100644 --- a/apps/info/src/app/app.tsx +++ b/apps/info/src/app/app.tsx @@ -1,20 +1,211 @@ -import { ContextMenuDemo } from '../components/context-menu'; +import { Product } from '@mfe-tutorial/data'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + Label, +} from '@mfe-tutorial/ui'; +import { Loader, Plus, RefreshCcwIcon, Trash } from 'lucide-react'; +import usePlatziStoreProducts from 'packages/data/src/hooks/use-platzi-store-products'; + +const getFormattedAmount = (amount: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); + +function ProductCarousel({ images }: { images: Product['images'] }) { + return ( + + + {images.map((image) => ( + + {image} + + ))} + + + + + ); +} + +function ProductCard({ + product, + children, +}: { + product: Product; + children?: React.ReactNode; +}) { + return ( + + + + {product.title} + {product.description} + + + + + + + {product.category.name} + {children} + + + ); +} + +function CreateProductButton({ callback }: { callback: () => void }) { + return ( + + ); +} export function App() { + const { create, data, fetchProduct, fetchProducts, remove, update } = + usePlatziStoreProducts(); + + if (data.status === 'loading') { + return ( +
+ + Loading... +
+ ); + } + + if (data.status === 'error') { + return ( +
+

An error occurred!

+

{data.error.message}

+ +
+ ); + } + + const renderContent = () => { + if (data.status === 'hasData') { + const { data: products, message } = data; + + return ( +
+ {message && ( + + {message} + + )} +
    + {products.map((product) => ( +
  • + +
    + + + +
    +
    +
  • + ))} +
+
+ ); + } + + if (data.status === 'hasSingleData') { + const { data: product } = data; + + return ( +
+ + + +
+ ); + } + + return ( +
+

No products found!

+ +
+ ); + }; + return ( -
-

Welcome to info!

-

This is a remote app that is part of the Nx plugin for Webpack 5.

-
-

-

Info

-

-

- This app is a remote app that is part of the Nx plugin for Webpack 5. -

-
- -
+
+
+

Platzi Store

+ { + const newProduct = { + title: 'New Product', + description: 'This is a new product.', + price: 100, + categoryId: 1, + images: ['https://via.placeholder.com/300'], + }; + + await create(newProduct); + }} + /> +
+ {renderContent()} +
); } diff --git a/apps/info/src/components/counter-actions/index.tsx b/apps/info/src/components/counter-actions/index.tsx new file mode 100644 index 0000000..7d72184 --- /dev/null +++ b/apps/info/src/components/counter-actions/index.tsx @@ -0,0 +1,21 @@ +import { useCounter } from '@mfe-tutorial/data'; +import { Button } from '@mfe-tutorial/ui'; +import React from 'react'; + +export default function CounterActions() { + const { + counterValue, + handleDecrement, + handleIncrement, + handleIncrementByAmount, + } = useCounter(); + + return ( +
+

Counter: {counterValue}

+ + + +
+ ); +} diff --git a/apps/info/webpack.config.custom.ts b/apps/info/webpack.config.custom.ts new file mode 100644 index 0000000..de3e8aa --- /dev/null +++ b/apps/info/webpack.config.custom.ts @@ -0,0 +1,3 @@ +import webPackConfig from './webpack.config'; + +export default webPackConfig; diff --git a/package.json b/package.json index f968ce5..7f6d68e 100644 --- a/package.json +++ b/package.json @@ -12,18 +12,32 @@ }, "private": true, "dependencies": { + "@hookform/resolvers": "^3.3.4", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "@reduxjs/toolkit": "^2.2.3", + "axios": "^1.6.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.0.2", "lucide-react": "^0.365.0", "react": "18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "18.2.0", + "react-hook-form": "^7.51.3", + "react-redux": "^9.1.1", "react-router-dom": "6.11.2", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zod": "^3.22.5" }, "devDependencies": { "@babel/core": "^7.14.5", diff --git a/packages/data/.babelrc b/packages/data/.babelrc new file mode 100644 index 0000000..1ea870e --- /dev/null +++ b/packages/data/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/packages/data/.eslintrc.json b/packages/data/.eslintrc.json new file mode 100644 index 0000000..a39ac5d --- /dev/null +++ b/packages/data/.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/packages/data/README.md b/packages/data/README.md new file mode 100644 index 0000000..0b472f6 --- /dev/null +++ b/packages/data/README.md @@ -0,0 +1,7 @@ +# data + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test data` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/packages/data/package.json b/packages/data/package.json new file mode 100644 index 0000000..fd931ad --- /dev/null +++ b/packages/data/package.json @@ -0,0 +1,12 @@ +{ + "name": "@mfe-tutorial/data", + "version": "0.0.1", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.mjs", + "require": "./index.js" + } + } +} diff --git a/packages/data/project.json b/packages/data/project.json new file mode 100644 index 0000000..148b05b --- /dev/null +++ b/packages/data/project.json @@ -0,0 +1,32 @@ +{ + "name": "data", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/data/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/data/**/*.{ts,tsx,js,jsx}"] + } + }, + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/packages/data" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + } + } +} diff --git a/packages/data/src/apis/base.api.ts b/packages/data/src/apis/base.api.ts new file mode 100644 index 0000000..5b25b38 --- /dev/null +++ b/packages/data/src/apis/base.api.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; + +const api = axios; + +api.defaults.headers.post['Content-Type'] = 'application/json'; +api.defaults.headers.Accept = 'application/json'; +api.defaults.withCredentials = false; +api.defaults.timeout = 1000 * 60 * 2; // Two minutes + +export { api }; diff --git a/packages/data/src/apis/index.ts b/packages/data/src/apis/index.ts new file mode 100644 index 0000000..c6f14f2 --- /dev/null +++ b/packages/data/src/apis/index.ts @@ -0,0 +1,3 @@ +export { platziStoreApi } from './platzi.store.api'; + +export { api } from './base.api'; diff --git a/packages/data/src/apis/platzi.store.api.ts b/packages/data/src/apis/platzi.store.api.ts new file mode 100644 index 0000000..4c83efe --- /dev/null +++ b/packages/data/src/apis/platzi.store.api.ts @@ -0,0 +1,17 @@ +import { ENV } from '../common'; +import { + errorInterceptor, + requestInterceptor, + responseInterceptor, +} from '../lib/api.interceptors'; +import { api } from './base.api'; + +export const platziStoreApi = api.create({ + baseURL: ENV.NX_BASE_PLATZI_STORE_SERVICE_URL, +}); + +platziStoreApi.interceptors.request.use(requestInterceptor, (error) => + Promise.reject(error) +); + +platziStoreApi.interceptors.response.use(responseInterceptor, errorInterceptor); diff --git a/packages/data/src/common/environments.ts b/packages/data/src/common/environments.ts new file mode 100644 index 0000000..897b9a8 --- /dev/null +++ b/packages/data/src/common/environments.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +import { getEnvParams } from '../helpers/environment.helpers'; + +/** + * @description The environment schema for the container app. + */ +const envSchema = z.object({ + // INFO (serif) : NX_* Custom Environment variables + NX_BASE_PLATZI_STORE_SERVICE_URL: z.string(), + NX_ACCESS_TOKEN_KEY: z.string(), + NX_REFRESH_TOKEN_KEY: z.string(), + + // INFO (serif) : NX_* Base environment variables + NX_CLI_SET: z.string(), + NX_LOAD_DOT_ENV_FILES: z.string(), + NX_WORKSPACE_ROOT: z.string(), + NX_TERMINAL_OUTPUT_PATH: z.string(), + NX_STREAM_OUTPUT: z.string(), + NX_TASK_TARGET_PROJECT: z.string(), + NX_TASK_TARGET_TARGET: z.string(), + NX_TASK_TARGET_CONFIGURATION: z.string(), + NX_TASK_HASH: z.string(), +}); + +function initEnvironment() { + const [errors, env] = getEnvParams( + process.env as Record, + envSchema + ); + + if (errors) { + window.console.error(errors); + + throw new Error('Environment variables are not valid'); + } + + return env as z.infer; +} + +export { initEnvironment }; diff --git a/packages/data/src/common/index.ts b/packages/data/src/common/index.ts new file mode 100644 index 0000000..e1ca609 --- /dev/null +++ b/packages/data/src/common/index.ts @@ -0,0 +1,5 @@ +import { initEnvironment } from './environments'; + +export { default as paths } from './paths'; + +export const ENV = initEnvironment(); diff --git a/packages/data/src/common/paths.ts b/packages/data/src/common/paths.ts new file mode 100644 index 0000000..984db32 --- /dev/null +++ b/packages/data/src/common/paths.ts @@ -0,0 +1,5 @@ +export default { + home: '/', + login: '/login', + info: '/info', +}; diff --git a/packages/data/src/features/counter/counterSlice.ts b/packages/data/src/features/counter/counterSlice.ts new file mode 100644 index 0000000..7a28f42 --- /dev/null +++ b/packages/data/src/features/counter/counterSlice.ts @@ -0,0 +1,40 @@ +/* eslint-disable import/no-cycle */ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +import { RootState } from '../../store'; + +// Define a type for the slice state +interface CounterState { + value: number; +} + +// Define the initial state using that type +const initialState: CounterState = { + value: 0, +}; + +export const counterSlice = createSlice({ + name: 'counter', + // `createSlice` will infer the state type from the `initialState` argument + initialState, + reducers: { + increment: (state) => { + state.value += 1; + }, + decrement: (state) => { + state.value -= 1; + }, + // Use the PayloadAction type to declare the contents of `action.payload` + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload; + }, + }, +}); + +export const { increment, decrement, incrementByAmount } = counterSlice.actions; + +// Other code such as selectors can use the imported `RootState` type +export const selectCount = (state: RootState) => state.counter.value; + +export default counterSlice.reducer; diff --git a/packages/data/src/helpers/environment.helpers.ts b/packages/data/src/helpers/environment.helpers.ts new file mode 100644 index 0000000..e2417da --- /dev/null +++ b/packages/data/src/helpers/environment.helpers.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { z } from 'zod'; + +/** + * @description Gets the parameters from the environment variables. + * @param {Record} env The environment variables. + * @param {z.ZodObject} schema The schema. + * @returns The errors and the data. + */ +export function getEnvParams( + env: Record, + schema: z.ZodObject +): [Record | null, z.infer | null] { + const data: Record = {}; + const errors: Record = {}; + + for (const key in schema.shape) { + if (Object.prototype.hasOwnProperty.call(schema.shape, key)) { + const value = env[key]; + + if (value === undefined) { + errors[key] = `ERROR (serif) : Missing required env var: ${key}`; + } else { + try { + data[key] = (schema.shape[key] as z.ZodTypeAny)?.parse(value); + } catch (error) { + let message = 'INFO (serif) : Invalid env var'; + + if (error instanceof z.ZodError) { + message = `ERROR (serif) : ${error.errors[0].message}`; + } else if (error instanceof Error) { + message = `ERROR (serif) : ${error.message}`; + } + + errors[key] = message; + } + } + } + } + + if (Object.keys(errors).length) { + return [errors, null]; + } + + return [null, data as z.infer]; +} diff --git a/packages/data/src/helpers/index.ts b/packages/data/src/helpers/index.ts new file mode 100644 index 0000000..4907dba --- /dev/null +++ b/packages/data/src/helpers/index.ts @@ -0,0 +1 @@ +export { handleErrorResponse } from './service.helpers'; diff --git a/packages/data/src/helpers/service.helpers.ts b/packages/data/src/helpers/service.helpers.ts new file mode 100644 index 0000000..ccee7a3 --- /dev/null +++ b/packages/data/src/helpers/service.helpers.ts @@ -0,0 +1,77 @@ +import { AxiosError } from 'axios'; +import { ZodError } from 'zod'; + +import { BaseServiceResponse } from '../types'; + +/** + * @description Handles the error response. + * @param {unknown} error - Error + * @param {string | undefined} message - Message + * @returns {BaseServiceResponse} The service response. + * @example + * const error = new Error('An error occurred.'); + * const result = handleErrorResponse(error); + * console.log(result); // { data: null, message: 'An error occurred.', success: false } + * @example + * const error = new AxiosError('An error occurred.'); + * const result = handleErrorResponse(error); + * console.log(result); // { data: null, message: 'An error occurred.', success: false } + */ +export const handleErrorResponse = ( + error: unknown, + message: string | undefined = 'Unknown error occurred.' +): BaseServiceResponse => { + let status: number | undefined; + + if (error instanceof Error) { + message = error.message; + status = 500; + } + + if (error instanceof AxiosError) { + message = error.message; + status = error.response?.status; + } + + if (error instanceof ZodError) { + const paths = error.errors.map((err) => err.path[1]); + const uniquePaths = [...new Set(paths)]; + + message = `Error in fields: ${uniquePaths.join(', ')}`; + + status = 400; + } + + return { + data: null, + message, + success: false, + status, + }; +}; + +/** + * @description Formats the message of a service response. + * @param {string} message The message to be formatted. + * @param {string[]} replacerValues The strings to replace the placeholders in message. + * @returns {string} The formatted message. + * @example + * const message = 'The {0} is {1}!'; + * const replace = ['answer', '42']; + * const result = getServiceResponseMessage(message, replace); + * console.log(result); // The answer is 42! + */ +export const getServiceResponseMessage = ( + message: string, + replacerValues?: string[] +): string => { + let result = message; + + if (replacerValues) { + replacerValues.forEach((item, index) => { + result = result.replace(`{${index}}`, item); + }); + } + + return result; +}; diff --git a/packages/data/src/hooks/base-query/index.ts b/packages/data/src/hooks/base-query/index.ts new file mode 100644 index 0000000..35764a0 --- /dev/null +++ b/packages/data/src/hooks/base-query/index.ts @@ -0,0 +1,44 @@ +import type { BaseQueryFn } from '@reduxjs/toolkit/query'; +import type { AxiosRequestConfig } from 'axios'; +import axios from 'axios'; + +import { handleErrorResponse } from '../../helpers/service.helpers'; + +const axiosBaseQuery = + ( + { baseUrl }: { baseUrl: string } = { baseUrl: '' } + ): BaseQueryFn< + { + url: string; + method?: AxiosRequestConfig['method']; + data?: AxiosRequestConfig['data']; + params?: AxiosRequestConfig['params']; + headers?: AxiosRequestConfig['headers']; + }, + unknown, + unknown + > => + async ({ url, method, data, params, headers }) => { + try { + const result = await axios({ + url: baseUrl + url, + method, + data, + params: params as unknown as Record, + headers, + }); + + return { data: result.data }; + } catch (error) { + const result = handleErrorResponse(error); + + return { + error: { + status: result.status, + data: result.message, + }, + }; + } + }; + +export { axiosBaseQuery }; diff --git a/packages/data/src/hooks/index.ts b/packages/data/src/hooks/index.ts new file mode 100644 index 0000000..300622d --- /dev/null +++ b/packages/data/src/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './use-random'; +export * from './use-counter'; +export * from './use-platzi-store-auth'; +export * from './use-platzi-store-products'; diff --git a/packages/data/src/hooks/use-counter/index.ts b/packages/data/src/hooks/use-counter/index.ts new file mode 100644 index 0000000..e8b4886 --- /dev/null +++ b/packages/data/src/hooks/use-counter/index.ts @@ -0,0 +1,24 @@ +import { + decrement, + increment, + incrementByAmount, + selectCount, +} from '../../features/counter/counterSlice'; +import { useAppDispatch, useAppSelector } from '../../store'; + +export function useCounter() { + const counterValue = useAppSelector(selectCount); + const dispatch = useAppDispatch(); + const handleIncrement = () => dispatch(increment()); + const handleDecrement = () => dispatch(decrement()); + + const handleIncrementByAmount = (amount: number) => + dispatch(incrementByAmount(amount)); + + return { + counterValue, + handleIncrement, + handleDecrement, + handleIncrementByAmount, + }; +} diff --git a/packages/data/src/hooks/use-platzi-store-auth/index.tsx b/packages/data/src/hooks/use-platzi-store-auth/index.tsx new file mode 100644 index 0000000..bea19af --- /dev/null +++ b/packages/data/src/hooks/use-platzi-store-auth/index.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; + +import { ENV } from '../../common'; +import { login, LoginRequest, refreshToken } from '../../services'; + +export function usePlatziStoreAuth() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogin = async (info: LoginRequest) => { + setLoading(true); + + const response = await login(info); + + const result = { + success: false, + message: 'Please check your email and password and try again.', + title: 'Login Failed', + }; + + if (response.success && response.data) { + localStorage.setItem(ENV.NX_ACCESS_TOKEN_KEY, response.data.access_token); + localStorage.setItem( + ENV.NX_REFRESH_TOKEN_KEY, + response.data.refresh_token + ); + + result.success = true; + result.message = 'You have successfully logged in!'; + result.title = 'Login Success'; + } else { + setError('Please check your email and password and try again.'); + } + + setLoading(false); + + return result; + }; + + const handleRefreshToken = async () => { + const token = localStorage.getItem(ENV.NX_REFRESH_TOKEN_KEY); + + if (token) { + const response = await refreshToken({ refreshToken: token }); + + if (response.success && response.data) { + localStorage.setItem( + ENV.NX_ACCESS_TOKEN_KEY, + response.data.access_token + ); + localStorage.setItem( + ENV.NX_REFRESH_TOKEN_KEY, + response.data.refresh_token + ); + } else { + setError(response.message); + } + } + }; + + const onResetError = () => setError(null); + + return { + loading, + error, + handleRefreshToken, + handleLogin, + onResetError, + }; +} diff --git a/packages/data/src/hooks/use-platzi-store-products/index.ts b/packages/data/src/hooks/use-platzi-store-products/index.ts new file mode 100644 index 0000000..f15812b --- /dev/null +++ b/packages/data/src/hooks/use-platzi-store-products/index.ts @@ -0,0 +1,163 @@ +import { useEffect, useState } from 'react'; + +import { + createProduct, + CreateProductRequest, + deleteProduct, + getProduct, + getProducts, + Product, + updateProduct, + UpdateProductRequest, +} from '../../services'; + +export type ProductError = { + message: string; + title: string; +}; + +export type Data = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; error: ProductError } + | { status: 'hasData'; data: Product[]; message?: string } + | { status: 'hasSingleData'; data: Product }; + +export default function usePlatziStoreProducts(fetchOnMount = true) { + const [data, setData] = useState({ status: 'idle' }); + + const fetchProducts = async (message?: string) => { + if (data.status !== 'loading') { + setData({ status: 'loading' }); + } + + const response = await getProducts(); + + if (response.success && response.data) { + setData({ status: 'hasData', data: response.data, message }); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Products Fetch Failed', + }, + }); + } + }; + + const fetchProduct = async (id: string) => { + setData({ status: 'loading' }); + + const response = await getProduct(id); + + if (response.success && response.data) { + setData({ status: 'hasSingleData', data: response.data }); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Fetch Failed', + }, + }); + } + }; + + const create = async ( + product: CreateProductRequest, + canGetProducts = true + ) => { + setData({ status: 'loading' }); + const response = await createProduct(product); + + if (response.success && response.data && canGetProducts) { + await fetchProducts('Product created successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Creation Failed', + }, + }); + } + + if (response.success && data.status === 'loading') { + setData({ status: 'idle' }); + } + }; + + const update = async ( + id: string, + product: UpdateProductRequest, + canGetProducts = true + ) => { + setData({ status: 'loading' }); + const response = await updateProduct(id, product); + + if ((response.success && response.data, canGetProducts)) { + await fetchProducts('Product updated successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Update Failed', + }, + }); + } + }; + + const remove = async (id: string) => { + setData({ status: 'loading' }); + const response = await deleteProduct(id); + + if (response.success && response.data) { + await fetchProducts('Product deleted successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Deletion Failed', + }, + }); + } + + if (response.success && data.status === 'loading') { + setData({ status: 'idle' }); + } + }; + + const hasDataMessage = data.status === 'hasData' ? !!data.message : false; + + useEffect(() => { + if (hasDataMessage) { + const timeout = setTimeout(() => { + setData((prev) => ({ + ...prev, + message: undefined, + })); + }, 3000); + + return () => clearTimeout(timeout); + } + }, [hasDataMessage]); + + useEffect(() => { + if (fetchOnMount) { + fetchProducts(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchOnMount]); + + return { + fetchProducts, + fetchProduct, + create, + update, + remove, + data, + }; +} diff --git a/packages/data/src/hooks/use-random/index.ts b/packages/data/src/hooks/use-random/index.ts new file mode 100644 index 0000000..2416436 --- /dev/null +++ b/packages/data/src/hooks/use-random/index.ts @@ -0,0 +1,9 @@ +export type UseRandomProps = { + multiplier: number; +}; + +export function useRandom({ multiplier }: UseRandomProps) { + return { + randomizedValue: Math.random() * multiplier, + }; +} diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts new file mode 100644 index 0000000..bfe896d --- /dev/null +++ b/packages/data/src/index.ts @@ -0,0 +1,8 @@ +export * from './hooks'; +export * from './types'; +export * from './apis'; +export * from './helpers'; +export * from './common'; +export * from './services'; +export * from './store'; +export { default as DataLayerProviders } from './providers'; diff --git a/packages/data/src/lib/api.interceptors.ts b/packages/data/src/lib/api.interceptors.ts new file mode 100644 index 0000000..d303c62 --- /dev/null +++ b/packages/data/src/lib/api.interceptors.ts @@ -0,0 +1,22 @@ +import { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +import { ENV } from '../common'; +import { handleErrorResponse } from '../helpers'; + +// TODO (serif) : handle request here +export const requestInterceptor = (config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem(ENV.NX_ACCESS_TOKEN_KEY); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}; + +// TODO (serif) : handle response here +export const responseInterceptor = (response: AxiosResponse) => response; + +// TODO (serif) : handle error response here +export const errorInterceptor = async (error: AxiosError) => + Promise.reject(handleErrorResponse(error)); diff --git a/packages/data/src/providers/index.tsx b/packages/data/src/providers/index.tsx new file mode 100644 index 0000000..4e039f2 --- /dev/null +++ b/packages/data/src/providers/index.tsx @@ -0,0 +1,8 @@ +import React, { PropsWithChildren } from 'react'; +import { Provider } from 'react-redux'; + +import { store } from '../store'; + +export default function DataLayerProviders({ children }: PropsWithChildren) { + return {children}; +} diff --git a/packages/data/src/services/index.ts b/packages/data/src/services/index.ts new file mode 100644 index 0000000..b3e8627 --- /dev/null +++ b/packages/data/src/services/index.ts @@ -0,0 +1 @@ +export * from './platzi'; diff --git a/packages/data/src/services/platzi/auth/index.ts b/packages/data/src/services/platzi/auth/index.ts new file mode 100644 index 0000000..f478ff7 --- /dev/null +++ b/packages/data/src/services/platzi/auth/index.ts @@ -0,0 +1,114 @@ +import { handleErrorResponse } from '../../../helpers'; +import { BaseServiceResponse } from '../../../types'; +import { PLATZI_STORE_PRODUCTS_PATHS } from '../contants'; +import { platziStoreApiMethods as methods } from '../methods'; +import { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +} from './schemas'; +import { + LoginRequest, + LoginResponse, + RefreshTokenRequest, + RefreshTokenResponse, + UserProfileResponse, +} from './types'; + +/** + * @description Logs a user in. + * @param {LoginRequest} info The user to log in. + * @returns {Promise>} A Promise that resolves to a LoginResponse. + */ +export const login = async ( + info: LoginRequest +): Promise> => { + try { + const infos = loginRequestSchema.parse(info); + + const response = await methods.post( + PLATZI_STORE_PRODUCTS_PATHS.AUTH.LOGIN, + infos + ); + + const data = loginResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Gets the user profile. + * @returns {Promise>} A Promise that resolves to a UserProfileResponse. + */ +export const getUserProfile = async (): Promise< + BaseServiceResponse +> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.AUTH.PROFILE + ); + + const data = userProfileResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Refreshes the token. + * @param {RefreshTokenRequest} refreshToken The refresh token. + * @returns {Promise>} A Promise that resolves to a RefreshTokenResponse. + */ +export const refreshToken = async ( + token: RefreshTokenRequest +): Promise> => { + try { + const values = refreshTokenRequestSchema.parse(token); + + const response = await methods.post< + RefreshTokenRequest, + RefreshTokenResponse + >(PLATZI_STORE_PRODUCTS_PATHS.AUTH.REFRESH_TOKEN, values); + + const data = refreshTokenResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +export type { + LoginRequest, + LoginResponse, + UserProfileResponse, + RefreshTokenRequest, + RefreshTokenResponse, +}; + +export { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +}; diff --git a/packages/data/src/services/platzi/auth/schemas.ts b/packages/data/src/services/platzi/auth/schemas.ts new file mode 100644 index 0000000..b1d5a62 --- /dev/null +++ b/packages/data/src/services/platzi/auth/schemas.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const loginRequestSchema = z.object({ + email: z.string().email('Please enter a valid email'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}); + +export const loginResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), +}); + +export const userProfileResponseSchema = z.object({ + id: z.number(), + email: z.string(), + password: z.string(), + name: z.string(), + role: z.string(), + avatar: z.string(), +}); + +export const refreshTokenRequestSchema = z.object({ refreshToken: z.string() }); + +export const refreshTokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), +}); diff --git a/packages/data/src/services/platzi/auth/types.ts b/packages/data/src/services/platzi/auth/types.ts new file mode 100644 index 0000000..29dc603 --- /dev/null +++ b/packages/data/src/services/platzi/auth/types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +} from './schemas'; + +export type LoginRequest = z.infer; + +export type LoginResponse = z.infer; + +export type UserProfileResponse = z.infer; + +export type RefreshTokenRequest = z.infer; + +export type RefreshTokenResponse = z.infer; diff --git a/packages/data/src/services/platzi/contants.ts b/packages/data/src/services/platzi/contants.ts new file mode 100644 index 0000000..b2f4774 --- /dev/null +++ b/packages/data/src/services/platzi/contants.ts @@ -0,0 +1,17 @@ +/** + * @description PRODUCTS paths for the PLATZI STORE API service + */ +export const PLATZI_STORE_PRODUCTS_PATHS = { + PRODUCT: { + GET_ALL: '/products', + GET_SINGLE: '/products/:id', + CREATE: '/products', + UPDATE: '/products/:id', + DELETE: '/products/:id', + }, + AUTH: { + LOGIN: '/auth/login', + PROFILE: '/auth/profile', + REFRESH_TOKEN: '/auth/refresh-token', + }, +}; diff --git a/packages/data/src/services/platzi/index.ts b/packages/data/src/services/platzi/index.ts new file mode 100644 index 0000000..205ef67 --- /dev/null +++ b/packages/data/src/services/platzi/index.ts @@ -0,0 +1,4 @@ +export * from './products'; +export * from './auth'; +export * from './methods'; +export * from './contants'; diff --git a/packages/data/src/services/platzi/methods.ts b/packages/data/src/services/platzi/methods.ts new file mode 100644 index 0000000..cc4b919 --- /dev/null +++ b/packages/data/src/services/platzi/methods.ts @@ -0,0 +1,94 @@ +import { AxiosRequestConfig, AxiosResponse } from 'axios'; + +import { platziStoreApi } from '../../apis'; + +/** + * @description Sends a GET request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +async function get( + url: string, + config?: AxiosRequestConfig +): Promise> { + const response = await platziStoreApi.get(url, config); + + return response; +} + +/** + * @description Sends a POST request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const post = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.post(url, data, config); + + return response; +}; + +/** + * @description Sends a PUT request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const put = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.put(url, data, config); + + return response; +}; + +/** + * @description Sends a PATCH request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const patch = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.patch(url, data, config); + + return response; +}; + +/** + * @description Sends a DELETE request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const remove = async ( + url: string, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.delete(url, config); + + return response; +}; + +const platziStoreApiMethods = { + get, + post, + put, + patch, + remove, +}; + +export { platziStoreApiMethods }; diff --git a/packages/data/src/services/platzi/products/index.ts b/packages/data/src/services/platzi/products/index.ts new file mode 100644 index 0000000..f423eda --- /dev/null +++ b/packages/data/src/services/platzi/products/index.ts @@ -0,0 +1,172 @@ +import { handleErrorResponse } from '../../../helpers'; +import { BaseServiceResponse } from '../../../types'; +import { PLATZI_STORE_PRODUCTS_PATHS } from '../contants'; +import { platziStoreApiMethods as methods } from '../methods'; +import { + allProductsResponseSchema, + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +} from './schemas'; +import { + CreateProductRequest, + CreateProductResponse, + Product, + UpdateProductRequest, + UpdateProductResponse, +} from './types'; + +/** + * @description Gets all products from the API. + * @returns {Promise>} A Promise that resolves to an array of Post. + */ +export const getProducts = async (): Promise< + BaseServiceResponse +> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.GET_ALL, + { + params: { + limit: 10, + offset: 1, + }, + } + ); + + const data = allProductsResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Gets a single product from the API. + * @param {string} id The product ID. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const getProduct = async ( + id: string +): Promise> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.GET_SINGLE.replace(':id', id) + ); + + const data = productSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Creates a new product. + * @param {CreateProductRequest} product The product to create. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const createProduct = async ( + product: CreateProductRequest +): Promise> => { + try { + const values = createProductRequestSchema.parse(product); + + const response = await methods.post< + CreateProductRequest, + CreateProductResponse + >(PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.CREATE, values); + + const data = createProductResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Updates a product. + * @param {string} id The product ID. + * @param {UpdateProductRequest} product The product to update. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const updateProduct = async ( + id: string, + product: UpdateProductRequest +): Promise> => { + try { + const values = updateProductRequestSchema.parse(product); + + const response = await methods.put< + UpdateProductRequest, + UpdateProductResponse + >(PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.UPDATE.replace(':id', id), values); + + const data = updateProductResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Deletes a product. + * @param {string} id The product ID. + * @returns {Promise>} A Promise that resolves to null. + */ +export const deleteProduct = async ( + id: string +): Promise> => { + try { + const res = await methods.remove( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.DELETE.replace(':id', id) + ); + + return { + data: res.data, + message: 'Product deleted successfully.', + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +export type { + CreateProductRequest, + CreateProductResponse, + Product, + UpdateProductRequest, + UpdateProductResponse, +}; + +export { + allProductsResponseSchema, + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +}; diff --git a/packages/data/src/services/platzi/products/schemas.ts b/packages/data/src/services/platzi/products/schemas.ts new file mode 100644 index 0000000..5f96556 --- /dev/null +++ b/packages/data/src/services/platzi/products/schemas.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +export const productSchema = z.object({ + id: z.number(), + title: z.string(), + price: z.number(), + description: z.string(), + category: z.object({ id: z.number(), name: z.string(), image: z.string() }), + images: z.array(z.string()), +}); + +export const allProductsResponseSchema = z.array(productSchema); + +export const createProductRequestSchema = z.object({ + title: z.string(), + price: z.number(), + description: z.string(), + categoryId: z.number(), + images: z.array(z.string()), +}); + +export const createProductResponseSchema = z.object({ + title: z.string(), + price: z.number(), + description: z.string(), + images: z.array(z.string()), + category: z.object({ + id: z.number(), + name: z.string(), + image: z.string(), + creationAt: z.string(), + updatedAt: z.string(), + }), + id: z.number(), + creationAt: z.string(), + updatedAt: z.string(), +}); + +export const updateProductRequestSchema = z.object({ + title: z.string(), + price: z.number(), +}); + +export const updateProductResponseSchema = z.object({ + id: z.number(), + title: z.string(), + price: z.number(), + description: z.string(), + images: z.array(z.string()), + creationAt: z.string(), + updatedAt: z.string(), + category: z.object({ + id: z.number(), + name: z.string(), + image: z.string(), + creationAt: z.string(), + updatedAt: z.string(), + }), +}); diff --git a/packages/data/src/services/platzi/products/types.ts b/packages/data/src/services/platzi/products/types.ts new file mode 100644 index 0000000..3b1bc30 --- /dev/null +++ b/packages/data/src/services/platzi/products/types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +} from './schemas'; + +export type Product = z.infer; + +export type CreateProductRequest = z.infer; + +export type CreateProductResponse = z.infer; + +export type UpdateProductRequest = z.infer; + +export type UpdateProductResponse = z.infer; + +export type DeleteProductResponse = boolean; diff --git a/packages/data/src/store/index.ts b/packages/data/src/store/index.ts new file mode 100644 index 0000000..8e9c29d --- /dev/null +++ b/packages/data/src/store/index.ts @@ -0,0 +1,19 @@ +/* eslint-disable import/no-cycle */ +import { configureStore } from '@reduxjs/toolkit'; +import { useDispatch, useSelector } from 'react-redux'; + +import counterSlice from '../features/counter/counterSlice'; + +export const store = configureStore({ + reducer: { + counter: counterSlice, + }, +}); + +export type RootState = ReturnType; +// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} +export type AppDispatch = typeof store.dispatch; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/packages/data/src/types/index.ts b/packages/data/src/types/index.ts new file mode 100644 index 0000000..a04a94b --- /dev/null +++ b/packages/data/src/types/index.ts @@ -0,0 +1,15 @@ +/** + * @description Base service response interface + * @template T - Generic type for data + * @interface BaseServiceResponse + * @property {T | null} data - Data + * @property {string} message - Message + * @property {boolean} success - Success + * @property {number | undefined} status - Status + */ +export type BaseServiceResponse = { + data: T | null; + message: string; + success: boolean; + status?: number; +}; diff --git a/packages/data/tsconfig.json b/packages/data/tsconfig.json new file mode 100644 index 0000000..6734c59 --- /dev/null +++ b/packages/data/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/packages/data/tsconfig.lib.json b/packages/data/tsconfig.lib.json new file mode 100644 index 0000000..41c1f2f --- /dev/null +++ b/packages/data/tsconfig.lib.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": [ + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.ts", + "src/**/*.tsx", + "src/helpers/index.ts", + "src/lib/api.interceptors.ts" + ] +} diff --git a/packages/data/vite.config.ts b/packages/data/vite.config.ts new file mode 100644 index 0000000..819a3b2 --- /dev/null +++ b/packages/data/vite.config.ts @@ -0,0 +1,44 @@ +// eslint-disable-next-line spaced-comment +/// +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import react from '@vitejs/plugin-react'; +import * as path from 'path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/data', + + plugins: [ + react(), + nxViteTsPaths(), + dts({ + entryRoot: 'src', + tsConfigFilePath: path.join(__dirname, 'tsconfig.lib.json'), + skipDiagnostics: true, + }), + ], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: 'data', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ['es', 'cjs'], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: ['react', 'react-dom', 'react/jsx-runtime'], + }, + }, +}); diff --git a/packages/ui/src/components/form-fields/date-field.tsx b/packages/ui/src/components/form-fields/date-field.tsx new file mode 100644 index 0000000..7575429 --- /dev/null +++ b/packages/ui/src/components/form-fields/date-field.tsx @@ -0,0 +1,75 @@ +import { format } from 'date-fns'; +import { CalendarIcon } from 'lucide-react'; +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { cn } from '../../lib'; +import { Button } from '../ui'; +import { Calendar } from '../ui/calendar'; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; + +type DateFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; +}; + +export function DateField({ + control, + label, + name, + description, +}: DateFieldProps) { + return ( + ( + + {label} + + + + + + + + + date > new Date() || date < new Date('1900-01-01') + } + mode="single" + onSelect={field.onChange} + selected={field.value} + /> + + + {description} + + + )} + /> + ); +} diff --git a/packages/ui/src/components/form-fields/index.ts b/packages/ui/src/components/form-fields/index.ts new file mode 100644 index 0000000..93cfde8 --- /dev/null +++ b/packages/ui/src/components/form-fields/index.ts @@ -0,0 +1,4 @@ +export * from './input-field'; +export * from './text-area-field'; +export * from './select-field'; +export * from './date-field'; diff --git a/packages/ui/src/components/form-fields/input-field.tsx b/packages/ui/src/components/form-fields/input-field.tsx new file mode 100644 index 0000000..229007b --- /dev/null +++ b/packages/ui/src/components/form-fields/input-field.tsx @@ -0,0 +1,43 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { Input, InputProps } from '../ui/input'; + +type InputFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; +} & InputProps; + +export function InputField({ + control, + label, + name, + description, + ...props +}: InputFieldProps) { + return ( + ( + + {label} + + + + {description} + + + )} + /> + ); +} diff --git a/packages/ui/src/components/form-fields/select-field.tsx b/packages/ui/src/components/form-fields/select-field.tsx new file mode 100644 index 0000000..6e7c25c --- /dev/null +++ b/packages/ui/src/components/form-fields/select-field.tsx @@ -0,0 +1,61 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; + +type SelectFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; + options: { label: string; value: string }[]; +}; + +export function SelectField({ + control, + label, + name, + description, + options, +}: SelectFieldProps) { + return ( + ( + + {label} + + {description} + + + )} + /> + ); +} diff --git a/packages/ui/src/components/form-fields/text-area-field.tsx b/packages/ui/src/components/form-fields/text-area-field.tsx new file mode 100644 index 0000000..7a0ba48 --- /dev/null +++ b/packages/ui/src/components/form-fields/text-area-field.tsx @@ -0,0 +1,43 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { Textarea, type TextareaProps } from '../ui/textarea'; + +type TextAreaFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; +} & TextareaProps; + +export function TextAreaField({ + control, + label, + name, + description, + ...props +}: TextAreaFieldProps) { + return ( + ( + + {label} + +