diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7cac7d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +helm-charts +.env +.editorconfig +.idea +coverage* +.DS_Store \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..7eb061b --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +VITE_APP_DOMAIN=http://localhost:3000 +VITE_APP_KEYCLOAK_URL=http://localhost:8090 +VITE_APP_BACKEND_URL=http://localhost:8080 \ No newline at end of file diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9538d6c..8dc05a2 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,42 +2,35 @@ name: NodeJS with Webpack on: push: - branches: ['main', 'release/*'] + branches: ['**'] pull_request: branches: ['**'] jobs: build: runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x] - steps: - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} + - uses: oven-sh/setup-bun@v2 - name: Install dependencies - run: npm install + run: bun install --frozen-lockfile + + - name: Codegen + run: bun run generate:graphql - name: Run the tests - run: npm test + run: bun run test - name: Build - run: npm run build + run: bun run build env: CI: false docker: - runs-on: ubuntu-latest needs: build - steps: - name: Checkout uses: actions/checkout@v4 @@ -50,9 +43,20 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Make env file for production + uses: SpicyPizza/create-envfile@v2.0 + with: + envkey_VITE_APP_DOMAIN: ${{ secrets.VITE_APP_DOMAIN }} + envkey_VITE_APP_KEYCLOAK_URL: ${{ secrets.VITE_APP_KEYCLOAK_URL }} + envkey_VITE_APP_BACKEND_URL: ${{ secrets.VITE_APP_BACKEND_URL }} + directory: . + file_name: .env.production + fail_on_empty: true + sort_keys: false - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true + platforms: linux/amd64, linux/arm64 tags: xcodeassociated/react-typescript-vite-template:latest diff --git a/.gitignore b/.gitignore index de9ee66..775da9f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,11 @@ coverage *.njsproj *.sln *.sw? + +# AI +CLAUDE.md + +src/graphql/*.ts +src/graphql/*.js + +.env.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c1e18b0..7c44a12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use an official Node.js runtime as the base image -FROM node:18 as build-stage +FROM oven/bun:1.1-slim as build-stage LABEL authors="xcodeassociated" # Set working directory @@ -9,18 +9,16 @@ WORKDIR /app COPY package*.json ./ # Install dependencies -RUN npm install +RUN bun install --frozen-lockfile # Copy app source code to the working directory COPY . . -## Generate types -#RUN npm run generate:graphql +# Generate types +RUN bun run generate:graphql -RUN node node_modules/esbuild/install.js - -# Build the app -RUN npm run build +# Build the prod app +RUN NODE_ENV=production bun run build # Use NGINX as the production server FROM nginx:stable-alpine-slim diff --git a/bun.lockb b/bun.lockb index b8c22ce..28b70c3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/codegen.ts b/codegen.ts new file mode 100644 index 0000000..6594a7c --- /dev/null +++ b/codegen.ts @@ -0,0 +1,17 @@ +import type { CodegenConfig } from '@graphql-codegen/cli' + +const config: CodegenConfig = { + schema: './src/graphql/schema.graphql', + documents: './src/**/*.graphql', + generates: { + './src/graphql/generated.ts': { + plugins: ['typescript', 'typescript-operations', 'typescript-react-apollo'], + config: { + withHooks: true, + withResultType: true, + }, + }, + }, +} + +export default config diff --git a/package.json b/package.json index fcf0c6a..180c0db 100644 --- a/package.json +++ b/package.json @@ -9,49 +9,80 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "prod": "serve -s dist", - "test": "vitest", + "test": "vitest --run", "test:ui": "vitest --ui", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "generate:graphql": "graphql-codegen --config codegen.ts" }, "dependencies": { - "@radix-ui/react-dropdown-menu": "^2.1.1", - "@radix-ui/react-slot": "^1.1.0", - "class-variance-authority": "^0.7.0", + "@apollo/client": "^3.13.9", + "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-dialog": "^1.1.3", + "@radix-ui/react-dropdown-menu": "^2.1.3", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-navigation-menu": "^1.2.2", + "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-slot": "^1.1.1", + "@react-keycloak/web": "^3.4.0", + "@reduxjs/toolkit": "^2.8.2", + "@tanstack/react-table": "^8.20.6", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.408.0", + "cmdk": "^1.0.4", + "graphql": "^16.10.0", + "graphql-codegen": "^0.4.0", + "i18next-browser-languagedetector": "^8.0.2", + "keycloak-js": "^26.2.0", + "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwind-merge": "^2.4.0", - "tailwindcss-animate": "^1.0.7" + "react-hook-form": "^7.54.2", + "react-i18next": "^15.1.3", + "react-redux": "^9.2.0", + "react-router-dom": "^6.28.1", + "redux-persist": "^6.0.0", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.1" }, "devDependencies": { - "@testing-library/jest-dom": "^6.4.6", - "@testing-library/react": "^16.0.0", + "@graphql-codegen/cli": "^5.0.7", + "@graphql-codegen/client-preset": "^4.5.1", + "@graphql-codegen/typescript-operations": "^4.3.1", + "@graphql-codegen/typescript-react-apollo": "^4.3.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", - "@types/jest": "^29.5.12", - "@types/node": "^20.14.9", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^7.13.1", - "@typescript-eslint/parser": "^7.13.1", - "@vitejs/plugin-react-swc": "^3.5.0", - "@vitest/coverage-v8": "^2.0.2", - "@vitest/ui": "^2.0.2", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", + "@types/jest": "^29.5.14", + "@types/keycloak-js": "^3.4.1", + "@types/node": "^22.10.2", + "@types/react": "^18.3.13", + "@types/react-dom": "^18.3.1", + "@types/react-i18next": "^8.1.0", + "@types/react-router-dom": "^5.3.3", + "@types/redux-persist": "^4.3.1", + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2", + "@vitejs/plugin-react-swc": "^3.7.2", + "@vitest/coverage-v8": "^2.1.8", + "@vitest/ui": "^2.1.8", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.7", - "jsdom": "^24.1.0", - "postcss": "^8.4.39", - "prettier": "^3.3.2", - "prettier-plugin-tailwindcss": "^0.6.5", - "tailwindcss": "^3.4.4", - "typescript": "^5.2.2", - "vite": "^5.3.1", - "vite-tsconfig-paths": "^4.3.2", - "vitest": "^2.0.2" + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "jsdom": "^25.0.1", + "msw": "^2.7.0", + "postcss": "^8.4.49", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.9", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^2.1.8" } } diff --git a/public/silent-check-sso.html b/public/silent-check-sso.html new file mode 100644 index 0000000..f259b4f --- /dev/null +++ b/public/silent-check-sso.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/App.test.tsx b/src/App.test.tsx index 019bc76..3e8d21d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,17 +1,66 @@ -import '@testing-library/jest-dom' +import { describe, expect, it, vi, beforeAll } from 'vitest' +import React, { act } from 'react' import { render, screen, waitFor } from '@testing-library/react' -import { expect } from 'vitest' +import { ThemeProvider } from '@/components/theme-provider.tsx' +import { Provider } from 'react-redux' +import { store } from '@/store/store.ts' +import { BrowserRouter } from 'react-router-dom' import App from '@/App.tsx' +import Keycloak, { KeycloakProfile } from 'keycloak-js' -describe('Page', () => { - it('renders', async () => { - render() +function mockUseKeycloak() { + const token = 'A random string that is non zero length' + const userProfile: KeycloakProfile = { + username: 'test', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + } + const realmAccess = { roles: ['user'] } + + const authClient: Keycloak = { + authenticated: true, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + hasRealmRole(_ignored: string) { + return true + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + hasResourceRole(_ignored: string) { + return true + }, + idToken: token, + profile: userProfile, + realm: 'TestRealm', + realmAccess, + refreshToken: token, + token, + } as Keycloak + return { initialized: true, keycloak: authClient } +} + +describe('App tests', () => { + beforeAll(() => { + vi.mock('@react-keycloak/web', () => ({ useKeycloak: mockUseKeycloak })) + }) + it('home render with keycloak authorized', async () => { + await act(async () => + render( + + + + + + + + + + ) + ) await waitFor(() => { - expect(screen.getByText(/test-softeno/i)).toBeInTheDocument() + // note: menu items shown only after login + expect(screen.getByText(/counter/i)).toBeInTheDocument() + expect(screen.getByText(/users/i)).toBeInTheDocument() }) }) - it('some logic', () => { - expect(1).toEqual(1) - }) }) diff --git a/src/App.tsx b/src/App.tsx index 9a2cf53..86e492e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,66 +1,180 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import '@/App.css' -import { Button } from '@/components/ui/button.tsx' -import { ModeToggle } from '@/components/custom/mode-toggle.tsx' +import React, { createContext } from 'react' +import { Counter } from '@/pages/counter/Counter' +import { useKeycloak } from '@react-keycloak/web' +import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { MenuItem } from '@/components/app/menu-item' +import { Label } from '@radix-ui/react-menu' +import { NavigationMenu, NavigationMenuList } from '@/components/ui/navigation-menu' +import { NavMenuItem } from '@/components/app/nav-item' +import { MessageSquare, Search } from 'lucide-react' +import { ModeToggle } from '@/components/custom/mode-toggle' +import { Input } from '@/components/ui/input' +import { UserDropdownMenu } from '@/components/app/user-dropdown-menu' +import { Errors, Unauthorized } from '@/pages/error/errors.tsx' +import { Home } from '@/pages/home/home' +import { SideMenu } from '@/components/app/side-menu' +import { Users } from '@/pages/users/Users' +import { setLanguage } from '@/locales/i18n' +import CookieConsent from '@/components/custom/coockie-consent' +import { LoadingScreenMemo } from '@/components/app/loading-screen' -function App() { - const [count, setCount] = useState(0) +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-expect-error +const ProtectedRoute = ({ predicate, redirectPath = '/', children }) => { + if (!predicate) { + return + } + return children +} + +export type GlobalSettings = { + data?: string +} + +const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + console.log(`search: ${e.currentTarget.search.value}`) + e.currentTarget.reset() +} + +const handleCookieConsentAccept = () => { + console.log(`Cookie consent ACCEPTED`) +} + +const handleCookieConsentDecline = () => { + console.log(`Cookie consent DECLINED`) +} + +const defaultGlobalSettings: GlobalSettings = { data: 'some-global-data' } +export const GlobalSettingsContext = createContext(defaultGlobalSettings) + +const App: React.FC = () => { + const { initialized, keycloak } = useKeycloak() + const navigate = useNavigate() + const { t } = useTranslation(['main']) + + const menuItems: MenuItem[] = [ + { + key: `${t('menu.home', { ns: ['main'] })}`, + route: '/', + restricted: false, + }, + { + key: `${t('menu.counter', { ns: ['main'] })}`, + route: '/counter', + restricted: true, + }, + { + key: `${t('menu.users', { ns: ['main'] })}`, + route: '/users', + restricted: true, + }, + ] + + const handleUserDropdownSelect = (item: string) => { + console.log(`User dropdown change: ${item}`) + switch (item) { + case 'profile': + console.log('Profile') + break + case 'settings': + console.log('Settings') + break + case 'lang/english': + setLanguage('en') + break + case 'lang/polish': + setLanguage('pl') + break + default: + throw Errors('Unsupported action') + } + } + + if (!initialized) { + return + } return ( -
-
-
-
-
- - Vite logo - -
-
- - React logo - -
-
+ +
+
+ + +
+
+
+ + +
+
+
+
-
-
-

Vite + React

-
-
-

This is a template application for React and Vite.

-

- There are some test components on the site. Please clear the content and put down your code. -

-
-
-
-
-
- - -
-
- - -
+
+
+
+
{t(`home.section1`, { ns: ['main'] })}
+
{t(`home.section2`, { ns: ['main'] })}
+ + } /> + } /> + } /> + + + + } + /> + + + + } + /> + +
+ +
+
+
+ + {t(`footer.text`, { ns: ['main'] })}
-
-
-
-

test-softeno

-
-
+ + + ) } diff --git a/src/components/app/loading-screen.tsx b/src/components/app/loading-screen.tsx new file mode 100644 index 0000000..2d28a28 --- /dev/null +++ b/src/components/app/loading-screen.tsx @@ -0,0 +1,14 @@ +import { Spinner } from '@/components/custom/spinner' +import React from 'react' + +export function LoadingScreen() { + return ( +
+
+ +
+
+ ) +} + +export const LoadingScreenMemo = React.memo(LoadingScreen) diff --git a/src/components/app/menu-item.ts b/src/components/app/menu-item.ts new file mode 100644 index 0000000..351b281 --- /dev/null +++ b/src/components/app/menu-item.ts @@ -0,0 +1,5 @@ +export type MenuItem = { + readonly key: string + readonly route: string + readonly restricted: boolean +} diff --git a/src/components/app/nav-item.tsx b/src/components/app/nav-item.tsx new file mode 100644 index 0000000..5c61992 --- /dev/null +++ b/src/components/app/nav-item.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router' +import { NavigationMenuItem, NavigationMenuLink, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu' +import { MenuItem } from '@/components/app/menu-item' + +export function NavMenuItem({ item }: { item: MenuItem }) { + const navigate = useNavigate() + const location = useLocation() + + return ( + + navigate(item.route)} + > + {item.key} + + + ) +} diff --git a/src/components/app/side-menu.tsx b/src/components/app/side-menu.tsx new file mode 100644 index 0000000..86c70df --- /dev/null +++ b/src/components/app/side-menu.tsx @@ -0,0 +1,56 @@ +import { MenuItem } from '@/components/app/menu-item' +import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetDescription, SheetClose } from '@/components/ui/sheet' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Menu } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router' + +function SideMenuItem({ item }: { item: MenuItem }) { + const navigate = useNavigate() + const location = useLocation() + return ( + + + + ) +} + +export interface SideMenuItemProps { + items: MenuItem[] + authenticated: boolean +} + +export function SideMenu({ items, authenticated }: SideMenuItemProps) { + return ( + + + + + + + + + ) +} diff --git a/src/components/app/user-dropdown-menu.tsx b/src/components/app/user-dropdown-menu.tsx new file mode 100644 index 0000000..9b772c9 --- /dev/null +++ b/src/components/app/user-dropdown-menu.tsx @@ -0,0 +1,112 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Languages, LogOut, Settings, User, UserCircle, UserPlus } from 'lucide-react' +import { useKeycloak } from '@react-keycloak/web' +import { useTranslation } from 'react-i18next' + +export interface UserDropdownMenuProps { + handleDropdownSelectFn: (action: string) => void +} + +export function UserDropdownMenu({ handleDropdownSelectFn }: UserDropdownMenuProps) { + const { keycloak } = useKeycloak() + const { t } = useTranslation(['main']) + + return ( + + + + + {keycloak.authenticated ? ( + <> + + {t('user_dropdown.my_account', { ns: ['main'] })} + + handleDropdownSelectFn('profile')}> + + {t('user_dropdown.profile', { ns: ['main'] })} + + handleDropdownSelectFn('settings')}> + + {t('user_dropdown.settings', { ns: ['main'] })} + + + + + + {t('user_dropdown.select_language', { ns: ['main'] })} + + + + handleDropdownSelectFn('lang/english')}> + {t('user_dropdown.en', { ns: ['main'] })} + + handleDropdownSelectFn('lang/polish')}> + {t('user_dropdown.pl', { ns: ['main'] })} + + + + + + { + keycloak.logout() + }} + > + + {t('user_dropdown.logout', { ns: ['main'] })} + + + + ) : ( + <> + + + + + {t('user_dropdown.select_language', { ns: ['main'] })} + + + + handleDropdownSelectFn('lang/english')}> + {t('user_dropdown.en', { ns: ['main'] })} + + handleDropdownSelectFn('lang/polish')}> + {t('user_dropdown.pl', { ns: ['main'] })} + + + + + + { + keycloak.login() + }} + > + + {t('user_dropdown.login', { ns: ['main'] })} + + keycloak.register()}> + + {t('user_dropdown.register', { ns: ['main'] })} + + + + )} + + ) +} diff --git a/src/components/custom/coockie-consent.tsx b/src/components/custom/coockie-consent.tsx new file mode 100644 index 0000000..f6b9576 --- /dev/null +++ b/src/components/custom/coockie-consent.tsx @@ -0,0 +1,99 @@ +import { CookieIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +export interface CookieConsentProps { + demo?: boolean + onAcceptCallback?: () => void + onDeclineCallback?: () => void +} + +export default function CookieConsent({ demo, onAcceptCallback, onDeclineCallback }: CookieConsentProps) { + const [isOpen, setIsOpen] = useState(false) + const [hide, setHide] = useState(false) + const { t } = useTranslation(['main']) + + const accept = () => { + setIsOpen(false) + document.cookie = 'cookieConsent=true; expires=Fri, 31 Dec 9999 23:59:59 GMT' + setTimeout(() => { + setHide(true) + }, 700) + if (onAcceptCallback) { + onAcceptCallback() + } + } + + const decline = () => { + setIsOpen(false) + setTimeout(() => { + setHide(true) + }, 700) + if (onDeclineCallback) { + onDeclineCallback() + } + } + + useEffect(() => { + try { + setIsOpen(true) + if (document.cookie.includes('cookieConsent=true')) { + if (!demo) { + setIsOpen(false) + setTimeout(() => { + setHide(true) + }, 700) + } + } + } catch { + // console.log("Error: ", e); + } + }, [demo]) + + return ( +
+
+
+
+

{t('cookie_consent.title', { ns: ['main'] })}

+ +
+
+

+ {t('cookie_consent.content', { ns: ['main'] })} +
+
+ + {t('cookie_consent.accept_pt1', { ns: ['main'] }) + ' '} " + {t('cookie_consent.accept', { ns: ['main'] })}"{' '} + {', ' + t('cookie_consent.accept_pt2', { ns: ['main'] })} + +
+ + {t('cookie_consent.learn_more', { ns: ['main'] })} + +

+
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/custom/data-table.tsx b/src/components/custom/data-table.tsx new file mode 100644 index 0000000..de19ff5 --- /dev/null +++ b/src/components/custom/data-table.tsx @@ -0,0 +1,183 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Button } from '@/components/ui/button' +import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons' +import { + ColumnDef, + flexRender, + getCoreRowModel, + PaginationState, + SortingState, + useReactTable, +} from '@tanstack/react-table' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +import { OnChangeFn } from '@tanstack/table-core/src/types' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +import { Table as TTable } from '@tanstack/table-core/build/lib/types' + +export interface DataTablePaginationProps { + table: TTable + withSelected?: boolean +} + +export function DataTablePagination({ table, withSelected }: DataTablePaginationProps) { + return ( +
+
+ {withSelected ? ( + <> + {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) + selected. + + ) : ( + <> + )} +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+
+ + + + +
+
+
+ ) +} + +export interface DataTableProps { + columns: ColumnDef[] + data: TData[] + total: number + pagination: PaginationState + paginationChangeFn: OnChangeFn + sorting?: SortingState + sortingChangeFn?: OnChangeFn +} + +export function DataTable({ + columns, + data, + total, + pagination, + paginationChangeFn, + sorting, + sortingChangeFn, +}: DataTableProps) { + const table = useReactTable({ + data: data, + columns: columns, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: paginationChangeFn, + rowCount: total, + state: { + pagination, + sorting, + }, + manualPagination: true, + debugTable: false, + onSortingChange: sortingChangeFn, + manualSorting: true, + }) + + return ( +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ +
+
+
+ ) +} diff --git a/src/components/custom/multiple-selector.tsx b/src/components/custom/multiple-selector.tsx new file mode 100644 index 0000000..8764506 --- /dev/null +++ b/src/components/custom/multiple-selector.tsx @@ -0,0 +1,511 @@ +import { Command as CommandPrimitive, useCommandState } from 'cmdk' +import { X } from 'lucide-react' +import * as React from 'react' +import { forwardRef, useEffect } from 'react' + +import { Badge } from '@/components/ui/badge' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { cn } from '@/lib/utils' + +export interface Option { + value: string + label: string + disable?: boolean + /** fixed option that can't be removed. */ + fixed?: boolean + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined +} +interface GroupOption { + [key: string]: Option[] +} + +interface MultipleSelectorProps { + value?: Option[] + defaultOptions?: Option[] + /** manually controlled options */ + options?: Option[] + placeholder?: string + /** Loading component. */ + loadingIndicator?: React.ReactNode + /** Empty component. */ + emptyIndicator?: React.ReactNode + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean + /** async search */ + onSearch?: (value: string) => Promise + onChange?: (options: Option[]) => void + /** Limit the maximum number of selected options. */ + maxSelected?: number + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean + disabled?: boolean + /** Group the options base on provided key. */ + groupBy?: string + className?: string + badgeClassName?: string + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @reference: https://github.com/pacocoursey/cmdk/issues/171 + */ + selectFirstItem?: boolean + /** Allow user to create option when there is no option matched. */ + creatable?: boolean + /** Props of `Command` */ + commandProps?: React.ComponentPropsWithoutRef + /** Props of `CommandInput` */ + inputProps?: Omit, 'value' | 'placeholder' | 'disabled'> + /** hide the clear all button. */ + hideClearAllButton?: boolean +} + +export interface MultipleSelectorRef { + selectedValue: Option[] + input: HTMLInputElement +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} + +function transToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {} + } + if (!groupBy) { + return { + '': options, + } + } + + const groupOption: GroupOption = {} + options.forEach((option) => { + const key = (option[groupBy] as string) || '' + if (!groupOption[key]) { + groupOption[key] = [] + } + groupOption[key].push(option) + }) + return groupOption +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value)) + } + return cloneOption +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + for (const [, value] of Object.entries(groupOption)) { + if (value.some((option) => targetOption.find((p) => p.value === option.value))) { + return true + } + } + return false +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef>( + ({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0) + + if (!render) return null + + return ( +
+ ) + } +) + +CommandEmpty.displayName = 'CommandEmpty' + +const MultipleSelector = React.forwardRef( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: MultipleSelectorProps, + ref: React.Ref + ) => { + const inputRef = React.useRef(null) + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + + const [selected, setSelected] = React.useState(value || []) + const [options, setOptions] = React.useState(transToGroupOption(arrayDefaultOptions, groupBy)) + const [inputValue, setInputValue] = React.useState('') + const debouncedSearchTerm = useDebounce(inputValue, delay || 500) + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef.current?.focus(), + }), + [selected] + ) + + const handleUnselect = React.useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value) + setSelected(newOptions) + onChange?.(newOptions) + }, + [onChange, selected] + ) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current + if (input) { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (input.value === '' && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1] + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]) + } + } + } + // This is not a default behavior of the field + if (e.key === 'Escape') { + input.blur() + } + } + }, + [handleUnselect, selected] + ) + + useEffect(() => { + if (value) { + setSelected(value) + } + }, [value]) + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return + } + const newOption = transToGroupOption(arrayOptions || [], groupBy) + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption) + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]) + + useEffect(() => { + const doSearch = async () => { + setIsLoading(true) + const res = await onSearch?.(debouncedSearchTerm) + setOptions(transToGroupOption(res || [], groupBy)) + setIsLoading(false) + } + + const exec = async () => { + if (!onSearch || !open) return + + if (triggerSearchOnFocus) { + await doSearch() + } + + if (debouncedSearchTerm) { + await doSearch() + } + } + + void exec() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]) + + const CreatableItem = () => { + if (!creatable) return undefined + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined + } + + const Item = ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length) + return + } + setInputValue('') + const newOptions = [...selected, { value, label: value }] + setSelected(newOptions) + onChange?.(newOptions) + }} + > + {`Create "${inputValue}"`} + + ) + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item + } + + return undefined + } + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ) + } + + return {emptyIndicator} + }, [creatable, emptyIndicator, onSearch, options]) + + const selectables = React.useMemo(() => removePickedOption(options, selected), [options, selected]) + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1 + } + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined + }, [creatable, commandProps?.filter]) + + return ( + { + handleKeyDown(e) + commandProps?.onKeyDown?.(e) + }} + className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)} + shouldFilter={commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch} // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return + inputRef.current?.focus() + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ) + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value) + inputProps?.onValueChange?.(value) + }} + onBlur={(event) => { + setOpen(false) + inputProps?.onBlur?.(event) + }} + onFocus={(event) => { + setOpen(true) + if (triggerSearchOnFocus) { + onSearch?.(debouncedSearchTerm) + } + inputProps?.onFocus?.(event) + }} + placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder} + className={cn( + 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground', + { + 'w-full': hidePlaceholderWhenSelected, + 'px-3 py-2': selected.length === 0, + 'ml-1': selected.length !== 0, + }, + inputProps?.className + )} + /> + +
+
+
+ {open && ( + + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && } + {Object.entries(selectables).map(([key, dropdowns]) => ( + + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length) + return + } + setInputValue('') + const newOptions = [...selected, option] + setSelected(newOptions) + onChange?.(newOptions) + }} + className={cn('cursor-pointer', option.disable && 'cursor-default text-muted-foreground')} + > + {option.label} + + ) + })} + + + ))} + + )} + + )} +
+
+ ) + } +) + +MultipleSelector.displayName = 'MultipleSelector' +export default MultipleSelector diff --git a/src/components/custom/spinner.tsx b/src/components/custom/spinner.tsx new file mode 100644 index 0000000..1c3f0f2 --- /dev/null +++ b/src/components/custom/spinner.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { VariantProps, cva } from 'class-variance-authority' +import { Loader2 } from 'lucide-react' + +const spinnerVariants = cva('flex-col items-center justify-center', { + variants: { + show: { + true: 'flex', + false: 'hidden', + }, + }, + defaultVariants: { + show: true, + }, +}) + +const loaderVariants = cva('animate-spin text-primary', { + variants: { + size: { + small: 'size-6', + medium: 'size-8', + large: 'size-12', + }, + }, + defaultVariants: { + size: 'medium', + }, +}) + +interface SpinnerContentProps extends VariantProps, VariantProps { + className?: string + children?: React.ReactNode +} + +export function Spinner({ size, show, children, className }: SpinnerContentProps) { + return ( + + + {children} + + ) +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..dc67b34 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +type CommandDialogProps = DialogProps + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c23630e --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..8dbd7a1 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,129 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form' + +import { cn } from '@/lib/utils' +import { Label } from '@/components/ui/label' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext({} as FormFieldContextValue) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext({} as FormItemContextValue) + +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) + } +) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return