dialog manager extended to be able to produce the New Account dialog

This commit is contained in:
2025-09-25 22:49:41 -06:00
parent 3a4d6151f6
commit be56b06d7a
11 changed files with 380 additions and 27 deletions
+41 -7
View File
@@ -19,13 +19,15 @@ import (
// DialogItem holds the dialog item definition.
type DialogItem struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Caption string `yaml:"caption,omitempty"`
Size int `yaml:"size,omitempty"`
MaxLength int `yaml:"maxlength,omitempty"`
Value string `yaml:"value,omitempty"`
Tone string `yaml:"tone,omitempty"`
Type string `yaml:"type"`
Name string `yaml:"name"`
Caption string `yaml:"caption,omitempty"`
Subcaption string `yaml:"subcaption,omitempty"`
Required bool `yaml:"required,omitempty"`
Size int `yaml:"size,omitempty"`
MaxLength int `yaml:"maxlength,omitempty"`
Value string `yaml:"value,omitempty"`
Param string `yaml:"param,omitempty"`
}
// Dialog holds the dialog definition.
@@ -52,15 +54,39 @@ func AmLoadDialog(name string) (*Dialog, error) {
var d Dialog
err = yaml.Unmarshal(b, &d)
if err == nil {
// "nil-patch" certain fields
if d.MenuSelector == "" {
d.MenuSelector = "nochange"
}
for _, fld := range d.Fields {
if fld.Type == "button" && fld.Param == "" {
fld.Param = "blue"
}
if fld.Type == "date" && fld.Param == "" {
fld.Param = "year:-100"
}
}
return &d, nil
}
}
return nil, err
}
/* Field returns a pointer to a dialog's field, given its name.
* Parameters:
* name - The name of the field to find.
* Returns:
* Pointer to the field, or nil.
*/
func (d *Dialog) Field(name string) *DialogItem {
for i := 0; i < len(d.Fields); i++ {
if d.Fields[i].Name == name {
return &(d.Fields[i])
}
}
return nil
}
/* Render sets up the rendering parameters to send this dialog to the output.
* Parameters:
* ctxt - The AmContext for this request.
@@ -70,6 +96,14 @@ func AmLoadDialog(name string) (*Dialog, error) {
* Standard Go error status.
*/
func (d *Dialog) Render(ctxt AmContext) (string, any, error) {
required := false
for _, fld := range d.Fields {
if fld.Required {
required = true
break
}
}
ctxt.VarMap().Set("amsterdam_required", required)
ctxt.VarMap().Set("amsterdam_dialog", d)
return "framed_template", "dialog.jet", nil
}
+4 -4
View File
@@ -18,7 +18,7 @@ fields:
- type: "hidden"
name: "tgt"
value: ""
- type: "veniceid"
- type: "ams_id"
name: "user"
caption: "User Name"
size: 32
@@ -34,12 +34,12 @@ fields:
- type: "button"
name: "login"
caption: "Log In"
tone: "blue"
param: "blue"
- type: "button"
name: "remind"
caption: "Password Reminder"
tone: "gray"
param: "gray"
- type: "button"
name: "cancel"
caption: "Cancel"
tone: "red"
param: "red"
+127
View File
@@ -0,0 +1,127 @@
#
# Amsterdam Web Communities System
# Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
#
name: "newaccount"
formName: "createform"
menuSelector: "top"
title: "Create Account"
action: "/TODO/newacct2"
instructions: >
To create a new account, please enter your information below.
fields:
- type: "hidden"
name: "tgt"
value: ""
- type: "header"
name: "header1"
caption: "Name"
- type: "text"
name: "prefix"
caption: "Prefix"
subcaption: "(Mr., Ms., etc.)"
size: 8
maxlength: 8
- type: "text"
name: "first"
caption: "First Name"
required: true
size: 32
maxlength: 64
- type: "text"
name: "mid"
caption: "Middle Initial"
size: 1
maxlength: 1
- type: "text"
name: "last"
caption: "Last Name"
required: true
size: 32
maxlength: 64
- type: "text"
name: "suffix"
caption: "Suffix"
subcaption: "(Jr., III, etc.)"
size: 8
maxlength: 8
- type: "header"
name: "header2"
caption: "Location"
- type: "text"
name: "loc"
caption: "City"
required: true
size: 32
maxlength: 64
- type: "text"
name: "reg"
caption: "State/Province"
required: true
size: 32
maxlength: 64
- type: "text"
name: "pcode"
caption: "Zip/Postal Code"
required: true
size: 32
maxlength: 64
- type: "countrylist"
name: "country"
caption: "Country"
required: true
- type: "header"
name: "header3"
caption: "E-Mail"
- type: "email"
name: "email"
caption: "E-Mail Address"
required: true
size: 32
maxlength: 255
- type: "header"
name: "header4"
caption: "Other Information"
- type: "date"
name: "dob"
caption: "Date of Birth"
param: "year:-100"
- type: "header"
name: "header5"
caption: "Account Information"
- type: "ams_id"
name: "user"
caption: "User Name"
required: true
size: 32
maxlength: 64
- type: "password"
name: "pass1"
caption: "Password"
required: true
size: 32
maxlength: 128
- type: "password"
name: "pass2"
caption: "Password"
subcaption: "(retype)"
required: true
size: 32
maxlength: 128
- type: "text"
name: "remind"
caption: "Password reminder phrase"
size: 32
maxlength: 255
- type: "button"
name: "create"
caption: "Create"
param: "blue"
- type: "button"
name: "cancel"
caption: "Cancel"
param: "red"
+107
View File
@@ -12,12 +12,21 @@ package ui
import (
"embed"
"fmt"
"io"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"git.erbosoft.com/amy/amsterdam/config"
"github.com/CloudyKit/jet/v6"
"github.com/CloudyKit/jet/v6/loaders/embedfs"
"github.com/CloudyKit/jet/v6/loaders/multi"
"github.com/biter777/countries"
"github.com/labstack/echo/v4"
)
@@ -27,6 +36,96 @@ var static_views embed.FS
// views is the main Jet template repository.
var views *jet.Set
var cachedCountryList []countries.CountryCode = nil
var countryListMutex sync.Mutex
func internalGetCountryList() []countries.CountryCode {
countryListMutex.Lock()
defer countryListMutex.Unlock()
if cachedCountryList == nil {
c := countries.All()
slices.SortFunc(c, func(a countries.CountryCode, b countries.CountryCode) int {
return strings.Compare(a.Info().Name, b.Info().Name)
})
cachedCountryList = c
}
return cachedCountryList
}
// getCountryList is a wrapper around countries.All() to be added to the template context.
func getCountryList(a jet.Arguments) reflect.Value {
return reflect.ValueOf(internalGetCountryList())
}
// getMonthList is a simple wrapper that returns the names of the months to the template context.
func getMonthList(a jet.Arguments) reflect.Value {
rc := make([]string, 12)
for m := time.January; m <= time.December; m++ {
rc[m-time.January] = m.String()
}
return reflect.ValueOf(rc)
}
// countRanger is a Ranger that can count from one value to another with a certain step.
type countRanger struct {
i int
val int64
step int64
to int64
}
/* Range (from Ranger) returns the "next" value of this iterator.
* Returns:
* Next index of the returned value
* Next returned value
* true if this is the last iteration, false if not
*/
func (r *countRanger) Range() (reflect.Value, reflect.Value, bool) {
r.i++
r.val += r.step
var end bool
if r.step < 0 {
end = r.val <= r.to
} else {
end = r.val >= r.to
}
return reflect.ValueOf(r.i), reflect.ValueOf(r.val), end
}
// ProvidesIndex (from Ranger) returns true to indicate that this Ranger has indexes.
func (r *countRanger) ProvidesIndex() bool {
return true
}
// makeIntRange creates and returns a countRanger.
func makeIntRange(a jet.Arguments) reflect.Value {
from := a.Get(0).Convert(reflect.TypeFor[int64]()).Int()
to := a.Get(1).Convert(reflect.TypeFor[int64]()).Int()
step := a.Get(2).Convert(reflect.TypeFor[int64]()).Int()
rc := &countRanger{i: -1, val: from - step, step: step, to: to}
return reflect.ValueOf(rc).Convert(reflect.TypeFor[jet.Ranger]())
}
// makeYearRange parses a year parameter and creates a countRanger that reflects it.
func makeYearRange(a jet.Arguments) reflect.Value {
param := a.Get(0).Convert(reflect.TypeFor[string]()).String()
yearRegex, _ := regexp.Compile(`year:(\S+)(\s+.+)?$`)
m := yearRegex.FindStringSubmatch(param)
if m != nil {
count, err := strconv.Atoi(m[1])
if err == nil {
start_year := time.Now().Year()
rc := &countRanger{i: -1, val: int64(start_year) + 1, step: -1, to: int64(start_year + count - 1)}
return reflect.ValueOf(rc).Convert(reflect.TypeFor[jet.Ranger]())
} else {
return reflect.ValueOf(err)
}
} else {
return reflect.ValueOf(fmt.Errorf("cannot locate year: marker in param"))
}
}
// SetupTemplates is called to set up the template renderer after the configuration is loaded.
func SetupTemplates() {
views = jet.NewSet(
@@ -39,6 +138,13 @@ func SetupTemplates() {
views.AddGlobal("AmsterdamVersion", config.AMSTERDAM_VERSION)
views.AddGlobal("AmsterdamCopyright", config.AMSTERDAM_COPYRIGHT)
views.AddGlobal("GlobalConfig", config.GlobalConfig)
views.AddGlobalFunc("GetCountryList", getCountryList)
views.AddGlobalFunc("GetMonthList", getMonthList)
views.AddGlobalFunc("MakeIntRange", makeIntRange)
views.AddGlobalFunc("MakeYearRange", makeYearRange)
// preload the country list in the background
go internalGetCountryList()
}
// TemplateRenderer is the Renderer instance set into the Echo context at creation time, to render Jet templates.
@@ -55,6 +161,7 @@ type TemplateRenderer struct{}
*/
func (r *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
view, err := views.GetTemplate(name)
if err != nil {
return err
}
+1 -1
View File
@@ -16,7 +16,7 @@
</p>
<div class="flex justify-center gap-4 mt-6">
<button type="button" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded font-medium transition-colors"
onclick="window.location.assign('/TODO/newacct2')">I Accept</button>
onclick="window.location.assign('/newacct2')">I Accept</button>
<button type="button" class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded font-medium transition-colors"
onclick="window.location.assign('/')">I Decline</button>
</div>
+65 -5
View File
@@ -21,13 +21,19 @@
{{ if amsterdam_dialog.Instructions != "" }}
<p class="text-black text-sm mb-6">{{ amsterdam_dialog.Instructions | raw }}</p>
{{ end }}
{{ if amsterdam_required }}
<p class="text-black text-sm mb-6">Required fields are marked with a <span class="text-red-600">*</span>.</p>
{{ end }}
<div class="bg-gray-50 p-6 rounded-lg">
<div class="space-y-4">
{{ range amsterdam_dialog.Fields }}
{{ if .Type == "veniceid" }}
{{ if .Type == "text" || .Type == "ams_id" || .Type == "email" }}
<div class="flex items-center">
<label for="{{ .Name }}" class="w-24 text-right pr-4 text-black text-sm">{{ .Caption }}:</label>
<label for="{{ .Name }}" class="w-64 text-right pr-4 text-black text-sm">
{{ .Caption }}{{ if .Subcaption != "" }} {{ .Subcaption }}{{ end }}:
{{ if .Required }}<span class="text-red-600">*</span>{{ end }}
</label>
<input type="text" id="{{ .Name }}" name="{{ .Name }}"
{{ if .Size > 0 }}size="{{ .Size }}"{{ end }}
{{ if .MaxLength > 0 }}maxlength="{{ .MaxLength }}"{{ end }}
@@ -36,7 +42,10 @@
</div>
{{ else if .Type == "password" }}
<div class="flex items-center">
<label for="{{ .Name }}" class="w-24 text-right pr-4 text-black text-sm">{{ .Caption }}:</label>
<label for="{{ .Name }}" class="w-64 text-right pr-4 text-black text-sm">
{{ .Caption }}{{ if .Subcaption != "" }} {{ .Subcaption }}{{ end }}:
{{ if .Required }}<span class="text-red-600">*</span>{{ end }}
</label>
<input type="password" id="{{ .Name }}" name="{{ .Name }}"
{{ if .Size > 0 }}size="{{ .Size }}"{{ end }}
{{ if .MaxLength > 0 }}maxlength="{{ .MaxLength }}"{{ end }}
@@ -49,15 +58,66 @@
<input type="checkbox" id="{{ .Name }}" name="{{ .Name }}"
value="Y" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
</div>
<label for="{{ .Name }}" class="flex-1 text-black text-sm">{{ .Caption }}</label>
<label for="{{ .Name }}" class="flex-1 text-black text-sm">
{{ .Caption }}{{ if .Subcaption != "" }} {{ .Subcaption }}{{ end }}
{{ if .Required }}<span class="text-red-600">*</span>{{ end }}
</label>
</div>
{{ else if .Type == "countrylist" }}
<div class="flex items-center">
<label for="{{ .Name }}" class="w-64 text-right pr-4 text-black text-sm">
{{ .Caption }}{{ if .Subcaption != "" }} {{ .Subcaption }}{{ end }}
{{ if .Required }}<span class="text-red-600">*</span>{{ end }}
</label>
{{ v := .Value }}
<select id="{{ .Name }}" name="{{ .Name }}" {{ if .Required }}required{{ end }}
class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="XX" {{ if v == "XX" }}selected{{ end }}>🏳️ (unknown)</option>
{{ range GetCountryList() }}
{{ cc := .Alpha2() }}
<option value="{{ cc }}" {{ if cc == v }}selected{{ end }}>{{ .Emoji() }} {{ .Info().Name }}</option>
{{ end }}
</select>
</div>
{{ else if .Type == "date" }}
<div class="flex items-center">
<label class="w-64 text-right pr-4 text-black text-sm">
{{ .Caption }}{{ if .Subcaption != "" }} {{ .Subcaption }}{{ end }}
{{ if .Required }}<span class="text-red-600">*</span>{{ end }}
</label>
<div class="flex gap-2">
<select name="{{ .Name }}_month"
class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="-1" selected>---</option>
{{ range i := GetMonthList() }}
<option value="{{ i + 1 }}">{{ . }}</option>
{{ end }}
</select>
<select name="{{ .Name }}_day" class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="-1" selected>---</option>
{{ range MakeIntRange(1, 32, 1) }}
<option value="{{ . }}">{{ . }}</option>
{{ end }}
</select>
<select name="{{ .Name }}_year" class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="-1" selected>---</option>
{{ range MakeYearRange(.Param) }}
<option value="{{ . }}">{{ . }}</option>
{{ end }}
</select>
</div>
</div>
{{ else if .Type == "header" }}
<h2 class="text-lg font-bold text-black mb-4">{{ .Caption }}</h2>
{{ else if .Type != "hidden" && .Type != "button" }}
BARF! I don't understand {{ .Type }} (field {{ .Name }})
{{ end }}
{{ end }}
</div>
<div class="flex justify-center gap-4 mt-6">
{{ range amsterdam_dialog.Fields }}
{{ if .Type == "button" }}
{{ clstmp := "bg-" + .Tone + "-600 hover:bg-" + .Tone + "-700" }}
{{ clstmp := "bg-" + .Param + "-600 hover:bg-" + .Param + "-700" }}
<button type="submit" name="{{ .Name }}"
class="{{ clstmp }} text-white px-6 py-2 rounded font-medium transition-colors">{{ .Caption }}</button>
{{ end }}