diff --git a/admin/table_reflect.go b/admin/table_reflect.go index b4bbccc..08fb451 100644 --- a/admin/table_reflect.go +++ b/admin/table_reflect.go @@ -102,18 +102,11 @@ func ModelToTableColumns(model any, options *TableColumnOptions) ([]TableColumn, return columns, nil } -// reflectSchema runs invopop reflection and resolves the root $ref so callers -// get the actual object schema. +// reflectSchema runs invopop reflection and inlines nested definitions so RJSF +// receives self-contained object schemas for arrays and nested structs. func reflectSchema(model any) *jsonschema.Schema { - r := new(jsonschema.Reflector) - schema := r.Reflect(model) - if schema.Ref != "" && len(schema.Ref) > len("#/$defs/") && schema.Definitions != nil { - defName := schema.Ref[len("#/$defs/"):] - if def, ok := schema.Definitions[defName]; ok { - return def - } - } - return schema + r := &jsonschema.Reflector{DoNotReference: true} + return r.Reflect(model) } func extractFieldInfos(t reflect.Type, schema *jsonschema.Schema) ([]fieldInfo, error) { diff --git a/admin/table_reflect_test.go b/admin/table_reflect_test.go index 458a310..d52ff47 100644 --- a/admin/table_reflect_test.go +++ b/admin/table_reflect_test.go @@ -22,6 +22,43 @@ type sampleModel struct { Internal string `json:"-"` } +type sampleRule struct { + Name string `json:"name" jsonschema:"title=Name"` + Action string `json:"action" jsonschema:"title=Action,enum=allow,enum=deny"` +} + +type sampleArrayObjectForm struct { + Rules []sampleRule `json:"rules" jsonschema:"title=Rules"` +} + +func TestReflectSchema_InlinesArrayObjectItems(t *testing.T) { + schema := reflectSchema(sampleArrayObjectForm{}) + rules, ok := schema.Properties.Get("rules") + if !ok { + t.Fatal("rules property missing") + } + if rules.Type != "array" { + t.Fatalf("rules type = %q, want array", rules.Type) + } + if rules.Items == nil { + t.Fatal("rules items missing") + } + if rules.Items.Ref != "" { + t.Fatalf("rules items ref = %q, want inlined object schema", rules.Items.Ref) + } + if rules.Items.Type != "object" { + t.Fatalf("rules items type = %q, want object", rules.Items.Type) + } + if _, ok := rules.Items.Properties.Get("name"); !ok { + t.Fatal("rules item name property missing") + } + if action, ok := rules.Items.Properties.Get("action"); !ok { + t.Fatal("rules item action property missing") + } else if len(action.Enum) != 2 { + t.Fatalf("rules item action enum len = %d, want 2", len(action.Enum)) + } +} + func TestModelToTableColumns(t *testing.T) { cols, err := ModelToTableColumns(&sampleModel{}, nil) if err != nil { diff --git a/datasource/gormds/gormds.go b/datasource/gormds/gormds.go index bc1c8f6..222bf45 100644 --- a/datasource/gormds/gormds.go +++ b/datasource/gormds/gormds.go @@ -3,6 +3,7 @@ package gormds import ( "context" + "encoding/json" "errors" "fmt" "reflect" @@ -172,19 +173,32 @@ func (d *DataSource[T]) Update(ctx context.Context, id string, patch map[string] if err := d.parseSchema(); err != nil { return zero, err } - // Translate JSON field names to column names; unknown keys are dropped so - // clients cannot write columns that aren't exposed on the model. - columnPatch := make(map[string]any, len(patch)) + // Keep only JSON field names exposed by the model. Update through a typed + // struct patch instead of a raw column map so GORM field metadata still + // applies, including serializers such as `serializer:json`. + modelPatchData := make(map[string]any, len(patch)) + selectedFields := make([]string, 0, len(patch)) for k, v := range patch { - if col, ok := d.jsonToColumn[k]; ok && col != d.pkColumn { - columnPatch[col] = v + col, ok := d.jsonToColumn[k] + if !ok || col == d.pkColumn { + continue } + field := d.jsonToField[k] + if field == "" { + continue + } + modelPatchData[k] = v + selectedFields = append(selectedFields, field) } - if len(columnPatch) > 0 { + if len(modelPatchData) > 0 { var model T + if err := convertVia(modelPatchData, &model); err != nil { + return zero, err + } res := d.db.WithContext(ctx).Model(&model). Where(fmt.Sprintf("%s = ?", d.pkColumn), id). - Updates(columnPatch) + Select(selectedFields). + Updates(&model) if res.Error != nil { return zero, res.Error } @@ -195,6 +209,14 @@ func (d *DataSource[T]) Update(ctx context.Context, id string, patch map[string] return d.Get(ctx, id) } +func convertVia(from, to any) error { + b, err := json.Marshal(from) + if err != nil { + return err + } + return json.Unmarshal(b, to) +} + func (d *DataSource[T]) Delete(ctx context.Context, id string) error { if err := d.parseSchema(); err != nil { return err diff --git a/examples/server/models/dto.go b/examples/server/models/dto.go index 78cccec..3cc5f43 100644 --- a/examples/server/models/dto.go +++ b/examples/server/models/dto.go @@ -21,15 +21,17 @@ type UpdateAuthorInput struct { // ForeignKey widget, which the frontend renders as a search-select backed by // the "authors" resource's search action. type CreatePostInput struct { - Title string `json:"title" jsonschema:"title=Title,required" validate:"required"` - Status string `json:"status" jsonschema:"title=Status,enum=draft,enum=published,enum=archived,default=draft" validate:"required,oneof=draft published archived"` - AuthorID uint `json:"authorId" jsonschema:"title=Author,required" validate:"required" uischema:"widget=ForeignKey;ui:options:resource=authors;ui:options:placeholder=Search authors"` - Color string `json:"color,omitempty" jsonschema:"title=Label Color" uischema:"widget=color"` + Title string `json:"title" jsonschema:"title=Title,required" validate:"required"` + Status string `json:"status" jsonschema:"title=Status,enum=draft,enum=published,enum=archived,default=draft" validate:"required,oneof=draft published archived"` + AuthorID uint `json:"authorId" jsonschema:"title=Author,required" validate:"required" uischema:"widget=ForeignKey;ui:options:resource=authors;ui:options:placeholder=Search authors"` + Color string `json:"color,omitempty" jsonschema:"title=Label Color" uischema:"widget=color"` + Tags []PostTag `json:"tags,omitempty" jsonschema:"title=Tags,description=Structured labels. Use Add to append a tag object." validate:"dive"` } type UpdatePostInput struct { - Title string `json:"title" jsonschema:"title=Title,required" validate:"required"` - Status string `json:"status" jsonschema:"title=Status,enum=draft,enum=published,enum=archived" validate:"required,oneof=draft published archived"` - AuthorID uint `json:"authorId" jsonschema:"title=Author,required" validate:"required" uischema:"widget=ForeignKey;ui:options:resource=authors;ui:options:placeholder=Search authors"` - Color string `json:"color,omitempty" jsonschema:"title=Label Color" uischema:"widget=color"` + Title string `json:"title" jsonschema:"title=Title,required" validate:"required"` + Status string `json:"status" jsonschema:"title=Status,enum=draft,enum=published,enum=archived" validate:"required,oneof=draft published archived"` + AuthorID uint `json:"authorId" jsonschema:"title=Author,required" validate:"required" uischema:"widget=ForeignKey;ui:options:resource=authors;ui:options:placeholder=Search authors"` + Color string `json:"color,omitempty" jsonschema:"title=Label Color" uischema:"widget=color"` + Tags []PostTag `json:"tags,omitempty" jsonschema:"title=Tags,description=Structured labels. Use Add to append a tag object." validate:"dive"` } diff --git a/examples/server/models/models.go b/examples/server/models/models.go index 515e2c0..c87133d 100644 --- a/examples/server/models/models.go +++ b/examples/server/models/models.go @@ -1,7 +1,10 @@ // Package models defines the demo entities exposed through the admin framework. package models -import "time" +import ( + "encoding/json" + "time" +) // Author is a blog author. The `table:` tags drive the admin list view and the // `jsonschema:` tags feed the form schema when Author is edited directly. @@ -14,6 +17,24 @@ type Author struct { CreatedAt time.Time `json:"createdAt" jsonschema:"title=Joined,format=date-time" table:"order=4;format=date-time"` } +// PostTag demonstrates an array-of-object form field. +type PostTag struct { + Name string `json:"name" jsonschema:"title=Name,required" validate:"required"` + Color string `json:"color,omitempty" jsonschema:"title=Color" uischema:"widget=color"` +} + +// UnmarshalJSON keeps old demo databases readable when they contain the former +// string tag shape, e.g. ["computing"]. +func (t *PostTag) UnmarshalJSON(data []byte) error { + var name string + if err := json.Unmarshal(data, &name); err == nil { + t.Name = name + return nil + } + type alias PostTag + return json.Unmarshal(data, (*alias)(t)) +} + // Post is a blog post belonging to an Author. type Post struct { ID uint `gorm:"primaryKey" json:"id" jsonschema:"title=ID" table:"order=0;pinned=true;width=60"` @@ -22,5 +43,6 @@ type Post struct { AuthorID uint `gorm:"index" json:"authorId" jsonschema:"title=Author" table:"order=3"` Author Author `gorm:"foreignKey:AuthorID" json:"author" table:"order=4;valuefrom={{.Author.Name}}"` Color string `gorm:"type:varchar(20)" json:"color" jsonschema:"title=Label Color" table:"order=5;format=color;width=90"` + Tags []PostTag `gorm:"serializer:json" json:"tags" jsonschema:"title=Tags" table:"omit"` CreatedAt time.Time `json:"createdAt" jsonschema:"title=Created,format=date-time" table:"order=6;format=date-time"` } diff --git a/examples/server/seed.go b/examples/server/seed.go index 59682d1..fb52805 100644 --- a/examples/server/seed.go +++ b/examples/server/seed.go @@ -27,6 +27,20 @@ func seed(db *gorm.DB) { statuses := []string{"draft", "published", "archived"} colors := []string{"#ef4444", "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6"} + tagSets := [][]models.PostTag{ + { + {Name: "computing", Color: "#3b82f6"}, + {Name: "history", Color: "#f59e0b"}, + }, + { + {Name: "algorithms", Color: "#10b981"}, + }, + { + {Name: "hardware", Color: "#ef4444"}, + {Name: "compilers", Color: "#8b5cf6"}, + {Name: "featured", Color: "#f59e0b"}, + }, + } var posts []models.Post for i := 0; i < 25; i++ { posts = append(posts, models.Post{ @@ -34,6 +48,7 @@ func seed(db *gorm.DB) { Status: statuses[i%len(statuses)], AuthorID: authors[i%len(authors)].ID, Color: colors[i%len(colors)], + Tags: tagSets[i%len(tagSets)], }) } if err := db.Create(&posts).Error; err != nil { diff --git a/packages/admin-next/src/components/array-templates.test.tsx b/packages/admin-next/src/components/array-templates.test.tsx new file mode 100644 index 0000000..daff339 --- /dev/null +++ b/packages/admin-next/src/components/array-templates.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import Form from "@rjsf/core"; +import type { TemplatesType } from "@rjsf/utils"; +import validator from "@rjsf/validator-ajv8"; +import { + AddButton, + ArrayFieldItemTemplate, + CopyButton, + MoveDownButton, + MoveUpButton, + RemoveButton, +} from "./array-templates.js"; + +// Mirror the ButtonTemplates wiring from resource-form so the test exercises +// the same registry the real form uses. +const templates: Partial = { + ArrayFieldItemTemplate, + ButtonTemplates: { + AddButton, + RemoveButton, + MoveUpButton, + MoveDownButton, + CopyButton, + } as TemplatesType["ButtonTemplates"], +}; + +const arraySchema = { + type: "object" as const, + properties: { + tags: { type: "array" as const, title: "Tags", items: { type: "string" as const } }, + }, +}; + +const objectArraySchema = { + type: "object" as const, + properties: { + rules: { + type: "array" as const, + title: "Rules", + items: { + type: "object" as const, + required: ["name"], + properties: { + name: { type: "string" as const, title: "Name" }, + action: { + type: "string" as const, + title: "Action", + enum: ["allow", "deny"], + }, + }, + }, + }, + }, +}; + +describe("array-templates", () => { + it("renders a visible Add button (not the invisible glyphicon default)", () => { + render( +
, + ); + // The default RJSF AddButton renders only a glyphicon with no text; ours + // renders an accessible, labeled button. + const add = screen.getByRole("button", { name: /add/i }); + expect(add).toBeTruthy(); + expect(add.textContent).toContain("Add"); + }); + + it("adds a row with a remove control when Add is clicked", () => { + render( + , + ); + expect(screen.queryByRole("textbox")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /add/i })); + + // A new array item (text input) plus its Remove button should appear. + expect(screen.getByRole("textbox")).toBeTruthy(); + expect(screen.getByRole("button", { name: /remove/i })).toBeTruthy(); + }); + + it("renders array object items as object fields", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /add/i })); + + expect(screen.getByRole("textbox", { name: /name/i })).toBeTruthy(); + expect(screen.getByRole("combobox", { name: /action/i })).toBeTruthy(); + }); +}); diff --git a/packages/admin-next/src/components/array-templates.tsx b/packages/admin-next/src/components/array-templates.tsx new file mode 100644 index 0000000..54e265f --- /dev/null +++ b/packages/admin-next/src/components/array-templates.tsx @@ -0,0 +1,133 @@ +"use client"; + +import * as React from "react"; +import type { + ArrayFieldItemTemplateProps, + IconButtonProps, +} from "@rjsf/utils"; +import { ChevronDown, ChevronUp, Copy, Plus, Trash2 } from "lucide-react"; +import { Button } from "./ui.js"; + +/** + * RJSF ships default array Add/Remove/Move/Copy buttons that rely on Bootstrap + * grid + glyphicon icon-font classes. In a shadcn/Tailwind app those classes + * don't exist, so the buttons render with no visible glyph or label — the array + * field looks like it has "no Add button". These templates re-implement the + * button set with the package's own shadcn + ); +} + +/** Compact icon-only per-row control shared by remove/move/copy. */ +function ItemIconButton({ + label, + children, + variant = "secondary", + props, +}: { + label: string; + children: React.ReactNode; + variant?: "secondary" | "danger"; + props: IconButtonProps; +}): React.JSX.Element { + const { className, ...rest } = iconButtonProps(props); + return ( + + ); +} + +export function RemoveButton(props: IconButtonProps): React.JSX.Element { + return ( + + + + ); +} + +export function MoveUpButton(props: IconButtonProps): React.JSX.Element { + return ( + + + + ); +} + +export function MoveDownButton(props: IconButtonProps): React.JSX.Element { + return ( + + + + ); +} + +export function CopyButton(props: IconButtonProps): React.JSX.Element { + return ( + + + + ); +} + +/** + * Card layout for a single array item. Replaces RJSF's default Bootstrap + * `col-xs-*` grid classes with a shadowless card and an in-card toolbar. + */ +export function ArrayFieldItemTemplate( + props: ArrayFieldItemTemplateProps, +): React.JSX.Element { + const { children, className, buttonsProps, hasToolbar, registry } = props; + const { ArrayFieldItemButtonsTemplate } = registry.templates; + return ( +
+ {hasToolbar && ( +
+ +
+ )} + {hasToolbar && ( +
+ )} +
{children}
+
+ ); +} diff --git a/packages/admin-next/src/components/resource-form.tsx b/packages/admin-next/src/components/resource-form.tsx index 56b2b05..a2a68e5 100644 --- a/packages/admin-next/src/components/resource-form.tsx +++ b/packages/admin-next/src/components/resource-form.tsx @@ -8,10 +8,18 @@ import type { TemplatesType, } from "@rjsf/utils"; import validator from "@rjsf/validator-ajv8"; -import type { FormSchema } from "../types.js"; +import type { ActionResponse, FormSchema } from "../types.js"; import { isDetail } from "../types.js"; import type { AdminActions } from "../server/actions.js"; import { ForeignKeyWidget } from "./widgets/foreign-key.js"; +import { + AddButton, + ArrayFieldItemTemplate, + CopyButton, + MoveDownButton, + MoveUpButton, + RemoveButton, +} from "./array-templates.js"; import { Button } from "./ui.js"; /** Keep only the keys declared in the form schema's `properties`. */ @@ -30,6 +38,52 @@ function pickSchemaKeys( return out; } +type SchemaNode = { + type?: string | string[]; + properties?: Record; + items?: SchemaNode; +}; + +function normalizeFormDataForSchema( + data: Record, + schema: FormSchema, +): Record { + return normalizeValueForSchema(data, schema.schema as SchemaNode) as Record< + string, + unknown + >; +} + +function normalizeValueForSchema(value: unknown, schema?: SchemaNode): unknown { + if (!schema) return value; + if (schemaHasType(schema, "array")) { + if (value === null || value === undefined) return []; + if (Array.isArray(value)) { + return value.map((item) => normalizeValueForSchema(item, schema.items)); + } + return value; + } + if ( + schemaHasType(schema, "object") && + value && + typeof value === "object" && + !Array.isArray(value) + ) { + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + out[key] = normalizeValueForSchema(item, schema.properties?.[key]); + } + return out; + } + return value; +} + +function schemaHasType(schema: SchemaNode, type: string): boolean { + return Array.isArray(schema.type) + ? schema.type.includes(type) + : schema.type === type; +} + const widgets: RegistryWidgetsType = { ForeignKey: ForeignKeyWidget, // ObjectSearch reuses the ForeignKey combo-search behavior. @@ -55,6 +109,17 @@ function ErrorListTemplate({ errors }: ErrorListProps): React.JSX.Element { const templates: Partial = { ErrorListTemplate, + // RJSF's default array buttons use Bootstrap glyphicon/grid classes that are + // invisible in this shadcn/Tailwind theme. Swap in shadcn-styled ones so + // array fields (Add row / remove / reorder) are usable. + ArrayFieldItemTemplate, + ButtonTemplates: { + AddButton, + RemoveButton, + MoveUpButton, + MoveDownButton, + CopyButton, + } as TemplatesType["ButtonTemplates"], }; export interface ResourceFormProps { @@ -63,7 +128,8 @@ export interface ResourceFormProps { dynamicPath?: string; schema: FormSchema; actions: AdminActions; - onDone: () => void; + onDone: (response?: ActionResponse) => void; + onDirtyChange?: (dirty: boolean) => void; } export function ResourceForm({ @@ -73,12 +139,31 @@ export function ResourceForm({ schema, actions, onDone, + onDirtyChange, }: ResourceFormProps): React.JSX.Element { const [formData, setFormData] = React.useState>({}); const [extraErrors, setExtraErrors] = React.useState>(); const [submitError, setSubmitError] = React.useState(); const [submitting, setSubmitting] = React.useState(false); const [loading, setLoading] = React.useState(action === "edit"); + const initialFormDataRef = React.useRef>({}); + + const reportDirty = React.useCallback( + (nextData: Record) => { + const dirty = + action === "create" + ? hasFormData(nextData) + : !formDataEquals(nextData, initialFormDataRef.current); + onDirtyChange?.(dirty); + }, + [action, onDirtyChange], + ); + + React.useEffect(() => { + if (action !== "create") return; + initialFormDataRef.current = {}; + onDirtyChange?.(false); + }, [action, onDirtyChange, schema]); // Prefill on edit by fetching the current record. Only keep keys the form // schema declares: the backend returns the full model (id, timestamps, @@ -90,7 +175,13 @@ export function ResourceForm({ actions.fetchAction(resourceId, "edit", { dynamicPath }).then((res) => { if (!active) return; if (res.ok && isDetail(res.data)) { - setFormData(pickSchemaKeys(res.data.data, schema)); + const picked = normalizeFormDataForSchema( + pickSchemaKeys(res.data.data, schema), + schema, + ); + initialFormDataRef.current = picked; + setFormData(picked); + onDirtyChange?.(false); } setLoading(false); }); @@ -106,7 +197,8 @@ export function ResourceForm({ const res = await actions.submitAction(resourceId, action, data, dynamicPath); setSubmitting(false); if (res.ok) { - onDone(); + onDirtyChange?.(false); + onDone(res.data); return; } if (res.fieldErrors) { @@ -136,7 +228,11 @@ export function ResourceForm({ templates={templates} validator={validator} extraErrors={extraErrors as never} - onChange={(e) => setFormData(e.formData as Record)} + onChange={(e) => { + const nextData = e.formData as Record; + setFormData(nextData); + reportDirty(nextData); + }} onSubmit={(e) => submit(e.formData as Record)} showErrorList="top" > @@ -152,3 +248,26 @@ export function ResourceForm({
); } + +function hasFormData(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0; + if (value && typeof value === "object") { + return Object.values(value).some(hasFormData); + } + if (typeof value === "string") return value.trim().length > 0; + return value !== undefined && value !== null; +} + +function formDataEquals(a: unknown, b: unknown): boolean { + return JSON.stringify(sortObjectKeys(a)) === JSON.stringify(sortObjectKeys(b)); +} + +function sortObjectKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortObjectKeys); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, sortObjectKeys(item)]), + ); +} diff --git a/packages/admin-next/src/components/resource-view.tsx b/packages/admin-next/src/components/resource-view.tsx index 69f3270..ea7ce2d 100644 --- a/packages/admin-next/src/components/resource-view.tsx +++ b/packages/admin-next/src/components/resource-view.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import type { + ActionResponse, ActionButton, ActionType, DetailResponse, @@ -14,6 +15,7 @@ import type { } from "../types.js"; import { isCustomResourcePage, + isDetail, isFormSchema, isPaginated, isTableSchema, @@ -56,6 +58,7 @@ export function ResourceView(props: ResourceViewProps): React.JSX.Element { ); const [currentPageUrl, setCurrentPageUrl] = React.useState(initialPageUrl); const [sheet, setSheet] = React.useState(null); + const [sheetHasUnsavedData, setSheetHasUnsavedData] = React.useState(false); const [pending, setPending] = React.useState(false); const fetchPage = React.useCallback( @@ -161,6 +164,7 @@ export function ResourceView(props: ResourceViewProps): React.JSX.Element { } const dynamicPath = extractDynamicPath(button.onClick); if (button.actionType === "edit") { + setSheetHasUnsavedData(false); setSheet({ action: "edit", dynamicPath }); } else if (button.actionType === "delete") { const label = String(row.title ?? row.name ?? row.id ?? "this item"); @@ -190,7 +194,12 @@ export function ResourceView(props: ResourceViewProps): React.JSX.Element { description={resource?.description} /> {resource?.supportedActions?.some((a) => a.actionType === "create") && ( - )} @@ -223,7 +232,16 @@ export function ResourceView(props: ResourceViewProps): React.JSX.Element { ? `Edit ${resource?.name ?? ""}` : `Create ${resource?.name ?? ""}` } - onClose={() => setSheet(null)} + onClose={() => { + if ( + sheetHasUnsavedData && + !confirm("Close this form? This will remove all data you entered.") + ) { + return; + } + setSheetHasUnsavedData(false); + setSheet(null); + }} > {sheet && ( { + onDirtyChange={setSheetHasUnsavedData} + onDone={(response) => { + if (sheet.action === "edit" && response && isDetail(response)) { + setData((current) => + current + ? { + ...current, + items: current.items.map((item) => + item.dynamicPath === sheet.dynamicPath + ? { ...item, data: response.data } + : item, + ), + } + : current, + ); + } + setSheetHasUnsavedData(false); setSheet(null); refresh(); }} @@ -322,13 +356,15 @@ function SheetForm({ action, dynamicPath, actions, + onDirtyChange, onDone, }: { resourceId: string; action: "create" | "edit"; dynamicPath?: string; actions: AdminActions; - onDone: () => void; + onDirtyChange: (dirty: boolean) => void; + onDone: (response?: ActionResponse) => void; }): React.JSX.Element { const [schema, setSchema] = React.useState(null); const [error, setError] = React.useState(); @@ -356,6 +392,7 @@ function SheetForm({ dynamicPath={dynamicPath} schema={schema} actions={actions} + onDirtyChange={onDirtyChange} onDone={onDone} /> ); diff --git a/packages/admin-next/src/components/ui.tsx b/packages/admin-next/src/components/ui.tsx index b54f010..d5145ed 100644 --- a/packages/admin-next/src/components/ui.tsx +++ b/packages/admin-next/src/components/ui.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", - icon: "size-8", + icon: "size-9", }, }, defaultVariants: { variant: "primary", size: "default" },