feat: Organisations admin screen with the avatar and the members manager

- controller golem15.user.organisations behind golem15.users.access_users
- list and form (name, slug preset from the name, description, avatar)
- members relation manager: link sets and unlink clears users.organisation_id
- deleting an organisation releases its members first
- Organisation gains Fillable, Rules, MorphName, AttachRelations, BeforeValidate
  and the Members relation; models.Slugify
- navigation side item organisations
- TestAdminOrganisations, TestAdminOrganisationMembers, TestSlugify
This commit is contained in:
Jakub Zych
2026-10-05 13:58:54 +02:00
parent a3992879db
commit 58595fef27
16 changed files with 828 additions and 5 deletions

View File

@@ -1,9 +1,16 @@
package models
import "time"
import (
"time"
// Organisation is the structure-only FK target for Fonoteka org credentials.
// Roles and membership behavior belong to Phase 7 AUTH-02.
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm"
)
// Organisation is a row of golem15_user_organisations, the PHP Organisation
// model: a named group of users (users.organisation_id) that other plugins
// hang organisation-level data on. The table is shared with those plugins
// and gets no column from the admin screen.
type Organisation struct {
ID uint `gorm:"column:id;primaryKey"`
Slug string `gorm:"column:slug"`
@@ -11,10 +18,50 @@ type Organisation struct {
Description *string `gorm:"column:description"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
// Members is the PHP hasMany members relation: the users whose
// organisation_id is this organisation. It is not a column. It is loaded
// only on request, written only by the admin members manager (which sets
// and clears users.organisation_id), and never serialized.
Members []User `gorm:"foreignKey:OrganisationID;references:ID" json:"-"`
}
func (Organisation) TableName() string { return "golem15_user_organisations" }
// MorphName is the PHP class string stored in system_files.attachment_type.
func (Organisation) MorphName() string { return `Golem15\User\Models\Organisation` }
// OrganisationAvatarField is the attachOne relation name of the
// organisation's picture.
const OrganisationAvatarField = "avatar"
// AttachRelations declares the public attachOne avatar (PHP $attachOne).
func (Organisation) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: OrganisationAvatarField, Public: true}}
}
// Fillable is the PHP $fillable list.
func (Organisation) Fillable() []string {
return []string{"name", "slug", "description"}
}
// Rules is the PHP rule set without alpha_dash on slug, which the rule
// engine does not know: the admin controller checks the slug's characters.
func (Organisation) Rules() map[string]string {
return map[string]string{
"name": "required|max:255",
"slug": "required|unique:golem15_user_organisations",
"description": "nullable|max:5000",
}
}
// BeforeValidate fills an empty slug from the name, the PHP beforeValidate.
func (o *Organisation) BeforeValidate(_ *gorm.DB) error {
if o.Slug == "" && o.Name != "" {
o.Slug = Slugify(o.Name)
}
return nil
}
func init() {
Register(Organisation{})
}

View File

@@ -0,0 +1,14 @@
columns:
id:
label: golem15.user::lang.organisation.id
invisible: true
name:
label: golem15.user::lang.organisation.name
searchable: true
slug:
label: golem15.user::lang.organisation.slug
searchable: true
created_at:
label: backend::lang.list.column_created
type: datetime
sortable: true

View File

@@ -0,0 +1,30 @@
fields:
name:
label: golem15.user::lang.organisation.name
type: text
span: left
required: true
slug:
label: golem15.user::lang.organisation.slug
comment: golem15.user::lang.organisation.slug_comment
type: text
span: right
preset:
field: name
type: slug
description:
label: golem15.user::lang.organisation.description
type: textarea
size: small
avatar:
label: golem15.user::lang.organisation.avatar
type: fileupload
mode: image
imageWidth: 120
imageHeight: 120
members:
type: relation-manager
relation: members
tab: golem15.user::lang.organisation.members
span: full
context: update

26
models/slug.go Normal file
View File

@@ -0,0 +1,26 @@
package models
import "strings"
// Slugify turns text into a slug by the rule the admin form's slug preset
// uses: lower-case ASCII letters and digits are kept, every run of other
// characters becomes one hyphen, and there is no hyphen at either end.
// Letters outside ASCII are not transliterated; they count as other
// characters. A text without a letter or digit gives the empty string.
func Slugify(text string) string {
var b strings.Builder
b.Grow(len(text))
gap := false
for _, r := range strings.ToLower(text) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
if gap && b.Len() > 0 {
b.WriteByte('-')
}
gap = false
b.WriteRune(r)
continue
}
gap = true
}
return b.String()
}