feat: relation field and picker

This commit is contained in:
Florian Sylvain committed 2026-10-23 00:13:42 +02:00
1 parent b45c99be3b
commit 37a77c9c22
92 files changed
+6826 -80

No files matched your search

@@ -13,6 +13,7 @@ var (
ErrInvalidIcon = errors.New("invalid content type icon")
ErrInvalidField = errors.New("invalid field")
ErrHasEntries = errors.New("the content type still has entries")
ErrReferenced = errors.New("the content type is the target of a relation field")
ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries")
)
@@ -26,6 +27,7 @@ const (
TypeDate FieldType = "date"
TypeSelect FieldType = "select"
TypeMedia FieldType = "media"
TypeRelation FieldType = "relation"
)
const (
@@ -54,6 +56,7 @@ type Field struct {
Min any `json:"min"`
Max any `json:"max"`
Options []string `json:"options"`
Relation *Relation `json:"relation"`
}
type ContentType struct {
@@ -21,6 +21,7 @@ var kinds = map[FieldType]kind{
TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true},
TypeSelect: {accepts: isString, unique: true},
TypeMedia: {accepts: isMediaID},
TypeRelation: {},
}
func MediaID(value any) (uint32, bool) {
+2 -2
View File
@@ -63,7 +63,7 @@ func (f Field) validSchema() bool {
if f.Unique && !fieldKind.unique {
return false
}
return f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
return f.validRelation() && f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
}
func (f Field) validOptions() bool {
@@ -109,7 +109,7 @@ func (f Field) validDefault() bool {
if f.Default == nil {
return true
}
if f.Type == TypeMedia {
if f.Type == TypeMedia || f.Type == TypeRelation {
return false
}
return !f.Unique && f.Violation(f.Default) == ""
@@ -3,6 +3,9 @@ package contenttype
import "slices"
func (f Field) Violation(value any) string {
if f.Type == TypeRelation {
return f.relationViolation(value)
}
fieldKind, known := kinds[f.Type]
if !known || !fieldKind.accepts(value) {
return RuleType
@@ -0,0 +1,134 @@
package contenttype
const MaxRelationIDs = 50
type RelationKind string
const (
RelationOneToOne RelationKind = "one_to_one"
RelationOneToMany RelationKind = "one_to_many"
RelationManyToMany RelationKind = "many_to_many"
)
type Relation struct {
ContentType string `json:"content_type"`
Kind RelationKind `json:"kind"`
}
func (r Relation) validKind() bool {
return r.Kind == RelationOneToOne || r.Kind == RelationOneToMany || r.Kind == RelationManyToMany
}
func (f Field) validRelation() bool {
if f.Type != TypeRelation {
return f.Relation == nil
}
return f.Relation != nil && f.Relation.ContentType != "" && f.Relation.validKind()
}
func (f Field) IsRelation() bool {
return f.Type == TypeRelation && f.Relation != nil
}
func (f Field) HoldsManyRelations() bool {
return f.IsRelation() && f.Relation.Kind == RelationManyToMany
}
func (f Field) IsExclusiveRelation() bool {
return f.IsRelation() && f.Relation.Kind == RelationOneToOne
}
func (f Field) RelationIDs(value any) ([]uint32, bool) {
if value == nil {
return []uint32{}, true
}
if !f.HoldsManyRelations() {
id, ok := MediaID(value)
if !ok {
return nil, false
}
return []uint32{id}, true
}
return manyRelationIDs(value)
}
func manyRelationIDs(value any) ([]uint32, bool) {
var items []any
switch typed := value.(type) {
case []any:
items = typed
case []uint32:
for _, id := range typed {
items = append(items, id)
}
default:
return nil, false
}
if len(items) > MaxRelationIDs {
return nil, false
}
ids := make([]uint32, 0, len(items))
seen := make(map[uint32]bool, len(items))
for _, item := range items {
id, ok := MediaID(item)
if !ok || seen[id] {
return nil, false
}
seen[id] = true
ids = append(ids, id)
}
return ids, true
}
func (f Field) relationViolation(value any) string {
if _, ok := f.RelationIDs(value); !ok {
return RuleType
}
return ""
}
func EmptyRelation(field Field, value any) bool {
if !field.HoldsManyRelations() {
return false
}
ids, ok := field.RelationIDs(value)
return ok && len(ids) == 0
}
type RelationRef struct {
Field Field
IDs []uint32
}
func RelationRefs(fields []Field, data map[string]any) []RelationRef {
var refs []RelationRef
for _, field := range RelationFields(fields) {
if ids, ok := field.RelationIDs(data[field.Key]); ok && len(ids) > 0 {
refs = append(refs, RelationRef{Field: field, IDs: ids})
}
}
return refs
}
func RelationFields(fields []Field) []Field {
var relations []Field
for _, field := range fields {
if field.IsRelation() {
relations = append(relations, field)
}
}
return relations
}
func ReferencesType(fields []Field, key string) bool {
for _, field := range RelationFields(fields) {
if field.Relation.ContentType == key {
return true
}
}
return false
}
func SameRelation(left Field, right Field) bool {
return left.IsRelation() && right.IsRelation() && *left.Relation == *right.Relation
}
+40 -2
View File
@@ -2,8 +2,11 @@ package entry
import (
"RenewCMS/internal/domain/contenttype"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
)
func SameValue(left any, right any) bool {
@@ -49,7 +52,7 @@ func Validate(fields []contenttype.Field, data map[string]any) error {
func validateField(field contenttype.Field, data map[string]any) error {
value := data[field.Key]
if value == nil {
if value == nil || contenttype.EmptyRelation(field, value) {
if field.Required {
return InvalidField(field.Key, RuleRequired)
}
@@ -82,7 +85,7 @@ func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue {
var values []UniqueValue
for _, field := range fields {
if value := data[field.Key]; field.Unique && value != nil {
if value := data[field.Key]; (field.Unique || field.IsExclusiveRelation()) && value != nil {
values = append(values, UniqueValue{Key: field.Key, Value: value})
}
}
@@ -103,3 +106,38 @@ func MissingMedia(field contenttype.Field, unchanged bool) error {
}
return InvalidField(field.Key, contenttype.RuleType)
}
func RelationIDs(fields []contenttype.Field, data map[string]any) map[string]map[uint32]bool {
ids := map[string]map[uint32]bool{}
for _, ref := range contenttype.RelationRefs(fields, data) {
held := make(map[uint32]bool, len(ref.IDs))
for _, id := range ref.IDs {
held[id] = true
}
ids[ref.Field.Key] = held
}
return ids
}
func MissingRelation(field contenttype.Field, unchanged bool) error {
return MissingMedia(field, unchanged)
}
func MatchesQuery(fields []contenttype.Field, data map[string]any, query string) bool {
if len(fields) == 0 {
return false
}
return strings.Contains(strings.ToLower(displayText(data[fields[0].Key])), strings.ToLower(query))
}
func displayText(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
return fmt.Sprint(value)
}