feat(12.2-03): add hasMany relation contracts, relation forms and child create
- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
This commit is contained in:
@@ -222,6 +222,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
|
||||
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true)
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
|
||||
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
|
||||
@@ -474,7 +477,7 @@ func dataID(t *testing.T, raw []byte) uint {
|
||||
func newConformEnv(t *testing.T) *conformEnv {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformSettings{}}
|
||||
models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformPart{}, &conformSettings{}}
|
||||
if err := gdb.Migrator().DropTable(models...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -543,6 +546,7 @@ type conformGadget struct {
|
||||
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
||||
StartsAt *time.Time `gorm:"column:starts_at"`
|
||||
Members []conformMember `gorm:"-"`
|
||||
Parts []conformPart `gorm:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
@@ -586,6 +590,20 @@ type conformGadgetMember struct {
|
||||
|
||||
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
|
||||
|
||||
// conformPart is the hasMany child of a gadget; its nullable gadget_id makes
|
||||
// the parts relation deferrable.
|
||||
type conformPart struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
GadgetID *uint `gorm:"column:gadget_id"`
|
||||
Label string `gorm:"column:label"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (conformPart) TableName() string { return "cabana_conform_parts" }
|
||||
func (conformPart) Fillable() []string { return []string{"label"} }
|
||||
func (conformPart) Rules() map[string]string { return map[string]string{"label": "required"} }
|
||||
|
||||
type conformSettings struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Enabled bool `gorm:"column:enabled"`
|
||||
@@ -630,6 +648,9 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
|
||||
return []cabana.RelationContract{{
|
||||
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
|
||||
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
||||
}, {
|
||||
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
|
||||
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
|
||||
}}
|
||||
}
|
||||
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
@@ -748,6 +769,20 @@ update:
|
||||
email:
|
||||
label: Email
|
||||
showSearch: true
|
||||
parts:
|
||||
label: Parts
|
||||
view:
|
||||
list:
|
||||
columns:
|
||||
label:
|
||||
label: Label
|
||||
toolbarButtons: create
|
||||
manage:
|
||||
form: $/acme/conform/models/part/fields.yaml
|
||||
list:
|
||||
columns:
|
||||
label:
|
||||
label: Label
|
||||
`),
|
||||
"models/gadget/columns.yaml": file(`columns:
|
||||
name:
|
||||
@@ -779,6 +814,9 @@ update:
|
||||
type: relation-manager
|
||||
relation: members
|
||||
context: [update]
|
||||
parts:
|
||||
type: relation-manager
|
||||
relation: parts
|
||||
lookup:
|
||||
label: Lookup
|
||||
type: widget
|
||||
@@ -814,6 +852,12 @@ update:
|
||||
fileTypes: [pdf, png, svg, txt]
|
||||
useCaption: true
|
||||
context: update
|
||||
`),
|
||||
"models/part/fields.yaml": file(`fields:
|
||||
label:
|
||||
label: Label
|
||||
type: text
|
||||
required: true
|
||||
`),
|
||||
"models/settings/fields.yaml": file(`fields:
|
||||
enabled:
|
||||
|
||||
Reference in New Issue
Block a user