- TestFormExtensionPlacement, TestFormExtensionBootErrors, TestFormExtensionTwoPluginsSameAnchor, TestFormExtensionOnePluginTwoControllers, TestFormExtensionUnextendedSchemaUnchanged, TestFormExtensionWidgetsAndPartials - TestFormExtensionSave (Postgres): rollback, 422 lines, notices, invisible extensions, locale maps, hidden owner fields - checkFormExtensionTabLabels declared (no-op) so the tests compile
616 lines
19 KiB
Go
616 lines
19 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"github.com/goccy/go-yaml/ast"
|
|
"github.com/goccy/go-yaml/parser"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// defaultTabKey is the name a form extension uses for the tab of the fields
|
|
// without a tab: key (D-91 P-04).
|
|
const defaultTabKey = "default"
|
|
|
|
// compiledFormExtension is one plugin's contribution to one controller's
|
|
// form (pact.AdminActionExtension.Form, D-91) after the boot checks.
|
|
type compiledFormExtension struct {
|
|
pluginID string
|
|
def pact.AdminFormExtension
|
|
// fields are the names of the fields the extension adds, in compiled
|
|
// order; own holds them by name.
|
|
fields []string
|
|
own map[string]FormField
|
|
}
|
|
|
|
// formContribution is one extension waiting to be compiled onto a target.
|
|
type formContribution struct {
|
|
plugin party.Plugin
|
|
def pact.AdminFormExtension
|
|
}
|
|
|
|
// extensionDoc is a parsed form extension fields document: the new tabs and
|
|
// the fields with their placement keys, in document order.
|
|
type extensionDoc struct {
|
|
tabs []extensionTab
|
|
fields []extensionField
|
|
}
|
|
|
|
// extensionTab is one entry of a fields document's tabs: list.
|
|
type extensionTab struct {
|
|
name, before, after, zone string
|
|
}
|
|
|
|
// extensionField is one compiled field of a fields document with its
|
|
// before:, after: and zone: keys.
|
|
type extensionField struct {
|
|
field FormField
|
|
before, after, zone string
|
|
}
|
|
|
|
// placementKeys are the keys a form extension's field takes next to the
|
|
// fields.yaml keys; they are taken out before the field is compiled.
|
|
var placementKeys = map[string]bool{"before": true, "after": true, "zone": true}
|
|
|
|
// compileFormExtensions merges the form fields and tabs plugins add through
|
|
// pact.AdminActionExtension.Form (D-91) into the compiled forms of reg, in
|
|
// plugin slice (activation) order. It must run after every controller is
|
|
// compiled. An extension's fields document is compiled like a fields.yaml
|
|
// (every type, option and plugin field type check applies), then each field
|
|
// is placed at its anchor. Every failure stops boot and names the plugin, the
|
|
// controller and the offending name.
|
|
func compileFormExtensions(reg *Registry, plugins []party.Plugin) error {
|
|
if reg == nil {
|
|
return fmt.Errorf("cabana: registry is nil")
|
|
}
|
|
targets := map[string][]formContribution{}
|
|
var order []string
|
|
for _, plugin := range plugins {
|
|
if plugin == nil {
|
|
continue
|
|
}
|
|
src, ok := plugin.(pact.HasAdminActionExtensions)
|
|
if !ok || src == nil {
|
|
continue
|
|
}
|
|
for _, ext := range src.AdminActionExtensions() {
|
|
if ext.Form == nil {
|
|
continue
|
|
}
|
|
cc, ok := reg.byID[ext.Controller]
|
|
if !ok || cc == nil {
|
|
return fmt.Errorf("cabana: plugin %s extends the form of unknown admin controller %q", plugin.ID(), ext.Controller)
|
|
}
|
|
if cc.Form == nil {
|
|
return fmt.Errorf("cabana: plugin %s extends the form of admin controller %s, which has no form (config_form.yaml)", plugin.ID(), ext.Controller)
|
|
}
|
|
if _, seen := targets[ext.Controller]; !seen {
|
|
order = append(order, ext.Controller)
|
|
}
|
|
targets[ext.Controller] = append(targets[ext.Controller], formContribution{plugin: plugin, def: *ext.Form})
|
|
}
|
|
}
|
|
for _, id := range order {
|
|
if err := applyFormExtensions(reg, reg.byID[id], targets[id]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extErr is a form extension boot error naming the plugin and the controller.
|
|
func extErr(pluginID, controller, format string, args ...any) error {
|
|
return fmt.Errorf("cabana: plugin %s: controller %q: "+format, append([]any{pluginID, controller}, args...)...)
|
|
}
|
|
|
|
// applyFormExtensions compiles the contributions of one target controller.
|
|
func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []formContribution) error {
|
|
controller := cc.Controller.ID()
|
|
docs := make([]extensionDoc, len(contributions))
|
|
layout := false
|
|
for i, c := range contributions {
|
|
doc, err := loadExtensionDoc(reg, c, controller)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
docs[i] = doc
|
|
if len(doc.fields) > 0 || len(doc.tabs) > 0 || len(c.def.Hide) > 0 {
|
|
layout = true
|
|
}
|
|
}
|
|
form := newFormLayout(cc.Form.Fields)
|
|
for i, c := range contributions {
|
|
ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}}
|
|
if err := form.apply(ext, docs[i], controller); err != nil {
|
|
return err
|
|
}
|
|
cc.formExtensions = append(cc.formExtensions, ext)
|
|
if cc.extensionFields == nil {
|
|
cc.extensionFields = map[string]*compiledFormExtension{}
|
|
}
|
|
for _, name := range ext.fields {
|
|
cc.extensionFields[name] = ext
|
|
}
|
|
}
|
|
if layout {
|
|
// A new slice: the owner's compiled list may be shared.
|
|
cc.Form.Fields = form.flatten()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// loadExtensionDoc reads and parses one extension's fields document.
|
|
func loadExtensionDoc(reg *Registry, c formContribution, controller string) (extensionDoc, error) {
|
|
pluginID := c.plugin.ID()
|
|
if strings.TrimSpace(c.def.Fields) != "" && len(c.def.FieldsYAML) > 0 {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension sets both Fields and FieldsYAML")
|
|
}
|
|
raw := c.def.FieldsYAML
|
|
source := "FieldsYAML"
|
|
if file := strings.TrimSpace(c.def.Fields); file != "" {
|
|
assets, ok := c.plugin.(pact.AdminAssets)
|
|
if !ok || assets == nil || assets.AdminFS() == nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: the plugin has no AdminFS", file)
|
|
}
|
|
name, err := assetPath(pluginID, file)
|
|
if err != nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err)
|
|
}
|
|
if raw, err = readAsset(assets.AdminFS(), name); err != nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err)
|
|
}
|
|
source = name
|
|
}
|
|
if len(raw) == 0 {
|
|
return extensionDoc{}, nil
|
|
}
|
|
doc, err := parseExtensionDoc(raw, reg.fieldTypes)
|
|
if err != nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", source, err)
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// parseExtensionDoc compiles a form extension fields document: a mapping
|
|
// with fields: (a fields.yaml mapping whose entries may add before:, after:
|
|
// and zone:) and tabs: (a list of name/before/after/zone mappings).
|
|
func parseExtensionDoc(raw []byte, types *fieldTypeRegistry) (extensionDoc, error) {
|
|
file, err := parser.ParseBytes(raw, 0)
|
|
if err != nil {
|
|
return extensionDoc{}, normalizeYAMLError(err)
|
|
}
|
|
var entries []*ast.MappingValueNode
|
|
for i, doc := range file.Docs {
|
|
if doc == nil {
|
|
continue
|
|
}
|
|
if i > 0 {
|
|
return extensionDoc{}, fmt.Errorf("want one YAML document")
|
|
}
|
|
switch body := unwrapNode(doc.Body).(type) {
|
|
case nil, *ast.NullNode:
|
|
case *ast.MappingNode:
|
|
entries = body.Values
|
|
case *ast.MappingValueNode:
|
|
entries = []*ast.MappingValueNode{body}
|
|
default:
|
|
return extensionDoc{}, fmt.Errorf("the document must be a mapping with fields: and tabs:")
|
|
}
|
|
}
|
|
var out extensionDoc
|
|
seen := map[string]bool{}
|
|
for _, entry := range entries {
|
|
key, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return extensionDoc{}, err
|
|
}
|
|
if seen[key] {
|
|
return extensionDoc{}, fmt.Errorf("duplicate key %s", key)
|
|
}
|
|
seen[key] = true
|
|
switch key {
|
|
case "fields":
|
|
if out.fields, err = decodeExtensionFields(entry.Value, types); err != nil {
|
|
return extensionDoc{}, err
|
|
}
|
|
case "tabs":
|
|
if out.tabs, err = decodeExtensionTabs(entry.Value); err != nil {
|
|
return extensionDoc{}, err
|
|
}
|
|
default:
|
|
return extensionDoc{}, fmt.Errorf("unknown key %s (want fields or tabs)", key)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decodeExtensionFields takes the placement keys out of every field entry
|
|
// and compiles the rest through decodeFieldMapping.
|
|
func decodeExtensionFields(node ast.Node, types *fieldTypeRegistry) ([]extensionField, error) {
|
|
node = unwrapNode(node)
|
|
placement := map[string]map[string]string{}
|
|
if mapping, ok := node.(*ast.MappingNode); ok {
|
|
for _, entry := range mapping.Values {
|
|
name, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
continue // decodeFieldMapping reports the name
|
|
}
|
|
body, ok := unwrapNode(entry.Value).(*ast.MappingNode)
|
|
if !ok {
|
|
continue
|
|
}
|
|
keys := map[string]string{}
|
|
kept := make([]*ast.MappingValueNode, 0, len(body.Values))
|
|
for _, item := range body.Values {
|
|
key, err := nodeString(unwrapNode(item.Key))
|
|
if err != nil || !placementKeys[key] {
|
|
kept = append(kept, item)
|
|
continue
|
|
}
|
|
value, err := nodeString(unwrapNode(item.Value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("field %s: %s: %w", name, key, err)
|
|
}
|
|
keys[key] = strings.TrimSpace(value)
|
|
}
|
|
body.Values = kept
|
|
placement[name] = keys
|
|
}
|
|
}
|
|
compiled, err := decodeFieldMapping(node, nil, types)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]extensionField, 0, len(compiled))
|
|
for _, field := range compiled {
|
|
keys := placement[field.Name]
|
|
out = append(out, extensionField{field: field, before: keys["before"], after: keys["after"], zone: keys["zone"]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decodeExtensionTabs decodes the tabs: list.
|
|
func decodeExtensionTabs(node ast.Node) ([]extensionTab, error) {
|
|
node = unwrapNode(node)
|
|
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
|
return nil, nil
|
|
}
|
|
list, ok := node.(*ast.SequenceNode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("tabs must be a list of mappings with name, before, after and zone")
|
|
}
|
|
out := make([]extensionTab, 0, len(list.Values))
|
|
for i, item := range list.Values {
|
|
mapping, ok := unwrapNode(item).(*ast.MappingNode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("tabs[%d] must be a mapping with name, before, after and zone", i)
|
|
}
|
|
var tab extensionTab
|
|
for _, entry := range mapping.Values {
|
|
key, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tabs[%d]: %w", i, err)
|
|
}
|
|
value, err := nodeString(unwrapNode(entry.Value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tabs[%d]: %s: %w", i, key, err)
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
switch key {
|
|
case "name":
|
|
tab.name = value
|
|
case "before":
|
|
tab.before = value
|
|
case "after":
|
|
tab.after = value
|
|
case "zone":
|
|
tab.zone = value
|
|
default:
|
|
return nil, fmt.Errorf("tabs[%d]: unknown key %s (want name, before, after or zone)", i, key)
|
|
}
|
|
}
|
|
if tab.name == "" {
|
|
return nil, fmt.Errorf("tabs[%d]: name is required", i)
|
|
}
|
|
out = append(out, tab)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// tabGroup is one tab of a form being laid out: its raw tab key ("" for the
|
|
// default tab) and its fields in order.
|
|
type tabGroup struct {
|
|
key string
|
|
fields []FormField
|
|
}
|
|
|
|
// formLayout is a form being laid out by its extensions: the fields grouped
|
|
// by tab in first-appearance order, the default tab first (P-02), plus who
|
|
// added each field.
|
|
type formLayout struct {
|
|
groups []*tabGroup
|
|
// fieldPlugin maps every field name to the plugin that added it; owner
|
|
// fields map to "".
|
|
fieldPlugin map[string]string
|
|
}
|
|
|
|
func newFormLayout(fields []FormField) *formLayout {
|
|
layout := &formLayout{fieldPlugin: map[string]string{}}
|
|
index := map[string]*tabGroup{}
|
|
for _, field := range fields {
|
|
group, ok := index[field.Tab]
|
|
if !ok {
|
|
group = &tabGroup{key: field.Tab}
|
|
index[field.Tab] = group
|
|
layout.groups = append(layout.groups, group)
|
|
}
|
|
group.fields = append(group.fields, field)
|
|
layout.fieldPlugin[field.Name] = ""
|
|
}
|
|
// The SPA always renders the default tab first.
|
|
for i, group := range layout.groups {
|
|
if group.key == "" && i > 0 {
|
|
copy(layout.groups[1:i+1], layout.groups[:i])
|
|
layout.groups[0] = group
|
|
break
|
|
}
|
|
}
|
|
return layout
|
|
}
|
|
|
|
// flatten is the laid-out field list.
|
|
func (l *formLayout) flatten() []FormField {
|
|
out := []FormField{}
|
|
for _, group := range l.groups {
|
|
out = append(out, group.fields...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// group returns the tab group with key, nil when the form has none.
|
|
func (l *formLayout) group(key string) *tabGroup {
|
|
for _, group := range l.groups {
|
|
if group.key == key {
|
|
return group
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// locate returns the group holding field name and its index there.
|
|
func (l *formLayout) locate(name string) (*tabGroup, int) {
|
|
for _, group := range l.groups {
|
|
for i, field := range group.fields {
|
|
if field.Name == name {
|
|
return group, i
|
|
}
|
|
}
|
|
}
|
|
return nil, -1
|
|
}
|
|
|
|
// apply places one extension's fields.
|
|
func (l *formLayout) apply(ext *compiledFormExtension, doc extensionDoc, controller string) error {
|
|
pluginID := ext.pluginID
|
|
if len(doc.tabs) > 0 {
|
|
return extErr(pluginID, controller, "tab %s: new tabs are not supported yet", doc.tabs[0].name)
|
|
}
|
|
for _, item := range doc.fields {
|
|
field := item.field
|
|
if owner, taken := l.fieldPlugin[field.Name]; taken {
|
|
by := "the owner"
|
|
if owner != "" {
|
|
by = "plugin " + owner
|
|
}
|
|
return extErr(pluginID, controller, "field %s: duplicate field name (%s already adds it)", field.Name, by)
|
|
}
|
|
anchor, before := item.after, false
|
|
if anchor == "" && item.before != "" {
|
|
anchor, before = item.before, true
|
|
}
|
|
var (
|
|
group *tabGroup
|
|
index int
|
|
)
|
|
if anchor != "" {
|
|
group, index = l.locate(anchor)
|
|
if group == nil {
|
|
return extErr(pluginID, controller, "field %s: unknown anchor %q", field.Name, anchor)
|
|
}
|
|
if field.Tab == "" {
|
|
field.Tab = group.key
|
|
} else if field.Tab != group.key {
|
|
return extErr(pluginID, controller, "field %s: anchor %q is in another tab than %q", field.Name, anchor, field.Tab)
|
|
}
|
|
if !before {
|
|
index++
|
|
}
|
|
} else {
|
|
group = l.group(field.Tab)
|
|
if group == nil {
|
|
if field.Tab != "" {
|
|
return extErr(pluginID, controller, "field %s: unknown tab %q", field.Name, field.Tab)
|
|
}
|
|
group = &tabGroup{}
|
|
l.groups = append([]*tabGroup{group}, l.groups...)
|
|
}
|
|
index = len(group.fields)
|
|
}
|
|
group.fields = append(group.fields[:index], append([]FormField{field}, group.fields[index:]...)...)
|
|
l.fieldPlugin[field.Name] = pluginID
|
|
ext.fields = append(ext.fields, field.Name)
|
|
ext.own[field.Name] = field
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extensionOf returns the form extension that added field name, nil for an
|
|
// owner field.
|
|
func (cc *CompiledController) extensionOf(name string) *compiledFormExtension {
|
|
if cc == nil {
|
|
return nil
|
|
}
|
|
return cc.extensionFields[name]
|
|
}
|
|
|
|
// extensionValues are the submitted values of one save, per extension.
|
|
type extensionValues map[*compiledFormExtension]map[string]any
|
|
|
|
// liftExtensionValues takes the values of every extension field out of a
|
|
// save body before anything else reads it, so they never reach Fill, the
|
|
// owner's bindings or translation rows (D-91, T-32-01). Only fields whose
|
|
// context allows op are kept for their extension's Save.
|
|
func liftExtensionValues(cc *CompiledController, body map[string]any, op string) extensionValues {
|
|
out := extensionValues{}
|
|
if cc == nil || len(cc.extensionFields) == 0 || body == nil {
|
|
return out
|
|
}
|
|
for name, ext := range cc.extensionFields {
|
|
value, present := body[name]
|
|
if !present {
|
|
continue
|
|
}
|
|
delete(body, name)
|
|
if !contextAllows(cc, name, op) {
|
|
continue
|
|
}
|
|
if out[ext] == nil {
|
|
out[ext] = map[string]any{}
|
|
}
|
|
out[ext][name] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
// saveFormExtensions runs every extension's Save inside the record's save
|
|
// transaction, after the owner row is written. created is true on create.
|
|
func saveFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, values extensionValues, created bool) ([]pluginNotice, error) {
|
|
if cc == nil {
|
|
return nil, nil
|
|
}
|
|
var notices []pluginNotice
|
|
for _, ext := range cc.formExtensions {
|
|
if ext.def.Save == nil {
|
|
continue
|
|
}
|
|
own := values[ext]
|
|
if own == nil {
|
|
own = map[string]any{}
|
|
}
|
|
result, err := ext.def.Save(ctx, pact.AdminFormSave{
|
|
DB: tx,
|
|
Controller: controllerID(cc),
|
|
Record: target,
|
|
ID: recordIDText(target),
|
|
Created: created,
|
|
Values: own,
|
|
})
|
|
if err != nil {
|
|
return nil, extensionFailure(cc, ext, "save", err)
|
|
}
|
|
notices = append(notices, extensionNotices(result)...)
|
|
}
|
|
return notices, nil
|
|
}
|
|
|
|
// extensionNotices are a Save result's notices in field name order.
|
|
func extensionNotices(result pact.AdminFormSaveResult) []pluginNotice {
|
|
var out []pluginNotice
|
|
names := make([]string, 0, len(result.Notices))
|
|
for name := range result.Notices {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
for _, notice := range result.Notices[name] {
|
|
out = append(out, pluginNotice{field: name, notice: notice})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extensionFailure classifies an extension hook error: AdminFormErrors is
|
|
// validation_failed with its messages on the fields, a cabana refusal passes
|
|
// through, anything else is logged and becomes the opaque lifecycle error.
|
|
func extensionFailure(cc *CompiledController, ext *compiledFormExtension, stage string, err error) error {
|
|
var invalid pact.AdminFormErrors
|
|
if errors.As(err, &invalid) && len(invalid) > 0 {
|
|
details := make(map[string]any, len(invalid))
|
|
for name, messages := range invalid {
|
|
details[name] = append([]string(nil), messages...)
|
|
}
|
|
return &ValidationError{Details: details}
|
|
}
|
|
var refused *ValidationError
|
|
var forbidden *ForbiddenError
|
|
if errors.As(err, &refused) || errors.As(err, &forbidden) {
|
|
return err
|
|
}
|
|
moduleLog.Error("cabana: form extension "+stage+" failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err)
|
|
return &lifecycleError{ControllerID: controllerID(cc)}
|
|
}
|
|
|
|
// hydrateFormExtensions adds every extension's Load values to a show or save
|
|
// response. Only keys that are the extension's own fields whose context
|
|
// allows op are kept.
|
|
func hydrateFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, data map[string]any, op string) error {
|
|
if cc == nil || data == nil {
|
|
return nil
|
|
}
|
|
for _, ext := range cc.formExtensions {
|
|
if ext.def.Load == nil {
|
|
continue
|
|
}
|
|
loaded, err := ext.def.Load(ctx, pact.AdminFormLoad{DB: tx, Controller: controllerID(cc), Record: target, ID: recordIDText(target)})
|
|
if err != nil {
|
|
moduleLog.Error("cabana: form extension load failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err)
|
|
return &lifecycleError{ControllerID: controllerID(cc)}
|
|
}
|
|
for _, name := range ext.fields {
|
|
value, ok := loaded[name]
|
|
if !ok || !contextAllows(cc, name, op) {
|
|
continue
|
|
}
|
|
data[name] = value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordIDText is the record's primary key as text, "" when it has none.
|
|
func recordIDText(model any) string {
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return ""
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
if !v.IsValid() {
|
|
return ""
|
|
}
|
|
field := fieldByColumn(v, primaryColumn(model))
|
|
if !field.IsValid() || !field.CanInterface() {
|
|
return ""
|
|
}
|
|
value := derefValue(field.Interface())
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprint(value)
|
|
}
|
|
|
|
// checkFormExtensionTabLabels refuses two tab keys of an extended form whose
|
|
// labels translate to the same text in a locale the phrasebook loads.
|
|
func checkFormExtensionTabLabels(reg *Registry, tr *phrasebook.Translator) error {
|
|
return nil
|
|
}
|