Compare commits
1
Commits
master
...
issue-40-tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7923b7c0 |
@@ -207,6 +207,10 @@ func (se *SchemaEditor) showDomainEditor(index int, domain *models.Domain) {
|
|||||||
se.showDomainList()
|
se.showDomainList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
form.AddButton("Tables", func() {
|
||||||
|
se.showDomainTables(index)
|
||||||
|
})
|
||||||
|
|
||||||
form.AddButton("Delete", func() {
|
form.AddButton("Delete", func() {
|
||||||
se.showDeleteDomainConfirm(index)
|
se.showDeleteDomainConfirm(index)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ func (se *SchemaEditor) createMainMenu() tview.Primitive {
|
|||||||
AddItem("Manage Domains", "View, create, edit, and delete domains", 'd', func() {
|
AddItem("Manage Domains", "View, create, edit, and delete domains", 'd', func() {
|
||||||
se.showDomainList()
|
se.showDomainList()
|
||||||
}).
|
}).
|
||||||
|
AddItem("Manage Indexes", "View, create, edit, and delete table indexes", 'x', func() {
|
||||||
|
se.showObjectList(se.indexKind())
|
||||||
|
}).
|
||||||
|
AddItem("Manage Views", "View, create, edit, and delete views", 'v', func() {
|
||||||
|
se.showObjectList(se.viewKind())
|
||||||
|
}).
|
||||||
|
AddItem("Manage Sequences", "View, create, edit, and delete sequences", 'u', func() {
|
||||||
|
se.showObjectList(se.sequenceKind())
|
||||||
|
}).
|
||||||
|
AddItem("Manage Scripts", "View, create, edit, and delete SQL scripts", 'c', func() {
|
||||||
|
se.showObjectList(se.scriptKind())
|
||||||
|
}).
|
||||||
AddItem("Import & Merge", "Import and merge schema from another database", 'i', func() {
|
AddItem("Import & Merge", "Import and merge schema from another database", 'i', func() {
|
||||||
se.showImportScreen()
|
se.showImportScreen()
|
||||||
}).
|
}).
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Data operations for indexes, views, sequences, scripts and domain/table assignment.
|
||||||
|
|
||||||
|
func (se *SchemaEditor) schemaAt(schemaIndex int) (*models.Schema, error) {
|
||||||
|
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||||
|
return nil, errors.New("schema not found")
|
||||||
|
}
|
||||||
|
return se.db.Schemas[schemaIndex], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) tableAt(schemaIndex, tableIndex int) (*models.Schema, *models.Table, error) {
|
||||||
|
schema, err := se.schemaAt(schemaIndex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if tableIndex < 0 || tableIndex >= len(schema.Tables) {
|
||||||
|
return nil, nil, errors.New("table not found")
|
||||||
|
}
|
||||||
|
return schema, schema.Tables[tableIndex], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitList splits a comma separated list, trimming blanks and dropping empty entries.
|
||||||
|
func splitList(s string) []string {
|
||||||
|
parts := make([]string, 0)
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
parts = append(parts, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveIndex adds an index to a table. When oldName is non-empty the index of that
|
||||||
|
// name is replaced (and renamed if needed).
|
||||||
|
func (se *SchemaEditor) SaveIndex(schemaIndex, tableIndex int, oldName string, idx *models.Index) error {
|
||||||
|
schema, table, err := se.tableAt(schemaIndex, tableIndex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idx.Name = strings.TrimSpace(idx.Name)
|
||||||
|
if idx.Name == "" {
|
||||||
|
return errors.New("index name is required")
|
||||||
|
}
|
||||||
|
if len(idx.Columns) == 0 {
|
||||||
|
return errors.New("index needs at least one column")
|
||||||
|
}
|
||||||
|
for _, c := range idx.Columns {
|
||||||
|
if _, ok := table.Columns[c]; !ok {
|
||||||
|
return fmt.Errorf("column %q not found in table %s", c, table.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists := table.Indexes[idx.Name]; exists && idx.Name != oldName {
|
||||||
|
return fmt.Errorf("index %q already exists", idx.Name)
|
||||||
|
}
|
||||||
|
if table.Indexes == nil {
|
||||||
|
table.Indexes = make(map[string]*models.Index)
|
||||||
|
}
|
||||||
|
if oldName != "" {
|
||||||
|
delete(table.Indexes, oldName)
|
||||||
|
}
|
||||||
|
idx.Table = table.Name
|
||||||
|
idx.Schema = schema.Name
|
||||||
|
table.Indexes[idx.Name] = idx
|
||||||
|
table.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIndex removes an index from a table.
|
||||||
|
func (se *SchemaEditor) DeleteIndex(schemaIndex, tableIndex int, name string) bool {
|
||||||
|
_, table, err := se.tableAt(schemaIndex, tableIndex)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := table.Indexes[name]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(table.Indexes, name)
|
||||||
|
table.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveView adds a view to a schema, or replaces the one at position at (use -1 to add).
|
||||||
|
func (se *SchemaEditor) SaveView(schemaIndex, at int, v *models.View) error {
|
||||||
|
schema, err := se.schemaAt(schemaIndex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v.Name = strings.TrimSpace(v.Name)
|
||||||
|
if v.Name == "" {
|
||||||
|
return errors.New("view name is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(v.Definition) == "" {
|
||||||
|
return errors.New("view definition is required")
|
||||||
|
}
|
||||||
|
for i, o := range schema.Views {
|
||||||
|
if i != at && o.Name == v.Name {
|
||||||
|
return fmt.Errorf("view %q already exists", v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.Schema = schema.Name
|
||||||
|
if at >= 0 && at < len(schema.Views) {
|
||||||
|
schema.Views[at] = v
|
||||||
|
} else {
|
||||||
|
schema.Views = append(schema.Views, v)
|
||||||
|
}
|
||||||
|
schema.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteView removes the view at position at.
|
||||||
|
func (se *SchemaEditor) DeleteView(schemaIndex, at int) bool {
|
||||||
|
schema, err := se.schemaAt(schemaIndex)
|
||||||
|
if err != nil || at < 0 || at >= len(schema.Views) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
schema.Views = append(schema.Views[:at], schema.Views[at+1:]...)
|
||||||
|
schema.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSequence adds a sequence to a schema, or replaces the one at position at (use -1 to add).
|
||||||
|
func (se *SchemaEditor) SaveSequence(schemaIndex, at int, s *models.Sequence) error {
|
||||||
|
schema, err := se.schemaAt(schemaIndex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Name = strings.TrimSpace(s.Name)
|
||||||
|
if s.Name == "" {
|
||||||
|
return errors.New("sequence name is required")
|
||||||
|
}
|
||||||
|
if s.IncrementBy == 0 {
|
||||||
|
return errors.New("increment must not be zero")
|
||||||
|
}
|
||||||
|
for i, o := range schema.Sequences {
|
||||||
|
if i != at && o.Name == s.Name {
|
||||||
|
return fmt.Errorf("sequence %q already exists", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Schema = schema.Name
|
||||||
|
if at >= 0 && at < len(schema.Sequences) {
|
||||||
|
schema.Sequences[at] = s
|
||||||
|
} else {
|
||||||
|
schema.Sequences = append(schema.Sequences, s)
|
||||||
|
}
|
||||||
|
schema.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSequence removes the sequence at position at.
|
||||||
|
func (se *SchemaEditor) DeleteSequence(schemaIndex, at int) bool {
|
||||||
|
schema, err := se.schemaAt(schemaIndex)
|
||||||
|
if err != nil || at < 0 || at >= len(schema.Sequences) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
schema.Sequences = append(schema.Sequences[:at], schema.Sequences[at+1:]...)
|
||||||
|
schema.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveScript adds a script to a schema, or replaces the one at position at (use -1 to add).
|
||||||
|
func (se *SchemaEditor) SaveScript(schemaIndex, at int, s *models.Script) error {
|
||||||
|
schema, err := se.schemaAt(schemaIndex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Name = strings.TrimSpace(s.Name)
|
||||||
|
if s.Name == "" {
|
||||||
|
return errors.New("script name is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.SQL) == "" {
|
||||||
|
return errors.New("script SQL is required")
|
||||||
|
}
|
||||||
|
for i, o := range schema.Scripts {
|
||||||
|
if i != at && o.Name == s.Name {
|
||||||
|
return fmt.Errorf("script %q already exists", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Schema = schema.Name
|
||||||
|
if at >= 0 && at < len(schema.Scripts) {
|
||||||
|
schema.Scripts[at] = s
|
||||||
|
} else {
|
||||||
|
schema.Scripts = append(schema.Scripts, s)
|
||||||
|
}
|
||||||
|
schema.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteScript removes the script at position at.
|
||||||
|
func (se *SchemaEditor) DeleteScript(schemaIndex, at int) bool {
|
||||||
|
schema, err := se.schemaAt(schemaIndex)
|
||||||
|
if err != nil || at < 0 || at >= len(schema.Scripts) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
schema.Scripts = append(schema.Scripts[:at], schema.Scripts[at+1:]...)
|
||||||
|
schema.UpdateDate()
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignTableToDomain adds a reference to schemaName.tableName to the domain at domainIndex.
|
||||||
|
func (se *SchemaEditor) AssignTableToDomain(domainIndex int, schemaName, tableName string) error {
|
||||||
|
if domainIndex < 0 || domainIndex >= len(se.db.Domains) {
|
||||||
|
return errors.New("domain not found")
|
||||||
|
}
|
||||||
|
domain := se.db.Domains[domainIndex]
|
||||||
|
var table *models.Table
|
||||||
|
for _, s := range se.db.Schemas {
|
||||||
|
if s.Name != schemaName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, t := range s.Tables {
|
||||||
|
if t.Name == tableName {
|
||||||
|
table = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table == nil {
|
||||||
|
return fmt.Errorf("table %s.%s not found", schemaName, tableName)
|
||||||
|
}
|
||||||
|
for _, dt := range domain.Tables {
|
||||||
|
if dt.SchemaName == schemaName && dt.TableName == tableName {
|
||||||
|
return fmt.Errorf("table %s.%s is already in domain %s", schemaName, tableName, domain.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dt := models.InitDomainTable(tableName, schemaName)
|
||||||
|
dt.RefTable = table
|
||||||
|
dt.Sequence = uint(len(domain.Tables))
|
||||||
|
domain.Tables = append(domain.Tables, dt)
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnassignTableFromDomain removes the reference to schemaName.tableName from the domain.
|
||||||
|
func (se *SchemaEditor) UnassignTableFromDomain(domainIndex int, schemaName, tableName string) bool {
|
||||||
|
if domainIndex < 0 || domainIndex >= len(se.db.Domains) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
domain := se.db.Domains[domainIndex]
|
||||||
|
for i, dt := range domain.Tables {
|
||||||
|
if dt.SchemaName == schemaName && dt.TableName == tableName {
|
||||||
|
domain.Tables = append(domain.Tables[:i], domain.Tables[i+1:]...)
|
||||||
|
se.db.UpdateDate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestEditor() *SchemaEditor {
|
||||||
|
db := models.InitDatabase("test")
|
||||||
|
schema := models.InitSchema("public")
|
||||||
|
table := models.InitTable("users", "public")
|
||||||
|
table.Columns["id"] = models.InitColumn("id", "users", "public")
|
||||||
|
table.Columns["email"] = models.InitColumn("email", "users", "public")
|
||||||
|
schema.Tables = append(schema.Tables, table)
|
||||||
|
db.Schemas = append(db.Schemas, schema)
|
||||||
|
return &SchemaEditor{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveIndex(t *testing.T) {
|
||||||
|
se := newTestEditor()
|
||||||
|
table := se.db.Schemas[0].Tables[0]
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
old string
|
||||||
|
idx *models.Index
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", "", &models.Index{Name: "idx_email", Columns: []string{"email"}, Unique: true}, false},
|
||||||
|
{"duplicate", "", &models.Index{Name: "idx_email", Columns: []string{"email"}}, true},
|
||||||
|
{"missing name", "", &models.Index{Columns: []string{"email"}}, true},
|
||||||
|
{"no columns", "", &models.Index{Name: "idx_none"}, true},
|
||||||
|
{"unknown column", "", &models.Index{Name: "idx_bad", Columns: []string{"nope"}}, true},
|
||||||
|
{"rename", "idx_email", &models.Index{Name: "idx_email2", Columns: []string{"email", "id"}}, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := se.SaveIndex(0, 0, tt.old, tt.idx); (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, ok := table.Indexes["idx_email"]; ok {
|
||||||
|
t.Error("renamed index should be gone under old name")
|
||||||
|
}
|
||||||
|
if idx := table.Indexes["idx_email2"]; idx == nil || idx.Table != "users" || idx.Schema != "public" {
|
||||||
|
t.Errorf("unexpected renamed index: %+v", idx)
|
||||||
|
}
|
||||||
|
if !se.DeleteIndex(0, 0, "idx_email2") || se.DeleteIndex(0, 0, "idx_email2") {
|
||||||
|
t.Error("delete should succeed once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveViewSequenceScript(t *testing.T) {
|
||||||
|
se := newTestEditor()
|
||||||
|
schema := se.db.Schemas[0]
|
||||||
|
|
||||||
|
if err := se.SaveView(0, -1, &models.View{Name: "v", Definition: "select 1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := se.SaveView(0, -1, &models.View{Name: "v", Definition: "select 2"}); err == nil {
|
||||||
|
t.Error("duplicate view accepted")
|
||||||
|
}
|
||||||
|
if err := se.SaveView(0, 0, &models.View{Name: "v", Definition: "select 3"}); err != nil {
|
||||||
|
t.Errorf("editing in place should not conflict: %v", err)
|
||||||
|
}
|
||||||
|
if err := se.SaveView(0, -1, &models.View{Name: "w"}); err == nil {
|
||||||
|
t.Error("view without definition accepted")
|
||||||
|
}
|
||||||
|
if len(schema.Views) != 1 || schema.Views[0].Definition != "select 3" || schema.Views[0].Schema != "public" {
|
||||||
|
t.Errorf("unexpected views: %+v", schema.Views)
|
||||||
|
}
|
||||||
|
if !se.DeleteView(0, 0) || se.DeleteView(0, 0) {
|
||||||
|
t.Error("view delete mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := se.SaveSequence(0, -1, &models.Sequence{Name: "s", IncrementBy: 1, StartValue: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := se.SaveSequence(0, -1, &models.Sequence{Name: "z"}); err == nil {
|
||||||
|
t.Error("zero increment accepted")
|
||||||
|
}
|
||||||
|
if !se.DeleteSequence(0, 0) || len(schema.Sequences) != 0 {
|
||||||
|
t.Error("sequence delete failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := se.SaveScript(0, -1, &models.Script{Name: "init", SQL: "select 1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := se.SaveScript(0, -1, &models.Script{Name: "empty"}); err == nil {
|
||||||
|
t.Error("script without SQL accepted")
|
||||||
|
}
|
||||||
|
if err := se.SaveScript(5, -1, &models.Script{Name: "x", SQL: "y"}); err == nil {
|
||||||
|
t.Error("bad schema index accepted")
|
||||||
|
}
|
||||||
|
if !se.DeleteScript(0, 0) || len(schema.Scripts) != 0 {
|
||||||
|
t.Error("script delete failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainTableAssignment(t *testing.T) {
|
||||||
|
se := newTestEditor()
|
||||||
|
se.createDomainNoUI("core")
|
||||||
|
|
||||||
|
if err := se.AssignTableToDomain(0, "public", "users"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := se.AssignTableToDomain(0, "public", "users"); err == nil {
|
||||||
|
t.Error("duplicate assignment accepted")
|
||||||
|
}
|
||||||
|
if err := se.AssignTableToDomain(0, "public", "missing"); err == nil {
|
||||||
|
t.Error("unknown table accepted")
|
||||||
|
}
|
||||||
|
if err := se.AssignTableToDomain(3, "public", "users"); err == nil {
|
||||||
|
t.Error("bad domain index accepted")
|
||||||
|
}
|
||||||
|
dt := se.db.Domains[0].Tables[0]
|
||||||
|
if dt.RefTable != se.db.Schemas[0].Tables[0] {
|
||||||
|
t.Error("RefTable not linked")
|
||||||
|
}
|
||||||
|
if !se.UnassignTableFromDomain(0, "public", "users") || se.UnassignTableFromDomain(0, "public", "users") {
|
||||||
|
t.Error("unassign mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) createDomainNoUI(name string) {
|
||||||
|
se.db.Domains = append(se.db.Domains, models.InitDomain(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitList(t *testing.T) {
|
||||||
|
got := splitList(" a, b,, c ,")
|
||||||
|
if len(got) != 3 || got[0] != "a" || got[2] != "c" {
|
||||||
|
t.Errorf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// objectLocation identifies where a new object is created: a schema, and for indexes also a table.
|
||||||
|
type objectLocation struct {
|
||||||
|
label string
|
||||||
|
schemaIndex int
|
||||||
|
tableIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectRow is one existing object shown in an object list.
|
||||||
|
type objectRow struct {
|
||||||
|
cells []string
|
||||||
|
schemaIndex int
|
||||||
|
tableIndex int
|
||||||
|
at int // position within the schema slice (views, sequences, scripts)
|
||||||
|
name string // map key (indexes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectKind describes how a kind of schema object is listed and edited.
|
||||||
|
type objectKind struct {
|
||||||
|
page string
|
||||||
|
title string
|
||||||
|
singular string
|
||||||
|
headers []string
|
||||||
|
rows func() []objectRow
|
||||||
|
locations func() []objectLocation
|
||||||
|
// buildForm adds the editable fields to the form for row (nil when creating) and
|
||||||
|
// returns a function that validates and saves the values at the given location.
|
||||||
|
buildForm func(form *tview.Form, row *objectRow) func(loc objectLocation) error
|
||||||
|
remove func(row objectRow) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) schemaLocations() []objectLocation {
|
||||||
|
locs := make([]objectLocation, 0, len(se.db.Schemas))
|
||||||
|
for si, s := range se.db.Schemas {
|
||||||
|
locs = append(locs, objectLocation{label: s.Name, schemaIndex: si, tableIndex: -1})
|
||||||
|
}
|
||||||
|
return locs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) tableLocations() []objectLocation {
|
||||||
|
locs := make([]objectLocation, 0)
|
||||||
|
for si, s := range se.db.Schemas {
|
||||||
|
for ti, t := range s.Tables {
|
||||||
|
locs = append(locs, objectLocation{label: s.Name + "." + t.Name, schemaIndex: si, tableIndex: ti})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return locs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) indexKind() objectKind {
|
||||||
|
return objectKind{
|
||||||
|
page: "indexes",
|
||||||
|
title: "Manage Indexes",
|
||||||
|
singular: "Index",
|
||||||
|
headers: []string{"Name", "Schema", "Table", "Type", "Unique", "Columns"},
|
||||||
|
locations: se.tableLocations,
|
||||||
|
rows: func() []objectRow {
|
||||||
|
var rows []objectRow
|
||||||
|
for si, s := range se.db.Schemas {
|
||||||
|
for ti, t := range s.Tables {
|
||||||
|
for _, name := range sortedKeys(t.Indexes) {
|
||||||
|
idx := t.Indexes[name]
|
||||||
|
rows = append(rows, objectRow{
|
||||||
|
cells: []string{idx.Name, s.Name, t.Name, idx.Type, strconv.FormatBool(idx.Unique), strings.Join(idx.Columns, ",")},
|
||||||
|
schemaIndex: si, tableIndex: ti, name: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||||
|
idx := models.InitIndex("", "", "")
|
||||||
|
idx.Type = "btree"
|
||||||
|
if row != nil {
|
||||||
|
idx = se.db.Schemas[row.schemaIndex].Tables[row.tableIndex].Indexes[row.name]
|
||||||
|
}
|
||||||
|
name, columns, typ, where := idx.Name, strings.Join(idx.Columns, ", "), idx.Type, idx.Where
|
||||||
|
unique := idx.Unique
|
||||||
|
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||||
|
form.AddInputField("Columns (comma separated)", columns, 50, nil, func(v string) { columns = v })
|
||||||
|
form.AddInputField("Type", typ, 20, nil, func(v string) { typ = v })
|
||||||
|
form.AddCheckbox("Unique", unique, func(v bool) { unique = v })
|
||||||
|
form.AddInputField("Where", where, 50, nil, func(v string) { where = v })
|
||||||
|
return func(loc objectLocation) error {
|
||||||
|
oldName := ""
|
||||||
|
if row != nil {
|
||||||
|
oldName = row.name
|
||||||
|
}
|
||||||
|
next := *idx
|
||||||
|
next.Name, next.Columns, next.Type, next.Unique, next.Where = name, splitList(columns), typ, unique, where
|
||||||
|
return se.SaveIndex(loc.schemaIndex, loc.tableIndex, oldName, &next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: func(r objectRow) bool { return se.DeleteIndex(r.schemaIndex, r.tableIndex, r.name) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) viewKind() objectKind {
|
||||||
|
return objectKind{
|
||||||
|
page: "views",
|
||||||
|
title: "Manage Views",
|
||||||
|
singular: "View",
|
||||||
|
headers: []string{"Name", "Schema", "Description"},
|
||||||
|
locations: se.schemaLocations,
|
||||||
|
rows: func() []objectRow {
|
||||||
|
var rows []objectRow
|
||||||
|
for si, s := range se.db.Schemas {
|
||||||
|
for i, v := range s.Views {
|
||||||
|
rows = append(rows, objectRow{cells: []string{v.Name, s.Name, v.Description}, schemaIndex: si, at: i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||||
|
view := models.InitView("", "")
|
||||||
|
at := -1
|
||||||
|
if row != nil {
|
||||||
|
view, at = se.db.Schemas[row.schemaIndex].Views[row.at], row.at
|
||||||
|
}
|
||||||
|
name, desc, def := view.Name, view.Description, view.Definition
|
||||||
|
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||||
|
form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v })
|
||||||
|
form.AddTextArea("Definition (SQL)", def, 60, 8, 0, func(v string) { def = v })
|
||||||
|
return func(loc objectLocation) error {
|
||||||
|
next := *view
|
||||||
|
next.Name, next.Description, next.Definition = name, desc, def
|
||||||
|
return se.SaveView(loc.schemaIndex, at, &next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: func(r objectRow) bool { return se.DeleteView(r.schemaIndex, r.at) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) sequenceKind() objectKind {
|
||||||
|
return objectKind{
|
||||||
|
page: "sequences",
|
||||||
|
title: "Manage Sequences",
|
||||||
|
singular: "Sequence",
|
||||||
|
headers: []string{"Name", "Schema", "Start", "Increment", "Cycle", "Description"},
|
||||||
|
locations: se.schemaLocations,
|
||||||
|
rows: func() []objectRow {
|
||||||
|
var rows []objectRow
|
||||||
|
for si, s := range se.db.Schemas {
|
||||||
|
for i, q := range s.Sequences {
|
||||||
|
rows = append(rows, objectRow{
|
||||||
|
cells: []string{q.Name, s.Name, strconv.FormatInt(q.StartValue, 10), strconv.FormatInt(q.IncrementBy, 10), strconv.FormatBool(q.Cycle), q.Description}, schemaIndex: si, at: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||||
|
seq := models.InitSequence("", "")
|
||||||
|
at := -1
|
||||||
|
if row != nil {
|
||||||
|
seq, at = se.db.Schemas[row.schemaIndex].Sequences[row.at], row.at
|
||||||
|
}
|
||||||
|
name, desc := seq.Name, seq.Description
|
||||||
|
start, incr := strconv.FormatInt(seq.StartValue, 10), strconv.FormatInt(seq.IncrementBy, 10)
|
||||||
|
minV, maxV := strconv.FormatInt(seq.MinValue, 10), strconv.FormatInt(seq.MaxValue, 10)
|
||||||
|
cycle := seq.Cycle
|
||||||
|
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||||
|
form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v })
|
||||||
|
form.AddInputField("Start", start, 20, nil, func(v string) { start = v })
|
||||||
|
form.AddInputField("Increment", incr, 20, nil, func(v string) { incr = v })
|
||||||
|
form.AddInputField("Min (0 = none)", minV, 20, nil, func(v string) { minV = v })
|
||||||
|
form.AddInputField("Max (0 = none)", maxV, 20, nil, func(v string) { maxV = v })
|
||||||
|
form.AddCheckbox("Cycle", cycle, func(v bool) { cycle = v })
|
||||||
|
return func(loc objectLocation) error {
|
||||||
|
next := *seq
|
||||||
|
next.Name, next.Description, next.Cycle = name, desc, cycle
|
||||||
|
for _, f := range []struct {
|
||||||
|
label string
|
||||||
|
text string
|
||||||
|
dst *int64
|
||||||
|
}{{"start", start, &next.StartValue}, {"increment", incr, &next.IncrementBy}, {"min", minV, &next.MinValue}, {"max", maxV, &next.MaxValue}} {
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(f.text), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s must be an integer", f.label)
|
||||||
|
}
|
||||||
|
*f.dst = n
|
||||||
|
}
|
||||||
|
return se.SaveSequence(loc.schemaIndex, at, &next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: func(r objectRow) bool { return se.DeleteSequence(r.schemaIndex, r.at) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SchemaEditor) scriptKind() objectKind {
|
||||||
|
return objectKind{
|
||||||
|
page: "scripts",
|
||||||
|
title: "Manage Scripts",
|
||||||
|
singular: "Script",
|
||||||
|
headers: []string{"Name", "Schema", "Version", "Priority", "Description"},
|
||||||
|
locations: se.schemaLocations,
|
||||||
|
rows: func() []objectRow {
|
||||||
|
var rows []objectRow
|
||||||
|
for si, s := range se.db.Schemas {
|
||||||
|
for i, sc := range s.Scripts {
|
||||||
|
rows = append(rows, objectRow{cells: []string{sc.Name, s.Name, sc.Version, strconv.Itoa(sc.Priority), sc.Description}, schemaIndex: si, at: i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||||
|
script := models.InitScript("")
|
||||||
|
at := -1
|
||||||
|
if row != nil {
|
||||||
|
script, at = se.db.Schemas[row.schemaIndex].Scripts[row.at], row.at
|
||||||
|
}
|
||||||
|
name, desc, version, sql, rollback := script.Name, script.Description, script.Version, script.SQL, script.Rollback
|
||||||
|
priority, runAfter := strconv.Itoa(script.Priority), strings.Join(script.RunAfter, ", ")
|
||||||
|
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||||
|
form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v })
|
||||||
|
form.AddInputField("Version", version, 20, nil, func(v string) { version = v })
|
||||||
|
form.AddInputField("Priority", priority, 10, nil, func(v string) { priority = v })
|
||||||
|
form.AddInputField("Run after (comma separated)", runAfter, 50, nil, func(v string) { runAfter = v })
|
||||||
|
form.AddTextArea("SQL", sql, 60, 8, 0, func(v string) { sql = v })
|
||||||
|
form.AddTextArea("Rollback SQL", rollback, 60, 4, 0, func(v string) { rollback = v })
|
||||||
|
return func(loc objectLocation) error {
|
||||||
|
prio, err := strconv.Atoi(strings.TrimSpace(priority))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("priority must be an integer")
|
||||||
|
}
|
||||||
|
next := *script
|
||||||
|
next.Name, next.Description, next.Version, next.Priority = name, desc, version, prio
|
||||||
|
next.RunAfter, next.SQL, next.Rollback = splitList(runAfter), sql, rollback
|
||||||
|
return se.SaveScript(loc.schemaIndex, at, &next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: func(r objectRow) bool { return se.DeleteScript(r.schemaIndex, r.at) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// showObjectList displays all objects of a kind across schemas.
|
||||||
|
func (se *SchemaEditor) showObjectList(k objectKind) {
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
|
title := tview.NewTextView().SetText("[::b]" + k.title).SetDynamicColors(true).SetTextAlign(tview.AlignCenter)
|
||||||
|
|
||||||
|
table := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||||
|
for i, h := range k.headers {
|
||||||
|
table.SetCell(0, i, tview.NewTableCell(h).SetTextColor(tcell.ColorYellow).SetSelectable(false).SetAlign(tview.AlignLeft))
|
||||||
|
}
|
||||||
|
rows := k.rows()
|
||||||
|
for r, row := range rows {
|
||||||
|
for c, text := range row.cells {
|
||||||
|
table.SetCell(r+1, c, tview.NewTableCell(text).SetSelectable(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.SetTitle(" " + k.title[len("Manage "):] + " ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||||
|
|
||||||
|
back := func() {
|
||||||
|
se.pages.SwitchToPage("main")
|
||||||
|
se.pages.RemovePage(k.page)
|
||||||
|
}
|
||||||
|
btnNew := tview.NewButton("New " + k.singular + " [n]").SetSelectedFunc(func() { se.showObjectForm(k, nil) })
|
||||||
|
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(back)
|
||||||
|
btnNew.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyBacktab:
|
||||||
|
se.app.SetFocus(table)
|
||||||
|
return nil
|
||||||
|
case tcell.KeyTab:
|
||||||
|
se.app.SetFocus(btnBack)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyBacktab:
|
||||||
|
se.app.SetFocus(btnNew)
|
||||||
|
return nil
|
||||||
|
case tcell.KeyTab:
|
||||||
|
se.app.SetFocus(table)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
btnFlex := tview.NewFlex().AddItem(btnNew, 0, 1, true).AddItem(btnBack, 0, 1, false)
|
||||||
|
|
||||||
|
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch {
|
||||||
|
case event.Key() == tcell.KeyEscape, event.Rune() == 'b':
|
||||||
|
back()
|
||||||
|
return nil
|
||||||
|
case event.Key() == tcell.KeyTab:
|
||||||
|
se.app.SetFocus(btnNew)
|
||||||
|
return nil
|
||||||
|
case event.Key() == tcell.KeyEnter:
|
||||||
|
if row, _ := table.GetSelection(); row > 0 && row <= len(rows) {
|
||||||
|
se.showObjectForm(k, &rows[row-1])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case event.Rune() == 'n':
|
||||||
|
se.showObjectForm(k, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
flex.AddItem(title, 1, 0, false).AddItem(table, 0, 1, true).AddItem(btnFlex, 1, 0, false)
|
||||||
|
se.pages.AddPage(k.page, flex, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// showObjectForm shows the create (row == nil) or edit form for an object.
|
||||||
|
func (se *SchemaEditor) showObjectForm(k objectKind, row *objectRow) {
|
||||||
|
formPage := k.page + "-form"
|
||||||
|
form := tview.NewForm()
|
||||||
|
errView := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
|
||||||
|
locs := k.locations()
|
||||||
|
loc := objectLocation{schemaIndex: -1, tableIndex: -1}
|
||||||
|
switch {
|
||||||
|
case row != nil:
|
||||||
|
loc = objectLocation{schemaIndex: row.schemaIndex, tableIndex: row.tableIndex}
|
||||||
|
case len(locs) > 0:
|
||||||
|
loc = locs[0]
|
||||||
|
labels := make([]string, len(locs))
|
||||||
|
for i, l := range locs {
|
||||||
|
labels[i] = l.label
|
||||||
|
}
|
||||||
|
form.AddDropDown("Location", labels, 0, func(_ string, i int) { loc = locs[i] })
|
||||||
|
}
|
||||||
|
|
||||||
|
save := k.buildForm(form, row)
|
||||||
|
|
||||||
|
closeForm := func() {
|
||||||
|
se.pages.RemovePage(formPage)
|
||||||
|
se.pages.RemovePage(k.page)
|
||||||
|
se.showObjectList(k)
|
||||||
|
}
|
||||||
|
form.AddButton("Save", func() {
|
||||||
|
if err := save(loc); err != nil {
|
||||||
|
errView.SetText("[red]" + tview.Escape(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeForm()
|
||||||
|
})
|
||||||
|
if row != nil {
|
||||||
|
form.AddButton("Delete", func() {
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(fmt.Sprintf("Delete %s '%s'? This action cannot be undone.", strings.ToLower(k.singular), row.cells[0])).
|
||||||
|
AddButtons([]string{"Cancel", "Delete"}).
|
||||||
|
SetDoneFunc(func(_ int, label string) {
|
||||||
|
se.pages.RemovePage(formPage + "-delete")
|
||||||
|
if label == "Delete" {
|
||||||
|
k.remove(*row)
|
||||||
|
closeForm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
se.pages.AddAndSwitchToPage(formPage+"-delete", modal, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
form.AddButton("Back", closeForm)
|
||||||
|
|
||||||
|
verb := "New"
|
||||||
|
if row != nil {
|
||||||
|
verb = "Edit"
|
||||||
|
}
|
||||||
|
form.SetBorder(true).SetTitle(" " + verb + " " + k.singular + " ").SetTitleAlign(tview.AlignLeft)
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
se.showExitConfirmation(formPage, k.page)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(locs) == 0 && row == nil {
|
||||||
|
errView.SetText("[red]No schema/table available. Create one first.")
|
||||||
|
}
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(form, 0, 1, true).AddItem(errView, 1, 0, false)
|
||||||
|
se.pages.AddPage(formPage, flex, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// showDomainTables lists the tables assigned to a domain and allows assigning/unassigning.
|
||||||
|
func (se *SchemaEditor) showDomainTables(domainIndex int) {
|
||||||
|
if domainIndex < 0 || domainIndex >= len(se.db.Domains) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domain := se.db.Domains[domainIndex]
|
||||||
|
page := "domain-tables"
|
||||||
|
list := tview.NewList().ShowSecondaryText(true)
|
||||||
|
refresh := func() {
|
||||||
|
se.pages.RemovePage(page)
|
||||||
|
se.showDomainTables(domainIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dt := range domain.Tables {
|
||||||
|
dt := dt
|
||||||
|
list.AddItem(dt.SchemaName+"."+dt.TableName, "Enter to remove from domain", 0, func() {
|
||||||
|
se.UnassignTableFromDomain(domainIndex, dt.SchemaName, dt.TableName)
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
list.AddItem("[Assign Table]", "Add a table to this domain", 'a', func() {
|
||||||
|
se.showAssignDomainTable(domainIndex, refresh)
|
||||||
|
})
|
||||||
|
list.AddItem("[Back]", "Return to domain", 'b', func() {
|
||||||
|
se.pages.RemovePage(page)
|
||||||
|
})
|
||||||
|
list.SetBorder(true).SetTitle(" Domain " + domain.Name + " - Tables ").SetTitleAlign(tview.AlignLeft)
|
||||||
|
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
se.pages.RemovePage(page)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
se.pages.AddPage(page, list, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// showAssignDomainTable shows a form to pick a table not yet in the domain.
|
||||||
|
func (se *SchemaEditor) showAssignDomainTable(domainIndex int, done func()) {
|
||||||
|
page := "assign-domain-table"
|
||||||
|
domain := se.db.Domains[domainIndex]
|
||||||
|
var options []string
|
||||||
|
var refs []models.DomainTable
|
||||||
|
for _, s := range se.db.Schemas {
|
||||||
|
for _, t := range s.Tables {
|
||||||
|
taken := false
|
||||||
|
for _, dt := range domain.Tables {
|
||||||
|
taken = taken || (dt.SchemaName == s.Name && dt.TableName == t.Name)
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
options = append(options, s.Name+"."+t.Name)
|
||||||
|
refs = append(refs, models.DomainTable{SchemaName: s.Name, TableName: t.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form := tview.NewForm()
|
||||||
|
selected := 0
|
||||||
|
form.AddDropDown("Table", options, 0, func(_ string, i int) { selected = i })
|
||||||
|
form.AddButton("Assign", func() {
|
||||||
|
if len(refs) > 0 {
|
||||||
|
_ = se.AssignTableToDomain(domainIndex, refs[selected].SchemaName, refs[selected].TableName)
|
||||||
|
}
|
||||||
|
se.pages.RemovePage(page)
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
form.AddButton("Back", func() { se.pages.RemovePage(page) })
|
||||||
|
form.SetBorder(true).SetTitle(" Assign Table ").SetTitleAlign(tview.AlignLeft)
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
se.pages.RemovePage(page)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
se.pages.AddPage(page, form, true, true)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user