V1 without presentation styling

This commit is contained in:
Daniel Heras Quesada
2024-01-16 23:53:33 +01:00
parent 39705a7b43
commit 3869d67172
295 changed files with 378 additions and 1255 deletions

View File

@@ -0,0 +1,156 @@
import { ApiContext, DEFAULT_API_CONTEXT } from '@/common/providers/api-context'
import {
{{ pascalCase name }}ApiResult,
{{ pascalCase name }}CreateApiParams,
{{ pascalCase name }}DeleteApiParams,
{{ pascalCase name }}GetApiParams,
{{ pascalCase name }}Id,
{{ pascalCase name }}ListApiParams,
{{ pascalCase name }}PaginatedApiResult,
{{ pascalCase name }}UpdateApiParams,
} from './{{ kebabCase name }}.types'
export const {{ camelCase name }}ApiProto = (
baseUrl: string = process.env.NEXT_PUBLIC_API_ENDPOINT || '/api',
defaultApiContext = DEFAULT_API_CONTEXT
) => {
const endpointUrl = `${baseUrl}/{{ kebabCase name }}`
type UrlParams = { resourceId?: {{ pascalCase name}}Id }
const endpoint = (
urlParams: UrlParams,
queryParams: Record<string, string>
) => {
const queryParamString = new URLSearchParams(queryParams).toString()
const resourceIdParam =
urlParams.resourceId === undefined ? '' : `/${urlParams.resourceId}`
// TODO: Customize the endpoint url generation here
return `${endpointUrl}${resourceIdParam}?${queryParamString}`
}
return {
async list(
this: ApiContext,
{ page, size, ...otherQueryParams }: {{ pascalCase name }}ListApiParams
): Promise<{{ pascalCase name }}PaginatedApiResult> {
const urlParams: UrlParams = {}
const queryParams = {
// TODO: Map the pagination params as required by the API
page: `${page}`,
size: `${size}`,
// limit: `${size}`,
// offset: `${Math.max((page - 1) * size, 0)}`,
...otherQueryParams,
}
const url = endpoint(urlParams, queryParams)
console.debug(
`Listing {{ pascalCase name }} with page: ${page}, size: ${size}`,
`on url: ${url}`
)
const response = await this.client.get(url)
// TODO: Add code handle the response if needed
return response.data as {{ pascalCase name }}PaginatedApiResult
},
async delete(
this: ApiContext,
{ resourceId, ...queryParams }: {{ pascalCase name }}DeleteApiParams
): Promise<boolean> {
const urlParams: UrlParams = { resourceId }
const url = endpoint(urlParams, queryParams)
console.debug(
`Deleting {{ pascalCase name }} with id:`,
resourceId,
`on url: ${url}`
)
const response = await this.client.delete(url)
// TODO: Add code handle the response if needed
return response.status >= 200 && response.status < 300
},
async create(
this: ApiContext,
{ newResource, ...queryParams }: {{ pascalCase name }}CreateApiParams
): Promise<{{ pascalCase name }}Id> {
const urlParams: UrlParams = {}
const url = endpoint(urlParams, queryParams)
console.debug(
`Creating {{ pascalCase name }} resource:`,
newResource,
`on url: ${url}`
)
const response = await this.client.post(url, newResource)
// TODO: Add code handle the response if needed
// TODO: Adapt code to handle the receiving of the resourceId (if any)
const locationHeader = response.headers.location as
| string
| undefined
if (locationHeader) {
const segments = new URL(locationHeader).pathname.split('/')
const lastIdx = segments.length - 1
const resourceId =
segments[lastIdx] || segments[Math.max(lastIdx - 1, 0)]
if (!resourceId)
console.warn(new Error('Invalid location header received'))
return resourceId as {{ pascalCase name }}Id
}
console.warn(new Error('No location header received'))
return '' as {{ pascalCase name }}Id
},
async update(
this: ApiContext,
{
updatedResource,
// resourceId,
...queryParams
}: {{ pascalCase name }}UpdateApiParams
): Promise<boolean> {
const urlParams: UrlParams = {
// resourceId
}
const url = endpoint(urlParams, queryParams)
console.debug(
`updating {{ pascalCase name }} resource:`,
updatedResource,
`on url: ${url}`
)
const response = await this.client.put(url, updatedResource)
// TODO: Add code handle the response if needed
return response.status >= 200 && response.status < 300
},
async get(
this: ApiContext,
{ resourceId, ...queryParams }: {{ pascalCase name }}GetApiParams
): Promise<{{ pascalCase name }}ApiResult> {
const urlParams: UrlParams = {
resourceId,
}
const url = endpoint(urlParams, queryParams)
console.debug(
`Getting {{ pascalCase name }} with id:`,
resourceId,
`on url: ${url}`
)
const response = await this.client.get(url)
// TODO: Add code handle the response if needed
return response.data as {{ pascalCase name }}ApiResult
},
...defaultApiContext,
}
}
export const {{ camelCase name }}Api = {{ camelCase name }}ApiProto()

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { Pagination } from '@/hookey'
import { useApiContext } from '@/common/providers/api-context'
import { {{ camelCase name }}Api } from './{{ kebabCase name }}.api'
import { {{ pascalCase name }}GetApiParams } from './{{ kebabCase name }}.types'
export const use{{ pascalCase name }}s = Pagination.makePaginationHook({
cacheKey: '{{ kebabCase name }}-api-list',
clientFn: {{ camelCase name }}Api.list,
useApiContext: useApiContext,
// TODO: Connect getCount and getPageData with the list response data
getCount: (data) => data.count,
getPageData: (data) => data.results,
})
export const use{{ pascalCase name }} = (params: {{ pascalCase name }}GetApiParams) => {
return useQuery(
['{{ kebabCase name }}-api-get', params] as [string, typeof params],
({ queryKey: [_key, params] }) => {{ camelCase name }}Api.get(params)
)
}

View File

@@ -0,0 +1,46 @@
import type { Pagination } from '@/hookey'
export type {{ pascalCase name }} = {
{{ camelCase name }}Id: {{ pascalCase name }}Id
}
// TODO: Set the id type
export type {{ pascalCase name }}Id = string | number
export type {{ pascalCase name }}ApiResult = {
// TODO: Replace with actual get api result
results: {{ pascalCase name }}
}
export type {{ pascalCase name }}PaginatedApiResult = {
// TODO: Replace with actual list api result
results: {{ pascalCase name }}[]
count: number
}
export type {{ pascalCase name }}ListApiParams = Pagination.UsePaginatedQueryParams<{
// TODO: Add other params here
}>
export type {{ pascalCase name }}GetApiParams = {
resourceId: {{ pascalCase name }}Id
// TODO: Add other params here
}
export type {{ pascalCase name }}CreateApiParams = {
newResource: Omit<{{ pascalCase name }}, '{{ camelCase name }}Id'>
// TODO: Add other params here
}
export type {{ pascalCase name }}UpdateApiParams = {
updatedResource: {{ pascalCase name }}
// TODO: Switch params if the api requires an id in the url for updates
// updatedResource: Omit<{{ pascalCase name }}, '{{ camelCase name }}Id'>
// resourceId: {{ pascalCase name }}Id
// TODO: Add other params here
}
export type {{ pascalCase name }}DeleteApiParams = {
resourceId: {{ pascalCase name }}Id
// TODO: Add other params here
}

View File

@@ -0,0 +1,3 @@
export * from './{{ kebabCase name }}.api'
export * from './{{ kebabCase name }}.hooks'
export * from './{{ kebabCase name }}.types'

View File

@@ -0,0 +1,8 @@
import React from 'react'
import styles from './{{ kebabCase name }}.module.css'
export type {{pascalCase name}}Props = {}
export function {{pascalCase name}}(props: {{pascalCase name}}Props) {
return <div data-testid="{{ kebabCase name }}" className={styles.container}></div>
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }} } from './{{ kebabCase name }}.component'
const meta: Meta<typeof {{ pascalCase name }}> = {
title: '{{ pascalCase name }}',
component: {{ pascalCase name }},
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './{{kebabCase name}}.component'

View File

@@ -0,0 +1,8 @@
import React, { PropsWithChildren } from 'react'
import styles from './{{ kebabCase name }}.module.css'
export type {{pascalCase name}}LayoutProps = PropsWithChildren<{}>
export function {{pascalCase name}}Layout(props: {{pascalCase name}}LayoutProps) {
return <div data-testid="{{ kebabCase name }}-layout" className={styles.container}>{props.children}</div>
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }}Layout } from './{{ kebabCase name }}.layout'
const meta: Meta<typeof {{ pascalCase name }}Layout> = {
title: '{{ pascalCase name }}Layout',
component: {{ pascalCase name }}Layout,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}Layout>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}-layout')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1 @@
export * from './{{kebabCase name}}.layout'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }}View } from './{{ kebabCase name }}.view'
const meta: Meta<typeof {{ pascalCase name }}View> = {
title: '{{ pascalCase name }}View',
component: {{ pascalCase name }}View,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}View>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}-view')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import styles from './{{ kebabCase name }}.module.css'
type {{pascalCase name}}ViewProps = {
// query parameters
searchParams: { [key: string]: string | string[] | undefined }
// url parameters
params: { [key: string]: string | undefined }
}
export function {{pascalCase name}}View(props: {{pascalCase name}}ViewProps) {
return <div data-testid="{{ kebabCase name }}-view" className={styles.container}></div>
}

View File

@@ -0,0 +1 @@
export * from './{{kebabCase name}}.view'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }}Widget } from './{{ kebabCase name }}.widget'
const meta: Meta<typeof {{ pascalCase name }}Widget> = {
title: '{{ pascalCase name }}Widget',
component: {{ pascalCase name }}Widget,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}Widget>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}-widget')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,8 @@
import React from 'react'
import styles from './{{ kebabCase name }}.module.css'
export type {{pascalCase name}}WidgetProps = {}
export function {{pascalCase name}}Widget(props: {{pascalCase name}}WidgetProps) {
return <div data-testid="{{ kebabCase name }}-widget" className={styles.container}></div>
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './{{kebabCase name}}.widget'

View File

@@ -0,0 +1,5 @@
{
"extends": [
"@commitlint/config-conventional"
]
}

View File

@@ -0,0 +1,10 @@
root = true
# Unix-style newlines with a newline ending every file
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -0,0 +1,4 @@
*.[tj]s
*.[tj]sx
!src/**/*

View File

@@ -0,0 +1,89 @@
{
"plugins": [
"sonarjs",
"@typescript-eslint",
"react",
"react-hooks",
"boundaries",
"prettier",
"jest-extended"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:sonarjs/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:boundaries/recommended",
"plugin:prettier/recommended",
"next",
"plugin:storybook/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@next/next/no-head-element": "off",
"boundaries/element-types": [
2,
{
"default": "allow",
"rules": [
{
"from": ["component"],
"allow": ["page", "globalStyle"],
"message": "Components cannot import pages or global styles"
}
]
}
],
"@typescript-eslint/no-misused-promises": [
2,
{
"checksVoidReturn": {
"attributes": false
}
}
],
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
],
"@next/next/no-html-link-for-pages": "off",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_|^props$" }
],
"@typescript-eslint/require-await": "off",
"no-unused-vars": "off"
},
"settings": {
"boundaries/include": ["src/**"],
"boundaries/ignore": ["src/**/*.spec.*", "src/**/*.test.*"],
"boundaries/elements": [
{
"type": "component",
"pattern": "components/*",
"mode": "folder",
"capture": ["component"]
},
{
"type": "page",
"pattern": "pages/**/*",
"mode": "file",
"capture": ["route", "elementName"]
},
{
"type": "globalStyle",
"pattern": "styles/*",
"mode": "file",
"capture": ["styleName"]
}
]
}
}

36
presentation/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

6
presentation/.husky/commit-msg Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx echo-cli "\033[0;34m>>> Checking commit message\033[0m"
npx commitlint --edit $1
npx echo-cli "\033[0;32mCommit message is OK\033[0m\n"

5
presentation/.husky/pre-commit Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx echo-cli "\033[0;34m>>> Linting the code\033[0m"
npm run --silent lint

View File

@@ -0,0 +1,8 @@
{
"editorconfig": true,
"trailingComma": "es5",
"semi": false,
"singleQuote": true,
"endOfLine": "lf",
"tabWidth": 4
}

View File

@@ -0,0 +1,32 @@
import path from 'path'
import type { StorybookConfig } from '@storybook/nextjs'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-styling',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: 'tag',
},
async webpackFinal(config, options) {
return {
...config,
resolve: {
...config.resolve,
alias: {
...config.resolve?.alias,
'@': path.resolve(__dirname, '../src/modules'),
},
},
}
},
}
export default config

View File

@@ -0,0 +1,16 @@
import '../src/styles/main.css'
import type { Preview } from '@storybook/react'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
}
export default preview

19
presentation/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"christian-kohler.npm-intellisense",
"aaron-bond.better-comments",
"formulahendry.auto-rename-tag",
"formulahendry.auto-close-tag",
"christian-kohler.path-intellisense",
"mrmlnc.vscode-scss",
"42crunch.vscode-openapi",
"ms-playwright.playwright",
"clinyong.vscode-css-modules",
"esbenp.prettier-vscode",
"teamchilla.blueprint",
"bradlc.vscode-tailwindcss",
"csstools.postcss",
"editorconfig.editorconfig"
]
}

16
presentation/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug",
"type": "node-terminal",
"request": "launch",
"command": "npm run start:dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

42
presentation/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.formatOnPaste": true,
"editor.formatOnType": false,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"javascript.validate.enable": false,
"javascript.suggestionActions.enabled": false,
"blueprint.templatesPath": ["./.blueprints"],
"eslint.validate": [
"typescript",
"typescriptreact",
"javascript",
"javascriptreact"
],
"files.eol": "\n",
"files.exclude": {
// "**/.git": true,
// "**/.svn": true,
// "**/.hg": true,
// "**/CVS": true,
// "**/.DS_Store": true,
// "**/Thumbs.db": true,
// "**/next-env.d.ts": true,
// "**/tsconfig.tsbuildinfo": true,
// "**/package-lock.json": true,
// "**/LICENSE": true,
// "**/.next": true,
// "**/.husky": true,
// "**/.commitlintrc*": true,
// "**/.prettierrc*": true,
// "**/.gitignore": true,
// "**/.eslint*": true,
// "**/.vscode": true
},
"typescript.tsdk": "node_modules/typescript/lib",
"css.validate": false,
"files.associations": {
"*.css": "css",
"css": "css"
}
}

7
presentation/LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2023 Daniel Peña Iglesias
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

38
presentation/README.md Normal file
View File

@@ -0,0 +1,38 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

170
presentation/global.d.ts vendored Normal file
View File

@@ -0,0 +1,170 @@
/// <reference types="jest-extended" />
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.module.scss' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module uxcale {
declare type ApiResult<TData extends object> = {
data: TData
}
declare type PaginatedApiResult<TResource extends object> = ApiResult<
TResource[]
> & {
pagination: {
pageSize: number
pageNumber: number
totalPages: number
totalElements: number
}
}
}
declare type EmptyObject = Record<string, never>
/**
* Opaque
* @desc Declares an Opaque type
* @see https://dev.to/stereobooster/pragmatic-types-opaque-types-and-how-they-could-have-saved-mars-climate-orbiter-1551
* @example
* // Expect: "string | null"
* NonUndefined<string | null | undefined>;
*/
declare type Opaque<K, T> = T & { __TYPE__: K }
/**
* NonUndefined
* @desc Exclude undefined from set `A`
* @example
* // Expect: "string | null"
* NonUndefined<string | null | undefined>;
*/
declare type NonUndefined<A> = A extends undefined ? never : A
/**
* FunctionKeys
* @desc Get union type of keys that are functions in object type `T`
* @example
* type MixedProps = {name: string; setName: (name: string) => void; someKeys?: string; someFn?: (...args: any) => any;};
*
* // Expect: "setName | someFn"
* type Keys = FunctionKeys<MixedProps>;
*/
declare type FunctionKeys<T extends object> = {
[K in keyof T]-?: NonUndefined<T[K]> extends Function ? K : never
}[keyof T]
/**
* NonFunctionKeys
* @desc Get union type of keys that are non-functions in object type `T`
* @example
* type MixedProps = {name: string; setName: (name: string) => void; someKeys?: string; someFn?: (...args: any) => any;};
*
* // Expect: "name | someKey"
* type Keys = NonFunctionKeys<MixedProps>;
*/
declare type NonFunctionKeys<T extends object> = {
[K in keyof T]-?: NonUndefined<T[K]> extends Function ? never : K
}[keyof T]
/**
* RequiredKeys
* @desc Get union type of keys that are required in object type `T`
* @see https://stackoverflow.com/questions/52984808/is-there-a-way-to-get-all-required-properties-of-a-typescript-object
* @example
* type Props = { req: number; reqUndef: number | undefined; opt?: string; optUndef?: number | undefined; };
*
* // Expect: "req" | "reqUndef"
* type Keys = RequiredKeys<Props>;
*/
declare type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
}[keyof T]
/**
* OptionalKeys
* @desc Get union type of keys that are optional in object type `T`
* @see https://stackoverflow.com/questions/52984808/is-there-a-way-to-get-all-required-properties-of-a-typescript-object
* @example
* type Props = { req: number; reqUndef: number | undefined; opt?: string; optUndef?: number | undefined; };
*
* // Expect: "opt" | "optUndef"
* type Keys = OptionalKeys<Props>;
*/
declare type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never
}[keyof T]
/**
* PromiseType
* @desc Obtain Promise resolve type
* @example
* // Expect: string;
* type Response = PromiseType<Promise<string>>;
*/
declare type PromiseType<T extends Promise<any>> = T extends Promise<infer U>
? U
: never
/**
* WithOptional
* @desc From `T` make a set of properties by key `K` become optional
* @example
* type Props = {
* name: string;
* age: number;
* visible: boolean;
* };
*
* // Expect: { name?: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props>;
*
* // Expect: { name: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props, 'age' | 'visible'>;
*/
declare type WithOptional<T, K extends keyof T = keyof T> = Omit<T, K> &
Partial<Pick<T, K>>
/**
* WithRequired
* @desc From `T` make a set of properties by key `K` become required
* @example
* type Props = {
* name?: string;
* age?: number;
* visible?: boolean;
* };
*
* // Expect: { name: string; age: number; visible: boolean; }
* type Props = WithRequired<Props>;
*
* // Expect: { name?: string; age: number; visible: boolean; }
* type Props = WithRequired<Props, 'age' | 'visible'>;
*/
declare type WithRequired<T, K extends keyof T = keyof T> = Omit<T, K> &
Required<Pick<T, K>>
/**
* FirstParam
* @desc From a function `T` get the first parameter type
* @example
* type Props = {
* name: string;
* age: number;
* visible: boolean;
* };
*
* // Expect: { name?: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props>;
*
* // Expect: { name: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props, 'age' | 'visible'>;
*/
declare type FirstParam<T extends (...args: any) => any> = Parameters<T>[0]

View File

@@ -0,0 +1,18 @@
import type { Config } from 'jest'
import nextJest from 'next/jest'
const createJestConfig = nextJest({
dir: './',
})
const config: Config = {
modulePathIgnorePatterns: ['<rootDir>/.blueprints'],
setupFiles: ['dotenv/config'],
setupFilesAfterEnv: [
'jest-extended/all',
'@testing-library/jest-dom/extend-expect',
],
testEnvironment: 'jest-environment-jsdom',
}
export default createJestConfig(config)

View File

@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
serverActions: true,
},
// Avoiding CORS issues
// async rewrites() {
// return [
// {
// source: '/api/:slug*',
// destination: 'http://localhost:3000/api/:slug*'
// }
// ]
// }
}
module.exports = nextConfig

37722
presentation/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

110
presentation/package.json Normal file
View File

@@ -0,0 +1,110 @@
{
"name": "front-arch-ssr-react",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "npm run build:prod",
"build:prod": "next build",
"build:storybook": "storybook build",
"serve": "next start",
"lint": "npm run lint:all",
"lint:all": "eslint .",
"start": "npm run start:storybook",
"start:dev": "next dev",
"start:storybook": "storybook dev -p 6006 --no-open",
"prepare": "husky install"
},
"dependencies": {
"@types/leaflet": "^1.9.3",
"@tanstack/react-query": "^4.29.12",
"axios": "^1.4.0",
"date-fns": "^2.30.0",
"leaflet": "^1.9.4",
"next": "^13.4.1",
"next-auth": "^4.22.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-leaflet": "^4.2.1",
"react-table": "^7.8.0",
"tailwindcss": "^3.3.2"
},
"devDependencies": {
"@commitlint/cli": "^17.5.0",
"@commitlint/config-conventional": "^17.4.4",
"@hookform/resolvers": "^3.1.0",
"@mertasan/tailwindcss-variables": "^2.6.1",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2",
"@radix-ui/react-avatar": "^1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",
"@radix-ui/react-collapsible": "^1.0.2",
"@radix-ui/react-context-menu": "^2.1.3",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-hover-card": "^1.0.5",
"@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-menubar": "^1.0.2",
"@radix-ui/react-navigation-menu": "^1.1.2",
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-progress": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.0.3",
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.1",
"@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.5",
"@storybook/addon-essentials": "^7.0.10",
"@storybook/addon-interactions": "^7.0.10",
"@storybook/addon-links": "^7.0.10",
"@storybook/addon-styling": "^1.0.6",
"@storybook/blocks": "^7.0.10",
"@storybook/jest": "^0.1.0",
"@storybook/nextjs": "^7.0.10",
"@storybook/react": "^7.0.10",
"@storybook/testing-library": "^0.0.14-next.2",
"@tanstack/react-query-devtools": "^4.29.12",
"@types/classnames": "^2.3.1",
"@types/node": "18.15.7",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react-table": "^7.7.14",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"autoprefixer": "^10.4.14",
"class-variance-authority": "^0.6.0",
"classnames": "^2.3.2",
"cmdk": "^0.2.0",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-boundaries": "^3.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-jest-extended": "^2.0.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-sonarjs": "^0.19.0",
"eslint-plugin-storybook": "^0.6.12",
"husky": "^8.0.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jest-extended": "^3.2.4",
"lucide-react": "^0.215.0",
"postcss": "^8.4.23",
"postcss-preset-env": "^8.3.2",
"prettier": "^2.8.7",
"react-day-picker": "^8.7.1",
"react-hook-form": "^7.43.9",
"sass": "^1.62.1",
"storybook": "^7.0.10",
"tailwindcss-animate": "^1.0.5",
"typescript": "5.0.2",
"typescript-plugin-css-modules": "^5.0.0",
"zod": "^3.21.4"
}
}

View File

@@ -0,0 +1,8 @@
module.exports = {
plugins: {
'tailwindcss/nesting': {},
'postcss-preset-env': {},
tailwindcss: {},
autoprefixer: {},
},
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1 @@
export { SignInView as default } from '@/auth/views/sign-in'

View File

@@ -0,0 +1,17 @@
import '../styles/main.css'
import { RootLayout } from '@/common/layouts/root'
import { ApplicationLayout } from '@/common/layouts/application'
import { PropsWithChildren } from 'react'
export const metadata = {
title: 'Next SSR Archetype',
description: 'Sample skeleton webpage',
}
export default function Layout(props: PropsWithChildren) {
return (
<RootLayout>
<ApplicationLayout>{props.children}</ApplicationLayout>
</RootLayout>
)
}

View File

@@ -0,0 +1 @@
export { AppMainView as default } from '@/common/views/main'

View File

@@ -0,0 +1,140 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function Presentation() {
const [current, setCurrent] = useState<number>(0)
const stages = [
<Introduction />,
<Benefits />,
<Rendering />,
<Strategies />,
<DataFetching />,
]
return (
<div className="main-presentation">
{stages[current]}
<button
onClick={() =>
setCurrent(current + 1 < stages.length ? current + 1 : 0)
}
>
Siguiente
</button>
</div>
)
}
// 1. Introduction
// 2. Rendering strategies.
// 3. Benefits.
// 4. Actual process and why its important to know it.
// 5. Serverside data fetching
function Introduction() {
return (
<div className="main-presentation-comparation">
<h3>Next</h3>
<div>
<p>Nos da la posibilidad de renderizar desde el servidor</p>
</div>
</div>
)
}
function Rendering() {
return (
<div className="main-presentation-comparation">
<h3>Rendering</h3>
<div>
The rendering works is split into chunks: - By individual route
segments - By React [Suspense
boundaries](https://react.dev/reference/react/Suspense) (react
way of having a fallback while a components has finished
loading) Each chunk is rendered in the server, then, on the
client: 1. The HTML is used to immediately show fast preview. 2.
The server components rendered are inserted to update the DOM
(components rendered in server with placeholders for client
components and props). 3. `JS` instructions are used to
[hydrate?](https://react.dev/reference/react-dom/client/hydrateRoot)
Client Components and make the application interactive.
</div>
</div>
)
}
function Strategies() {
return (
<div className="main-presentation-strategies">
<h3>Strategies</h3>
<div>
<ul>
<li>
- Static rendering (default): Good for static pages:
Rendered in build time or in the background after data
revalidation
</li>
<li>
- Security: sensitive data is kept int the server (API
keys and tokens)
</li>
<li>
- Dynamic rendering: rendered per user request, Next
uses this type of rendering automatically when discovers
a dynamic function (`cookies()`, `headers()`,
`userSearchParams()`).
</li>
<li>
- Streaming: work is split into chunks and streamed as
they become ready so the load is progressive.
</li>
</ul>
</div>
</div>
)
}
function Benefits() {
return (
<div className="main-presentation-benefits">
<h3>Beneficios</h3>
<div>
<ul>
<li>
- Fetch data directly on the server, performance and
load benefits.
</li>
<li>
- Security: sensitive data is kept int the server (API
keys and tokens)
</li>
<li>
- Caching: results can be cached to improve performance
between users.
</li>
<li>
- Bundle size: will be reduced as part of the
application will reside in the server.
</li>
<li>
- SEO: because the pages will be rendered the search
engine bots will make good use of it.
</li>
<li>
- Streaming: to split the rendering into chunks and
stream them as they become ready.
</li>
</ul>
</div>
</div>
)
}
function DataFetching() {
const router = useRouter()
router.push('/word-list')
return <>Redirecting...</>
}

View File

@@ -0,0 +1 @@
export { UsersMainView as default } from '@/users/views/main'

View File

@@ -0,0 +1,6 @@
'use server'
import { revalidateTag } from 'next/cache'
export async function revalidateFetchByTag(tag: string) {
revalidateTag(tag)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { useEffect, useState } from 'react'
import { WordListRepsonse, fetchWordlist } from '../utils'
export default function ClientWordList() {
const [data, setData] = useState<WordListRepsonse>()
const fetchData = () =>
fetchWordlist().then((response) => setData(response))
useEffect(() => {
fetchData()
}, [])
return (
<div className="data-fetching-client">
<h3>
Wordlist fetched in the <span>client</span>
</h3>
<div>
{data?.wordList.map((word) => (
<span>{word.word}</span>
))}
</div>
<div>
<button onClick={fetchData}>Revalidate!</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
'use client'
import { useRouter } from 'next/navigation'
export default function GoBackButton() {
const router = useRouter()
return (
<button
style={{ position: 'absolute' }}
onClick={() => router.push('/presentation')}
>
Go back to presentation
</button>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { revalidateFetchByTag } from '../actions'
export default function RevalidateButton() {
return (
<button onClick={() => revalidateFetchByTag('wordlist')}>
Revalidate!
</button>
)
}

View File

@@ -0,0 +1,22 @@
import RevalidateButton from './revalidate-button'
import { fetchWordlist } from '../utils'
export default async function ServerWordList() {
const response = await fetchWordlist()
return (
<div data-testid="word-list-view" className="data-fetching-server">
<h3>
Wordlist fetched in the <span>server</span>
</h3>
<div>
{response?.wordList.map((word) => (
<span>{word.word}</span>
))}
</div>
<div>
<RevalidateButton />
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import ClientWordList from './components/clientside-word-list'
import GoBackButton from './components/go-back-button'
import ServerWordList from './components/serverside-word-list'
export default function WordList() {
return (
<div className="data-fetching">
<ServerWordList />
<ClientWordList />
</div>
)
}

View File

@@ -0,0 +1,22 @@
const WORDLIST_API_URL =
'http://localhost:3000/words/es?complexity=medium&howMany=30'
export type WordElement = {
word: string
correct?: boolean
}
export type WordListRepsonse = {
wordList: WordElement[]
}
// fetch("https://...", { cache: "no-store" });
export const fetchWordlist = async () => {
const wordList: WordListRepsonse = await fetch(WORDLIST_API_URL, {
next: { tags: ['wordlist'] },
}).then((response) => response.json())
console.log('Data fetch is done', wordList)
return wordList
}

View File

@@ -0,0 +1,95 @@
import React from 'react'
import cn from 'classnames'
import { LoadingButton } from '@/common/components/loading-button'
import * as Form from '@/common/components/ui/form'
import { z } from 'zod'
export type AuthFormCredentials = { username: string; password: string }
export type AuthFormProps = Omit<
React.HTMLAttributes<HTMLDivElement>,
'onSubmit'
> & {
onSubmit: (credentials: AuthFormCredentials) => Promise<void | string>
}
export function AuthForm({ className, onSubmit, ...props }: AuthFormProps) {
const form = Form.useZodForm<AuthFormCredentials>({
criteriaMode: 'firstError',
schema: z.object({
username: z
.string()
.min(2, {
message: 'Username must be at least 2 characters',
})
.max(50),
password: z.string().min(8, {
message: 'Password must contain at least 8 characters',
}),
}),
onSubmit: async (data) => {
try {
const error = await onSubmit(data)
if (typeof error === 'string') {
form.setError('root.submit', {
type: 'server',
message: error,
})
}
} catch (e: any) {
console.error('AuthForm.onSubmit error', e)
form.setError('root.submit', {
type: 'unknown',
message: 'Unknown error',
})
}
},
})
return (
<div
data-testid="auth-form"
className={cn('grid gap-6', className)}
{...props}
>
<Form.Root {...form} className="grid gap-4">
<div className="grid gap-2">
<Form.Field
control={form.control}
name="username"
render={({ field }) => (
<Form.Item>
<Form.Label>Username</Form.Label>
<Form.Input placeholder="pibone" {...field} />
<Form.Message />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => (
<Form.Item>
<Form.Label>Password</Form.Label>
<Form.Input
placeholder="*****"
type="password"
{...field}
/>
<Form.Message />
</Form.Item>
)}
/>
</div>
<Form.CustomMessage isError>
{form.formState.errors.root?.submit?.message || null}
</Form.CustomMessage>
<LoadingButton
loading={form.formState.isSubmitting}
type="submit"
>
Sign In
</LoadingButton>
</Form.Root>
</div>
)
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { AuthForm } from './auth-form.component'
const meta: Meta<typeof AuthForm> = {
title: 'AuthForm',
component: AuthForm,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof AuthForm>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('auth-form')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './auth-form.component'

View File

@@ -0,0 +1,17 @@
import { useApiContext } from '@/common/providers/api-context'
import { useSession } from 'next-auth/react'
import { PropsWithChildren, useLayoutEffect } from 'react'
export const AuthToApiContextConnectionProvider = ({
children,
}: PropsWithChildren) => {
const apiContext = useApiContext()
const { data: session } = useSession()
const apiSession = session?.apiSession
useLayoutEffect(() => {
apiContext.setAuthorizationToken(apiSession?.accessToken)
}, [apiContext, apiSession])
return <>{children}</>
}

View File

@@ -0,0 +1,2 @@
'use client'
export * from './auth-to-apicontext-connection.provider'

View File

@@ -0,0 +1,57 @@
import { getApiContext } from '@/common/providers/api-context/api-context.default'
import { AuthOptions, User } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
export const authOptions: AuthOptions = {
session: {
strategy: 'jwt',
},
pages: {
signIn: '/auth/sign-in',
},
callbacks: {
async session({ session, token }) {
if (token?.accessToken && session) {
// Update server side API_CONTEXT
getApiContext().setAuthorizationToken(token.accessToken)
session.apiSession = {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
}
}
return session
},
async jwt({ token, user }) {
if (user?.apiSession) {
token.accessToken = user.apiSession.accessToken
token.refreshToken = user.apiSession.refreshToken
}
return token
},
},
providers: [
CredentialsProvider({
name: 'custom-credentials',
credentials: {
email: { type: 'email' },
password: { type: 'password' },
},
authorize: async (credentials, _req) => {
// TODO: Connect with login API in the backend
const user: User = {
id: '1',
email: 'dpenai@pibone.com',
name: 'Dani Peña Iglesias',
role: 'admin',
// TODO: Set the incoming api token here, if needed
apiSession: {
accessToken: 'jwt-token',
// refreshToken: 'refresh-token-if-any',
},
}
return credentials?.password === 'safe-password' ? user : null
},
}),
],
}

View File

@@ -0,0 +1,10 @@
import { Session } from 'next-auth'
import { SessionProvider } from 'next-auth/react'
import { PropsWithChildren } from 'react'
export const AuthProvider = ({
children,
session,
}: PropsWithChildren<{ session?: Session }>) => {
return <SessionProvider session={session}>{children}</SessionProvider>
}

View File

@@ -0,0 +1,2 @@
'use client'
export * from './auth.provider'

View File

@@ -0,0 +1,28 @@
import { ApiSession, DefaultSession } from 'next-auth'
import { DefaultJWT } from 'next-auth/jwt'
declare module 'next-auth' {
declare type Role = 'user' | 'admin'
declare interface ApiSession {
accessToken: string
refreshToken?: string
}
declare interface User {
id: string
role: Role
image?: string
name?: string
email?: string
apiSession?: ApiSession
}
declare interface Session extends DefaultSession {
apiSession?: ApiSession
}
}
declare module 'next-auth/jwt' {
declare interface JWT
extends WithOptional<ApiSession, 'accessToken'>,
DefaultJWT {}
}

View File

@@ -0,0 +1 @@
export * from './sign-in.view'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { SignInView } from './sign-in.view'
const meta: Meta<typeof SignInView> = {
title: 'SignInView',
component: SignInView,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof SignInView>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('sign-in-view')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { LoginWidget } from '../../widgets/login'
type SignInViewProps = {
// query parameters
searchParams: { [key: string]: string | string[] | undefined }
// url parameters
params: { [key: string]: string | undefined }
}
export function SignInView(props: SignInViewProps) {
return (
<>
<div className="container relative md:h-[800px] grid items-center justify-stretch md:justify-center lg:max-w-none p-0">
<div
className="absolute hidden md:block inset-0 bg-cover -z-10"
style={{
backgroundImage:
'url(https://images.unsplash.com/photo-1590069261209-f8e9b8642343?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1376&q=80)',
}}
/>
<div className="relative flex items-center justify-center px-4 lg:px-6 pt-4 pb-6">
<div className="absolute w-full h-full md:rounded-md bg-white p-2 -z-10" />
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold">
Account Sign In
</h1>
<p className="text-sm text-primary">
Enter your username below to create your account
</p>
</div>
<LoginWidget />
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { AccountWidget } from './account.widget'
import { AuthProvider } from '@/auth/providers/auth'
const meta: Meta<typeof AccountWidget> = {
title: 'AccountWidget',
component: AccountWidget,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof AccountWidget>
export const LoggedIn: Story = {
render: (p) => (
<AuthProvider
session={{
user: {
email: 'example@pibone.com',
name: 'Dani Peña Iglesias',
image: 'https://github.com/pibone.png',
},
}}
>
<AccountWidget {...p} />
</AuthProvider>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('account-widget')
expect(container).toBeTruthy()
},
}
export const LoggedOut: Story = {
render: (p) => (
<AuthProvider session={undefined}>
<AccountWidget {...p} />
</AuthProvider>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('account-widget')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
import styles from './account.module.css'
import classNames from 'classnames'
import { useSession, signIn, signOut } from 'next-auth/react'
import * as Avatar from '@/common/components/ui/avatar'
import * as Popover from '@/common/components/ui/popover'
import { Button } from '@/common/components/ui/button'
export type AccountWidgetProps = {
className: string
}
export function AccountWidget(props: AccountWidgetProps) {
const { status } = useSession()
return (
<div
data-testid="account-widget"
className={classNames(styles.container, props.className)}
>
{selectComponent(status)}
</div>
)
}
function selectComponent(authStatus: ReturnType<typeof useSession>['status']) {
return authStatus === 'authenticated' ? (
<LoggedInUser />
) : (
<Button onClick={() => signIn()}>Sign In</Button>
)
}
function LoggedInUser() {
const { data: session } = useSession()
return (
<Popover.Root>
<Popover.Trigger asChild className="cursor-pointer">
<Avatar.Root>
<Avatar.Image src={session?.user?.image ?? undefined} />
<Avatar.Fallback>
{(session?.user?.name ?? '')
.split(/\s+/)
.map((v) => v[0].toUpperCase())
.join('') || 'Unknown user'}
</Avatar.Fallback>
</Avatar.Root>
</Popover.Trigger>
<Popover.Content>
<LoggedInMenu />
</Popover.Content>
</Popover.Root>
)
}
function LoggedInMenu() {
return (
<Button
onClick={() =>
signOut({
redirect: true,
})
}
>
Sign Out
</Button>
)
}

View File

@@ -0,0 +1,4 @@
'use client'
export type { AccountWidgetProps } from './account.widget'
export { AccountWidget } from './account.widget'

View File

@@ -0,0 +1,3 @@
'use client'
export * from './login.widget'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { LoginWidget } from './login.widget'
const meta: Meta<typeof LoginWidget> = {
title: 'LoginWidget',
component: LoginWidget,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof LoginWidget>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('login-widget')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,34 @@
import React from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { AuthForm } from '../../components/auth-form'
import styles from './login.module.css'
export type LoginWidgetProps = {}
export function LoginWidget(props: LoginWidgetProps) {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get('callbackUrl') || '/profile'
return (
<div data-testid="login-widget" className={styles.container}>
<AuthForm
onSubmit={async (credentials) => {
const result = await signIn('credentials', {
redirect: false,
...credentials,
callbackUrl,
})
console.debug('Login result:', result)
if (result?.error === 'CredentialsSignin') {
return 'Invalid credentials'
}
router.replace(callbackUrl)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import {
AlertTriangle,
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
ClipboardCheck,
Copy,
File,
HelpCircle,
Image,
Loader2,
Moon,
MoreVertical,
Plus,
Settings,
SunMedium,
Trash,
Twitter,
User,
X,
Minus,
Linkedin,
Facebook,
Instagram,
Youtube,
} from 'lucide-react'
export const Icons = {
logo: (props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
</svg>
),
// informative icons
spinner: Loader2,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
trash: Trash,
file: File,
media: Image,
warning: AlertTriangle,
user: User,
arrowRight: ArrowRight,
check: Check,
// action icons
darkTheme: Moon,
lightTheme: SunMedium,
close: X,
settings: Settings,
options: MoreVertical,
add: Plus,
remove: Minus,
help: HelpCircle,
copy: Copy,
copyDone: ClipboardCheck,
// social icons
twitter: Twitter,
linkedin: Linkedin,
facebook: Facebook,
instagram: Instagram,
youtube: Youtube,
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { Icons } from './icons.component'
const meta: Meta<typeof Icons> = {
title: 'Icons',
component: Icons,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Icons>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('icons')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1 @@
export * from './icons.component'

View File

@@ -0,0 +1,3 @@
'use client'
export * from './loading-button.component'

View File

@@ -0,0 +1,16 @@
import React, { ComponentPropsWithRef } from 'react'
import { Button } from '../ui/button'
import { Icons } from '../icons'
export function LoadingButton({
loading,
children,
...props
}: ComponentPropsWithRef<typeof Button> & { loading?: boolean }) {
return (
<Button {...props} data-testid="loading-button">
{loading && <Icons.spinner className="animate-spin w-8 h-8 mr-2" />}
{children}
</Button>
)
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { LoadingButton } from './loading-button.component'
const meta: Meta<typeof LoadingButton> = {
title: 'LoadingButton',
component: LoadingButton,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof LoadingButton>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('loading-button')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,70 @@
import React from 'react'
import cn from 'classnames'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDown } from 'lucide-react'
import styles from './accordion.module.css'
export const Root = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Root
data-testid="accordion"
ref={ref}
className={cn(styles.container, className)}
{...props}
>
{children}
</AccordionPrimitive.Root>
))
export const AccordionRoot = Root
Root.displayName = 'AccordionRoot'
export const Item = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn(styles.item, className)}
{...props}
/>
))
export const AccordionItem = Item
Item.displayName = 'AccordionItem'
export const Trigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className={styles.header}>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(styles.trigger, className)}
{...props}
>
{children}
<ChevronDown className={styles.icon} />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
export const AccordionTrigger = Trigger
Trigger.displayName = 'AccordionTrigger'
export const Content = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(styles.content, className)}
{...props}
>
<div className={styles.contentWrapper}>{children}</div>
</AccordionPrimitive.Content>
))
export const AccordionContent = Content
Content.displayName = 'AccordionContent'

View File

@@ -0,0 +1,24 @@
div:where(.container) {
:where(.item) {
@apply border-b;
}
:where(.header) {
@apply flex;
}
:where(.content) {
@apply overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down;
}
:where(.contentWrapper) {
@apply pb-4 pt-0;
}
:where(.trigger) {
@apply flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180;
}
:where(.icon) {
@apply h-4 w-4 transition-transform duration-200;
}
}

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import * as Accordion from './accordion.component'
const meta: Meta<typeof Accordion.Root> = {
title: 'Accordion',
component: Accordion.Root,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Accordion.Root>
export const Default: Story = {
render: (props) => (
<Accordion.Root {...props}>
<Accordion.Item value="item-1">
<Accordion.Trigger>Sección 1</Accordion.Trigger>
<Accordion.Content>Contenido 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2" onClick={() => console.log('hola')}>
<Accordion.Trigger>Sección 2</Accordion.Trigger>
<Accordion.Content>Contenido 2</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('accordion')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './accordion.component'

View File

@@ -0,0 +1,144 @@
import React, { HTMLProps } from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import cn from 'classnames'
import { buttonVariants } from '../button'
import styles from './alert-dialog.module.css'
export const Root = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Root>
>(({ children, ...props }, ref) => (
<AlertDialogPrimitive.Root ref={ref} {...props}>
{children}
</AlertDialogPrimitive.Root>
))
export const AlertDialogRoot = Root
Root.displayName = 'AlertDialogRoot'
export const Trigger = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Trigger>
>(({ children, ...props }, ref) => (
<AlertDialogPrimitive.Trigger
data-testid="alert-dialog"
ref={ref}
{...props}
>
{children}
</AlertDialogPrimitive.Trigger>
))
export const AlertDialogTrigger = Trigger
Trigger.displayName = 'AlertDialogTrigger'
const AlertDialogPortal = ({
className,
children,
...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
<div className={styles.portal}>{children}</div>
</AlertDialogPrimitive.Portal>
)
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(styles.overlay, className)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
export const Modal = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(styles.content, className)}
{...props}
/>
</AlertDialogPortal>
))
export const AlertDialogModal = Modal
Modal.displayName = AlertDialogPrimitive.Content.displayName
export const Header = React.forwardRef<
HTMLDivElement,
HTMLProps<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn(styles.header, className)} {...props} />
))
export const AlertDialogHeader = Header
Header.displayName = 'AlertDialogHeader'
export const Footer = React.forwardRef<
HTMLDivElement,
HTMLProps<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn(styles.footer, className)} {...props} />
))
export const AlertDialogFooter = Footer
Footer.displayName = 'AlertDialogFooter'
export const Title = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn(styles.title, className)}
{...props}
/>
))
export const AlertDialogTitle = Title
Title.displayName = AlertDialogPrimitive.Title.displayName
export const Description = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn(styles.description, className)}
{...props}
/>
))
export const AlertDialogDescription = Description
Description.displayName = AlertDialogPrimitive.Description.displayName
export const Action = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
export const AlertDialogAction = Action
Action.displayName = AlertDialogPrimitive.Action.displayName
export const Cancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
styles.cancel,
className
)}
{...props}
/>
))
export const AlertDialogCancel = Cancel
Cancel.displayName = AlertDialogPrimitive.Cancel.displayName

View File

@@ -0,0 +1,31 @@
/* elements */
div:where(.portal) {
@apply fixed inset-0 z-50 flex items-end justify-center sm:items-center;
:where(.overlay) {
@apply fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity;
}
:where(.content) {
@apply fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg sm:rounded-lg md:w-full;
}
:where(.header) {
@apply flex flex-col space-y-2 text-center sm:text-left;
}
:where(.footer) {
@apply flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2;
}
:where(.title) {
@apply text-lg font-semibold;
}
:where(.description) {
@apply text-sm text-muted-fg;
}
:where(.cancel) {
@apply mt-2 sm:mt-0;
}
}

View File

@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import * as AlertDialog from './alert-dialog.component'
import { Button } from '../button'
const meta: Meta<typeof AlertDialog.Root> = {
title: 'AlertDialog',
component: AlertDialog.Root,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof AlertDialog.Root>
export const Default: Story = {
render: (p) => (
<AlertDialog.Root {...p}>
<AlertDialog.Trigger asChild>
<Button variant="outline">Show Dialog</Button>
</AlertDialog.Trigger>
<AlertDialog.Modal>
<AlertDialog.Header>
<AlertDialog.Title>Are you sure?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Modal>
</AlertDialog.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('alert-dialog')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './alert-dialog.component'

View File

@@ -0,0 +1,49 @@
import React from 'react'
import { VariantProps, cva } from 'class-variance-authority'
import cn from 'classnames'
import styles from './alert.module.css'
export const alertVariants = cva(styles.container, {
variants: {
variant: {
default: styles.defaultVariant,
danger: styles.danger,
},
},
defaultVariants: {
variant: 'default',
},
})
export const Root = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
data-testid="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
export const AlertRoot = Root
Root.displayName = 'AlertRoot'
export const Title = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5 ref={ref} className={cn(styles.title, className)} {...props} />
))
export const AlertTitle = Title
Title.displayName = 'AlertTitle'
export const Description = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn(styles.description, className)} {...props} />
))
export const AlertDescription = Description
Description.displayName = 'AlertDescription'

View File

@@ -0,0 +1,21 @@
div:where(.container) {
@apply relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11;
/* variants */
:where(.defaultVariant) {
@apply bg-background text-foreground;
}
:where(.danger) {
@apply text-danger border-danger/50 dark:border-danger [&>svg]:text-danger;
}
/* elements */
:where(.title) {
@apply flex mb-1 font-medium leading-none tracking-tight;
}
:where(.description) {
@apply text-sm [&_p]:leading-relaxed;
}
}

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import * as Alert from './alert.component'
import { Terminal } from 'lucide-react'
const meta: Meta<typeof Alert.Root> = {
title: 'Alert',
component: Alert.Root,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Alert.Root>
export const Default: Story = {
render: (p) => (
<Alert.Root {...p}>
<Alert.Title>
<Terminal className="h-4 w-4" />
<span className="pl-2">Heads up!</span>
</Alert.Title>
<Alert.Description>
You can add new components to your app using the blueprints.
</Alert.Description>
</Alert.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('alert')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './alert.component'

Some files were not shown because too many files have changed in this diff Show More