diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index d4cb217cf..f29bdaf3c 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -22,7 +22,7 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 10.18.3 + version: 11.1.3 - name: Install Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/bump_publish.yml b/.github/workflows/bump_publish.yml index 84304b5a4..8ef3b1ead 100644 --- a/.github/workflows/bump_publish.yml +++ b/.github/workflows/bump_publish.yml @@ -52,7 +52,7 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 10.18.3 + version: 11.1.3 - name: Install Node.js uses: actions/setup-node@v4 diff --git a/apps/api/package.json b/apps/api/package.json index ef54d038c..560cc3e7b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,11 +15,11 @@ }, "dependencies": { "@hono/zod-openapi": "^1.3.0", - "@hono/zod-validator": "^0.7.6", + "@hono/zod-validator": "^0.8.0", "@vitnode/core": "workspace:*", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", - "hono": "^4.12.16", + "hono": "^4.12.21", "next-intl": "^4.11.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -36,9 +36,9 @@ "@vitnode/nodemailer": "workspace:*", "dotenv": "^17.4.2", "eslint": "^10.2.1", - "react-email": "^6.0.5", + "react-email": "^6.1.5", "tsc-alias": "^1.8.16", - "tsx": "^4.21.0", + "tsx": "^4.22.3", "typescript": "^6.0.3" } } diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 5d2f42ed1..8fa1fee6c 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -1,6 +1,15 @@ { "core": { "global": { + "close": "Close", + "confirm": "Confirm", + "previous": "Previous", + "next": "Next", + "current_page": "Current page", + "go_to_page": "Go to page", + "previous_page": "Previous page", + "next_page": "Next page", + "remove": "Remove", "editor": { "undo": "Undo", "redo": "Redo", diff --git a/apps/docs/components.json b/apps/docs/components.json index 9a3fe27e5..58c323fd0 100644 --- a/apps/docs/components.json +++ b/apps/docs/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-nova", + "style": "radix-vega", "rsc": true, "tsx": true, "tailwind": { diff --git a/apps/docs/content/docs/dev/email/components/button.mdx b/apps/docs/content/docs/dev/email/components/button.mdx index 150a8a590..1d5f31012 100644 --- a/apps/docs/content/docs/dev/email/components/button.mdx +++ b/apps/docs/content/docs/dev/email/components/button.mdx @@ -5,15 +5,15 @@ description: A button or link component for emails. ## Preview -import { ImgDocs } from '@/components/fumadocs/img'; -import buttonPreviewImg from './button-preview.png'; +import { ImgDocs } from "@/components/fumadocs/img"; +import buttonPreviewImg from "./button-preview.png"; ## Usage ```ts -import { EmailButton } from '@vitnode/core/emails/ui/button'; +import { EmailButton } from "@vitnode/core/emails/ui/button"; ``` ```tsx @@ -22,24 +22,24 @@ import { EmailButton } from '@vitnode/core/emails/ui/button'; ## Props -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/content/docs/ui/auto-form.mdx b/apps/docs/content/docs/ui/auto-form.mdx index f83632f6b..7e5b2e761 100644 --- a/apps/docs/content/docs/ui/auto-form.mdx +++ b/apps/docs/content/docs/ui/auto-form.mdx @@ -15,6 +15,9 @@ import { AutoFormCheckbox } from "@vitnode/core/components/form/fields/checkbox" import { AutoFormInput } from "@vitnode/core/components/form/fields/input"; import { AutoFormSelect } from "@vitnode/core/components/form/fields/select"; import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"; +import { AutoFormArray } from "@vitnode/core/components/form/fields/array"; +import { InputGroupAddon } from "@vitnode/core/components/ui/input-group"; +import { Search } from "lucide-react"; import { z } from "zod"; ``` @@ -25,10 +28,19 @@ const formSchema = z.object({ .email("Please enter a valid email address") .describe("We'll use this email to contact you. (from zod schema)"), user_type: z.enum(["admin", "editor", "viewer"]), + links: z + .array( + z.object({ + title: z.string().min(1, "Title is required"), + url: z.string().url(), + }), + ) + .optional(), accept_terms: z.boolean().refine(val => val, { message: "You must accept the terms and conditions", }), description: z.string().min(10, "Description must be at least 10 characters"), + search: z.string().optional(), }); ``` @@ -64,6 +76,29 @@ const formSchema = z.object({ /> ), }, + { + id: "links", + component: props => ( + ( + + ), + }, + { + id: "url", + component: subProps => ( + + ), + }, + ]} + /> + ), + }, { id: "accept_terms", component: props => ( @@ -84,6 +119,17 @@ const formSchema = z.object({ /> ), }, + { + id: "search", + component: props => ( + + + + + 12 results + + ), + }, ]} formSchema={formSchema} /> @@ -166,6 +212,57 @@ const formSchema = z.object({ }); ``` +### Arrays + +You can create dynamic list of fields using `AutoFormArray` component working with `z.array(z.object(...))` schema: + +```ts +const formSchema = z.object({ + guests: z + .array( + z.object({ + name: z.string(), + email: z.string().email(), + }), + ) + .min(1), +}); +``` + +```tsx +{ + id: "guests", + component: props => ( + }, + { id: "email", component: p => }, + ]} + /> + ) +} +``` + +You can customize the wrapper, items layout, and Add/Remove buttons logic through `className`, `addButtonLabel` props: + +```tsx +{ + id: "guests", + component: props => ( + }, + { id: "email", component: p => }, + ]} + /> + ) +} +``` + ### Advanced Validation Auto Form supports all Zod validators: @@ -186,9 +283,48 @@ const formSchema = z.object({ ## Custom Fields - - We're working hard to bring you the best documentation experience. - +Because Auto Form fields are controlled entirely by the `component` property function, creating a custom field component is just a matter of rendering your own UI using the provided props. The props passed to the `component` function contain the field properties managed by `react-hook-form` along with Zod validation details. + +Here is an example of creating a custom color picker input: + +```ts +const formSchema = z.object({ + custom_color: z + .string() + .default("#000000") + .describe("Pick your favorite color."), +}); +``` + +```tsx + ( +
+
+ Custom Color Picker +
+ + {props.description && ( +
+ {props.description} +
+ )} +
+ ), + }, + ]} + onSubmit={values => console.log(values)} +/> +``` ## Form Submission diff --git a/apps/docs/content/docs/ui/button.mdx b/apps/docs/content/docs/ui/button.mdx index 176ab8855..996682188 100644 --- a/apps/docs/content/docs/ui/button.mdx +++ b/apps/docs/content/docs/ui/button.mdx @@ -10,8 +10,8 @@ description: A button component for triggering actions in your application. ## Usage ```ts -import { Home } from 'lucide-react'; -import { Button } from '@vitnode/core/components/ui/button'; +import { Home } from "lucide-react"; +import { Button } from "@vitnode/core/components/ui/button"; ``` ```tsx @@ -23,19 +23,19 @@ import { Button } from '@vitnode/core/components/ui/button'; ## Props -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/content/docs/ui/checkbox.mdx b/apps/docs/content/docs/ui/checkbox.mdx index 3226157a3..0cc420a80 100644 --- a/apps/docs/content/docs/ui/checkbox.mdx +++ b/apps/docs/content/docs/ui/checkbox.mdx @@ -9,21 +9,21 @@ description: Toggle between checked and unchecked states. ## Usage -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; ```ts -import { z } from 'zod'; -import { AutoForm } from '@vitnode/core/components/form/auto-form'; -import { AutoFormCheckbox } from '@vitnode/core/components/form/fields/checkbox'; +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormCheckbox } from "@vitnode/core/components/form/fields/checkbox"; ``` ```ts const formSchema = z.object({ acceptTerms: z.boolean().refine(val => val, { - message: 'You must accept the terms and conditions', + message: "You must accept the terms and conditions", }), }); ``` @@ -33,7 +33,7 @@ const formSchema = z.object({ formSchema={formSchema} fields={[ { - id: 'acceptTerms', + id: "acceptTerms", component: props => ( ```ts -import { Checkbox } from '@vitnode/core-frontend/components/ui/checkbox'; +import { Checkbox } from '@vitnode/core/components/ui/checkbox'; ``` ```tsx @@ -61,21 +61,21 @@ import { Checkbox } from '@vitnode/core-frontend/components/ui/checkbox'; ## Props -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/content/docs/ui/combobox-async.mdx b/apps/docs/content/docs/ui/combobox-async.mdx deleted file mode 100644 index 4514add41..000000000 --- a/apps/docs/content/docs/ui/combobox-async.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Combobox Async -description: Asynchronously load options for a combobox with search functionality. ---- - -## Preview - -This preview component if from [Combobox](/docs/ui/combobox). - - - -## Usage - -```ts -import { z } from 'zod'; -import { AutoForm } from '@vitnode/core/components/form/auto-form'; -import { AutoFormComboboxAsync } from '@vitnode/core/components/form/fields/combobox-async'; -import { fetcherClient } from '@vitnode/core/lib/fetcher-client'; -``` - -```ts -const formSchema = z.object({ - categoryId: z.object({ value: z.string(), label: z.string() }), -}); -``` - -```tsx - ( - { - const res = await fetcherClient(categoriesModule, { - path: '/', - method: 'get', - module: 'categories', - args: { - query: { - search, - }, - }, - }); - const data = await res.json(); - - return data.edges.map(category => ({ - label: category.title, - value: category.id.toString(), - })); - }} - id="categoryId" - label="Category" - /> - ), - }, - ]} -/> -``` - -## Props - -import { TypeTable } from 'fumadocs-ui/components/type-table'; - -', - default: '[]', - }, - placeholder: { - description: 'Placeholder text for the combobox input.', - type: 'string', - default: 'Select an option', - }, - searchPlaceholder: { - description: 'Placeholder text for the search input within the combobox.', - type: 'string', - default: 'Search...', - }, - }} -/> diff --git a/apps/docs/content/docs/ui/combobox.mdx b/apps/docs/content/docs/ui/combobox.mdx index 47c20801a..0e2203d2c 100644 --- a/apps/docs/content/docs/ui/combobox.mdx +++ b/apps/docs/content/docs/ui/combobox.mdx @@ -11,7 +11,7 @@ description: Select from a list of options with a search input. import { Tab, Tabs } from "fumadocs-ui/components/tabs"; - + ```ts @@ -22,7 +22,7 @@ import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox" ```ts const formSchema = z.object({ - type: z.enum(["option-one", "option-two"]) + type: z.enum(["option-one", "option-two"]), }); ``` @@ -32,7 +32,7 @@ const formSchema = z.object({ fields={[ { id: "type", - component: (props) => ( + component: props => ( - ) - } + ), + }, + ]} +/> +``` + + + + + +```ts +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; +import { fetcherClient } from "@vitnode/core/lib/fetcher-client"; +``` + +```ts +const formSchema = z.object({ + categoryId: z.object({ value: z.string(), label: z.string() }), +}); +``` + +```tsx + ( + { + const res = await fetcherClient(categoriesModule, { + path: "/", + method: "get", + module: "categories", + args: { + query: { + search, + }, + }, + }); + const data = await res.json(); + + return data.edges.map(category => ({ + label: category.title, + value: category.id.toString(), + })); + }} + id="categoryId" + label="Category" + placeholder="Select a category" + searchPlaceholder="Search categories" + /> + ), + }, ]} /> ``` @@ -61,92 +116,38 @@ const formSchema = z.object({ ```tsx "use client"; -import React from "react"; -import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; - -import { cn } from "@vitnode/core/lib/utils"; -import { Button } from "@vitnode/core/components/ui/button"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@vitnode/core/components/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@vitnode/core/components/ui/popover"; + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@vitnode/core/components/ui/combobox"; const frameworks = [ - { - value: "next.js", - label: "Next.js" - }, - { - value: "sveltekit", - label: "SvelteKit" - }, - { - value: "nuxt.js", - label: "Nuxt.js" - }, - { - value: "remix", - label: "Remix" - }, - { - value: "astro", - label: "Astro" - } -]; - -export function ExampleCombobox() { - const [open, setOpen] = React.useState(false); - const [value, setValue] = React.useState(""); - + "Next.js", + "SvelteKit", + "Nuxt.js", + "Remix", + "Astro", +] as const; + +export function ComboboxBasic() { return ( - - - - - - - - - No framework found. - - {frameworks.map((framework) => ( - { - setValue(currentValue === value ? "" : currentValue); - setOpen(false); - }} - > - - {framework.label} - - ))} - - - - - + + + + No items found. + + {item => ( + + {item} + + )} + + + ); } ``` @@ -164,27 +165,56 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; label: { description: "The label for the combobox, displayed above the input.", type: "string", - default: "" + default: "", }, description: { - description: "An optional description for the combobox, displayed below the input.", + description: + "An optional description for the combobox, displayed below the input.", + type: "string", + default: "", + }, + fetchData: { + description: + "Callback to load options based on the search query. When provided, the combobox switches to async search.", + type: "(params: { search: string }) => Promise> | Array<{ value: string; label: string }>", + default: "", + }, + id: { + description: "Stable id for query caching and list updates.", type: "string", - default: "" + default: "", }, labels: { - description: "An array of options for the combobox, each with a value and label.", + description: + "An array of options for the combobox, each with a value and label. Used for static options.", type: "Array<{ value: string; label: string }>", - default: "[]" + default: "[]", }, placeholder: { description: "Placeholder text for the combobox input.", type: "string", - default: "Select an option" + default: "Select an option", }, searchPlaceholder: { description: "Placeholder text for the search input within the combobox.", type: "string", - default: "Search..." - } + default: "Search...", + }, + disabled: { + description: "Whether the combobox is disabled.", + type: "boolean", + default: "false", + }, + multiple: { + description: + "Whether multiple selections are allowed. Not used when fetchData is provided.", + type: "boolean", + default: "false", + }, + showClear: { + description: "Whether to show a clear button when a value is selected.", + type: "boolean", + default: "false", + }, }} /> diff --git a/apps/docs/content/docs/ui/input-group.mdx b/apps/docs/content/docs/ui/input-group.mdx new file mode 100644 index 000000000..868f0aff7 --- /dev/null +++ b/apps/docs/content/docs/ui/input-group.mdx @@ -0,0 +1,113 @@ +--- +title: Input Group +description: Component used for grouping related input fields together +--- + +## Preview + + + +## Usage + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```ts +import { z } from 'zod'; +import { AutoForm } from '@vitnode/core/components/form/auto-form'; +import { AutoFormInput } from '@vitnode/core/components/form/fields/input'; +import { AutoFormTextarea } from '@vitnode/core/components/form/fields/textarea'; +import { InputGroupAddon, InputGroupText } from "@vitnode/core/components/ui/input-group"; +``` + +```ts +const formSchema = z.object({ + search: z.string().min(1, "Search is required"), + description: z + .string() + .max(500, "Description must be less than 500 characters"), +}); +``` + +```tsx + ( + + + + + 12 results + + ), + }, + { + id: "description", + component: props => ( + + + + {props.field.value?.toString().length ?? 0} of 500 characters + + + + ), + }, + ]} +/> +``` + + + + +```ts +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, + InputGroupTextarea, +} from "@vitnode/core/components/ui/input-group"; +import { Search } from "lucide-react"; +``` + +```tsx +
+ + + + + + 12 results + + + + + + + 0 of 500 characters + + + +
+``` + +
+
+ +## API Reference + +[Shadcn UI - Input Group](https://ui.shadcn.com/docs/components/radix/input-group#api-reference) diff --git a/apps/docs/content/docs/ui/input.mdx b/apps/docs/content/docs/ui/input.mdx index 78193dd0a..ad6bb31e4 100644 --- a/apps/docs/content/docs/ui/input.mdx +++ b/apps/docs/content/docs/ui/input.mdx @@ -9,7 +9,7 @@ description: Component used for collecting data from users ## Usage -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; @@ -21,8 +21,8 @@ import { AutoFormInput } from '@vitnode/core/components/form/fields/input'; ```ts const formSchema = z.object({ - username: z.string().min(3, 'Username must be at least 3 characters'), - email: z.email('Please enter a valid email address'), + username: z.string().min(3, "Username must be at least 3 characters"), + email: z.email("Please enter a valid email address"), }); ``` @@ -31,7 +31,7 @@ const formSchema = z.object({ formSchema={formSchema} fields={[ { - id: 'username', + id: "username", component: props => ( ( ```ts -import { Input } from '@vitnode/core-frontend/components/ui/input'; +import { Input } from '@vitnode/core/components/ui/input'; ``` ```tsx @@ -70,32 +70,32 @@ import { Input } from '@vitnode/core-frontend/components/ui/input'; ## Props -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/content/docs/ui/meta.json b/apps/docs/content/docs/ui/meta.json index ca3bbcc42..8fc96c8b4 100644 --- a/apps/docs/content/docs/ui/meta.json +++ b/apps/docs/content/docs/ui/meta.json @@ -15,9 +15,9 @@ "---Forms---", "auto-form", "checkbox", - "combobox-async", "combobox", "input", + "input-group", "radio-group", "select", "switch", diff --git a/apps/docs/content/docs/ui/radio-group.mdx b/apps/docs/content/docs/ui/radio-group.mdx index 8796c0ed3..daeb4274e 100644 --- a/apps/docs/content/docs/ui/radio-group.mdx +++ b/apps/docs/content/docs/ui/radio-group.mdx @@ -21,7 +21,7 @@ import { AutoFormRadioGroup } from '@vitnode/core/components/form/fields/radio-g ```ts const formSchema = z.object({ - options: z.enum(["option1", "option2", "option3"]) + options: z.enum(["option1", "option2", "option3"]), }); ``` @@ -31,7 +31,7 @@ const formSchema = z.object({ fields={[ { id: "options", - component: (props) => ( + component: props => ( - ) - } + ), + }, ]} /> ``` @@ -61,29 +64,90 @@ const formSchema = z.object({ ```ts -import { Label } from '@vitnode/core-frontend/components/ui/label'; import { RadioGroup, RadioGroupItem, -} from '@vitnode/core-frontend/components/ui/radio-group'; +} from '@vitnode/core/components/ui/radio-group'; +import { Field, FieldLabel } from "@vitnode/core/components/ui/field" ``` ```tsx -
+ - -
-
+ + Option One + + This is the description for option one. It provides more information + about the option. + + + + + - -
+ + Option Two + + + + + + + Option Three + + This is the description for option three. It provides more information + about the option. + + +
```
+### variant="block" + +You can also use the `block` variant to display the radio buttons in a block style. + +```tsx + ( + + ), + }, + ]} +/> +``` + ## Props import { TypeTable } from "fumadocs-ui/components/type-table"; @@ -91,21 +155,29 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/content/docs/ui/select.mdx b/apps/docs/content/docs/ui/select.mdx index abe3cce37..4c24d5cf8 100644 --- a/apps/docs/content/docs/ui/select.mdx +++ b/apps/docs/content/docs/ui/select.mdx @@ -9,20 +9,20 @@ description: Choose an option from a list of options. ## Usage -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; ```ts -import { z } from 'zod'; -import { AutoForm } from '@vitnode/core/components/form/auto-form'; -import { AutoFormSelect } from '@vitnode/core/components/form/fields/select'; +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormSelect } from "@vitnode/core/components/form/fields/select"; ``` ```ts const formSchema = z.object({ - options: z.enum(['option1', 'option2', 'option3']).default('option1'), + options: z.enum(["option1", "option2", "option3"]).default("option1"), }); ``` @@ -31,26 +31,26 @@ const formSchema = z.object({ formSchema={formSchema} fields={[ { - id: 'options', + id: "options", component: props => ( ), }, @@ -68,7 +68,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '@vitnode/core-frontend/components/ui/select'; +} from '@vitnode/core/components/ui/select'; ``` ```tsx @@ -88,33 +88,33 @@ import { ## Props -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/content/docs/ui/switch.mdx b/apps/docs/content/docs/ui/switch.mdx index 412ca7bb4..c9b6197a8 100644 --- a/apps/docs/content/docs/ui/switch.mdx +++ b/apps/docs/content/docs/ui/switch.mdx @@ -9,21 +9,21 @@ description: Toggle between checked and unchecked states. ## Usage -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; ```ts -import { z } from 'zod'; -import { AutoForm } from '@vitnode/core/components/form/auto-form'; -import { AutoFormSwitch } from '@vitnode/core/components/form/fields/switch'; +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormSwitch } from "@vitnode/core/components/form/fields/switch"; ``` ```ts const formSchema = z.object({ acceptTerms: z.boolean().refine(val => val, { - message: 'You must accept the terms and conditions', + message: "You must accept the terms and conditions", }), }); ``` @@ -33,7 +33,7 @@ const formSchema = z.object({ formSchema={formSchema} fields={[ { - id: 'acceptTerms', + id: "acceptTerms", component: props => ( ), @@ -46,7 +46,7 @@ const formSchema = z.object({ ```ts -import { Switch } from '@vitnode/core-frontend/components/ui/switch'; +import { Switch } from '@vitnode/core/components/ui/switch'; ``` ```tsx diff --git a/apps/docs/content/docs/ui/textarea.mdx b/apps/docs/content/docs/ui/textarea.mdx index 42559f631..41ebf0345 100644 --- a/apps/docs/content/docs/ui/textarea.mdx +++ b/apps/docs/content/docs/ui/textarea.mdx @@ -9,20 +9,20 @@ description: Input for multi-line text input. ## Usage -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; ```ts -import { z } from 'zod'; -import { AutoForm } from '@vitnode/core/components/form/auto-form'; -import { AutoFormTextarea } from '@vitnode/core/components/form/fields/textarea'; +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"; ``` ```ts const formSchema = z.object({ - desc: z.string().min(10, 'Description must be at least 10 characters'), + desc: z.string().min(10, "Description must be at least 10 characters"), }); ``` @@ -31,7 +31,7 @@ const formSchema = z.object({ formSchema={formSchema} fields={[ { - id: 'desc', + id: "desc", component: props => ( ```ts -import { Textarea } from '@vitnode/core-frontend/components/ui/textarea'; +import { Textarea } from '@vitnode/core/components/ui/textarea'; ``` ```tsx @@ -61,26 +61,26 @@ import { Textarea } from '@vitnode/core-frontend/components/ui/textarea'; ## Props -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/package.json b/apps/docs/package.json index 81311561c..240abee3b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -23,15 +23,15 @@ }, "dependencies": { "@hono/zod-openapi": "^1.3.0", - "@hono/zod-validator": "^0.7.6", + "@hono/zod-validator": "^0.8.0", "@vitnode/blog": "workspace:*", "@vitnode/core": "workspace:*", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", "fumadocs-core": "^16.8.5", - "fumadocs-mdx": "^14.3.2", + "fumadocs-mdx": "^15.0.6", "fumadocs-ui": "^16.8.5", - "hono": "^4.12.16", + "hono": "^4.12.21", "lucide-react": "^1.14.0", "motion": "^12.38.0", "next": "^16.2.4", @@ -41,6 +41,7 @@ "react-dom": "^19.2.5", "react-hook-form": "^7.74.0", "react-use": "^17.6.0", + "shadcn": "^4.7.0", "sonner": "^2.0.7" }, "devDependencies": { @@ -60,7 +61,7 @@ "class-variance-authority": "^0.7.1", "eslint": "^10.2.1", "postcss": "^8.5.12", - "react-email": "^6.0.5", + "react-email": "^6.1.5", "shiki": "^4.0.2", "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", diff --git a/apps/docs/src/app/global.css b/apps/docs/src/app/global.css index a3723c255..90890f9cd 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -1,7 +1,8 @@ @import "tailwindcss"; -@import "fumadocs-ui/css/shadcn.css"; +@import "fumadocs-ui/css/neutral.css"; @import "fumadocs-ui/css/preset.css"; @import "@vitnode/core/styles/tiptap.css"; +@import "shadcn/tailwind.css"; @import "tw-animate-css"; @@ -11,37 +12,37 @@ :root:not(.dark) { --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); + --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); + --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.488 0.243 264.376); - --primary-foreground: oklch(0.97 0.014 254.604); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.51 0.16 262.61); + --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); - --chart-1: oklch(0.871 0.006 286.286); - --chart-2: oklch(0.552 0.016 285.938); - --chart-3: oklch(0.442 0.017 285.786); - --chart-4: oklch(0.37 0.013 285.805); - --chart-5: oklch(0.274 0.006 286.033); - --radius: 0.625rem; + --warn: oklch(0.54 0.12 82.58); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.546 0.245 262.881); --sidebar-primary-foreground: oklch(0.97 0.014 254.604); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.705 0.015 286.067); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); --dev-color: oklch(0.6 0.18 50); --ui-color: oklch(0.65 0.18 170); @@ -49,37 +50,38 @@ } .dark { - --background: oklch(0.141 0.005 285.823); + --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); + --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); + --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.424 0.199 265.638); - --primary-foreground: oklch(0.97 0.014 254.604); + --primary: oklch(0.6 0.18 262.65); + --primary-foreground: oklch(0.98 0 0); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + --warn: oklch(0.76 0.18 81.84); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.871 0.006 286.286); - --chart-2: oklch(0.552 0.016 285.938); - --chart-3: oklch(0.442 0.017 285.786); - --chart-4: oklch(0.37 0.013 285.805); - --chart-5: oklch(0.274 0.006 286.033); - --sidebar: oklch(0.21 0.006 285.885); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.623 0.214 259.815); --sidebar-primary-foreground: oklch(0.97 0.014 254.604); - --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.552 0.016 285.938); + --sidebar-ring: oklch(0.556 0 0); --dev-color: oklch(0.75 0.18 50); --ui-color: oklch(0.7 0.18 170); diff --git a/apps/docs/src/components/infinite-slider.tsx b/apps/docs/src/components/infinite-slider.tsx index 502b7b423..1964f6cd3 100644 --- a/apps/docs/src/components/infinite-slider.tsx +++ b/apps/docs/src/components/infinite-slider.tsx @@ -29,13 +29,12 @@ export function InfiniteSlider({ reverse = false, className, }: InfiniteSliderProps) { - // eslint-disable-next-line @eslint-react/no-unused-state const [currentSpeed, setCurrentSpeed] = React.useState(speed); const [ref, { width, height }] = useMeasure(); const translation = useMotionValue(0); - // eslint-disable-next-line @eslint-react/no-unused-state + const [isTransitioning, setIsTransitioning] = React.useState(false); - // eslint-disable-next-line @eslint-react/no-unused-state + const [key, setKey] = React.useState(0); React.useEffect(() => { diff --git a/apps/docs/src/examples/auto-form.tsx b/apps/docs/src/examples/auto-form.tsx index 6d9119d9e..3a8993858 100644 --- a/apps/docs/src/examples/auto-form.tsx +++ b/apps/docs/src/examples/auto-form.tsx @@ -5,6 +5,8 @@ import { AutoFormCheckbox } from "@vitnode/core/components/form/fields/checkbox" import { AutoFormInput } from "@vitnode/core/components/form/fields/input"; import { AutoFormSelect } from "@vitnode/core/components/form/fields/select"; import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"; +import { InputGroupAddon } from "@vitnode/core/components/ui/input-group"; +import { Search } from "lucide-react"; import { z } from "zod"; export default function AutoFormExample() { @@ -20,6 +22,7 @@ export default function AutoFormExample() { description: z .string() .min(10, "Description must be at least 10 characters"), + search: z.string().optional(), }); return ( @@ -45,6 +48,7 @@ export default function AutoFormExample() { id: "user_type", component: props => ( ), }, @@ -76,6 +79,17 @@ export default function AutoFormExample() { /> ), }, + { + id: "search", + component: props => ( + + + + + 12 results + + ), + }, ]} formSchema={formSchema} /> diff --git a/apps/docs/src/examples/button.tsx b/apps/docs/src/examples/button.tsx index b3b87f56c..be3b1d502 100644 --- a/apps/docs/src/examples/button.tsx +++ b/apps/docs/src/examples/button.tsx @@ -38,7 +38,7 @@ export default function ButtonExample() { aria-label="Delete" isLoading={isLoading} size="icon" - variant="destructiveGhost" + variant="destructive" > diff --git a/apps/docs/src/examples/combobox.tsx b/apps/docs/src/examples/combobox.tsx index f5cc03f31..1feb02d5f 100644 --- a/apps/docs/src/examples/combobox.tsx +++ b/apps/docs/src/examples/combobox.tsx @@ -16,6 +16,7 @@ export default function ComboboxExample() { id: "type", component: props => ( ), }, diff --git a/apps/docs/src/examples/input-group.tsx b/apps/docs/src/examples/input-group.tsx new file mode 100644 index 000000000..016d24696 --- /dev/null +++ b/apps/docs/src/examples/input-group.tsx @@ -0,0 +1,35 @@ +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, + InputGroupTextarea, +} from "@vitnode/core/components/ui/input-group"; +import { Search } from "lucide-react"; + +export default function InputGroupDemo() { + return ( +
+ + + + + + 12 results + + + + + + + 0 of 500 characters + + + +
+ ); +} diff --git a/apps/docs/src/examples/radio-group.tsx b/apps/docs/src/examples/radio-group.tsx index 265668984..bffa2feb9 100644 --- a/apps/docs/src/examples/radio-group.tsx +++ b/apps/docs/src/examples/radio-group.tsx @@ -6,7 +6,8 @@ import { z } from "zod"; export default function RadioGroupExample() { const formSchema = z.object({ - options: z.enum(["option1", "option2", "option3"]), + options: z.enum(["option1", "option2", "option3"]).default("option1"), + options_block: z.enum(["option1", "option2", "option3"]).default("option1"), }); return ( @@ -16,12 +17,14 @@ export default function RadioGroupExample() { id: "options", component: props => ( + ), + }, + { + id: "options_block", + component: props => ( + ), }, diff --git a/apps/docs/src/examples/select.tsx b/apps/docs/src/examples/select.tsx index 683375d08..aa82153f0 100644 --- a/apps/docs/src/examples/select.tsx +++ b/apps/docs/src/examples/select.tsx @@ -16,6 +16,7 @@ export default function SelectExample() { id: "options", component: props => ( ), }, diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 5d2f42ed1..8fa1fee6c 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -1,6 +1,15 @@ { "core": { "global": { + "close": "Close", + "confirm": "Confirm", + "previous": "Previous", + "next": "Next", + "current_page": "Current page", + "go_to_page": "Go to page", + "previous_page": "Previous page", + "next_page": "Next page", + "remove": "Remove", "editor": { "undo": "Undo", "redo": "Redo", diff --git a/apps/docs/src/vitnode.config.ts b/apps/docs/src/vitnode.config.ts index ca8d7836d..708f35f2f 100644 --- a/apps/docs/src/vitnode.config.ts +++ b/apps/docs/src/vitnode.config.ts @@ -8,7 +8,7 @@ export const vitNodeConfig = buildConfig({ shortTitle: "VitNode", }, plugins: [blogPlugin()], - debug: true, + debug: false, i18n: { locales: [ { diff --git a/package.json b/package.json index 017477e1f..f50be9554 100644 --- a/package.json +++ b/package.json @@ -17,19 +17,19 @@ "test:e2e": "turbo test:e2e" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^25.9.0", "@vitnode/config": "workspace:*", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", - "tsx": "^4.21.0", - "turbo": "^2.9.6", + "tsx": "^4.22.3", + "turbo": "^2.9.14", "typescript": "^6.0.3", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "engines": { "node": ">=22" }, - "packageManager": "pnpm@10.18.3", + "packageManager": "pnpm@11.1.3", "workspaces": [ "apps/*", "packages/*", diff --git a/packages/config/package.json b/packages/config/package.json index 370f82979..b8272ec3b 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -53,7 +53,7 @@ "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-you-might-not-need-an-effect": "^0.9.3", + "eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1", "prettier-plugin-tailwindcss": "^0.8.0", "typescript-eslint": "^8.59.1" } diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global.css b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global.css index 734f04a10..5a1390c82 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global.css +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global.css @@ -1,77 +1,79 @@ @import "tailwindcss"; @import "@vitnode/core/styles/tiptap.css"; @import "tw-animate-css"; +@import "shadcn/tailwind.css"; @source "../../node_modules/@vitnode/core/dist/src/components"; @source "../../node_modules/@vitnode/core/dist/src/views"; :root:not(.dark) { - --background: oklch(0.96 0.01 250); - --foreground: oklch(0.18 0.01 250); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); - --card-foreground: oklch(0.22 0.01 250); + --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.22 0.01 250); + --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.51 0.16 262.61); --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.94 0.02 254.94); - --secondary-foreground: oklch(0.25 0.01 250); - --muted: oklch(0.95 0.01 250); - --muted-foreground: oklch(0.53 0.01 250); - --accent: oklch(0.92 0.01 250); - --accent-foreground: oklch(0.25 0.01 250); - --destructive: oklch(0.6 0.2 24.45); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); --warn: oklch(0.54 0.12 82.58); - --border: oklch(0.9 0.01 250); - --input: oklch(0.9 0.01 250); - --ring: oklch(0.7 0.13 250); - --chart-1: oklch(0.65 0.13 250); - --chart-2: oklch(0.6 0.11 260); - --chart-3: oklch(0.45 0.09 250); - --chart-4: oklch(0.8 0.13 250); - --chart-5: oklch(0.75 0.13 250); - --sidebar: var(--card); - --sidebar-foreground: oklch(0.22 0.01 250); - --sidebar-primary: var(--primary); - --sidebar-primary-foreground: var(--primary-foreground); - --sidebar-accent: oklch(0.97 0.01 250); - --sidebar-accent-foreground: oklch(0.25 0.01 250); - --sidebar-border: oklch(0.91 0.01 250); - --sidebar-ring: oklch(0.7 0.13 250); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.16 0.01 250); - --foreground: oklch(0.96 0.01 250); - --card: oklch(0.2 0.01 250); - --card-foreground: oklch(0.96 0.01 250); - --popover: oklch(0.22 0.01 250); - --popover-foreground: oklch(0.96 0.01 250); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.6 0.18 262.65); --primary-foreground: oklch(0.98 0 0); - --secondary: oklch(0.2 0.01 250); - --secondary-foreground: oklch(0.96 0.01 250); - --muted: oklch(0.24 0.01 250); - --muted-foreground: oklch(0.7 0.01 250); - --accent: oklch(0.28 0.01 250); - --destructive: oklch(0.62 0.2 25.35); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); --warn: oklch(0.76 0.18 81.84); - --border: oklch(0.3 0.01 250); - --input: oklch(0.3 0.01 250); - --ring: oklch(0.54 0.13 250); - --chart-1: oklch(0.45 0.09 250); - --chart-2: oklch(0.6 0.11 260); - --chart-3: oklch(0.75 0.13 250); - --chart-4: oklch(0.8 0.13 250); - --chart-5: oklch(0.65 0.13 250); - --sidebar: var(--card); - --sidebar-foreground: oklch(0.96 0.01 250); - --sidebar-primary: var(--primary); - --sidebar-primary-foreground: var(--primary-foreground); - --sidebar-accent: oklch(0.23 0.01 250); - --sidebar-accent-foreground: oklch(0.96 0.01 250); - --sidebar-border: oklch(0.26 0.01 250); - --sidebar-ring: oklch(0.54 0.13 250); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } :root { diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index 51d80f4c1..77890fe51 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -39,7 +39,7 @@ "commander": "^14.0.3", "ora": "^9.4.0", "picocolors": "^1.1.1", - "validate-npm-package-name": "^7.0.2" + "validate-npm-package-name": "^8.0.0" }, "devDependencies": { "@types/node": "^25.6.0", diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index 159da5622..35c21330c 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -131,6 +131,7 @@ const apiDeps = { react: versionsPackageJson.react, "react-dom": versionsPackageJson.reactDom, "use-intl": versionsPackageJson.useIntl, + shadcn: versionsPackageJson.shadcnUi, zod: versionsPackageJson.zod, }; diff --git a/packages/create-vitnode-app/src/create/package-versions.ts b/packages/create-vitnode-app/src/create/package-versions.ts index b80e164ff..d3d2991dc 100644 --- a/packages/create-vitnode-app/src/create/package-versions.ts +++ b/packages/create-vitnode-app/src/create/package-versions.ts @@ -6,7 +6,7 @@ export const versionsPackageJson = { turbo: "^2.9", typescript: "^6.0", - tsx: "^4.21", + tsx: "^4", tscAlias: "^1.8.16", eslint: "^10", prettier: "^3.8.3", @@ -32,7 +32,7 @@ export const versionsPackageJson = { hono: "^4.12", honoZodOpenapi: "^1.3", - honoZodValidator: "^0.7.6", + honoZodValidator: "^0.8", reactEmail: "^6.0", reactEmailComponents: "^1.0", zod: "^4.4", @@ -43,4 +43,5 @@ export const versionsPackageJson = { swcCli: "^0.8.1", swcCore: "^1.15", concurrently: "^9.2.1", + shadcnUi: "^4", }; diff --git a/packages/vitnode/components.json b/packages/vitnode/components.json index 797c749cb..68fa65fe9 100644 --- a/packages/vitnode/components.json +++ b/packages/vitnode/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-nova", + "style": "radix-vega", "rsc": true, "tsx": true, "tailwind": { diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 4762347a5..e63cc9a1c 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -37,45 +37,45 @@ "zod": "^4.x.x" }, "devDependencies": { - "@hono/zod-openapi": "^1.3.0", - "@hono/zod-validator": "^0.7.6", + "@hono/zod-openapi": "^1.4.0", + "@hono/zod-validator": "^0.8.0", "@hookform/resolvers": "^5.2.2", "@react-email/components": "^1.0.12", "@swc/cli": "^0.8.1", - "@swc/core": "^1.15.32", + "@swc/core": "^1.15.33", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", - "@types/node": "^25.6.0", + "@types/node": "^25.9.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.5", + "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.6", "@vitnode/config": "workspace:*", "chokidar": "^5.0.0", "concurrently": "^9.2.1", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", - "eslint": "^10.2.1", - "hono": "^4.12.16", - "jiti": "^2.6.1", + "eslint": "^10.4.0", + "hono": "^4.12.21", + "jiti": "^2.7.0", "jsdom": "^29.1.1", - "lucide-react": "^1.14.0", - "next": "^16.2.4", - "next-intl": "^4.11.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "react-email": "^6.0.5", - "react-hook-form": "^7.74.0", + "lucide-react": "^1.16.0", + "next": "^16.2.6", + "next-intl": "^4.12.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-email": "^6.1.5", + "react-hook-form": "^7.76.0", "sonner": "^2.0.7", - "tailwindcss": "^4.2.4", - "tsc-alias": "^1.8.16", + "tailwindcss": "^4.3.0", + "tsc-alias": "^1.8.17", "tsup": "^8.5.1", - "tsx": "^4.21.0", + "tsx": "^4.22.3", "tw-animate-css": "^1.4.0", "typescript": "^6.0.3", - "vite": "^8.0.10", - "vitest": "^4.1.5", - "zod": "^4.4.1" + "vite": "^8.0.13", + "vitest": "^4.1.6", + "zod": "^4.4.3" }, "bin": { "vitnode": "./dist/scripts/scripts.js" @@ -109,31 +109,38 @@ }, "type": "module", "dependencies": { + "@base-ui/react": "^1.5.0", "@bprogress/next": "^3.2.12", "@dnd-kit/core": "^6.3.1", "@hono/swagger-ui": "^0.6.1", "@react-email/preview-server": "^5.2.10", - "@tanstack/react-query": "^5.100.6", - "@tiptap/extension-text-align": "^3.22.5", - "@tiptap/pm": "^3.22.5", - "@tiptap/react": "^3.22.5", - "@tiptap/starter-kit": "^3.22.5", + "@tanstack/react-query": "^5.100.11", + "@tiptap/extension-text-align": "^3.23.5", + "@tiptap/pm": "^3.23.5", + "@tiptap/react": "^3.23.5", + "@tiptap/starter-kit": "^3.23.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "cron-parser": "^5.5.0", + "date-fns": "^4.2.1", "dotenv": "^17.4.2", + "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "motion": "^12.38.0", + "motion": "^12.39.0", "next-themes": "^0.4.6", "postgres": "^3.4.9", "radix-ui": "^1.4.3", - "rate-limiter-flexible": "^11.0.1", - "react-scan": "^0.5.3", + "rate-limiter-flexible": "^11.1.0", + "react-day-picker": "^10.0.1", + "react-resizable-panels": "^4.11.1", + "react-scan": "^0.5.6", + "recharts": "3.8.1", "server-only": "^0.0.1", - "tailwind-merge": "^3.5.0", + "shadcn": "^4.7.0", + "tailwind-merge": "^3.6.0", "use-debounce": "^10.1.1", - "use-intl": "^4.11.0", + "use-intl": "^4.12.0", "vaul": "^1.1.2" } } diff --git a/packages/vitnode/src/components/date-format.tsx b/packages/vitnode/src/components/date-format.tsx index 5825d591c..8886de441 100644 --- a/packages/vitnode/src/components/date-format.tsx +++ b/packages/vitnode/src/components/date-format.tsx @@ -2,12 +2,7 @@ import { useFormatter, useNow } from "next-intl"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./ui/tooltip"; +import { TooltipWithContent } from "./ui/tooltip"; export const DateFormat = ({ date, @@ -44,15 +39,9 @@ export const DateFormat = ({ // When date is < 7 days if (now.getTime() - dateToFormat.getTime() < 604800000) { return ( - - - - {format.relativeTime(dateToFormat, now)} - - - {fullDate} - - + + {format.relativeTime(dateToFormat, now)} + ); } diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 06bd2c57b..16ab51913 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -19,9 +19,11 @@ import { getDefaults, getNestedParam, getZodInputParams, + type InputParams, } from "../../lib/helpers/auto-form"; import { Button } from "../ui/button"; import { DialogClose, DialogFooter, useDialog } from "../ui/dialog"; +import { Field } from "../ui/field"; import { Form, FormField } from "../ui/form"; type ItemAutoFormProps< @@ -42,12 +44,16 @@ type ItemAutoFormProps< export interface ItemAutoFormComponentProps { description?: React.ReactNode; field: ControllerRenderProps; + itemParams?: InputParams; label?: React.ReactNode; labelRight?: React.ReactNode; otherProps: { + ["aria-invalid"]?: boolean; enum?: string[]; isOptional?: boolean; + maxItems?: number; maxLength?: number; + minItems?: number; minLength?: number; pattern?: string; type?: string; @@ -161,15 +167,22 @@ export function AutoForm< { + render={({ field, fieldState }) => { return ( - <> + {item.component({ field, description: typeof params.description === "string" ? params.description : "", + itemParams: + "itemParams" in params + ? (params.itemParams as InputParams) + : undefined, otherProps: { isOptional: !params.required, enum: Array.isArray(params.enum) @@ -179,10 +192,19 @@ export function AutoForm< typeof params.maxLength === "number" ? params.maxLength : undefined, + maxItems: + typeof params.maxItems === "number" + ? params.maxItems + : undefined, minLength: typeof params.minLength === "number" ? params.minLength : undefined, + ["aria-invalid"]: fieldState.invalid, + minItems: + typeof params.minItems === "number" + ? params.minItems + : undefined, pattern: typeof params.pattern === "string" ? params.pattern @@ -193,7 +215,7 @@ export function AutoForm< : undefined, }, })} - + ); }} /> diff --git a/packages/vitnode/src/components/form/common/label.tsx b/packages/vitnode/src/components/form/common/label.tsx index 7a37e5d6e..5d24e39b4 100644 --- a/packages/vitnode/src/components/form/common/label.tsx +++ b/packages/vitnode/src/components/form/common/label.tsx @@ -1,26 +1,38 @@ -import { FormLabel } from "@/components/ui/form"; +import { useTranslations } from "next-intl"; + +import { FieldLabel } from "@/components/ui/field"; +import { useFormField } from "@/components/ui/form"; import { cn } from "@/lib/utils"; export const AutoFormLabel = ({ children, labelRight, className, + isOptional, ...props -}: React.ComponentProps & { +}: React.ComponentProps & { + isOptional?: boolean; labelRight?: React.ReactNode; }) => { + const t = useTranslations("core.global"); + const { formItemId } = useFormField(); + return ( - {children} + {isOptional && ( + {t("optional")} + )} {labelRight && {labelRight}} - + ); }; diff --git a/packages/vitnode/src/components/form/fields/array.tsx b/packages/vitnode/src/components/form/fields/array.tsx new file mode 100644 index 000000000..d3ce25d76 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/array.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; + +import { cn } from "@/lib/utils"; + +import type { InputParams } from "../../../lib/helpers/auto-form"; +import type { ItemAutoFormComponentProps } from "../auto-form"; + +import { getNestedParam } from "../../../lib/helpers/auto-form"; +import { Button } from "../../ui/button"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLegend, + FieldSet, +} from "../../ui/field"; +import { FormField } from "../../ui/form"; + +export interface AutoFormArrayField { + className?: string; + component: (props: ItemAutoFormComponentProps) => React.ReactNode; + id: string; +} + +export interface AutoFormArrayProps extends ItemAutoFormComponentProps { + addButtonLabel?: string; + className?: string; + fields: AutoFormArrayField[]; + itemParams?: InputParams; + maxItems?: number; + minItems?: number; + showRemoveButton?: boolean; +} + +export const AutoFormArray = ({ + label, + field: parentField, + description, + fields: fieldDefinitions, + addButtonLabel = "Add Item", + maxItems: maxItemsProp, + minItems: minItemsProp, + showRemoveButton = true, + className, + itemParams, + otherProps, +}: AutoFormArrayProps) => { + const { control, formState } = useFormContext(); + const t = useTranslations("core.global"); + const id = parentField.name; + + const { fields, append, remove } = useFieldArray({ + control, + name: id, + }); + + const maxItems = maxItemsProp ?? otherProps.maxItems; + const minItems = minItemsProp ?? otherProps.minItems ?? 0; + + const canRemove = fields.length > minItems; + const canAdd = !maxItems || fields.length < maxItems; + + const arrayError = formState.errors[id] as + | undefined + | { message?: string; root?: { message?: string } }; + + return ( +
+ {label && {label}} + {description && {description}} + + + {fields.map((field, index) => ( + + {fieldDefinitions.map(fieldDef => { + const fullFieldName = `${id}.${index}.${fieldDef.id}`; + const fieldParams = itemParams + ? getNestedParam(itemParams, fieldDef.id) + : undefined; + + return ( + ( + + {fieldDef.component({ + field, + itemParams: + fieldParams && + typeof fieldParams === "object" && + "itemParams" in fieldParams + ? (fieldParams.itemParams as InputParams) + : undefined, + description: + typeof fieldParams === "object" && + fieldParams && + "description" in fieldParams && + typeof fieldParams.description === "string" + ? fieldParams.description + : undefined, + otherProps: { + isOptional: false, + ["aria-invalid"]: fieldState.invalid, + enum: + fieldParams && + typeof fieldParams === "object" && + "enum" in fieldParams && + Array.isArray(fieldParams.enum) + ? fieldParams.enum + : undefined, + maxLength: + typeof fieldParams === "object" && + fieldParams && + "maxLength" in fieldParams && + typeof fieldParams.maxLength === "number" + ? fieldParams.maxLength + : undefined, + minLength: + typeof fieldParams === "object" && + fieldParams && + "minLength" in fieldParams && + typeof fieldParams.minLength === "number" + ? fieldParams.minLength + : undefined, + pattern: + typeof fieldParams === "object" && + fieldParams && + "pattern" in fieldParams && + typeof fieldParams.pattern === "string" + ? fieldParams.pattern + : undefined, + type: + typeof fieldParams === "object" && + fieldParams && + "type" in fieldParams && + typeof fieldParams.type === "string" + ? fieldParams.type + : undefined, + }, + })} + + )} + /> + ); + })} + + {canRemove && showRemoveButton && fields.length > 0 && ( + + + + )} + + ))} + + + + + {(arrayError?.root?.message ?? arrayError?.message) && ( + + )} +
+ ); +}; diff --git a/packages/vitnode/src/components/form/fields/checkbox.tsx b/packages/vitnode/src/components/form/fields/checkbox.tsx index 0c96090dd..54e23ca74 100644 --- a/packages/vitnode/src/components/form/fields/checkbox.tsx +++ b/packages/vitnode/src/components/form/fields/checkbox.tsx @@ -1,7 +1,9 @@ +import { cn } from "@/lib/utils"; + import type { ItemAutoFormComponentProps } from "../auto-form"; import { Checkbox } from "../../ui/checkbox"; -import { FormControl, FormItem, FormMessage } from "../../ui/form"; +import { FormControl, FormMessage } from "../../ui/form"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; @@ -11,11 +13,19 @@ export const AutoFormCheckbox = ({ description, otherProps: { isOptional }, field, + className, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, ...props }: ItemAutoFormComponentProps & Omit, "checked">) => { return ( - +
)} -
+ ); }; diff --git a/packages/vitnode/src/components/form/fields/combobox-async.tsx b/packages/vitnode/src/components/form/fields/combobox-async.tsx deleted file mode 100644 index 0a94bfdc4..000000000 --- a/packages/vitnode/src/components/form/fields/combobox-async.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronsUpDown } from "lucide-react"; -import { useTranslations } from "next-intl"; -import React from "react"; -import { useDebouncedCallback } from "use-debounce"; - -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { FormControl, FormItem, FormMessage } from "@/components/ui/form"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; - -import type { ItemAutoFormComponentProps } from "../auto-form"; - -import { Skeleton } from "../../ui/skeleton"; -import { AutoFormDesc } from "../common/desc"; -import { AutoFormLabel } from "../common/label"; - -export const AutoFormComboboxAsync = ({ - label, - field, - description, - placeholder, - className, - labelRight, - id, - otherProps: { isOptional }, - searchPlaceholder, - fetchData, - ...props -}: ItemAutoFormComponentProps & - Omit, "role" | "variant"> & { - fetchData: (params: { search: string }) => - | Promise< - { - label: string; - value: string; - }[] - > - | { - label: string; - value: string; - }[]; - id: string; - placeholder?: string; - searchPlaceholder?: string; - }) => { - const t = useTranslations("core.global"); - const [search, setSearch] = React.useState(""); - const { data, isLoading } = useQuery({ - queryKey: [id, { search }], - queryFn: async () => { - return await fetchData({ search }); - }, - }); - - const handleChangeSearch = useDebouncedCallback((value: string) => { - setSearch(value); - }, 500); - - const renderCommandList = () => { - if (isLoading) { - return ( -
- - -
- ); - } - - if ((data ?? []).length === 0) { - return {t("results_not_found")}; - } - - return ( - - {(data ?? []).map(({ label, value }) => ( - { - field.onChange({ - label, - value, - }); - }} - value={label} - > - {label} - - - ))} - - ); - }; - - return ( - - {label && ( - - {label} - - )} - - - - - - - - - - - { - handleChangeSearch(e.currentTarget.value); - }} - placeholder={searchPlaceholder ?? t("search_placeholder")} - /> - - {renderCommandList()} - - - - - {description && {description}} - - - ); -}; diff --git a/packages/vitnode/src/components/form/fields/combobox.test.tsx b/packages/vitnode/src/components/form/fields/combobox.test.tsx new file mode 100644 index 000000000..36d77c39e --- /dev/null +++ b/packages/vitnode/src/components/form/fields/combobox.test.tsx @@ -0,0 +1,320 @@ +import type { ReactElement } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; + +import { AutoFormCombobox } from "./combobox"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/components/ui/form", () => ({ + FormMessage: () => null, + useFormField: () => ({ + error: undefined, + formDescriptionId: "form-item-description", + formItemId: "form-item", + formMessageId: "form-item-message", + }), +})); + +afterEach(() => { + vi.useRealTimers(); +}); + +const createField = ( + value: unknown = null, +): ItemAutoFormComponentProps["field"] => { + return { + value, + onChange: vi.fn(), + onBlur: vi.fn(), + name: "field", + ref: vi.fn(), + }; +}; + +const renderWithClient = (ui: ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + {ui}, + ); +}; + +describe("AutoFormCombobox", () => { + it("uses the translated default placeholder when no placeholder is provided", () => { + renderWithClient( + , + ); + + expect(screen.getByPlaceholderText("select_option")).toBeTruthy(); + }); + + it("renders label and placeholder for static options", () => { + renderWithClient( + , + ); + + expect(screen.getByText("Type")).toBeTruthy(); + expect(screen.getByPlaceholderText("Pick a type")).toBeTruthy(); + }); + + it("renders optional marker, labelRight and description", () => { + renderWithClient( + Admin only} + otherProps={{ + enum: ["option-one"], + isOptional: true, + "aria-invalid": false, + }} + />, + ); + + expect(screen.getByText("Type")).toBeTruthy(); + expect(screen.getByText("optional")).toBeTruthy(); + expect(screen.getByText("Admin only")).toBeTruthy(); + expect(screen.getByText("Choose the primary item type.")).toBeTruthy(); + }); + + it("passes disabled and invalid state to the single input", () => { + renderWithClient( + , + ); + + const input = screen.getByPlaceholderText("Pick a type"); + + expect((input as HTMLInputElement).disabled).toBe(true); + expect(input.getAttribute("aria-invalid")).toBe("true"); + }); + + it("renders chips when multiple for static options", () => { + const { container } = renderWithClient( + , + ); + + expect( + container.querySelector("[data-slot='combobox-chips']"), + ).toBeTruthy(); + }); + + it("renders mapped labels for selected values", () => { + renderWithClient( + , + ); + + expect(screen.getByText("Option One")).toBeTruthy(); + }); + + it("falls back to raw selected values when labels are missing", () => { + renderWithClient( + , + ); + + expect(screen.getByText("unknown-option")).toBeTruthy(); + }); + + it("calls onInputValueChange for static input", () => { + const onInputValueChange = vi.fn(); + + renderWithClient( + , + ); + + fireEvent.change(screen.getByPlaceholderText("Pick a type"), { + target: { + value: "opt", + }, + }); + + expect(onInputValueChange).toHaveBeenCalled(); + }); + + it("calls field.onChange and onValueChange when a static option is selected", async () => { + const field = createField(); + const onValueChange = vi.fn(); + + renderWithClient( + , + ); + + const input = screen.getByPlaceholderText("Pick a type"); + + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => { + expect(field.onChange).toHaveBeenCalledWith("option-one"); + }); + expect(onValueChange).toHaveBeenCalledWith("option-one", expect.anything()); + }); + + it("loads async options and ignores multiple", async () => { + const fetchData = vi + .fn() + .mockResolvedValue([{ label: "Category", value: "1" }]); + const { container } = renderWithClient( + , + ); + + await waitFor(() => { + expect(fetchData).toHaveBeenCalledWith({ search: "" }); + }); + + expect(screen.getByPlaceholderText("Search categories")).toBeTruthy(); + expect(container.querySelector("[data-slot='combobox-chips']")).toBeNull(); + }); + + it("uses placeholder as async search placeholder fallback", async () => { + const fetchData = vi.fn().mockResolvedValue([]); + + renderWithClient( + , + ); + + await waitFor(() => { + expect(fetchData).toHaveBeenCalledWith({ search: "" }); + }); + + expect(screen.getByPlaceholderText("Search from placeholder")).toBeTruthy(); + }); + + it("forwards async input changes without refetching immediately", async () => { + const fetchData = vi.fn().mockResolvedValue([]); + const onInputValueChange = vi.fn(); + + renderWithClient( + , + ); + + await waitFor(() => { + expect(fetchData).toHaveBeenCalledWith({ search: "" }); + }); + + const input = screen.getByPlaceholderText("Search categories"); + + fireEvent.input(input, { + target: { + value: "cat", + }, + }); + + expect(onInputValueChange).toHaveBeenCalledWith("cat", expect.anything()); + expect(fetchData).not.toHaveBeenCalledWith({ search: "cat" }); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/combobox.tsx b/packages/vitnode/src/components/form/fields/combobox.tsx index 7e1e70094..1531d637e 100644 --- a/packages/vitnode/src/components/form/fields/combobox.tsx +++ b/packages/vitnode/src/components/form/fields/combobox.tsx @@ -1,121 +1,248 @@ -import type React from "react"; - -import { Check, ChevronsUpDown } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; +import React from "react"; +import { useDebouncedCallback } from "use-debounce"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { FormControl, FormItem, FormMessage } from "@/components/ui/form"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "@/components/ui/combobox"; +import { FormMessage } from "@/components/ui/form"; +import { Skeleton } from "@/components/ui/skeleton"; import type { ItemAutoFormComponentProps } from "../auto-form"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +interface ComboboxAsyncItem { + label: string; + value: string; +} +type ComboboxFetchData = (params: { + search: string; +}) => ComboboxAsyncItem[] | Promise; + +type AutoFormComboboxProps = ItemAutoFormComponentProps & + Omit, "items" | "value"> & { + className?: string; + labels?: { label: string; value: string }[]; + placeholder?: string; + showClear?: boolean; + } & ( + | { + fetchData: ComboboxFetchData; + id: string; + searchPlaceholder?: string; + } + | { + fetchData?: undefined; + id?: string; + searchPlaceholder?: string; + } + ); + export const AutoFormCombobox = ({ label, field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, description, placeholder, - className, - otherProps: { enum: enumValues = [], isOptional }, + otherProps, labels = [], labelRight, + onValueChange, + onInputValueChange, + disabled, + multiple = false, + className, + showClear, + fetchData, + id, searchPlaceholder, + filter, ...props -}: ItemAutoFormComponentProps & - Omit, "role" | "variant"> & { - labels?: { label: string; value: string }[]; - placeholder?: string; - searchPlaceholder?: string; - }) => { +}: AutoFormComboboxProps) => { const t = useTranslations("core.global"); + const anchor = useComboboxAnchor(); + const isAsync = typeof fetchData === "function"; + const isMultiple = !isAsync && multiple; + const [search, setSearch] = React.useState(""); + const { data, isLoading } = useQuery({ + queryKey: [id ?? "combobox", { search }], + queryFn: async () => { + if (!fetchData) return []; - const values: { label: string; value: string }[] = enumValues.map(value => { - const label = labels.find(l => l.value === value)?.label; - - return { - value, - label: label ?? value, - }; + return await fetchData({ search }); + }, + enabled: isAsync, }); + const handleChangeSearch = useDebouncedCallback((value: string) => { + setSearch(value); + }, 500); + + const items = isAsync ? (data ?? []) : (otherProps?.enum ?? []); + const inputPlaceholder = isAsync + ? (searchPlaceholder ?? placeholder ?? t("select_option")) + : (placeholder ?? t("select_option")); + const getComboboxValue = () => { + if (isAsync) { + return field.value ?? null; + } + + if (isMultiple) { + return field.value ?? []; + } + + return field.value ?? null; + }; + const comboboxValue = getComboboxValue(); + const comboboxDefaultValue = comboboxValue; + const comboboxFilter = isAsync ? null : filter; + const comboboxItemEqual: React.ComponentProps< + typeof Combobox + >["isItemEqualToValue"] = isAsync + ? (itemValue, value) => { + const item = itemValue as ComboboxAsyncItem | null | undefined; + const currentValue = value as ComboboxAsyncItem | null | undefined; + + return item?.value === currentValue?.value; + } + : undefined; + const onComboboxInputValueChange = ( + value: string, + event: Parameters< + NonNullable["onInputValueChange"]> + >[1], + ) => { + if (isAsync) { + handleChangeSearch(value); + } + onInputValueChange?.(value, event); + }; + const onComboboxValueChange = ( + value: Parameters< + NonNullable["onValueChange"]> + >[0], + event: Parameters< + NonNullable["onValueChange"]> + >[1], + ) => { + field.onChange(value); + onValueChange?.(value, event); + }; + + const renderItems = () => { + if (isAsync) { + return ( + + {(item: ComboboxAsyncItem) => ( + + {item.label} + + )} + + ); + } + + return ( + + {(item: string) => ( + + {labels.find(l => l.value === item)?.label ?? item} + + )} + + ); + }; + + const renderContent = () => { + if (isAsync && isLoading) { + return ( +
+ + +
+ ); + } + + return ( + <> + {t("results_not_found")} + {renderItems()} + + ); + }; + return ( - + <> {label && ( - + {label} )} - - - - - - - - - - + {isMultiple ? ( + <> + + + {values => ( + <> + {values.map((value: string) => ( + + {labels.find(l => l.value === value)?.label ?? value} + + ))} + + + )} + + + {renderContent()} + + ) : ( + <> + - - {t("results_not_found")} - - {values.map(({ label, value }) => ( - { - field.onChange(value); - }} - value={label} - > - {label} - - - ))} - - - - - + {renderContent()} + + )} + {description && {description}} - + ); }; diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index bb8554754..6f9c00d52 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -1,6 +1,8 @@ +import { InputGroup, InputGroupInput } from "@/components/ui/input-group"; + import type { ItemAutoFormComponentProps } from "../auto-form"; -import { FormControl, FormItem, FormMessage } from "../../ui/form"; +import { FormControl, FormMessage } from "../../ui/form"; import { Input } from "../../ui/input"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; @@ -11,38 +13,67 @@ export const AutoFormInput = ({ description, otherProps: { isOptional, maxLength, minLength, pattern, type }, field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, + children, ...props }: ItemAutoFormComponentProps & Omit, "value">) => { return ( - + <> {label && ( {label} )} - - { - field.onBlur(); - props.onBlur?.(e); - }} - onChange={e => { - field.onChange(e); - props.onChange?.(e); - }} - pattern={pattern} - type={type ?? "text"} - value={field.value ?? ""} - {...props} - /> - + + {children ? ( + + + { + field.onBlur(); + props.onBlur?.(e); + }} + onChange={e => { + field.onChange(e); + props.onChange?.(e); + }} + pattern={pattern} + type={type ?? "text"} + value={field.value ?? ""} + {...props} + /> + + {children} + + ) : ( + + { + field.onBlur(); + props.onBlur?.(e); + }} + onChange={e => { + field.onChange(e); + props.onChange?.(e); + }} + pattern={pattern} + type={type ?? "text"} + value={field.value ?? ""} + {...props} + /> + + )} {description && {description}} - + ); }; diff --git a/packages/vitnode/src/components/form/fields/radio-group.tsx b/packages/vitnode/src/components/form/fields/radio-group.tsx index 1b5aa0994..78e934f12 100644 --- a/packages/vitnode/src/components/form/fields/radio-group.tsx +++ b/packages/vitnode/src/components/form/fields/radio-group.tsx @@ -1,11 +1,13 @@ import type React from "react"; import { - FormControl, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldTitle, +} from "@/components/ui/field"; +import { FormControl, FormMessage } from "@/components/ui/form"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -13,34 +15,48 @@ import type { ItemAutoFormComponentProps } from "../auto-form"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +interface ItemAutoFormRadioGroupLabelsProps { + description?: string; + disabled?: boolean; + label: string; + value: string; +} + export const AutoFormRadioGroup = ({ label, labelRight, field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, description, otherProps: { enum: enumValues = [], isOptional }, labels = [], + variant = "default", ...props }: ItemAutoFormComponentProps & Omit, "value"> & { - labels?: { label: string; value: string }[]; + labels?: ItemAutoFormRadioGroupLabelsProps[]; + variant?: "blocks" | "default"; }) => { - const values: { label: string; value: string }[] = enumValues.map(value => { - const label = labels.find(l => l.value === value)?.label; + const values: ItemAutoFormRadioGroupLabelsProps[] = enumValues.map(value => { + const item = labels.find(l => l.value === value); return { value, - label: label ?? value, + label: item?.label ?? value, + description: item?.description, + disabled: item?.disabled, }; }); return ( - + <> {label && ( {label} )} + {description && {description}} - {values.map(({ value, label }) => ( - - - - - {label} - - ))} + {values.map(({ value, label, description, disabled }) => + variant === "default" ? ( + + + + + + + {label} + + {description && ( + {description} + )} + + + ) : ( + + + + {label} + {description && ( + {description} + )} + + + + + + + ), + )} - {description && {description}} - + ); }; diff --git a/packages/vitnode/src/components/form/fields/select.tsx b/packages/vitnode/src/components/form/fields/select.tsx index 6597244fe..204153042 100644 --- a/packages/vitnode/src/components/form/fields/select.tsx +++ b/packages/vitnode/src/components/form/fields/select.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useTranslations } from "next-intl"; -import { FormControl, FormItem, FormMessage } from "@/components/ui/form"; +import { FormControl, FormMessage } from "@/components/ui/form"; import { Select, SelectContent, @@ -19,6 +19,8 @@ import { AutoFormLabel } from "../common/label"; export const AutoFormSelect = ({ label, field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, description, otherProps: { enum: enumValues = [], isOptional }, placeholder, @@ -45,7 +47,7 @@ export const AutoFormSelect = ({ t("select_option"); return ( - + <> {label && ( {label} @@ -83,6 +85,6 @@ export const AutoFormSelect = ({ {description && {description}} - + ); }; diff --git a/packages/vitnode/src/components/form/fields/switch.tsx b/packages/vitnode/src/components/form/fields/switch.tsx index b9a901d7a..91a3d3ed0 100644 --- a/packages/vitnode/src/components/form/fields/switch.tsx +++ b/packages/vitnode/src/components/form/fields/switch.tsx @@ -1,5 +1,6 @@ -import { FormControl, FormItem } from "@/components/ui/form"; +import { FormControl } from "@/components/ui/form"; import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -9,14 +10,22 @@ import { AutoFormLabel } from "../common/label"; export const AutoFormSwitch = ({ label, field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, labelRight, otherProps: { isOptional }, + className, description, ...props }: ItemAutoFormComponentProps & Omit, "checked">) => { return ( - +
{(label ?? description) && (
{label && ( @@ -42,6 +51,6 @@ export const AutoFormSwitch = ({ {...props} /> - +
); }; diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index 8bfb8df63..237531219 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -1,6 +1,7 @@ import type React from "react"; -import { FormControl, FormItem, FormMessage } from "@/components/ui/form"; +import { FormControl, FormMessage } from "@/components/ui/form"; +import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"; import { Textarea } from "@/components/ui/textarea"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -14,6 +15,9 @@ export const AutoFormTextarea = ({ labelRight, otherProps: { isOptional, maxLength, minLength }, field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, + children, ...props }: ItemAutoFormComponentProps & Omit, "value"> & { @@ -21,32 +25,54 @@ export const AutoFormTextarea = ({ label?: React.ReactNode; }) => { return ( - + <> {label && ( {label} )} - -