Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions admin/table_reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions admin/table_reflect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 29 additions & 7 deletions datasource/gormds/gormds.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gormds

import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
18 changes: 10 additions & 8 deletions examples/server/models/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
24 changes: 23 additions & 1 deletion examples/server/models/models.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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"`
Expand All @@ -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"`
}
15 changes: 15 additions & 0 deletions examples/server/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,28 @@ 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{
Title: fmt.Sprintf("Post number %d about computing", i+1),
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 {
Expand Down
96 changes: 96 additions & 0 deletions packages/admin-next/src/components/array-templates.test.tsx
Original file line number Diff line number Diff line change
@@ -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<TemplatesType> = {
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(
<Form schema={arraySchema} validator={validator} templates={templates} />,
);
// The default RJSF AddButton renders only a glyphicon <i> 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(
<Form schema={arraySchema} validator={validator} templates={templates} />,
);
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(
<Form
schema={objectArraySchema}
validator={validator}
templates={templates}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /add/i }));

expect(screen.getByRole("textbox", { name: /name/i })).toBeTruthy();
expect(screen.getByRole("combobox", { name: /action/i })).toBeTruthy();
});
});
Loading