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/.env.production b/.env.production new file mode 100644 index 0000000..4ae8cbd --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +VITE_APP_DOMAIN=https://app.softeno.com +VITE_APP_KEYCLOAK_URL=https://auth.softeno.com +VITE_APP_BACKEND_URL=https://api.softeno.com/spring-reactive-template \ No newline at end of file diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9538d6c..8c95f9a 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,7 +2,7 @@ name: NodeJS with Webpack on: push: - branches: ['main', 'release/*'] + branches: ['**'] pull_request: branches: ['**'] @@ -25,6 +25,9 @@ jobs: - name: Install dependencies run: npm install + - name: Codegen + run: npm run generate:graphql + - name: Run the tests run: npm test diff --git a/.gitignore b/.gitignore index de9ee66..d3f1c2b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ coverage *.njsproj *.sln *.sw? + +src/graphql/*.ts +src/graphql/*.js \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c1e18b0..59e58de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,13 +14,13 @@ RUN npm install # Copy app source code to the working directory COPY . . -## Generate types -#RUN npm run generate:graphql +# Generate types +RUN npm 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 npm run build # Use NGINX as the production server FROM nginx:stable-alpine-slim diff --git a/bun.lockb b/bun.lockb index b8c22ce..3dfe285 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..35d7072 100644 --- a/package.json +++ b/package.json @@ -11,18 +11,40 @@ "prod": "serve -s dist", "test": "vitest", "test:ui": "vitest --ui", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "generate:graphql": "graphql-codegen --config codegen.ts" }, "dependencies": { + "@apollo/client": "^3.10.5", + "@hookform/resolvers": "^3.6.0", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@react-keycloak/web": "^3.4.0", + "@reduxjs/toolkit": "^2.2.5", + "@tanstack/react-table": "^8.17.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "lucide-react": "^0.408.0", + "cmdk": "^1.0.0", + "graphql": "^16.8.2", + "graphql-codegen": "^0.4.0", + "i18next-browser-languagedetector": "^8.0.0", + "keycloak-js": "^25.0.0", + "lucide-react": "^0.396.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwind-merge": "^2.4.0", - "tailwindcss-animate": "^1.0.7" + "react-hook-form": "^7.52.0", + "react-i18next": "^14.1.2", + "react-redux": "^9.1.2", + "react-router-dom": "^6.23.1", + "redux-persist": "^6.0.0", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.6", @@ -32,6 +54,14 @@ "@types/node": "^20.14.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/keycloak-js": "^3.4.1", + "@types/react-i18next": "^8.1.0", + "@types/react-router-dom": "^5.3.3", + "@types/redux-persist": "^4.3.1", + "@graphql-codegen/cli": "^5.0.2", + "@graphql-codegen/client-preset": "^4.3.0", + "@graphql-codegen/typescript-operations": "^4.2.1", + "@graphql-codegen/typescript-react-apollo": "^4.3.0", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vitejs/plugin-react-swc": "^3.5.0", @@ -52,6 +82,7 @@ "typescript": "^5.2.2", "vite": "^5.3.1", "vite-tsconfig-paths": "^4.3.2", - "vitest": "^2.0.2" + "vitest": "^2.0.2", + "msw": "^2.3.1" } } 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..7de5e8c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,16 +1,6 @@ -import '@testing-library/jest-dom' -import { render, screen, waitFor } from '@testing-library/react' import { expect } from 'vitest' -import App from '@/App.tsx' describe('Page', () => { - it('renders', async () => { - render() - - await waitFor(() => { - expect(screen.getByText(/test-softeno/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..9c06cf3 --- /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 (e) { + // console.log("Error: ", e); + } + }, []) + + 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..3703354 --- /dev/null +++ b/src/components/custom/multiple-selector.tsx @@ -0,0 +1,509 @@ +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) + 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..d34152d --- /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 + +interface CommandDialogProps extends 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