From 2a49e922f7dc3098fc89d85705a3ee9238420c48 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 11 Feb 2026 12:42:54 +0100 Subject: [PATCH 01/25] initial --- README.md | 47 ++++++ jaws.go | 81 +++++----- jaws/clickhandler_test.go | 5 +- jaws/dateformat.go | 4 + jaws/jawsevent_test.go | 6 +- jaws/jawsjaws_test.go | 2 +- jaws/makehtmlgetter_test.go | 6 + jaws/namedbool_test.go | 2 +- jaws/namedboolarray.go | 2 +- jaws/namedboolarray_test.go | 2 +- jaws/{uioption.go => namedbooloption.go} | 10 +- jaws/{uiregister.go => register.go} | 8 +- jaws/request.go | 9 ++ jaws/request_test.go | 10 +- jaws/requestwriter_widgets.go | 167 ++++++++++++++++++++ jaws/requestwriter_widgets_test.go | 127 +++++++++++++++ jaws/testpage_test.go | 109 ------------- jaws/testuiwidget_test.go | 61 ++++++++ jaws/ui_test.go | 114 -------------- jaws/uia.go | 25 --- jaws/uia_test.go | 63 -------- jaws/uibutton.go | 25 --- jaws/uibutton_test.go | 16 -- jaws/uicheckbox.go | 25 --- jaws/uicheckbox_test.go | 80 ---------- jaws/uicontainer.go | 27 ---- jaws/uicontainer_test.go | 188 ----------------------- jaws/uidate.go | 28 ---- jaws/uidate_test.go | 84 ---------- jaws/uidiv.go | 25 --- jaws/uidiv_test.go | 16 -- jaws/uihtmlinner.go | 20 --- jaws/uihtmlinner_test.go | 40 ----- jaws/uiimg.go | 34 ---- jaws/uiimg_test.go | 34 ---- jaws/uiinput.go | 21 --- jaws/uiinputbool.go | 51 ------ jaws/uiinputdate.go | 46 ------ jaws/uiinputfloat.go | 46 ------ jaws/uiinputtext.go | 36 ----- jaws/uilabel.go | 25 --- jaws/uilabel_test.go | 16 -- jaws/uili.go | 25 --- jaws/uili_test.go | 16 -- jaws/uinumber.go | 25 --- jaws/uinumber_test.go | 81 ---------- jaws/uioption_test.go | 51 ------ jaws/uipassword.go | 25 --- jaws/uipassword_test.go | 17 -- jaws/uiradio.go | 25 --- jaws/uiradio_test.go | 18 --- jaws/uiradiogroup.go | 42 ----- jaws/uiradiogroup_test.go | 27 ---- jaws/uirange.go | 25 --- jaws/uirange_test.go | 69 --------- jaws/uiregister_test.go | 19 --- jaws/uiselect.go | 40 ----- jaws/uiselect_test.go | 129 ---------------- jaws/uispan.go | 25 --- jaws/uispan_test.go | 16 -- jaws/uitbody.go | 25 --- jaws/uitbody_test.go | 16 -- jaws/uitd.go | 25 --- jaws/uitd_test.go | 16 -- jaws/uitext.go | 25 --- jaws/uitext_test.go | 69 --------- jaws/uitextarea.go | 34 ---- jaws/uitextarea_test.go | 52 ------- jaws/uitr.go | 25 --- jaws/uitr_test.go | 16 -- jaws/uiwrapcontainer.go | 99 ------------ jaws/uiwrapcontainer_test.go | 153 ------------------ ui/README.md | 105 +++++++++++++ ui/a.go | 14 ++ ui/button.go | 14 ++ ui/checkbox.go | 14 ++ ui/common.go | 21 +++ ui/common_test.go | 40 +++++ ui/constructors_test.go | 56 +++++++ ui/container.go | 27 ++++ ui/container_widgets.go | 108 +++++++++++++ ui/container_widgets_test.go | 134 ++++++++++++++++ ui/date.go | 15 ++ ui/div.go | 14 ++ ui/doc.go | 13 ++ ui/html_widgets.go | 23 +++ ui/html_widgets_test.go | 90 +++++++++++ ui/img.go | 22 +++ ui/input_widgets.go | 179 +++++++++++++++++++++ ui/input_widgets_test.go | 126 +++++++++++++++ ui/label.go | 14 ++ ui/li.go | 14 ++ ui/number.go | 14 ++ ui/option.go | 30 ++++ ui/password.go | 14 ++ ui/radio.go | 14 ++ ui/range.go | 14 ++ ui/register.go | 15 ++ ui/requestwriter_register.go | 72 +++++++++ ui/requestwriter_register_test.go | 84 ++++++++++ ui/requestwriter_types.go | 13 ++ ui/select.go | 33 ++++ ui/span.go | 14 ++ ui/tbody.go | 23 +++ ui/td.go | 14 ++ ui/testhelpers_test.go | 137 +++++++++++++++++ ui/text.go | 14 ++ ui/textarea.go | 20 +++ ui/tr.go | 14 ++ 109 files changed, 2070 insertions(+), 2485 deletions(-) create mode 100644 jaws/dateformat.go rename jaws/{uioption.go => namedbooloption.go} (57%) rename jaws/{uiregister.go => register.go} (77%) create mode 100644 jaws/requestwriter_widgets.go create mode 100644 jaws/requestwriter_widgets_test.go delete mode 100644 jaws/testpage_test.go create mode 100644 jaws/testuiwidget_test.go delete mode 100644 jaws/ui_test.go delete mode 100644 jaws/uia.go delete mode 100644 jaws/uia_test.go delete mode 100644 jaws/uibutton.go delete mode 100644 jaws/uibutton_test.go delete mode 100644 jaws/uicheckbox.go delete mode 100644 jaws/uicheckbox_test.go delete mode 100644 jaws/uicontainer.go delete mode 100644 jaws/uicontainer_test.go delete mode 100644 jaws/uidate.go delete mode 100644 jaws/uidate_test.go delete mode 100644 jaws/uidiv.go delete mode 100644 jaws/uidiv_test.go delete mode 100644 jaws/uihtmlinner.go delete mode 100644 jaws/uihtmlinner_test.go delete mode 100644 jaws/uiimg.go delete mode 100644 jaws/uiimg_test.go delete mode 100644 jaws/uiinput.go delete mode 100644 jaws/uiinputbool.go delete mode 100644 jaws/uiinputdate.go delete mode 100644 jaws/uiinputfloat.go delete mode 100644 jaws/uiinputtext.go delete mode 100644 jaws/uilabel.go delete mode 100644 jaws/uilabel_test.go delete mode 100644 jaws/uili.go delete mode 100644 jaws/uili_test.go delete mode 100644 jaws/uinumber.go delete mode 100644 jaws/uinumber_test.go delete mode 100644 jaws/uioption_test.go delete mode 100644 jaws/uipassword.go delete mode 100644 jaws/uipassword_test.go delete mode 100644 jaws/uiradio.go delete mode 100644 jaws/uiradio_test.go delete mode 100644 jaws/uiradiogroup.go delete mode 100644 jaws/uiradiogroup_test.go delete mode 100644 jaws/uirange.go delete mode 100644 jaws/uirange_test.go delete mode 100644 jaws/uiregister_test.go delete mode 100644 jaws/uiselect.go delete mode 100644 jaws/uiselect_test.go delete mode 100644 jaws/uispan.go delete mode 100644 jaws/uispan_test.go delete mode 100644 jaws/uitbody.go delete mode 100644 jaws/uitbody_test.go delete mode 100644 jaws/uitd.go delete mode 100644 jaws/uitd_test.go delete mode 100644 jaws/uitext.go delete mode 100644 jaws/uitext_test.go delete mode 100644 jaws/uitextarea.go delete mode 100644 jaws/uitextarea_test.go delete mode 100644 jaws/uitr.go delete mode 100644 jaws/uitr_test.go delete mode 100644 jaws/uiwrapcontainer.go delete mode 100644 jaws/uiwrapcontainer_test.go create mode 100644 ui/README.md create mode 100644 ui/a.go create mode 100644 ui/button.go create mode 100644 ui/checkbox.go create mode 100644 ui/common.go create mode 100644 ui/common_test.go create mode 100644 ui/constructors_test.go create mode 100644 ui/container.go create mode 100644 ui/container_widgets.go create mode 100644 ui/container_widgets_test.go create mode 100644 ui/date.go create mode 100644 ui/div.go create mode 100644 ui/doc.go create mode 100644 ui/html_widgets.go create mode 100644 ui/html_widgets_test.go create mode 100644 ui/img.go create mode 100644 ui/input_widgets.go create mode 100644 ui/input_widgets_test.go create mode 100644 ui/label.go create mode 100644 ui/li.go create mode 100644 ui/number.go create mode 100644 ui/option.go create mode 100644 ui/password.go create mode 100644 ui/radio.go create mode 100644 ui/range.go create mode 100644 ui/register.go create mode 100644 ui/requestwriter_register.go create mode 100644 ui/requestwriter_register_test.go create mode 100644 ui/requestwriter_types.go create mode 100644 ui/select.go create mode 100644 ui/span.go create mode 100644 ui/tbody.go create mode 100644 ui/td.go create mode 100644 ui/testhelpers_test.go create mode 100644 ui/text.go create mode 100644 ui/textarea.go create mode 100644 ui/tr.go diff --git a/README.md b/README.md index 38d9a232..b2ff7b72 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,53 @@ go get github.com/linkdata/jaws After the dependency is added, your Go module will be able to import and use JaWS as demonstrated below. +## UI package + +The widget types are also available from `github.com/linkdata/jaws/ui` +using short names: + +```go +import ( + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/ui" +) + +var span *ui.Span = ui.NewSpan(jaws.MakeHTMLGetter("hello")) +``` + +This maps legacy names to the new package naming: + +* `jaws.UiSpan` -> `ui.Span` +* `jaws.NewUiSpan(...)` -> `ui.NewSpan(...)` + +For widget authoring guidance see `ui/README.md`. + +### RequestWriter widget calls + +`RequestWriter` keeps the intuitive widget helper API: + +```go +rw.Span("hello", "hidden") +rw.Text(mySetter) +rw.Select(mySelectHandler, "disabled") +``` + +Template usage remains concise: + +```gotemplate +{{$.Span "hello"}} +{{$.Text .MySetter}} +``` + +The explicit constructor style is also supported and is useful when you want +to prebuild or share widget instances: + +```go +rw.UI(ui.NewSpan(jaws.MakeHTMLGetter("hello")), "hidden") +rw.UI(ui.NewText(mySetter)) +rw.UI(ui.NewSelect(mySelectHandler), "disabled") +``` + ## Quick start The following minimal program renders a single range input whose value diff --git a/jaws.go b/jaws.go index 5fe530e9..364e4521 100644 --- a/jaws.go +++ b/jaws.go @@ -6,6 +6,7 @@ import ( pkg "github.com/linkdata/jaws/jaws" "github.com/linkdata/jaws/jid" + uipkg "github.com/linkdata/jaws/ui" ) // The point of this is to not have a zillion files in the repository root @@ -46,8 +47,8 @@ type ( NamedBool = pkg.NamedBool NamedBoolArray = pkg.NamedBoolArray Template = pkg.Template - RequestWriter = pkg.RequestWriter - With = pkg.With + RequestWriter = uipkg.RequestWriter + With = uipkg.With Session = pkg.Session Tag = pkg.Tag TestRequest = pkg.TestRequest @@ -94,72 +95,72 @@ func NewJsVar[T any](l sync.Locker, v *T) *JsVar[T] { } type ( - UiA = pkg.UiA - UiButton = pkg.UiButton - UiCheckbox = pkg.UiCheckbox - UiContainer = pkg.UiContainer - UiDate = pkg.UiDate - UiDiv = pkg.UiDiv - UiImg = pkg.UiImg - UiLabel = pkg.UiLabel - UiLi = pkg.UiLi - UiNumber = pkg.UiNumber - UiPassword = pkg.UiPassword - UiRadio = pkg.UiRadio - UiRange = pkg.UiRange - UiSelect = pkg.UiSelect - UiSpan = pkg.UiSpan - UiTbody = pkg.UiTbody - UiTd = pkg.UiTd - UiText = pkg.UiText - UiTr = pkg.UiTr + UiA = uipkg.A + UiButton = uipkg.Button + UiCheckbox = uipkg.Checkbox + UiContainer = uipkg.Container + UiDate = uipkg.Date + UiDiv = uipkg.Div + UiImg = uipkg.Img + UiLabel = uipkg.Label + UiLi = uipkg.Li + UiNumber = uipkg.Number + UiPassword = uipkg.Password + UiRadio = uipkg.Radio + UiRange = uipkg.Range + UiSelect = uipkg.Select + UiSpan = uipkg.Span + UiTbody = uipkg.Tbody + UiTd = uipkg.Td + UiText = uipkg.Text + UiTr = uipkg.Tr ) // UI constructor assignments (generic types require wrappers, others are direct) var ( - NewUiA = pkg.NewUiA - NewUiButton = pkg.NewUiButton - NewUiContainer = pkg.NewUiContainer - NewUiDiv = pkg.NewUiDiv - NewUiLabel = pkg.NewUiLabel - NewUiLi = pkg.NewUiLi - NewUiSelect = pkg.NewUiSelect - NewUiSpan = pkg.NewUiSpan - NewUiTbody = pkg.NewUiTbody - NewUiTd = pkg.NewUiTd - NewUiTr = pkg.NewUiTr + NewUiA = uipkg.NewA + NewUiButton = uipkg.NewButton + NewUiContainer = uipkg.NewContainer + NewUiDiv = uipkg.NewDiv + NewUiLabel = uipkg.NewLabel + NewUiLi = uipkg.NewLi + NewUiSelect = uipkg.NewSelect + NewUiSpan = uipkg.NewSpan + NewUiTbody = uipkg.NewTbody + NewUiTd = uipkg.NewTd + NewUiTr = uipkg.NewTr NewTestRequest = pkg.NewTestRequest ) // UI constructors with generic parameters must be wrapped func NewUiCheckbox(g Setter[bool]) *UiCheckbox { - return pkg.NewUiCheckbox(g) + return uipkg.NewCheckbox(g) } func NewUiDate(g Setter[time.Time]) *UiDate { - return pkg.NewUiDate(g) + return uipkg.NewDate(g) } func NewUiImg(g Getter[string]) *UiImg { - return pkg.NewUiImg(g) + return uipkg.NewImg(g) } func NewUiNumber(g Setter[float64]) *UiNumber { - return pkg.NewUiNumber(g) + return uipkg.NewNumber(g) } func NewUiPassword(g Setter[string]) *UiPassword { - return pkg.NewUiPassword(g) + return uipkg.NewPassword(g) } func NewUiRadio(vp Setter[bool]) *UiRadio { - return pkg.NewUiRadio(vp) + return uipkg.NewRadio(vp) } func NewUiRange(g Setter[float64]) *UiRange { - return pkg.NewUiRange(g) + return uipkg.NewRange(g) } func NewUiText(vp Setter[string]) *UiText { - return pkg.NewUiText(vp) + return uipkg.NewText(vp) } diff --git a/jaws/clickhandler_test.go b/jaws/clickhandler_test.go index 268efea8..2015fb44 100644 --- a/jaws/clickhandler_test.go +++ b/jaws/clickhandler_test.go @@ -1,6 +1,7 @@ package jaws import ( + "html/template" "testing" "github.com/linkdata/jaws/what" @@ -32,9 +33,9 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { } want := `
inner
` - rq.Div("inner", tjc) + rq.UI(testDivWidget{inner: template.HTML("inner")}, tjc) if got := rq.BodyString(); got != want { - t.Errorf("Request.Div() = %q, want %q", got, want) + t.Errorf("Request.UI(NewDiv()) = %q, want %q", got, want) } rq.InCh <- wsMsg{Data: "text", Jid: 1, What: what.Input} diff --git a/jaws/dateformat.go b/jaws/dateformat.go new file mode 100644 index 00000000..1755573c --- /dev/null +++ b/jaws/dateformat.go @@ -0,0 +1,4 @@ +package jaws + +// ISO8601 is the date format used by date input widgets (YYYY-MM-DD). +const ISO8601 = "2006-01-02" diff --git a/jaws/jawsevent_test.go b/jaws/jawsevent_test.go index 4af7a00d..be2e881b 100644 --- a/jaws/jawsevent_test.go +++ b/jaws/jawsevent_test.go @@ -132,11 +132,11 @@ func Test_JawsEvent_ExtraHandler(t *testing.T) { msgCh := make(chan string, 1) defer close(msgCh) - je := NewUiDiv(&testJawsEventHandler{msgCh: msgCh}) + je := &testJawsEventHandler{msgCh: msgCh} var sb strings.Builder - elem := rq.NewElement(je) - th.NoErr(je.JawsRender(elem, &sb, nil)) + elem := rq.NewElement(testDivWidget{inner: "tjEH"}) + th.NoErr(elem.JawsRender(&sb, []any{je})) th.Equal(sb.String(), "
tjEH
") rq.InCh <- wsMsg{Data: "name", Jid: 1, What: what.Click} diff --git a/jaws/jawsjaws_test.go b/jaws/jawsjaws_test.go index 75d36df0..e2a50eea 100644 --- a/jaws/jawsjaws_test.go +++ b/jaws/jawsjaws_test.go @@ -319,7 +319,7 @@ func TestJaws_CleansUpUnconnected(t *testing.T) { for i := 0; i < numReqs; i++ { rq := jw.NewRequest(hr) if (i % (numReqs / 5)) == 0 { - rq.NewElement(NewUiDiv(MakeHTMLGetter("meh"))) + rq.NewElement(testDivWidget{inner: "meh"}) } err := context.Cause(rq.ctx) if err == nil && rq.lastWrite.Before(deadline) { diff --git a/jaws/makehtmlgetter_test.go b/jaws/makehtmlgetter_test.go index 71dcdc55..42bea59e 100644 --- a/jaws/makehtmlgetter_test.go +++ b/jaws/makehtmlgetter_test.go @@ -8,6 +8,12 @@ import ( "testing" ) +type testStringer struct{} + +func (testStringer) String() string { + return "" +} + /*type testAnySetter struct { Value any } diff --git a/jaws/namedbool_test.go b/jaws/namedbool_test.go index 51907684..f85eacc7 100644 --- a/jaws/namedbool_test.go +++ b/jaws/namedbool_test.go @@ -13,7 +13,7 @@ func TestNamedBool(t *testing.T) { nb := nba.data[0] rq := newTestRequest(t) - e := rq.NewElement(NewUiCheckbox(nb)) + e := rq.NewElement(&testUi{}) defer rq.Close() is.Equal(nba, nb.Array()) diff --git a/jaws/namedboolarray.go b/jaws/namedboolarray.go index 899dfef3..466e3d8a 100644 --- a/jaws/namedboolarray.go +++ b/jaws/namedboolarray.go @@ -40,7 +40,7 @@ func (nba *NamedBoolArray) WriteLocked(fn func(nbl []*NamedBool) []*NamedBool) { func (nba *NamedBoolArray) JawsContains(e *Element) (contents []UI) { nba.mu.RLock() for _, nb := range nba.data { - contents = append(contents, UiOption{nb}) + contents = append(contents, namedBoolOption{nb}) } nba.mu.RUnlock() return diff --git a/jaws/namedboolarray_test.go b/jaws/namedboolarray_test.go index 6db85b95..2692b9cd 100644 --- a/jaws/namedboolarray_test.go +++ b/jaws/namedboolarray_test.go @@ -79,7 +79,7 @@ func Test_NamedBoolArray(t *testing.T) { is.Equal(nba.IsChecked("2"), true) rq := newTestRequest(t) - e := rq.NewElement(NewUiSelect(nba)) + e := rq.NewElement(&testUi{}) defer rq.Close() is.Equal(nba.JawsGet(e), "2") diff --git a/jaws/uioption.go b/jaws/namedbooloption.go similarity index 57% rename from jaws/uioption.go rename to jaws/namedbooloption.go index aa4748f1..9666a012 100644 --- a/jaws/uioption.go +++ b/jaws/namedbooloption.go @@ -6,9 +6,13 @@ import ( "io" ) -type UiOption struct{ *NamedBool } +// namedBoolOption is an internal UI wrapper used by NamedBoolArray.JawsContains. +// It intentionally stays unexported; public option widgets live in package ui. +type namedBoolOption struct { + *NamedBool +} -func (ui UiOption) JawsRender(e *Element, w io.Writer, params []any) error { +func (ui namedBoolOption) JawsRender(e *Element, w io.Writer, params []any) error { e.Tag(ui.NamedBool) attrs := e.ApplyParams(params) valattr := template.HTMLAttr(`value="` + html.EscapeString(ui.Name()) + `"`) // #nosec G203 @@ -19,7 +23,7 @@ func (ui UiOption) JawsRender(e *Element, w io.Writer, params []any) error { return WriteHTMLInner(w, e.Jid(), "option", "", ui.JawsGetHTML(e), attrs...) } -func (ui UiOption) JawsUpdate(e *Element) { +func (ui namedBoolOption) JawsUpdate(e *Element) { if ui.Checked() { e.SetAttr("selected", "") } else { diff --git a/jaws/uiregister.go b/jaws/register.go similarity index 77% rename from jaws/uiregister.go rename to jaws/register.go index d8c2b46f..010e28f3 100644 --- a/jaws/uiregister.go +++ b/jaws/register.go @@ -6,12 +6,12 @@ import ( "github.com/linkdata/jaws/jid" ) -type UiRegister struct { +type registerUI struct { Updater } -func (ui UiRegister) JawsRender(e *Element, w io.Writer, params []any) (err error) { - return +func (registerUI) JawsRender(*Element, io.Writer, []any) error { + return nil } // Register creates a new Element with the given Updater as a tag @@ -23,7 +23,7 @@ func (ui UiRegister) JawsRender(e *Element, w io.Writer, params []any) (err erro // //
...
func (rq RequestWriter) Register(updater Updater, params ...any) jid.Jid { - elem := rq.rq.NewElement(UiRegister{Updater: updater}) + elem := rq.rq.NewElement(registerUI{Updater: updater}) elem.Tag(updater) elem.ApplyParams(params) updater.JawsUpdate(elem) diff --git a/jaws/request.go b/jaws/request.go index fc94b5af..267f81ac 100644 --- a/jaws/request.go +++ b/jaws/request.go @@ -742,6 +742,15 @@ func (rq *Request) deleteElement(e *Element) { rq.deleteElementLocked(e) } +// DeleteElement removes elem from the Request element registry. +// +// This is primarily intended for UI implementations that manage dynamic child +// element sets and need to drop stale elements after issuing a corresponding +// DOM remove operation. +func (rq *Request) DeleteElement(elem *Element) { + rq.deleteElement(elem) +} + func (rq *Request) makeUpdateList() (todo []*Element) { rq.mu.Lock() seen := map[*Element]struct{}{} diff --git a/jaws/request_test.go b/jaws/request_test.go index 3d1c9169..fed8fd69 100644 --- a/jaws/request_test.go +++ b/jaws/request_test.go @@ -604,8 +604,8 @@ func TestRequest_Dirty(t *testing.T) { tss1 := &testUi{s: "foo1"} tss2 := &testUi{s: "foo2"} - rq.UI(NewUiText(tss1)) - rq.UI(NewUiText(tss2)) + rq.UI(newTestTextInputWidget(tss1)) + rq.UI(newTestTextInputWidget(tss2)) th.Equal(tss1.getCalled, int32(1)) th.Equal(tss2.getCalled, int32(1)) th.True(strings.Contains(string(rq.BodyString()), "foo1")) @@ -654,7 +654,7 @@ func TestRequest_IncomingRemove(t *testing.T) { defer rq.Close() tss := newTestSetter("") - rq.UI(NewUiText(tss)) + rq.UI(newTestTextInputWidget(tss)) select { case <-th.C: @@ -690,8 +690,8 @@ func TestRequest_IncomingClick(t *testing.T) { testSetter: newTestSetter(""), } - rq.Div("1", tjc1) - rq.Div("2", tjc2) + rq.UI(testDivWidget{inner: "1"}, tjc1) + rq.UI(testDivWidget{inner: "2"}, tjc2) select { case <-th.C: diff --git a/jaws/requestwriter_widgets.go b/jaws/requestwriter_widgets.go new file mode 100644 index 00000000..d1c9ba16 --- /dev/null +++ b/jaws/requestwriter_widgets.go @@ -0,0 +1,167 @@ +package jaws + +import "time" + +// RequestWriterWidgetFactory provides widget constructors used by +// RequestWriter helper methods (A, Span, Text, ...). +// +// The ui package registers the canonical implementation via init(), so core +// RequestWriter keeps its legacy API without duplicating widget logic. +type RequestWriterWidgetFactory struct { + A func(HTMLGetter) UI + Button func(HTMLGetter) UI + Checkbox func(Setter[bool]) UI + Container func(string, Container) UI + Date func(Setter[time.Time]) UI + Div func(HTMLGetter) UI + Img func(Getter[string]) UI + Label func(HTMLGetter) UI + Li func(HTMLGetter) UI + Number func(Setter[float64]) UI + Password func(Setter[string]) UI + Radio func(Setter[bool]) UI + Range func(Setter[float64]) UI + Select func(SelectHandler) UI + Span func(HTMLGetter) UI + Tbody func(Container) UI + Td func(HTMLGetter) UI + Text func(Setter[string]) UI + Textarea func(Setter[string]) UI + Tr func(HTMLGetter) UI +} + +var requestWriterWidgets RequestWriterWidgetFactory + +// RegisterRequestWriterWidgets installs constructor hooks for RequestWriter +// helper methods. This is called by package ui during init(). +func RegisterRequestWriterWidgets(f RequestWriterWidgetFactory) { + requestWriterWidgets = f +} + +func mustRequestWriterWidgets() RequestWriterWidgetFactory { + f := requestWriterWidgets + if f.Span == nil { + panic("jaws: RequestWriter widget helpers are not registered; import github.com/linkdata/jaws/ui or github.com/linkdata/jaws") + } + return f +} + +// A writes an element. +func (rw RequestWriter) A(innerHTML any, params ...any) error { + f := mustRequestWriterWidgets() + return rw.UI(f.A(MakeHTMLGetter(innerHTML)), params...) +} + +// Button writes a ` - rq.Button("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Button() = %q, want %q", got, want) - } -} diff --git a/jaws/uicheckbox.go b/jaws/uicheckbox.go deleted file mode 100644 index df19cf8b..00000000 --- a/jaws/uicheckbox.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiCheckbox struct { - UiInputBool -} - -func (ui *UiCheckbox) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderBoolInput(e, w, "checkbox", params...) -} - -func NewUiCheckbox(g Setter[bool]) *UiCheckbox { - return &UiCheckbox{ - UiInputBool{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Checkbox(value any, params ...any) error { - return rq.UI(NewUiCheckbox(makeSetter[bool](value)), params...) -} diff --git a/jaws/uicheckbox_test.go b/jaws/uicheckbox_test.go deleted file mode 100644 index b537889b..00000000 --- a/jaws/uicheckbox_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Checkbox(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(true) - want := `` - rq.Checkbox(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Checkbox() = %q, want %q", got, want) - } - - val := false - rq.InCh <- wsMsg{Data: "false", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != val { - t.Error(ts.Get(), "!=", val) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - - val = true - ts.Set(val) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"true\"\n" { - t.Errorf("%q", s) - } - } - if ts.Get() != val { - t.Error("not set") - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nstrconv.ParseBool: parsing "omg": invalid syntax\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: "true", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } -} diff --git a/jaws/uicontainer.go b/jaws/uicontainer.go deleted file mode 100644 index ecd8689c..00000000 --- a/jaws/uicontainer.go +++ /dev/null @@ -1,27 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiContainer struct { - OuterHTMLTag string - uiWrapContainer -} - -func NewUiContainer(outerHTMLTag string, c Container) *UiContainer { - return &UiContainer{ - OuterHTMLTag: outerHTMLTag, - uiWrapContainer: uiWrapContainer{ - Container: c, - }, - } -} - -func (ui *UiContainer) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderContainer(e, w, ui.OuterHTMLTag, params) -} - -func (rq RequestWriter) Container(outerHTMLTag string, c Container, params ...any) error { - return rq.UI(NewUiContainer(outerHTMLTag, c), params...) -} diff --git a/jaws/uicontainer_test.go b/jaws/uicontainer_test.go deleted file mode 100644 index 45730529..00000000 --- a/jaws/uicontainer_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package jaws - -import ( - "html/template" - "net/http" - "net/http/httptest" - "reflect" - "slices" - "strings" - "testing" - - "github.com/linkdata/jaws/what" -) - -type testContainer struct{ contents []UI } - -func (tc *testContainer) JawsContains(e *Element) (contents []UI) { - return tc.contents -} - -var _ Container = &testContainer{} - -func TestRequest_Container(t *testing.T) { - type args struct { - c Container - params []any - } - tests := []struct { - name string - args args - want template.HTML - }{ - { - name: "empty", - args: args{ - c: &testContainer{}, - params: []any{}, - }, - want: `
`, - }, - { - name: "one", - args: args{ - c: &testContainer{[]UI{NewUiSpan(testHTMLGetter("foo"))}}, - params: []any{"hidden"}, - }, - want: ``, - }, - { - name: "two", - args: args{ - c: &testContainer{[]UI{NewUiSpan(testHTMLGetter("foo")), NewUiSpan(testHTMLGetter("bar"))}}, - params: []any{"hidden"}, - }, - want: ``, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - err := rq.Container("div", tt.args.c, tt.args.params...) - if err != nil { - t.Error(err) - } - if got := rq.BodyHTML(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("Request.Container()\nwant %v\n got %v", tt.want, got) - } - }) - } -} - -func TestRequest_Container_Alteration(t *testing.T) { - span1 := NewUiSpan(MakeHTMLGetter("span1")) - span2 := NewUiSpan(MakeHTMLGetter("span2")) - span3 := NewUiSpan(MakeHTMLGetter("span3")) - tests := []struct { - name string - c *testContainer - l []UI - want []wsMsg - }{ - { - name: "no change", - c: &testContainer{contents: []UI{span1, span2, span3}}, - l: []UI{span1, span2, span3}, - want: []wsMsg{}, - }, - { - name: "add one to empty", - c: &testContainer{}, - l: []UI{span1}, - want: []wsMsg{ - { - Data: `span1`, - Jid: 1, - What: what.Append, - }, - { - Data: `Jid.2`, - Jid: 1, - What: what.Order, - }, - }, - }, - { - name: "append two", - c: &testContainer{contents: []UI{span1}}, - l: []UI{span1, span2, span3}, - want: []wsMsg{ - { - Data: `span2`, - Jid: 1, - What: what.Append, - }, - { - Data: `span3`, - Jid: 1, - What: what.Append, - }, - { - Data: `Jid.2 Jid.3 Jid.4`, - Jid: 1, - What: what.Order, - }, - }, - }, - { - name: "remove first", - c: &testContainer{contents: []UI{span1, span2, span3}}, - l: []UI{span2, span3}, - want: []wsMsg{ - { - Data: `Jid.2`, - Jid: 1, - What: what.Remove, - }, - { - Data: `Jid.3 Jid.4`, - Jid: 1, - What: what.Order, - }, - }, - }, - { - name: "reorder and replace", - c: &testContainer{contents: []UI{span1, span2}}, - l: []UI{span3, span1}, - want: []wsMsg{ - { - Data: `Jid.3`, - Jid: 1, - What: what.Remove, - }, - { - Data: `span3`, - Jid: 1, - What: what.Append, - }, - { - Data: `Jid.4 Jid.2`, - Jid: 1, - What: what.Order, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - jw, _ := New() - defer jw.Close() - nextJid = 0 - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) - ui := NewUiContainer("div", tt.c) - elem := rq.NewElement(ui) - var sb strings.Builder - if err := ui.JawsRender(elem, &sb, nil); err != nil { - t.Fatal(err) - } - tt.c.contents = tt.l - elem.JawsUpdate() - if !slices.Equal(rq.wsQueue, tt.want) { - t.Errorf("got %v, want %v", rq.wsQueue, tt.want) - } - }) - } -} diff --git a/jaws/uidate.go b/jaws/uidate.go deleted file mode 100644 index 7b725230..00000000 --- a/jaws/uidate.go +++ /dev/null @@ -1,28 +0,0 @@ -package jaws - -import ( - "io" - "time" -) - -const ISO8601 = "2006-01-02" - -type UiDate struct { - UiInputDate -} - -func (ui *UiDate) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderDateInput(e, w, "date", params...) -} - -func NewUiDate(g Setter[time.Time]) *UiDate { - return &UiDate{ - UiInputDate{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Date(value any, params ...any) error { - return rq.UI(NewUiDate(makeSetter[time.Time](value)), params...) -} diff --git a/jaws/uidate_test.go b/jaws/uidate_test.go deleted file mode 100644 index de30cab8..00000000 --- a/jaws/uidate_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package jaws - -import ( - "errors" - "fmt" - "testing" - "time" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Date(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(time.Now()) - want := fmt.Sprintf(``, ts.Get().Format(ISO8601)) - rq.Date(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Date() = %q, want %q", got, want) - } - - val, _ := time.Parse(ISO8601, "1970-02-03") - rq.InCh <- wsMsg{Data: val.Format(ISO8601), Jid: 1, What: what.Input} - tmr := time.NewTimer(testTimeout) - defer tmr.Stop() - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != val { - t.Error(ts.Get(), "!=", val) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - - val = time.Now() - ts.Set(val) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != fmt.Sprintf("Value\tJid.1\t\"%s\"\n", val.Format(ISO8601)) { - t.Error("wrong Value") - } - } - if ts.Get() != val { - t.Error("not set") - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nparsing time "omg" as "2006-01-02": cannot parse "omg" as "2006"\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: val.Format(ISO8601), Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } -} diff --git a/jaws/uidiv.go b/jaws/uidiv.go deleted file mode 100644 index b40dddaa..00000000 --- a/jaws/uidiv.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiDiv struct { - UiHTMLInner -} - -func (ui *UiDiv) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "div", "", params) -} - -func NewUiDiv(innerHTML HTMLGetter) *UiDiv { - return &UiDiv{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Div(innerHTML any, params ...any) error { - return rq.UI(NewUiDiv(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uidiv_test.go b/jaws/uidiv_test.go deleted file mode 100644 index 95ddc020..00000000 --- a/jaws/uidiv_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Div(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `
inner
` - rq.Div("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Div() = %q, want %q", got, want) - } -} diff --git a/jaws/uihtmlinner.go b/jaws/uihtmlinner.go deleted file mode 100644 index 93d61061..00000000 --- a/jaws/uihtmlinner.go +++ /dev/null @@ -1,20 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiHTMLInner struct { - HTMLGetter -} - -func (ui *UiHTMLInner) renderInner(e *Element, w io.Writer, htmltag, htmltype string, params []any) (err error) { - if _, err = e.ApplyGetter(ui.HTMLGetter); err == nil { - err = WriteHTMLInner(w, e.Jid(), htmltag, htmltype, ui.JawsGetHTML(e), e.ApplyParams(params)...) - } - return -} - -func (ui *UiHTMLInner) JawsUpdate(e *Element) { - e.SetInner(ui.JawsGetHTML(e)) -} diff --git a/jaws/uihtmlinner_test.go b/jaws/uihtmlinner_test.go deleted file mode 100644 index edfab8e3..00000000 --- a/jaws/uihtmlinner_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package jaws - -import ( - "html/template" - "net/http" - "net/http/httptest" - "slices" - "strings" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestUiHTMLInner_JawsUpdate(t *testing.T) { - jw, _ := New() - defer jw.Close() - nextJid = 0 - ts := newTestSetter(template.HTML("first")) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) - ui := NewUiDiv(ts) - elem := rq.NewElement(ui) - var sb strings.Builder - if err := ui.JawsRender(elem, &sb, nil); err != nil { - t.Fatal(err) - } - wantHTML := "
first
" - if sb.String() != wantHTML { - t.Errorf("got %q, want %q", sb.String(), wantHTML) - } - ts.Set(template.HTML("second")) - ui.JawsUpdate(elem) - want := []wsMsg{{ - Data: "second", - Jid: 1, - What: what.Inner, - }} - if !slices.Equal(rq.wsQueue, want) { - t.Errorf("got %v, want %v", elem.wsQueue, want) - } -} diff --git a/jaws/uiimg.go b/jaws/uiimg.go deleted file mode 100644 index e5403e39..00000000 --- a/jaws/uiimg.go +++ /dev/null @@ -1,34 +0,0 @@ -package jaws - -import ( - "html/template" - "io" - "strconv" -) - -type UiImg struct { - Getter[string] -} - -func (ui *UiImg) JawsRender(e *Element, w io.Writer, params []any) (err error) { - if _, err = e.ApplyGetter(ui.Getter); err == nil { - srcattr := template.HTMLAttr("src=" + strconv.Quote(ui.JawsGet(e))) // #nosec G203 - attrs := append(e.ApplyParams(params), srcattr) - err = WriteHTMLInner(w, e.Jid(), "img", "", "", attrs...) - } - return -} - -func (ui *UiImg) JawsUpdate(e *Element) { - e.SetAttr("src", ui.JawsGet(e)) -} - -func NewUiImg(g Getter[string]) *UiImg { - return &UiImg{ - Getter: g, - } -} - -func (rq RequestWriter) Img(imageSrc any, params ...any) error { - return rq.UI(NewUiImg(makeGetter[string](imageSrc)), params...) -} diff --git a/jaws/uiimg_test.go b/jaws/uiimg_test.go deleted file mode 100644 index 341c7cfa..00000000 --- a/jaws/uiimg_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package jaws - -import ( - "strconv" - "testing" -) - -func TestRequest_Img(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter("image.png") - - wantHTML := "" - rq.Img(ts, "hidden") - if gotHTML := rq.BodyString(); gotHTML != wantHTML { - t.Errorf("Request.Img() = %q, want %q", gotHTML, wantHTML) - } - - ts.Set("image2.jpg") - rq.Dirty(ts) - - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "SAttr\tJid.1\t\"src\\nimage2.jpg\"\n" { - t.Error(strconv.Quote(s)) - } - } -} diff --git a/jaws/uiinput.go b/jaws/uiinput.go deleted file mode 100644 index 2204959c..00000000 --- a/jaws/uiinput.go +++ /dev/null @@ -1,21 +0,0 @@ -package jaws - -import "sync/atomic" - -type UiInput struct { - Tag any - Last atomic.Value -} - -func (ui *UiInput) applyGetter(e *Element, getter any) (err error) { - ui.Tag, err = e.ApplyGetter(getter) - return -} - -func (ui *UiInput) maybeDirty(val any, e *Element, err error) error { - var changed bool - if changed, err = e.maybeDirty(ui.Tag, err); changed { - ui.Last.Store(val) - } - return err -} diff --git a/jaws/uiinputbool.go b/jaws/uiinputbool.go deleted file mode 100644 index 498e1a05..00000000 --- a/jaws/uiinputbool.go +++ /dev/null @@ -1,51 +0,0 @@ -package jaws - -import ( - "io" - "strconv" - - "github.com/linkdata/jaws/what" -) - -type UiInputBool struct { - UiInput - Setter[bool] -} - -func (ui *UiInputBool) renderBoolInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - v := ui.JawsGet(e) - ui.Last.Store(v) - if v { - attrs = append(attrs, "checked") - } - err = WriteHTMLInput(w, e.Jid(), htmltype, "", attrs) - } - return -} - -func (ui *UiInputBool) JawsUpdate(e *Element) { - v := ui.JawsGet(e) - if ui.Last.Swap(v) != v { - txt := "false" - if v { - txt = "true" - } - e.SetValue(txt) - } -} - -func (ui *UiInputBool) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - var v bool - if val != "" { - if v, err = strconv.ParseBool(val); err != nil { - return - } - } - err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) - } - return -} diff --git a/jaws/uiinputdate.go b/jaws/uiinputdate.go deleted file mode 100644 index 9261eb41..00000000 --- a/jaws/uiinputdate.go +++ /dev/null @@ -1,46 +0,0 @@ -package jaws - -import ( - "io" - "time" - - "github.com/linkdata/jaws/what" -) - -type UiInputDate struct { - UiInput - Setter[time.Time] -} - -func (ui *UiInputDate) str() string { - return ui.Last.Load().(time.Time).Format(ISO8601) -} - -func (ui *UiInputDate) renderDateInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - ui.Last.Store(ui.JawsGet(e)) - err = WriteHTMLInput(w, e.Jid(), htmltype, ui.str(), attrs) - } - return -} - -func (ui *UiInputDate) JawsUpdate(e *Element) { - if t := ui.JawsGet(e); ui.Last.Swap(t) != t { - e.SetValue(ui.str()) - } -} - -func (ui *UiInputDate) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - var v time.Time - if val != "" { - if v, err = time.Parse(ISO8601, val); err != nil { - return - } - } - err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) - } - return -} diff --git a/jaws/uiinputfloat.go b/jaws/uiinputfloat.go deleted file mode 100644 index 2dca8def..00000000 --- a/jaws/uiinputfloat.go +++ /dev/null @@ -1,46 +0,0 @@ -package jaws - -import ( - "io" - "strconv" - - "github.com/linkdata/jaws/what" -) - -type UiInputFloat struct { - UiInput - Setter[float64] -} - -func (ui *UiInputFloat) str() string { - return strconv.FormatFloat(ui.Last.Load().(float64), 'f', -1, 64) -} - -func (ui *UiInputFloat) renderFloatInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - ui.Last.Store(ui.JawsGet(e)) - err = WriteHTMLInput(w, e.Jid(), htmltype, ui.str(), attrs) - } - return -} - -func (ui *UiInputFloat) JawsUpdate(e *Element) { - if f := ui.JawsGet(e); ui.Last.Swap(f) != f { - e.SetValue(ui.str()) - } -} - -func (ui *UiInputFloat) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - var v float64 - if val != "" { - if v, err = strconv.ParseFloat(val, 64); err != nil { - return - } - } - err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) - } - return -} diff --git a/jaws/uiinputtext.go b/jaws/uiinputtext.go deleted file mode 100644 index 0395320f..00000000 --- a/jaws/uiinputtext.go +++ /dev/null @@ -1,36 +0,0 @@ -package jaws - -import ( - "io" - - "github.com/linkdata/jaws/what" -) - -type UiInputText struct { - UiInput - Setter[string] -} - -func (ui *UiInputText) renderStringInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - v := ui.JawsGet(e) - ui.Last.Store(v) - err = WriteHTMLInput(w, e.Jid(), htmltype, v, attrs) - } - return -} - -func (ui *UiInputText) JawsUpdate(e *Element) { - if v := ui.JawsGet(e); ui.Last.Swap(v) != v { - e.SetValue(v) - } -} - -func (ui *UiInputText) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - err = ui.maybeDirty(val, e, ui.Setter.JawsSet(e, val)) - } - return -} diff --git a/jaws/uilabel.go b/jaws/uilabel.go deleted file mode 100644 index b503ab51..00000000 --- a/jaws/uilabel.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiLabel struct { - UiHTMLInner -} - -func (ui *UiLabel) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "label", "", params) -} - -func NewUiLabel(innerHTML HTMLGetter) *UiLabel { - return &UiLabel{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Label(innerHTML any, params ...any) error { - return rq.UI(NewUiLabel(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uilabel_test.go b/jaws/uilabel_test.go deleted file mode 100644 index db60b725..00000000 --- a/jaws/uilabel_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Label(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `` - rq.Label("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Label() = %q, want %q", got, want) - } -} diff --git a/jaws/uili.go b/jaws/uili.go deleted file mode 100644 index 3cea94b5..00000000 --- a/jaws/uili.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiLi struct { - UiHTMLInner -} - -func (ui *UiLi) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "li", "", params) -} - -func NewUiLi(innerHTML HTMLGetter) *UiLi { - return &UiLi{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Li(innerHTML any, params ...any) error { - return rq.UI(NewUiLi(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uili_test.go b/jaws/uili_test.go deleted file mode 100644 index 979f1438..00000000 --- a/jaws/uili_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Li(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `
  • inner
  • ` - rq.Li("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Li() = %q, want %q", got, want) - } -} diff --git a/jaws/uinumber.go b/jaws/uinumber.go deleted file mode 100644 index 03d9db79..00000000 --- a/jaws/uinumber.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiNumber struct { - UiInputFloat -} - -func (ui *UiNumber) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderFloatInput(e, w, "number", params...) -} - -func NewUiNumber(g Setter[float64]) *UiNumber { - return &UiNumber{ - UiInputFloat{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Number(value any, params ...any) error { - return rq.UI(NewUiNumber(makeSetterFloat64(value)), params...) -} diff --git a/jaws/uinumber_test.go b/jaws/uinumber_test.go deleted file mode 100644 index a93a6752..00000000 --- a/jaws/uinumber_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package jaws - -import ( - "errors" - "fmt" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Number(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(float64(1.2)) - want := fmt.Sprintf(``, ts.Get()) - rq.Number(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Number() = %q, want %q", got, want) - } - - val := float64(2.3) - rq.InCh <- wsMsg{Data: fmt.Sprint(val), Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != val { - t.Error(ts.Get(), "!=", val) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - - val = 3.4 - ts.Set(val) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != fmt.Sprintf("Value\tJid.1\t\"%v\"\n", val) { - t.Error("wrong Value") - } - } - if ts.Get() != val { - t.Error("not set") - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nstrconv.ParseFloat: parsing "omg": invalid syntax\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: fmt.Sprint(val), Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } -} diff --git a/jaws/uioption_test.go b/jaws/uioption_test.go deleted file mode 100644 index 363e5e36..00000000 --- a/jaws/uioption_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package jaws - -import ( - "strings" - "testing" -) - -func TestUiOption(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - nba := NewNamedBoolArray() - nb := NewNamedBool(nba, "escape\"me", "", true) - - ui := UiOption{nb} - elem := rq.NewElement(ui) - var sb strings.Builder - if err := ui.JawsRender(elem, &sb, []any{"hidden"}); err != nil { - t.Fatal(err) - } - wantHTML := "" - if gotHTML := sb.String(); gotHTML != wantHTML { - t.Errorf("got %q, want %q", gotHTML, wantHTML) - } - - nb.Set(false) - rq.Dirty(nb) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "RAttr\tJid.1\t\"selected\"\n" { - t.Errorf("%q", s) - } - } - - nb.Set(true) - rq.Dirty(nb) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "SAttr\tJid.1\t\"selected\\n\"\n" { - t.Errorf("%q", s) - } - } -} diff --git a/jaws/uipassword.go b/jaws/uipassword.go deleted file mode 100644 index b3d1a287..00000000 --- a/jaws/uipassword.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiPassword struct { - UiInputText -} - -func (ui *UiPassword) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderStringInput(e, w, "password", params...) -} - -func NewUiPassword(g Setter[string]) *UiPassword { - return &UiPassword{ - UiInputText{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Password(value any, params ...any) error { - return rq.UI(NewUiPassword(makeSetter[string](value)), params...) -} diff --git a/jaws/uipassword_test.go b/jaws/uipassword_test.go deleted file mode 100644 index 8e42e7d1..00000000 --- a/jaws/uipassword_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Password(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - ts := newTestSetter("") - want := `` - rq.Password(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Password() = %q, want %q", got, want) - } -} diff --git a/jaws/uiradio.go b/jaws/uiradio.go deleted file mode 100644 index a321a1e7..00000000 --- a/jaws/uiradio.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiRadio struct { - UiInputBool -} - -func (ui *UiRadio) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderBoolInput(e, w, "radio", params...) -} - -func NewUiRadio(vp Setter[bool]) *UiRadio { - return &UiRadio{ - UiInputBool{ - Setter: vp, - }, - } -} - -func (rq RequestWriter) Radio(value any, params ...any) error { - return rq.UI(NewUiRadio(makeSetter[bool](value)), params...) -} diff --git a/jaws/uiradio_test.go b/jaws/uiradio_test.go deleted file mode 100644 index c0c40391..00000000 --- a/jaws/uiradio_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Radio(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(true) - want := `` - rq.Radio(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Radio() = %q, want %q", got, want) - } -} diff --git a/jaws/uiradiogroup.go b/jaws/uiradiogroup.go deleted file mode 100644 index 1ab03974..00000000 --- a/jaws/uiradiogroup.go +++ /dev/null @@ -1,42 +0,0 @@ -package jaws - -import ( - "html/template" - "strings" -) - -type RadioElement struct { - radio *Element - label *Element - nameAttr string -} - -func (rw RequestWriter) RadioGroup(nba *NamedBoolArray) (rel []RadioElement) { - nameAttr := `name="` + MakeID() + `"` - nba.ReadLocked(func(nbl []*NamedBool) { - for _, nb := range nbl { - rel = append(rel, RadioElement{ - radio: rw.Request().NewElement(NewUiRadio(nb)), - label: rw.Request().NewElement(NewUiLabel(nb)), - nameAttr: nameAttr, - }, - ) - } - }) - return -} - -// Radio renders a HTML input element of type 'radio'. -func (re RadioElement) Radio(params ...any) template.HTML { - var sb strings.Builder - maybePanic(re.radio.JawsRender(&sb, append(params, re.nameAttr))) - return template.HTML(sb.String()) // #nosec G203 -} - -// Label renders a HTML label element. -func (re RadioElement) Label(params ...any) template.HTML { - var sb strings.Builder - forAttr := string(re.radio.Jid().AppendQuote([]byte("for="))) - maybePanic(re.label.JawsRender(&sb, append(params, forAttr))) - return template.HTML(sb.String()) // #nosec G203 -} diff --git a/jaws/uiradiogroup_test.go b/jaws/uiradiogroup_test.go deleted file mode 100644 index 50e9c17d..00000000 --- a/jaws/uiradiogroup_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_RadioGroup(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - nba := NewNamedBoolArray() - nba.Add("1", "one") - rel := rq.RadioGroup(nba) - - wantHTML := "" - gotHTML := string(rel[0].Radio("radioattr")) - if gotHTML != wantHTML { - t.Errorf("got %q, want %q", gotHTML, wantHTML) - } - - wantHTML = "" - gotHTML = string(rel[0].Label("labelattr")) - if gotHTML != wantHTML { - t.Errorf("got %q, want %q", gotHTML, wantHTML) - } -} diff --git a/jaws/uirange.go b/jaws/uirange.go deleted file mode 100644 index b087753a..00000000 --- a/jaws/uirange.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiRange struct { - UiInputFloat -} - -func (ui *UiRange) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderFloatInput(e, w, "range", params...) -} - -func NewUiRange(g Setter[float64]) *UiRange { - return &UiRange{ - UiInputFloat{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Range(value any, params ...any) error { - return rq.UI(NewUiRange(makeSetterFloat64(value)), params...) -} diff --git a/jaws/uirange_test.go b/jaws/uirange_test.go deleted file mode 100644 index 8e93beeb..00000000 --- a/jaws/uirange_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Range(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(float64(1)) - want := `` - rq.Range(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Range() = %q, want %q", got, want) - } - rq.InCh <- wsMsg{Data: "2.1", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != 2.1 { - t.Error(ts.Get()) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - ts.Set(2.3) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"2.3\"\n" { - t.Error(s) - } - } - if ts.Get() != 2.3 { - t.Error(ts.Get()) - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: "3.4", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - if ts.Get() != 2.3 { - t.Error(ts.Get()) - } -} diff --git a/jaws/uiregister_test.go b/jaws/uiregister_test.go deleted file mode 100644 index 96242d9d..00000000 --- a/jaws/uiregister_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package jaws - -import ( - "sync/atomic" - "testing" -) - -func TestRequestWriter_Register(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - item := &testUi{} - jid := rq.Register(item) - th.Equal(jid, Jid(1)) - th.Equal(atomic.LoadInt32(&item.updateCalled), int32(1)) - e := rq.GetElementByJid(jid) - th.NoErr(e.JawsRender(nil, nil)) -} diff --git a/jaws/uiselect.go b/jaws/uiselect.go deleted file mode 100644 index 1ca32514..00000000 --- a/jaws/uiselect.go +++ /dev/null @@ -1,40 +0,0 @@ -package jaws - -import ( - "io" - - "github.com/linkdata/jaws/what" -) - -type UiSelect struct { - uiWrapContainer -} - -func NewUiSelect(sh SelectHandler) *UiSelect { - return &UiSelect{ - uiWrapContainer: uiWrapContainer{ - Container: sh, - }, - } -} - -func (ui *UiSelect) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderContainer(e, w, "select", params) -} - -func (ui *UiSelect) JawsUpdate(e *Element) { - e.SetValue(ui.uiWrapContainer.Container.(Getter[string]).JawsGet(e)) - ui.uiWrapContainer.JawsUpdate(e) -} - -func (ui *UiSelect) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - _, err = e.maybeDirty(ui.Tag, ui.uiWrapContainer.Container.(Setter[string]).JawsSet(e, val)) - } - return -} - -func (rq RequestWriter) Select(sh SelectHandler, params ...any) error { - return rq.UI(NewUiSelect(sh), params...) -} diff --git a/jaws/uiselect_test.go b/jaws/uiselect_test.go deleted file mode 100644 index 521fcdea..00000000 --- a/jaws/uiselect_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/deadlock" - "github.com/linkdata/jaws/what" -) - -type testNamedBoolArray struct { - mu deadlock.Mutex - setCount int - setCalled chan struct{} - err error - *NamedBoolArray -} - -func (ts *testNamedBoolArray) JawsSet(e *Element, val string) (err error) { - ts.mu.Lock() - defer ts.mu.Unlock() - if err = ts.err; err == nil { - err = ts.NamedBoolArray.JawsSet(e, val) - ts.setCount++ - if ts.setCount == 1 { - close(ts.setCalled) - } - } - return -} - -func TestRequest_Select(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - a := &testNamedBoolArray{ - setCalled: make(chan struct{}), - NamedBoolArray: NewNamedBoolArray(), - } - a.Add("1", "one") - a.Add("2", "two") - a.Set("1", true) - - wantHTML := "" - rq.Select(a, "disabled") - if gotHTML := rq.BodyString(); gotHTML != wantHTML { - t.Errorf("Request.Select() = %q, want %q", gotHTML, wantHTML) - } - - if !a.IsChecked("1") { - t.Error("1 is not checked") - } - if a.IsChecked("2") { - t.Error("2 is checked") - } - - rq.InCh <- wsMsg{Data: "2", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-a.setCalled: - } - - if a.IsChecked("1") { - t.Error("1 is checked") - } - if !a.IsChecked("2") { - t.Error("2 is not checked") - } - - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"2\"\n" { - t.Errorf("wrong Value %q", s) - } - } - - a.Set("2", false) - rq.Dirty(a) - - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"\"\n" { - t.Errorf("wrong Value %q", s) - } - } - - if a.IsChecked("1") { - t.Error("1 is checked") - } - if a.IsChecked("2") { - t.Error("2 is checked") - } - - a.mu.Lock() - a.err = errors.New("meh") - a.mu.Unlock() - rq.InCh <- wsMsg{Data: "1", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - select { - case msg := <-rq.OutCh: - s := msg.Format() - t.Errorf("queued msg: %q", s) - default: - } - } - } - - if a.IsChecked("1") { - t.Error("1 is checked") - } - if a.IsChecked("2") { - t.Error("2 is checked") - } -} diff --git a/jaws/uispan.go b/jaws/uispan.go deleted file mode 100644 index 394cd4b6..00000000 --- a/jaws/uispan.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiSpan struct { - UiHTMLInner -} - -func (ui *UiSpan) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "span", "", params) -} - -func NewUiSpan(innerHTML HTMLGetter) *UiSpan { - return &UiSpan{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Span(innerHTML any, params ...any) error { - return rq.UI(NewUiSpan(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uispan_test.go b/jaws/uispan_test.go deleted file mode 100644 index c16551ac..00000000 --- a/jaws/uispan_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Span(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `inner` - rq.Span("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Span() = %q, want %q", got, want) - } -} diff --git a/jaws/uitbody.go b/jaws/uitbody.go deleted file mode 100644 index c8c03742..00000000 --- a/jaws/uitbody.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiTbody struct { - uiWrapContainer -} - -func NewUiTbody(c Container) *UiTbody { - return &UiTbody{ - uiWrapContainer{ - Container: c, - }, - } -} - -func (ui *UiTbody) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderContainer(e, w, "tbody", params) -} - -func (rq RequestWriter) Tbody(c Container, params ...any) error { - return rq.UI(NewUiTbody(c), params...) -} diff --git a/jaws/uitbody_test.go b/jaws/uitbody_test.go deleted file mode 100644 index b64d4fc4..00000000 --- a/jaws/uitbody_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Tbody(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `` - rq.Tbody(&testContainer{}) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Span() = %q, want %q", got, want) - } -} diff --git a/jaws/uitd.go b/jaws/uitd.go deleted file mode 100644 index 6c730b96..00000000 --- a/jaws/uitd.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiTd struct { - UiHTMLInner -} - -func (ui *UiTd) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "td", "", params) -} - -func NewUiTd(innerHTML HTMLGetter) *UiTd { - return &UiTd{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Td(innerHTML any, params ...any) error { - return rq.UI(NewUiTd(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uitd_test.go b/jaws/uitd_test.go deleted file mode 100644 index 952effcc..00000000 --- a/jaws/uitd_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Td(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `inner` - rq.Td("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Td() = %q, want %q", got, want) - } -} diff --git a/jaws/uitext.go b/jaws/uitext.go deleted file mode 100644 index 9ce4a580..00000000 --- a/jaws/uitext.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiText struct { - UiInputText -} - -func (ui *UiText) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderStringInput(e, w, "text", params...) -} - -func NewUiText(vp Setter[string]) (ui *UiText) { - return &UiText{ - UiInputText{ - Setter: vp, - }, - } -} - -func (rq RequestWriter) Text(value any, params ...any) error { - return rq.UI(NewUiText(makeSetter[string](value)), params...) -} diff --git a/jaws/uitext_test.go b/jaws/uitext_test.go deleted file mode 100644 index 17263816..00000000 --- a/jaws/uitext_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Text(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ss := newTestSetter("foo") - want := `` - rq.Text(ss) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Text() = %q, want %q", got, want) - } - rq.InCh <- wsMsg{Data: "bar", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ss.setCalled: - } - if ss.Get() != "bar" { - t.Error(ss.Get()) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - ss.Set("quux") - rq.Dirty(ss) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"quux\"\n" { - t.Error("wrong Value") - } - } - if ss.Get() != "quux" { - t.Error("not quux") - } - if ss.SetCount() != 1 { - t.Error("SetCount", ss.SetCount()) - } - - ss.err = errors.New("meh") - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - if ss.Get() != "quux" { - t.Error("unexpected change", ss.Get()) - } -} diff --git a/jaws/uitextarea.go b/jaws/uitextarea.go deleted file mode 100644 index 0603ec6e..00000000 --- a/jaws/uitextarea.go +++ /dev/null @@ -1,34 +0,0 @@ -package jaws - -import ( - "html/template" - "io" -) - -type UiTextarea struct { - UiInputText -} - -func (ui *UiTextarea) JawsRender(e *Element, w io.Writer, params []any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - err = WriteHTMLInner(w, e.Jid(), "textarea", "", template.HTML(ui.JawsGet(e)), attrs...) // #nosec G203 - } - return -} - -func (ui *UiTextarea) JawsUpdate(e *Element) { - e.SetValue(ui.JawsGet(e)) -} - -func NewUiTextarea(g Setter[string]) (ui *UiTextarea) { - return &UiTextarea{ - UiInputText{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Textarea(value any, params ...any) error { - return rq.UI(NewUiTextarea(makeSetter[string](value)), params...) -} diff --git a/jaws/uitextarea_test.go b/jaws/uitextarea_test.go deleted file mode 100644 index fe4ef77d..00000000 --- a/jaws/uitextarea_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package jaws - -import ( - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Textarea(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ss := newTestSetter("foo") - want := `` - rq.Textarea(ss) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Textarea() = %q, want %q", got, want) - } - rq.InCh <- wsMsg{Data: "bar", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ss.setCalled: - } - if ss.Get() != "bar" { - t.Fail() - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - ss.Set("quux") - rq.Dirty(ss) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"quux\"\n" { - t.Fail() - } - } - if ss.Get() != "quux" { - t.Fail() - } - if ss.SetCount() != 1 { - t.Fail() - } -} diff --git a/jaws/uitr.go b/jaws/uitr.go deleted file mode 100644 index 47ea4534..00000000 --- a/jaws/uitr.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiTr struct { - UiHTMLInner -} - -func (ui *UiTr) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "tr", "", params) -} - -func NewUiTr(innerHTML HTMLGetter) *UiTr { - return &UiTr{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Tr(innerHTML any, params ...any) error { - return rq.UI(NewUiTr(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uitr_test.go b/jaws/uitr_test.go deleted file mode 100644 index 1be3f270..00000000 --- a/jaws/uitr_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Tr(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `inner` - rq.Tr("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Tr() = %q, want %q", got, want) - } -} diff --git a/jaws/uiwrapcontainer.go b/jaws/uiwrapcontainer.go deleted file mode 100644 index 9fab47b7..00000000 --- a/jaws/uiwrapcontainer.go +++ /dev/null @@ -1,99 +0,0 @@ -package jaws - -import ( - "html/template" - "io" - "slices" - "strings" - - "github.com/linkdata/deadlock" -) - -type uiWrapContainer struct { - Container - Tag any - mu deadlock.Mutex - contents []*Element -} - -func (ui *uiWrapContainer) renderContainer(e *Element, w io.Writer, outerhtmltag string, params []any) (err error) { - if ui.Tag, err = e.ApplyGetter(ui.Container); err == nil { - attrs := e.ApplyParams(params) - b := e.Jid().AppendStartTagAttr(nil, outerhtmltag) - for _, attr := range attrs { - b = append(b, ' ') - b = append(b, attr...) - } - b = append(b, '>') - _, err = w.Write(b) - if err == nil { - var contents []*Element - for _, cui := range ui.Container.JawsContains(e) { - elem := e.Request.NewElement(cui) - if err = elem.JawsRender(w, nil); err != nil { - break - } - contents = append(contents, elem) - } - ui.mu.Lock() - ui.contents = contents - ui.mu.Unlock() - b = b[:0] - b = append(b, "') - if _, err2 := w.Write(b); err == nil { - err = err2 - } - } - } - return -} - -func (ui *uiWrapContainer) JawsUpdate(e *Element) { - var toRemove, toAppend []*Element - var orderData []Jid - - oldMap := make(map[UI]*Element) - newMap := make(map[UI]struct{}) - newContents := ui.Container.JawsContains(e) - for _, t := range newContents { - newMap[t] = struct{}{} - } - - ui.mu.Lock() - oldOrder := make([]Jid, len(ui.contents)) - for i, elem := range ui.contents { - oldOrder[i] = elem.Jid() - oldMap[elem.Ui()] = elem - if _, ok := newMap[elem.Ui()]; !ok { - toRemove = append(toRemove, elem) - } - } - ui.contents = ui.contents[:0] - for _, cui := range newContents { - var elem *Element - if elem = oldMap[cui]; elem == nil { - elem = e.Request.NewElement(cui) - toAppend = append(toAppend, elem) - } - ui.contents = append(ui.contents, elem) - orderData = append(orderData, elem.Jid()) - } - ui.mu.Unlock() - - for _, elem := range toRemove { - e.Remove(elem.Jid().String()) - e.Request.deleteElement(elem) - } - - for _, elem := range toAppend { - var sb strings.Builder - maybePanic(elem.JawsRender(&sb, nil)) - e.Append(template.HTML(sb.String())) // #nosec G203 - } - - if !slices.Equal(oldOrder, orderData) { - e.Order(orderData) - } -} diff --git a/jaws/uiwrapcontainer_test.go b/jaws/uiwrapcontainer_test.go deleted file mode 100644 index fe98a3fa..00000000 --- a/jaws/uiwrapcontainer_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package jaws - -import ( - "errors" - "io" - "strings" - "testing" -) - -func TestUiWrapContainer_RenderError(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - // Create a test UI that returns an error when rendering - renderErr := errors.New("render error") - errorUI := &testUi{ - renderFn: func(e *Element, w io.Writer, params []any) error { - // Write partial content before returning error - _, _ = w.Write([]byte("partial")) - return renderErr - }, - } - - // Create a container with a successful UI followed by an error UI - tc := &testContainer{ - contents: []UI{ - NewUiSpan(testHTMLGetter("first")), - errorUI, - NewUiSpan(testHTMLGetter("third")), // This should not be rendered - }, - } - - ui := NewUiContainer("div", tc) - elem := rq.NewElement(ui) - var sb strings.Builder - - // Render should return the error - err := ui.JawsRender(elem, &sb, nil) - if err == nil { - t.Fatal("expected error from JawsRender, got nil") - } - if !errors.Is(err, renderErr) { - t.Errorf("expected error %v, got %v", renderErr, err) - } - - // Verify the output contains the opening tag and first successful element - output := sb.String() - if !strings.Contains(output, `
    `) { - t.Errorf("expected opening tag in output, got: %s", output) - } - if !strings.Contains(output, `first`) { - t.Errorf("expected first span in output, got: %s", output) - } - if !strings.Contains(output, "partial") { - t.Errorf("expected partial content from error UI, got: %s", output) - } - - // The third element should not be rendered - if strings.Contains(output, "third") { - t.Errorf("third element should not be rendered, got: %s", output) - } - - // Verify that ui.contents contains only the successfully rendered elements - ui.mu.Lock() - contentsLen := len(ui.contents) - ui.mu.Unlock() - - if contentsLen != 1 { - t.Errorf("expected ui.contents to have 1 element (only first successful), got %d", contentsLen) - } -} - -func TestUiWrapContainer_RenderErrorFirstElement(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - renderErr := errors.New("immediate error") - errorUI := &testUi{ - renderFn: func(e *Element, w io.Writer, params []any) error { - return renderErr - }, - } - - // Container with only an error-producing UI as the first element - tc := &testContainer{ - contents: []UI{errorUI}, - } - - ui := NewUiContainer("div", tc) - elem := rq.NewElement(ui) - var sb strings.Builder - - // Render should return the error immediately - err := ui.JawsRender(elem, &sb, nil) - if err == nil { - t.Fatal("expected error from JawsRender, got nil") - } - if !errors.Is(err, renderErr) { - t.Errorf("expected error %v, got %v", renderErr, err) - } - - // Verify ui.contents is empty since no elements were successfully rendered - ui.mu.Lock() - contentsLen := len(ui.contents) - ui.mu.Unlock() - - if contentsLen != 0 { - t.Errorf("expected ui.contents to be empty, got %d elements", contentsLen) - } -} - -func TestUiWrapContainer_RenderAllSuccess(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - // Container with all successful renders - tc := &testContainer{ - contents: []UI{ - NewUiSpan(testHTMLGetter("first")), - NewUiSpan(testHTMLGetter("second")), - NewUiSpan(testHTMLGetter("third")), - }, - } - - ui := NewUiContainer("div", tc) - elem := rq.NewElement(ui) - var sb strings.Builder - - // Render should succeed - err := ui.JawsRender(elem, &sb, nil) - if err != nil { - t.Fatalf("unexpected error from JawsRender: %v", err) - } - - // Verify all elements are rendered - output := sb.String() - expected := `
    firstsecondthird
    ` - if output != expected { - t.Errorf("output mismatch\nwant: %s\ngot: %s", expected, output) - } - - // Verify ui.contents has all three elements - ui.mu.Lock() - contentsLen := len(ui.contents) - ui.mu.Unlock() - - if contentsLen != 3 { - t.Errorf("expected ui.contents to have 3 elements, got %d", contentsLen) - } -} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..c8977f38 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,105 @@ +# `github.com/linkdata/jaws/ui` + +This package is the home of JaWS widget implementations. + +## Goals + +- Keep widget logic out of JaWS core request/session internals. +- Make new widget authoring local to this package. +- Provide short widget naming (`ui.Span`, `ui.NewSpan`). +- Expose template context types (`ui.RequestWriter`, `ui.With`). + +## Migration + +### Type and constructor names + +Every legacy `jaws.UiX` / `jaws.NewUiX` maps directly to `ui.X` / `ui.NewX`. + +Examples: + +- `jaws.UiA` -> `ui.A` +- `jaws.NewUiA(...)` -> `ui.NewA(...)` +- `jaws.UiSpan` -> `ui.Span` +- `jaws.NewUiSpan(...)` -> `ui.NewSpan(...)` +- `jaws.UiSelect` -> `ui.Select` +- `jaws.NewUiSelect(...)` -> `ui.NewSelect(...)` + +### RequestWriter helper calls + +`jaws.RequestWriter` still exposes helper methods like `rw.Span(...)`, +`rw.Text(...)`, and `rw.Select(...)` for concise template use. + +You can also use explicit constructors through: + +```go +rw.UI(ui.NewX(...), params...) +``` + +Examples: + +```go +rw.UI(ui.NewDiv(jaws.MakeHTMLGetter("content"))) +rw.UI(ui.NewCheckbox(myBoolSetter), "disabled") +rw.UI(ui.NewRange(myFloatSetter)) +``` + +## Building blocks + +- `HTMLInner` + - For tags like `
    ...
    `, `...`, `...`. +- `Input`, `InputText`, `InputBool`, `InputFloat`, `InputDate` + - For interactive inputs with typed parse/update behavior. +- `WrapContainer` + - For widgets that render and maintain dynamic child lists. + +## Adding a simple static widget + +Use `HTMLInner`: + +```go +type Article struct{ ui.HTMLInner } + +func NewArticle(inner jaws.HTMLGetter) *Article { + return &Article{HTMLInner: ui.HTMLInner{HTMLGetter: inner}} +} + +func (w *Article) JawsRender(e *jaws.Element, wr io.Writer, params []any) error { + return w.renderInner(e, wr, "article", "", params) +} +``` + +## Adding an interactive input widget + +Use one of the typed input bases: + +- `InputText` for string-based inputs +- `InputBool` for boolean inputs +- `InputFloat` for numeric inputs +- `InputDate` for `time.Time` inputs + +Each base handles: + +- tracking last rendered value +- receiving `what.Input` +- applying dirty tags on successful set +- update-driven `SetValue` pushes + +## Adding a container widget + +Use `WrapContainer`: + +```go +type UList struct{ ui.WrapContainer } + +func NewUList(c jaws.Container) *UList { + return &UList{WrapContainer: ui.NewWrapContainer(c)} +} + +func (w *UList) JawsRender(e *jaws.Element, wr io.Writer, params []any) error { + return w.RenderContainer(e, wr, "ul", params) +} + +func (w *UList) JawsUpdate(e *jaws.Element) { + w.UpdateContainer(e) +} +``` diff --git a/ui/a.go b/ui/a.go new file mode 100644 index 00000000..7d73e026 --- /dev/null +++ b/ui/a.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type A struct{ HTMLInner } + +func NewA(innerHTML pkg.HTMLGetter) *A { return &A{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *A) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "a", "", params) +} diff --git a/ui/button.go b/ui/button.go new file mode 100644 index 00000000..ac6bcd6a --- /dev/null +++ b/ui/button.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Button struct{ HTMLInner } + +func NewButton(innerHTML pkg.HTMLGetter) *Button { return &Button{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *Button) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "button", "button", params) +} diff --git a/ui/checkbox.go b/ui/checkbox.go new file mode 100644 index 00000000..e307d5a3 --- /dev/null +++ b/ui/checkbox.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Checkbox struct{ InputBool } + +func NewCheckbox(g pkg.Setter[bool]) *Checkbox { return &Checkbox{InputBool{Setter: g}} } +func (ui *Checkbox) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderBoolInput(e, w, "checkbox", params...) +} diff --git a/ui/common.go b/ui/common.go new file mode 100644 index 00000000..4157c938 --- /dev/null +++ b/ui/common.go @@ -0,0 +1,21 @@ +package ui + +import pkg "github.com/linkdata/jaws/jaws" + +func must(err error) { + if err != nil { + panic(err) + } +} + +func applyDirty(tag any, e *pkg.Element, err error) (changed bool, retErr error) { + switch err { + case nil: + e.Dirty(tag) + return true, nil + case pkg.ErrValueUnchanged: + return false, nil + default: + return false, err + } +} diff --git a/ui/common_test.go b/ui/common_test.go new file mode 100644 index 00000000..ab7768e9 --- /dev/null +++ b/ui/common_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "errors" + "testing" + + pkg "github.com/linkdata/jaws/jaws" +) + +func TestCommon_applyDirty(t *testing.T) { + _, rq := newRequest(t) + elem, _ := renderUI(t, rq, NewSpan(testHTMLGetter("x"))) + tag := &struct{}{} + + changed, err := applyDirty(tag, elem, nil) + if err != nil || !changed { + t.Fatalf("want changed,nil got %v,%v", changed, err) + } + + changed, err = applyDirty(tag, elem, pkg.ErrValueUnchanged) + if err != nil || changed { + t.Fatalf("want unchanged,nil got %v,%v", changed, err) + } + + wantErr := errors.New("boom") + changed, err = applyDirty(tag, elem, wantErr) + if !errors.Is(err, wantErr) || changed { + t.Fatalf("want unchanged,%v got %v,%v", wantErr, changed, err) + } +} + +func TestCommon_must(t *testing.T) { + must(nil) + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + must(errors.New("panic")) +} diff --git a/ui/constructors_test.go b/ui/constructors_test.go new file mode 100644 index 00000000..e8f9262e --- /dev/null +++ b/ui/constructors_test.go @@ -0,0 +1,56 @@ +package ui + +import ( + "html/template" + "sync" + "testing" + "time" + + pkg "github.com/linkdata/jaws/jaws" +) + +func TestConstructors(t *testing.T) { + var mu sync.Mutex + txt := "" + checked := false + num := 0.0 + when := time.Now() + + textSetter := pkg.Bind(&mu, &txt) + boolSetter := pkg.Bind(&mu, &checked) + numSetter := pkg.Bind(&mu, &num) + timeSetter := pkg.Bind(&mu, &when) + + htmlGetter := pkg.MakeHTMLGetter("x") + imgGetter := pkg.StringGetterFunc(func(*pkg.Element) string { return "img" }) + nba := pkg.NewNamedBoolArray().Add("a", template.HTML("A")) + tc := testContainer{contents: []pkg.UI{NewSpan(htmlGetter)}} + + all := []pkg.UI{ + NewA(htmlGetter), + NewButton(htmlGetter), + NewCheckbox(boolSetter), + NewContainer("div", &tc), + NewDate(timeSetter), + NewDiv(htmlGetter), + NewImg(imgGetter), + NewLabel(htmlGetter), + NewLi(htmlGetter), + NewNumber(numSetter), + NewPassword(textSetter), + NewRadio(boolSetter), + NewRange(numSetter), + NewSelect(nba), + NewSpan(htmlGetter), + NewTbody(&tc), + NewTd(htmlGetter), + NewText(textSetter), + NewTextarea(textSetter), + NewTr(htmlGetter), + } + for i, ui := range all { + if ui == nil { + t.Fatalf("constructor[%d] returned nil", i) + } + } +} diff --git a/ui/container.go b/ui/container.go new file mode 100644 index 00000000..56e3871e --- /dev/null +++ b/ui/container.go @@ -0,0 +1,27 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Container struct { + OuterHTMLTag string + WrapContainer +} + +func NewContainer(outerHTMLTag string, c pkg.Container) *Container { + return &Container{ + OuterHTMLTag: outerHTMLTag, + WrapContainer: NewWrapContainer(c), + } +} + +func (ui *Container) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.RenderContainer(e, w, ui.OuterHTMLTag, params) +} + +func (ui *Container) JawsUpdate(e *pkg.Element) { + ui.UpdateContainer(e) +} diff --git a/ui/container_widgets.go b/ui/container_widgets.go new file mode 100644 index 00000000..b5a9a2c4 --- /dev/null +++ b/ui/container_widgets.go @@ -0,0 +1,108 @@ +package ui + +import ( + "html/template" + "io" + "slices" + "strings" + "sync" + + pkg "github.com/linkdata/jaws/jaws" +) + +// WrapContainer is a helper for widgets that render dynamic child collections. +// +// It tracks previously rendered child elements and performs append/remove/order +// updates during JawsUpdate. +type WrapContainer struct { + Container pkg.Container + Tag any + mu sync.Mutex + contents []*pkg.Element +} + +func NewWrapContainer(c pkg.Container) WrapContainer { + return WrapContainer{Container: c} +} + +func (ui *WrapContainer) RenderContainer(e *pkg.Element, w io.Writer, outerHTMLTag string, params []any) (err error) { + if ui.Tag, err = e.ApplyGetter(ui.Container); err == nil { + attrs := e.ApplyParams(params) + b := e.Jid().AppendStartTagAttr(nil, outerHTMLTag) + for _, attr := range attrs { + b = append(b, ' ') + b = append(b, attr...) + } + b = append(b, '>') + _, err = w.Write(b) + if err == nil { + var contents []*pkg.Element + for _, childUI := range ui.Container.JawsContains(e) { + elem := e.Request.NewElement(childUI) + if err = elem.JawsRender(w, nil); err != nil { + break + } + contents = append(contents, elem) + } + ui.mu.Lock() + ui.contents = contents + ui.mu.Unlock() + b = b[:0] + b = append(b, "') + if _, err2 := w.Write(b); err == nil { + err = err2 + } + } + } + return +} + +func (ui *WrapContainer) UpdateContainer(e *pkg.Element) { + var toRemove, toAppend []*pkg.Element + var orderData []pkg.Jid + + oldMap := make(map[pkg.UI]*pkg.Element) + newMap := make(map[pkg.UI]struct{}) + newContents := ui.Container.JawsContains(e) + for _, childUI := range newContents { + newMap[childUI] = struct{}{} + } + + ui.mu.Lock() + oldOrder := make([]pkg.Jid, len(ui.contents)) + for i, elem := range ui.contents { + oldOrder[i] = elem.Jid() + oldMap[elem.Ui()] = elem + if _, ok := newMap[elem.Ui()]; !ok { + toRemove = append(toRemove, elem) + } + } + ui.contents = ui.contents[:0] + for _, childUI := range newContents { + elem := oldMap[childUI] + if elem == nil { + elem = e.Request.NewElement(childUI) + toAppend = append(toAppend, elem) + } + ui.contents = append(ui.contents, elem) + orderData = append(orderData, elem.Jid()) + } + ui.mu.Unlock() + + for _, elem := range toRemove { + e.Remove(elem.Jid().String()) + e.Request.DeleteElement(elem) + } + + for _, elem := range toAppend { + var sb strings.Builder + must(elem.JawsRender(&sb, nil)) + e.Append(template.HTML(sb.String())) // #nosec G203 + } + + if !slices.Equal(oldOrder, orderData) { + e.Order(orderData) + } +} diff --git a/ui/container_widgets_test.go b/ui/container_widgets_test.go new file mode 100644 index 00000000..2fc4ab55 --- /dev/null +++ b/ui/container_widgets_test.go @@ -0,0 +1,134 @@ +package ui + +import ( + "errors" + "io" + "strings" + "testing" + + pkg "github.com/linkdata/jaws/jaws" + "github.com/linkdata/jaws/what" +) + +func TestContainerAndTbodyRender(t *testing.T) { + _, rq := newRequest(t) + tc := &testContainer{contents: []pkg.UI{NewSpan(testHTMLGetter("foo")), NewSpan(testHTMLGetter("bar"))}} + + container := NewContainer("div", tc) + _, got := renderUI(t, rq, container, "hidden") + mustMatch(t, `^$`, got) + + tbody := NewTbody(tc) + elem, got := renderUI(t, rq, tbody) + mustMatch(t, `^foobar$`, got) + tbody.JawsUpdate(elem) +} + +func TestWrapContainerUpdateContainer(t *testing.T) { + _, rq := newRequest(t) + span1 := NewSpan(testHTMLGetter("span1")) + span2 := NewSpan(testHTMLGetter("span2")) + span3 := NewSpan(testHTMLGetter("span3")) + + tc := &testContainer{contents: []pkg.UI{span1}} + container := NewContainer("div", tc) + elem, _ := renderUI(t, rq, container) + + if len(container.contents) != 1 { + t.Fatalf("want 1 content got %d", len(container.contents)) + } + + // append + reorder path + tc.contents = []pkg.UI{span1, span2, span3} + container.JawsUpdate(elem) + if len(container.contents) != 3 { + t.Fatalf("want 3 contents got %d", len(container.contents)) + } + + // remove path + removedJid := container.contents[0].Jid() + tc.contents = []pkg.UI{span2, span3} + container.JawsUpdate(elem) + if got := rq.GetElementByJid(removedJid); got != nil { + t.Fatal("expected removed element to be deleted from request") + } + + // reorder + replace path + tc.contents = []pkg.UI{span3, span1} + container.JawsUpdate(elem) + if len(container.contents) != 2 { + t.Fatalf("want 2 contents got %d", len(container.contents)) + } +} + +func TestWrapContainerRenderErrorPaths(t *testing.T) { + _, rq := newRequest(t) + renderErr := errors.New("render error") + errChild := testRenderErrorUI{err: renderErr} + tc := &testContainer{contents: []pkg.UI{NewSpan(testHTMLGetter("first")), errChild, NewSpan(testHTMLGetter("third"))}} + + container := NewContainer("div", tc) + elem := rq.NewElement(container) + var sb strings.Builder + err := elem.JawsRender(&sb, nil) + if !errors.Is(err, renderErr) { + t.Fatalf("want %v got %v", renderErr, err) + } + if len(container.contents) != 1 { + t.Fatalf("want 1 successful child got %d", len(container.contents)) + } + + // panic path from must() during append + tc2 := &testContainer{} + container2 := NewContainer("div", tc2) + elem2, _ := renderUI(t, rq, container2) + tc2.contents = []pkg.UI{testRenderErrorUI{err: errors.New("append fail")}} + defer func() { + if recover() == nil { + t.Fatal("expected panic from must") + } + }() + container2.JawsUpdate(elem2) +} + +type testRenderErrorUI struct { + err error +} + +func (ui testRenderErrorUI) JawsRender(*pkg.Element, io.Writer, []any) error { + return ui.err +} + +func (testRenderErrorUI) JawsUpdate(*pkg.Element) {} + +type testSelectHandler struct { + *testContainer + *testSetter[string] +} + +func TestSelectWidget(t *testing.T) { + _, rq := newRequest(t) + sh := &testSelectHandler{ + testContainer: &testContainer{contents: []pkg.UI{NewOption(pkg.NewNamedBool(nil, "1", "one", true))}}, + testSetter: newTestSetter("1"), + } + selectUI := NewSelect(sh) + elem, got := renderUI(t, rq, selectUI) + mustMatch(t, `^$`, got) + + selectUI.JawsUpdate(elem) + + if err := selectUI.JawsEvent(elem, what.Click, "noop"); !errors.Is(err, pkg.ErrEventUnhandled) { + t.Fatalf("want ErrEventUnhandled got %v", err) + } + if err := selectUI.JawsEvent(elem, what.Input, "2"); err != nil { + t.Fatal(err) + } + if sh.Get() != "2" { + t.Fatalf("want 2 got %q", sh.Get()) + } + sh.SetErr(errors.New("meh")) + if err := selectUI.JawsEvent(elem, what.Input, "3"); err == nil || err.Error() != "meh" { + t.Fatalf("want meh got %v", err) + } +} diff --git a/ui/date.go b/ui/date.go new file mode 100644 index 00000000..946145f4 --- /dev/null +++ b/ui/date.go @@ -0,0 +1,15 @@ +package ui + +import ( + "io" + "time" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Date struct{ InputDate } + +func NewDate(g pkg.Setter[time.Time]) *Date { return &Date{InputDate{Setter: g}} } +func (ui *Date) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderDateInput(e, w, "date", params...) +} diff --git a/ui/div.go b/ui/div.go new file mode 100644 index 00000000..1243c5d1 --- /dev/null +++ b/ui/div.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Div struct{ HTMLInner } + +func NewDiv(innerHTML pkg.HTMLGetter) *Div { return &Div{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *Div) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "div", "", params) +} diff --git a/ui/doc.go b/ui/doc.go new file mode 100644 index 00000000..2e69e4e3 --- /dev/null +++ b/ui/doc.go @@ -0,0 +1,13 @@ +// Package ui contains the standard JaWS widget implementations. +// +// The package is intentionally organized around extension-oriented building +// blocks so new widgets can be authored here without reading JaWS core code: +// +// - `HTMLInner`: base renderer for tags with inner HTML content. +// - `Input`, `InputText`, `InputBool`, `InputFloat`, `InputDate`: +// typed input helpers that handle event/update flow. +// - `WrapContainer`: helper for widgets that render dynamic child UI lists. +// +// Naming follows short widget names (`Span`, `NewSpan`) instead of the +// legacy core names (`UiSpan`, `NewUiSpan`). +package ui diff --git a/ui/html_widgets.go b/ui/html_widgets.go new file mode 100644 index 00000000..6a46ff48 --- /dev/null +++ b/ui/html_widgets.go @@ -0,0 +1,23 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +// HTMLInner is a reusable base for widgets that render as `inner`. +type HTMLInner struct { + HTMLGetter pkg.HTMLGetter +} + +func (ui *HTMLInner) renderInner(e *pkg.Element, w io.Writer, htmlTag, htmlType string, params []any) (err error) { + if _, err = e.ApplyGetter(ui.HTMLGetter); err == nil { + err = pkg.WriteHTMLInner(w, e.Jid(), htmlTag, htmlType, ui.HTMLGetter.JawsGetHTML(e), e.ApplyParams(params)...) + } + return +} + +func (ui *HTMLInner) JawsUpdate(e *pkg.Element) { + e.SetInner(ui.HTMLGetter.JawsGetHTML(e)) +} diff --git a/ui/html_widgets_test.go b/ui/html_widgets_test.go new file mode 100644 index 00000000..2203879f --- /dev/null +++ b/ui/html_widgets_test.go @@ -0,0 +1,90 @@ +package ui + +import ( + "errors" + "html/template" + "strings" + "testing" + + pkg "github.com/linkdata/jaws/jaws" +) + +func TestHTMLWidgets_ConstructorsAndRender(t *testing.T) { + _, rq := newRequest(t) + + tests := []struct { + name string + ui pkg.UI + params []any + pattern string + }{ + {"A", NewA(testHTMLGetter("inner")), nil, `^inner$`}, + {"Button", NewButton(testHTMLGetter("inner")), nil, `^$`}, + {"Div", NewDiv(testHTMLGetter("inner")), nil, `^
    inner
    $`}, + {"Label", NewLabel(testHTMLGetter("inner")), nil, `^$`}, + {"Li", NewLi(testHTMLGetter("inner")), nil, `^
  • inner
  • $`}, + {"Span", NewSpan(testHTMLGetter("inner")), nil, `^inner$`}, + {"Td", NewTd(testHTMLGetter("inner")), nil, `^inner$`}, + {"Tr", NewTr(testHTMLGetter("inner")), nil, `^inner$`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + elem, got := renderUI(t, rq, tt.ui, tt.params...) + mustMatch(t, tt.pattern, got) + tt.ui.JawsUpdate(elem) + }) + } +} + +func TestHTMLInner_RenderInnerApplyGetterError(t *testing.T) { + _, rq := newRequest(t) + + wantErr := errors.New("init fail") + g := &initFailGetter{err: wantErr} + elem := rq.NewElement(NewA(g)) + var sb strings.Builder + if err := elem.JawsRender(&sb, nil); !errors.Is(err, wantErr) { + t.Fatalf("want %v got %v", wantErr, err) + } +} + +type initFailGetter struct { + err error +} + +func (g *initFailGetter) JawsGetHTML(*pkg.Element) template.HTML { return "x" } +func (g *initFailGetter) JawsGetTag(*pkg.Request) any { return g } +func (g *initFailGetter) JawsInit(*pkg.Element) error { return g.err } + +func TestImg_RenderAndUpdate(t *testing.T) { + _, rq := newRequest(t) + src := newTestSetter("image.png") + ui := NewImg(src) + elem, got := renderUI(t, rq, ui, "hidden") + mustMatch(t, `^$`, got) + src.Set("image2.jpg") + ui.JawsUpdate(elem) +} + +func TestOption_RenderAndUpdate(t *testing.T) { + _, rq := newRequest(t) + nba := pkg.NewNamedBoolArray() + nb := pkg.NewNamedBool(nba, `escape"me`, "", true) + ui := NewOption(nb) + elem, got := renderUI(t, rq, ui, "hidden") + mustMatch(t, `^$`, got) + + nb.Set(false) + ui.JawsUpdate(elem) + nb.Set(true) + ui.JawsUpdate(elem) +} + +func TestRegister_Render(t *testing.T) { + _, rq := newRequest(t) + ui := NewRegister(NewSpan(testHTMLGetter("x"))) + _, got := renderUI(t, rq, ui) + if got != "" { + t.Fatalf("expected empty output got %q", got) + } +} diff --git a/ui/img.go b/ui/img.go new file mode 100644 index 00000000..9fbed3bb --- /dev/null +++ b/ui/img.go @@ -0,0 +1,22 @@ +package ui + +import ( + "html/template" + "io" + "strconv" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Img struct{ pkg.Getter[string] } + +func NewImg(g pkg.Getter[string]) *Img { return &Img{Getter: g} } +func (ui *Img) JawsRender(e *pkg.Element, w io.Writer, params []any) (err error) { + if _, err = e.ApplyGetter(ui.Getter); err == nil { + srcAttr := template.HTMLAttr("src=" + strconv.Quote(ui.JawsGet(e))) // #nosec G203 + attrs := append(e.ApplyParams(params), srcAttr) + err = pkg.WriteHTMLInner(w, e.Jid(), "img", "", "", attrs...) + } + return +} +func (ui *Img) JawsUpdate(e *pkg.Element) { e.SetAttr("src", ui.JawsGet(e)) } diff --git a/ui/input_widgets.go b/ui/input_widgets.go new file mode 100644 index 00000000..abe3eeeb --- /dev/null +++ b/ui/input_widgets.go @@ -0,0 +1,179 @@ +package ui + +import ( + "io" + "strconv" + "sync/atomic" + "time" + + pkg "github.com/linkdata/jaws/jaws" + "github.com/linkdata/jaws/what" +) + +// Input stores common state for interactive input widgets. +type Input struct { + Tag any + Last atomic.Value +} + +func (ui *Input) applyGetter(e *pkg.Element, getter any) (err error) { + ui.Tag, err = e.ApplyGetter(getter) + return +} + +func (ui *Input) maybeDirty(val any, e *pkg.Element, err error) error { + if changed, err := applyDirty(ui.Tag, e, err); err != nil { + return err + } else if changed { + ui.Last.Store(val) + } + return nil +} + +type InputText struct { + Input + pkg.Setter[string] +} + +func (ui *InputText) renderStringInput(e *pkg.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + v := ui.JawsGet(e) + ui.Last.Store(v) + err = pkg.WriteHTMLInput(w, e.Jid(), htmlType, v, attrs) + } + return +} + +func (ui *InputText) JawsUpdate(e *pkg.Element) { + if v := ui.JawsGet(e); ui.Last.Swap(v) != v { + e.SetValue(v) + } +} + +func (ui *InputText) JawsEvent(e *pkg.Element, wht what.What, val string) (err error) { + err = pkg.ErrEventUnhandled + if wht == what.Input { + err = ui.maybeDirty(val, e, ui.Setter.JawsSet(e, val)) + } + return +} + +type InputBool struct { + Input + pkg.Setter[bool] +} + +func (ui *InputBool) renderBoolInput(e *pkg.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + v := ui.JawsGet(e) + ui.Last.Store(v) + if v { + attrs = append(attrs, "checked") + } + err = pkg.WriteHTMLInput(w, e.Jid(), htmlType, "", attrs) + } + return +} + +func (ui *InputBool) JawsUpdate(e *pkg.Element) { + v := ui.JawsGet(e) + if ui.Last.Swap(v) != v { + txt := "false" + if v { + txt = "true" + } + e.SetValue(txt) + } +} + +func (ui *InputBool) JawsEvent(e *pkg.Element, wht what.What, val string) (err error) { + err = pkg.ErrEventUnhandled + if wht == what.Input { + var v bool + if val != "" { + if v, err = strconv.ParseBool(val); err != nil { + return + } + } + err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) + } + return +} + +type InputFloat struct { + Input + pkg.Setter[float64] +} + +func (ui *InputFloat) str() string { + return strconv.FormatFloat(ui.Last.Load().(float64), 'f', -1, 64) +} + +func (ui *InputFloat) renderFloatInput(e *pkg.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + ui.Last.Store(ui.JawsGet(e)) + err = pkg.WriteHTMLInput(w, e.Jid(), htmlType, ui.str(), attrs) + } + return +} + +func (ui *InputFloat) JawsUpdate(e *pkg.Element) { + if f := ui.JawsGet(e); ui.Last.Swap(f) != f { + e.SetValue(ui.str()) + } +} + +func (ui *InputFloat) JawsEvent(e *pkg.Element, wht what.What, val string) (err error) { + err = pkg.ErrEventUnhandled + if wht == what.Input { + var v float64 + if val != "" { + if v, err = strconv.ParseFloat(val, 64); err != nil { + return + } + } + err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) + } + return +} + +type InputDate struct { + Input + pkg.Setter[time.Time] +} + +func (ui *InputDate) str() string { + return ui.Last.Load().(time.Time).Format(pkg.ISO8601) +} + +func (ui *InputDate) renderDateInput(e *pkg.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + ui.Last.Store(ui.JawsGet(e)) + err = pkg.WriteHTMLInput(w, e.Jid(), htmlType, ui.str(), attrs) + } + return +} + +func (ui *InputDate) JawsUpdate(e *pkg.Element) { + if t := ui.JawsGet(e); ui.Last.Swap(t) != t { + e.SetValue(ui.str()) + } +} + +func (ui *InputDate) JawsEvent(e *pkg.Element, wht what.What, val string) (err error) { + err = pkg.ErrEventUnhandled + if wht == what.Input { + var v time.Time + if val != "" { + if v, err = time.Parse(pkg.ISO8601, val); err != nil { + return + } + } + err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) + } + return +} diff --git a/ui/input_widgets_test.go b/ui/input_widgets_test.go new file mode 100644 index 00000000..f2673dfb --- /dev/null +++ b/ui/input_widgets_test.go @@ -0,0 +1,126 @@ +package ui + +import ( + "errors" + "testing" + "time" + + pkg "github.com/linkdata/jaws/jaws" + "github.com/linkdata/jaws/what" +) + +func TestInputTextWidgets(t *testing.T) { + _, rq := newRequest(t) + ss := newTestSetter("foo") + + text := NewText(ss) + elem, got := renderUI(t, rq, text) + mustMatch(t, `^$`, got) + + if err := text.JawsEvent(elem, what.Input, "bar"); err != nil { + t.Fatal(err) + } + if ss.Get() != "bar" { + t.Fatalf("want bar got %q", ss.Get()) + } + if err := text.JawsEvent(elem, what.Click, "noop"); !errors.Is(err, pkg.ErrEventUnhandled) { + t.Fatalf("want ErrEventUnhandled got %v", err) + } + ss.SetErr(errors.New("meh")) + if err := text.JawsEvent(elem, what.Input, "omg"); err == nil || err.Error() != "meh" { + t.Fatalf("want meh got %v", err) + } + ss.SetErr(nil) + ss.Set("quux") + text.JawsUpdate(elem) + + password := NewPassword(ss) + _, got = renderUI(t, rq, password) + mustMatch(t, `^$`, got) + + textarea := NewTextarea(ss) + _, got = renderUI(t, rq, textarea) + mustMatch(t, `^$`, got) + textarea.JawsUpdate(elem) +} + +func TestInputBoolWidgets(t *testing.T) { + _, rq := newRequest(t) + sb := newTestSetter(true) + + checkbox := NewCheckbox(sb) + elem, got := renderUI(t, rq, checkbox) + mustMatch(t, `^$`, got) + if err := checkbox.JawsEvent(elem, what.Input, "false"); err != nil { + t.Fatal(err) + } + if sb.Get() { + t.Fatal("expected false") + } + if err := checkbox.JawsEvent(elem, what.Input, "bad"); err == nil { + t.Fatal("expected parse error") + } + sb.Set(true) + checkbox.JawsUpdate(elem) + + radio := NewRadio(sb) + _, got = renderUI(t, rq, radio) + mustMatch(t, `^$`, got) +} + +func TestInputFloatWidgets(t *testing.T) { + _, rq := newRequest(t) + sf := newTestSetter(1.2) + + number := NewNumber(sf) + elem, got := renderUI(t, rq, number) + mustMatch(t, `^$`, got) + if err := number.JawsEvent(elem, what.Input, "2.3"); err != nil { + t.Fatal(err) + } + if sf.Get() != 2.3 { + t.Fatalf("want 2.3 got %v", sf.Get()) + } + if err := number.JawsEvent(elem, what.Input, "bad"); err == nil { + t.Fatal("expected parse error") + } + sf.Set(3.4) + number.JawsUpdate(elem) + + rng := NewRange(sf) + _, got = renderUI(t, rq, rng) + mustMatch(t, `^$`, got) +} + +func TestInputDateWidget(t *testing.T) { + _, rq := newRequest(t) + d0, _ := time.Parse(pkg.ISO8601, "2020-01-02") + sd := newTestSetter(d0) + + date := NewDate(sd) + elem, got := renderUI(t, rq, date, "dateattr") + mustMatch(t, `^$`, got) + + if err := date.JawsEvent(elem, what.Input, "2021-02-03"); err != nil { + t.Fatal(err) + } + if sd.Get().Format(pkg.ISO8601) != "2021-02-03" { + t.Fatalf("unexpected date %v", sd.Get()) + } + if err := date.JawsEvent(elem, what.Input, "bad"); err == nil { + t.Fatal("expected parse error") + } + d1, _ := time.Parse(pkg.ISO8601, "2022-03-04") + sd.Set(d1) + date.JawsUpdate(elem) +} + +func TestInputMaybeDirtyErrValueUnchanged(t *testing.T) { + _, rq := newRequest(t) + ss := newTestSetter("foo") + text := NewText(ss) + elem, _ := renderUI(t, rq, text) + if err := text.JawsEvent(elem, what.Input, "foo"); err != nil { + t.Fatalf("want nil got %v", err) + } +} diff --git a/ui/label.go b/ui/label.go new file mode 100644 index 00000000..709c1e8c --- /dev/null +++ b/ui/label.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Label struct{ HTMLInner } + +func NewLabel(innerHTML pkg.HTMLGetter) *Label { return &Label{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *Label) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "label", "", params) +} diff --git a/ui/li.go b/ui/li.go new file mode 100644 index 00000000..618f7539 --- /dev/null +++ b/ui/li.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Li struct{ HTMLInner } + +func NewLi(innerHTML pkg.HTMLGetter) *Li { return &Li{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *Li) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "li", "", params) +} diff --git a/ui/number.go b/ui/number.go new file mode 100644 index 00000000..7fd42815 --- /dev/null +++ b/ui/number.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Number struct{ InputFloat } + +func NewNumber(g pkg.Setter[float64]) *Number { return &Number{InputFloat{Setter: g}} } +func (ui *Number) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderFloatInput(e, w, "number", params...) +} diff --git a/ui/option.go b/ui/option.go new file mode 100644 index 00000000..63d3957e --- /dev/null +++ b/ui/option.go @@ -0,0 +1,30 @@ +package ui + +import ( + "html" + "html/template" + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Option struct{ *pkg.NamedBool } + +func NewOption(nb *pkg.NamedBool) Option { return Option{NamedBool: nb} } +func (ui Option) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + e.Tag(ui.NamedBool) + attrs := e.ApplyParams(params) + valAttr := template.HTMLAttr(`value="` + html.EscapeString(ui.Name()) + `"`) // #nosec G203 + attrs = append(attrs, valAttr) + if ui.Checked() { + attrs = append(attrs, "selected") + } + return pkg.WriteHTMLInner(w, e.Jid(), "option", "", ui.JawsGetHTML(e), attrs...) +} +func (ui Option) JawsUpdate(e *pkg.Element) { + if ui.Checked() { + e.SetAttr("selected", "") + } else { + e.RemoveAttr("selected") + } +} diff --git a/ui/password.go b/ui/password.go new file mode 100644 index 00000000..9bee925b --- /dev/null +++ b/ui/password.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Password struct{ InputText } + +func NewPassword(g pkg.Setter[string]) *Password { return &Password{InputText{Setter: g}} } +func (ui *Password) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderStringInput(e, w, "password", params...) +} diff --git a/ui/radio.go b/ui/radio.go new file mode 100644 index 00000000..ab63d3c4 --- /dev/null +++ b/ui/radio.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Radio struct{ InputBool } + +func NewRadio(vp pkg.Setter[bool]) *Radio { return &Radio{InputBool{Setter: vp}} } +func (ui *Radio) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderBoolInput(e, w, "radio", params...) +} diff --git a/ui/range.go b/ui/range.go new file mode 100644 index 00000000..060ce0f5 --- /dev/null +++ b/ui/range.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Range struct{ InputFloat } + +func NewRange(g pkg.Setter[float64]) *Range { return &Range{InputFloat{Setter: g}} } +func (ui *Range) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderFloatInput(e, w, "range", params...) +} diff --git a/ui/register.go b/ui/register.go new file mode 100644 index 00000000..c7a3fd81 --- /dev/null +++ b/ui/register.go @@ -0,0 +1,15 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +// Register creates an element used for update-only registration. +type Register struct{ pkg.Updater } + +func NewRegister(updater pkg.Updater) Register { return Register{Updater: updater} } +func (ui Register) JawsRender(*pkg.Element, io.Writer, []any) error { + return nil +} diff --git a/ui/requestwriter_register.go b/ui/requestwriter_register.go new file mode 100644 index 00000000..d62a52a7 --- /dev/null +++ b/ui/requestwriter_register.go @@ -0,0 +1,72 @@ +package ui + +import ( + "time" + + pkg "github.com/linkdata/jaws/jaws" +) + +func init() { + pkg.RegisterRequestWriterWidgets(pkg.RequestWriterWidgetFactory{ + A: func(inner pkg.HTMLGetter) pkg.UI { + return NewA(inner) + }, + Button: func(inner pkg.HTMLGetter) pkg.UI { + return NewButton(inner) + }, + Checkbox: func(setter pkg.Setter[bool]) pkg.UI { + return NewCheckbox(setter) + }, + Container: func(outerHTMLTag string, c pkg.Container) pkg.UI { + return NewContainer(outerHTMLTag, c) + }, + Date: func(setter pkg.Setter[time.Time]) pkg.UI { + return NewDate(setter) + }, + Div: func(inner pkg.HTMLGetter) pkg.UI { + return NewDiv(inner) + }, + Img: func(getter pkg.Getter[string]) pkg.UI { + return NewImg(getter) + }, + Label: func(inner pkg.HTMLGetter) pkg.UI { + return NewLabel(inner) + }, + Li: func(inner pkg.HTMLGetter) pkg.UI { + return NewLi(inner) + }, + Number: func(setter pkg.Setter[float64]) pkg.UI { + return NewNumber(setter) + }, + Password: func(setter pkg.Setter[string]) pkg.UI { + return NewPassword(setter) + }, + Radio: func(setter pkg.Setter[bool]) pkg.UI { + return NewRadio(setter) + }, + Range: func(setter pkg.Setter[float64]) pkg.UI { + return NewRange(setter) + }, + Select: func(sh pkg.SelectHandler) pkg.UI { + return NewSelect(sh) + }, + Span: func(inner pkg.HTMLGetter) pkg.UI { + return NewSpan(inner) + }, + Tbody: func(c pkg.Container) pkg.UI { + return NewTbody(c) + }, + Td: func(inner pkg.HTMLGetter) pkg.UI { + return NewTd(inner) + }, + Text: func(setter pkg.Setter[string]) pkg.UI { + return NewText(setter) + }, + Textarea: func(setter pkg.Setter[string]) pkg.UI { + return NewTextarea(setter) + }, + Tr: func(inner pkg.HTMLGetter) pkg.UI { + return NewTr(inner) + }, + }) +} diff --git a/ui/requestwriter_register_test.go b/ui/requestwriter_register_test.go new file mode 100644 index 00000000..2d253bdf --- /dev/null +++ b/ui/requestwriter_register_test.go @@ -0,0 +1,84 @@ +package ui + +import ( + "strings" + "sync" + "testing" + "time" + + pkg "github.com/linkdata/jaws/jaws" +) + +func TestRequestWriterRegisteredHelpers(t *testing.T) { + _, rq := newRequest(t) + var sb strings.Builder + rw := rq.Writer(&sb) + + var mu sync.RWMutex + vbool := true + vtime, _ := time.Parse("2006-01-02", "2020-01-02") + vnumber := float64(1.2) + vstring := "x" + nba := pkg.NewNamedBoolArray() + + if err := rw.A("a"); err != nil { + t.Fatal(err) + } + if err := rw.Button("b"); err != nil { + t.Fatal(err) + } + if err := rw.Checkbox(pkg.Bind(&mu, &vbool)); err != nil { + t.Fatal(err) + } + if err := rw.Container("x", &testContainer{}); err != nil { + t.Fatal(err) + } + if err := rw.Date(pkg.Bind(&mu, &vtime)); err != nil { + t.Fatal(err) + } + if err := rw.Div("d"); err != nil { + t.Fatal(err) + } + if err := rw.Img("img"); err != nil { + t.Fatal(err) + } + if err := rw.Label("l"); err != nil { + t.Fatal(err) + } + if err := rw.Li("li"); err != nil { + t.Fatal(err) + } + if err := rw.Number(pkg.Bind(&mu, &vnumber)); err != nil { + t.Fatal(err) + } + if err := rw.Password(pkg.Bind(&mu, &vstring)); err != nil { + t.Fatal(err) + } + if err := rw.Radio(pkg.Bind(&mu, &vbool)); err != nil { + t.Fatal(err) + } + if err := rw.Range(pkg.Bind(&mu, &vnumber)); err != nil { + t.Fatal(err) + } + if err := rw.Select(nba); err != nil { + t.Fatal(err) + } + if err := rw.Span("s"); err != nil { + t.Fatal(err) + } + if err := rw.Tbody(&testContainer{}); err != nil { + t.Fatal(err) + } + if err := rw.Td("td"); err != nil { + t.Fatal(err) + } + if err := rw.Text(pkg.Bind(&mu, &vstring)); err != nil { + t.Fatal(err) + } + if err := rw.Textarea(pkg.Bind(&mu, &vstring)); err != nil { + t.Fatal(err) + } + if err := rw.Tr("tr"); err != nil { + t.Fatal(err) + } +} diff --git a/ui/requestwriter_types.go b/ui/requestwriter_types.go new file mode 100644 index 00000000..b5f46d3e --- /dev/null +++ b/ui/requestwriter_types.go @@ -0,0 +1,13 @@ +package ui + +import pkg "github.com/linkdata/jaws/jaws" + +// RequestWriter is the template/request rendering context used by JaWS templates. +// +// It aliases the core type so methods and behavior remain identical. +type RequestWriter = pkg.RequestWriter + +// With is the template execution context passed to Go html/template execution. +// +// It aliases the core type so fields and behavior remain identical. +type With = pkg.With diff --git a/ui/select.go b/ui/select.go new file mode 100644 index 00000000..a67e7155 --- /dev/null +++ b/ui/select.go @@ -0,0 +1,33 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" + "github.com/linkdata/jaws/what" +) + +type Select struct { + WrapContainer +} + +func NewSelect(sh pkg.SelectHandler) *Select { + return &Select{WrapContainer: NewWrapContainer(sh)} +} + +func (ui *Select) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.RenderContainer(e, w, "select", params) +} + +func (ui *Select) JawsUpdate(e *pkg.Element) { + e.SetValue(ui.WrapContainer.Container.(pkg.Getter[string]).JawsGet(e)) + ui.UpdateContainer(e) +} + +func (ui *Select) JawsEvent(e *pkg.Element, wht what.What, val string) (err error) { + err = pkg.ErrEventUnhandled + if wht == what.Input { + _, err = applyDirty(ui.Tag, e, ui.WrapContainer.Container.(pkg.Setter[string]).JawsSet(e, val)) + } + return +} diff --git a/ui/span.go b/ui/span.go new file mode 100644 index 00000000..f18fef92 --- /dev/null +++ b/ui/span.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Span struct{ HTMLInner } + +func NewSpan(innerHTML pkg.HTMLGetter) *Span { return &Span{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *Span) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "span", "", params) +} diff --git a/ui/tbody.go b/ui/tbody.go new file mode 100644 index 00000000..845b5bbf --- /dev/null +++ b/ui/tbody.go @@ -0,0 +1,23 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Tbody struct { + WrapContainer +} + +func NewTbody(c pkg.Container) *Tbody { + return &Tbody{WrapContainer: NewWrapContainer(c)} +} + +func (ui *Tbody) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.RenderContainer(e, w, "tbody", params) +} + +func (ui *Tbody) JawsUpdate(e *pkg.Element) { + ui.UpdateContainer(e) +} diff --git a/ui/td.go b/ui/td.go new file mode 100644 index 00000000..26f13913 --- /dev/null +++ b/ui/td.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Td struct{ HTMLInner } + +func NewTd(innerHTML pkg.HTMLGetter) *Td { return &Td{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *Td) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "td", "", params) +} diff --git a/ui/testhelpers_test.go b/ui/testhelpers_test.go new file mode 100644 index 00000000..1ed32868 --- /dev/null +++ b/ui/testhelpers_test.go @@ -0,0 +1,137 @@ +package ui + +import ( + "errors" + "html/template" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + "time" + + pkg "github.com/linkdata/jaws/jaws" +) + +var jidPattern = regexp.MustCompile(`Jid\.[0-9]+`) + +func mustMatch(t *testing.T, pattern, got string) { + t.Helper() + re := regexp.MustCompile(pattern) + if !re.MatchString(got) { + t.Fatalf("pattern %q did not match %q", pattern, got) + } +} + +func newRequest(t *testing.T) (*pkg.Jaws, *pkg.Request) { + t.Helper() + jw, err := pkg.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + if rq == nil { + t.Fatal("nil request") + } + return jw, rq +} + +func renderUI(t *testing.T, rq *pkg.Request, ui pkg.UI, params ...any) (*pkg.Element, string) { + t.Helper() + elem := rq.NewElement(ui) + var sb strings.Builder + if err := elem.JawsRender(&sb, params); err != nil { + t.Fatal(err) + } + return elem, sb.String() +} + +type testHTMLGetter string + +func (g testHTMLGetter) JawsGetHTML(*pkg.Element) template.HTML { + return template.HTML(g) +} + +type testSetter[T comparable] struct { + mu sync.Mutex + v T + err error + setCount int +} + +func newTestSetter[T comparable](v T) *testSetter[T] { + return &testSetter[T]{v: v} +} + +func (ts *testSetter[T]) JawsGet(*pkg.Element) T { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.v +} + +func (ts *testSetter[T]) JawsSet(_ *pkg.Element, v T) error { + ts.mu.Lock() + defer ts.mu.Unlock() + if ts.err != nil { + return ts.err + } + if ts.v == v { + return pkg.ErrValueUnchanged + } + ts.v = v + ts.setCount++ + return nil +} + +func (ts *testSetter[T]) Set(v T) { + ts.mu.Lock() + ts.v = v + ts.mu.Unlock() +} + +func (ts *testSetter[T]) Get() T { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.v +} + +func (ts *testSetter[T]) SetErr(err error) { + ts.mu.Lock() + ts.err = err + ts.mu.Unlock() +} + +type testContainer struct { + contents []pkg.UI +} + +func (tc *testContainer) JawsContains(*pkg.Element) []pkg.UI { + return tc.contents +} + +type errorUI struct { + err error +} + +func (ui errorUI) JawsRender(*pkg.Element, io.Writer, []any) error { + if ui.err != nil { + return ui.err + } + return errors.New("errorUI") +} + +func (errorUI) JawsUpdate(*pkg.Element) {} + +func waitUntil(t *testing.T, fn func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for !fn() { + if time.Now().After(deadline) { + t.Fatal("timeout") + } + time.Sleep(time.Millisecond) + } +} diff --git a/ui/text.go b/ui/text.go new file mode 100644 index 00000000..337f0d7d --- /dev/null +++ b/ui/text.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Text struct{ InputText } + +func NewText(vp pkg.Setter[string]) *Text { return &Text{InputText{Setter: vp}} } +func (ui *Text) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderStringInput(e, w, "text", params...) +} diff --git a/ui/textarea.go b/ui/textarea.go new file mode 100644 index 00000000..f79a6c10 --- /dev/null +++ b/ui/textarea.go @@ -0,0 +1,20 @@ +package ui + +import ( + "html/template" + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Textarea struct{ InputText } + +func NewTextarea(g pkg.Setter[string]) *Textarea { return &Textarea{InputText{Setter: g}} } +func (ui *Textarea) JawsRender(e *pkg.Element, w io.Writer, params []any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + err = pkg.WriteHTMLInner(w, e.Jid(), "textarea", "", template.HTML(ui.JawsGet(e)), attrs...) // #nosec G203 + } + return +} +func (ui *Textarea) JawsUpdate(e *pkg.Element) { e.SetValue(ui.JawsGet(e)) } diff --git a/ui/tr.go b/ui/tr.go new file mode 100644 index 00000000..6696a202 --- /dev/null +++ b/ui/tr.go @@ -0,0 +1,14 @@ +package ui + +import ( + "io" + + pkg "github.com/linkdata/jaws/jaws" +) + +type Tr struct{ HTMLInner } + +func NewTr(innerHTML pkg.HTMLGetter) *Tr { return &Tr{HTMLInner{HTMLGetter: innerHTML}} } +func (ui *Tr) JawsRender(e *pkg.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "tr", "", params) +} From 96fb0df7e636b2db67bb5bcc8d94185e16a0e074 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 11 Feb 2026 13:51:40 +0100 Subject: [PATCH 02/25] wip --- example_test.go | 3 +- jaws.go | 102 +++++++++-------- jaws/auth.go | 8 +- jaws/auth_test.go | 2 +- jaws/clickhandler_test.go | 4 +- jaws/element.go | 9 +- jaws/element_test.go | 2 +- jaws/eventhandler.go | 2 +- jaws/getter.go | 2 +- jaws/getter_test.go | 4 +- jaws/jawsevent_test.go | 6 +- jaws/register.go | 31 ------ jaws/request.go | 43 ++++--- jaws/request_test.go | 16 +-- jaws/requestwriter.go | 56 ---------- jaws/requestwriter_widgets.go | 167 ---------------------------- jaws/session_test.go | 2 +- jaws/setter.go | 2 +- jaws/setter_test.go | 6 +- jaws/setterfloat64.go | 2 +- jaws/setterfloat64_test.go | 10 +- jaws/ws.go | 12 +- jaws/ws_test.go | 34 +++--- jaws/wsmsg.go | 18 +-- jaws/wsmsg_test.go | 18 +-- jaws_test.go | 18 +-- jawsboot/example_test.go | 3 +- jawsboot/jawsboot_test.go | 3 +- jawstest/README.md | 1 + {jaws => jawstest}/jawsjaws_test.go | 4 +- {jaws => jawstest}/jsvar_test.go | 2 +- {jaws => jawstest}/template_test.go | 2 +- {jaws => jawstest}/testjaws_test.go | 4 +- {jaws => jawstest}/testrequest.go | 25 +++-- jawstree/example_test.go | 3 +- jawstree/tree.go | 5 +- jawstree/tree_test.go | 3 +- {jaws => ui}/errmissingtemplate.go | 2 +- {jaws => ui}/handler.go | 14 ++- {jaws => ui}/jsvar.go | 70 +++++++----- ui/register.go | 25 ++++- ui/requestwriter.go | 32 ++++++ ui/requestwriter_register.go | 72 ------------ ui/requestwriter_register_test.go | 84 -------------- ui/requestwriter_types.go | 13 --- ui/requestwriter_widgets.go | 107 ++++++++++++++++++ ui/rwlocker.go | 15 +++ {jaws => ui}/template.go | 37 +++--- {jaws => ui}/with.go | 8 +- 49 files changed, 439 insertions(+), 674 deletions(-) delete mode 100644 jaws/register.go delete mode 100644 jaws/requestwriter.go delete mode 100644 jaws/requestwriter_widgets.go create mode 100644 jawstest/README.md rename {jaws => jawstest}/jawsjaws_test.go (99%) rename {jaws => jawstest}/jsvar_test.go (99%) rename {jaws => jawstest}/template_test.go (97%) rename {jaws => jawstest}/testjaws_test.go (95%) rename {jaws => jawstest}/testrequest.go (74%) rename {jaws => ui}/errmissingtemplate.go (96%) rename {jaws => ui}/handler.go (78%) rename {jaws => ui}/jsvar.go (63%) create mode 100644 ui/requestwriter.go delete mode 100644 ui/requestwriter_register.go delete mode 100644 ui/requestwriter_register_test.go delete mode 100644 ui/requestwriter_types.go create mode 100644 ui/requestwriter_widgets.go create mode 100644 ui/rwlocker.go rename {jaws => ui}/template.go (76%) rename {jaws => ui}/with.go (69%) diff --git a/example_test.go b/example_test.go index 4328eac8..2bbac279 100644 --- a/example_test.go +++ b/example_test.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/linkdata/jaws" + "github.com/linkdata/jaws/ui" ) const indexhtml = ` @@ -37,6 +38,6 @@ func Example() { var mu sync.Mutex var f float64 - http.DefaultServeMux.Handle("/", jw.Handler("index", jaws.Bind(&mu, &f))) + http.DefaultServeMux.Handle("/", ui.NewHandler(jw, "index", jaws.Bind(&mu, &f))) slog.Error(http.ListenAndServe("localhost:8080", nil).Error()) } diff --git a/jaws.go b/jaws.go index 364e4521..843e55a0 100644 --- a/jaws.go +++ b/jaws.go @@ -6,7 +6,7 @@ import ( pkg "github.com/linkdata/jaws/jaws" "github.com/linkdata/jaws/jid" - uipkg "github.com/linkdata/jaws/ui" + "github.com/linkdata/jaws/ui" ) // The point of this is to not have a zillion files in the repository root @@ -25,8 +25,8 @@ type ( Renderer = pkg.Renderer TemplateLookuper = pkg.TemplateLookuper HandleFunc = pkg.HandleFunc - PathSetter = pkg.PathSetter - SetPather = pkg.SetPather + PathSetter = ui.PathSetter + SetPather = ui.SetPather Formatter = pkg.Formatter Auth = pkg.Auth InitHandler = pkg.InitHandler @@ -38,26 +38,25 @@ type ( Setter[T comparable] = pkg.Setter[T] Binder[T comparable] = pkg.Binder[T] HTMLGetter = pkg.HTMLGetter - JsVar[T any] = pkg.JsVar[T] - IsJsVar = pkg.IsJsVar - JsVarMaker = pkg.JsVarMaker + JsVar[T any] = ui.JsVar[T] + IsJsVar = ui.IsJsVar + JsVarMaker = ui.JsVarMaker Logger = pkg.Logger RWLocker = pkg.RWLocker TagGetter = pkg.TagGetter NamedBool = pkg.NamedBool NamedBoolArray = pkg.NamedBoolArray - Template = pkg.Template - RequestWriter = uipkg.RequestWriter - With = uipkg.With + Template = ui.Template + RequestWriter = ui.RequestWriter + With = ui.With Session = pkg.Session Tag = pkg.Tag - TestRequest = pkg.TestRequest ) var ( ErrEventUnhandled = pkg.ErrEventUnhandled ErrIllegalTagType = pkg.ErrIllegalTagType // ErrIllegalTagType is returned when a UI tag type is disallowed - ErrMissingTemplate = pkg.ErrMissingTemplate + ErrMissingTemplate = ui.ErrMissingTemplate ErrNotComparable = pkg.ErrNotComparable ErrNoWebSocketRequest = pkg.ErrNoWebSocketRequest ErrPendingCancelled = pkg.ErrPendingCancelled @@ -77,7 +76,7 @@ var ( New = pkg.New JawsKeyString = pkg.JawsKeyString WriteHTMLTag = pkg.WriteHTMLTag - NewTemplate = pkg.NewTemplate + NewTemplate = ui.NewTemplate HTMLGetterFunc = pkg.HTMLGetterFunc StringGetterFunc = pkg.StringGetterFunc MakeHTMLGetter = pkg.MakeHTMLGetter @@ -91,76 +90,75 @@ func Bind[T comparable](l sync.Locker, p *T) Binder[T] { } func NewJsVar[T any](l sync.Locker, v *T) *JsVar[T] { - return pkg.NewJsVar(l, v) + return ui.NewJsVar(l, v) } type ( - UiA = uipkg.A - UiButton = uipkg.Button - UiCheckbox = uipkg.Checkbox - UiContainer = uipkg.Container - UiDate = uipkg.Date - UiDiv = uipkg.Div - UiImg = uipkg.Img - UiLabel = uipkg.Label - UiLi = uipkg.Li - UiNumber = uipkg.Number - UiPassword = uipkg.Password - UiRadio = uipkg.Radio - UiRange = uipkg.Range - UiSelect = uipkg.Select - UiSpan = uipkg.Span - UiTbody = uipkg.Tbody - UiTd = uipkg.Td - UiText = uipkg.Text - UiTr = uipkg.Tr + UiA = ui.A + UiButton = ui.Button + UiCheckbox = ui.Checkbox + UiContainer = ui.Container + UiDate = ui.Date + UiDiv = ui.Div + UiImg = ui.Img + UiLabel = ui.Label + UiLi = ui.Li + UiNumber = ui.Number + UiPassword = ui.Password + UiRadio = ui.Radio + UiRange = ui.Range + UiSelect = ui.Select + UiSpan = ui.Span + UiTbody = ui.Tbody + UiTd = ui.Td + UiText = ui.Text + UiTr = ui.Tr ) // UI constructor assignments (generic types require wrappers, others are direct) var ( - NewUiA = uipkg.NewA - NewUiButton = uipkg.NewButton - NewUiContainer = uipkg.NewContainer - NewUiDiv = uipkg.NewDiv - NewUiLabel = uipkg.NewLabel - NewUiLi = uipkg.NewLi - NewUiSelect = uipkg.NewSelect - NewUiSpan = uipkg.NewSpan - NewUiTbody = uipkg.NewTbody - NewUiTd = uipkg.NewTd - NewUiTr = uipkg.NewTr - NewTestRequest = pkg.NewTestRequest + NewUiA = ui.NewA + NewUiButton = ui.NewButton + NewUiContainer = ui.NewContainer + NewUiDiv = ui.NewDiv + NewUiLabel = ui.NewLabel + NewUiLi = ui.NewLi + NewUiSelect = ui.NewSelect + NewUiSpan = ui.NewSpan + NewUiTbody = ui.NewTbody + NewUiTd = ui.NewTd + NewUiTr = ui.NewTr ) // UI constructors with generic parameters must be wrapped func NewUiCheckbox(g Setter[bool]) *UiCheckbox { - return uipkg.NewCheckbox(g) + return ui.NewCheckbox(g) } func NewUiDate(g Setter[time.Time]) *UiDate { - return uipkg.NewDate(g) + return ui.NewDate(g) } func NewUiImg(g Getter[string]) *UiImg { - return uipkg.NewImg(g) + return ui.NewImg(g) } func NewUiNumber(g Setter[float64]) *UiNumber { - return uipkg.NewNumber(g) + return ui.NewNumber(g) } func NewUiPassword(g Setter[string]) *UiPassword { - return uipkg.NewPassword(g) + return ui.NewPassword(g) } func NewUiRadio(vp Setter[bool]) *UiRadio { - return uipkg.NewRadio(vp) + return ui.NewRadio(vp) } func NewUiRange(g Setter[float64]) *UiRange { - return uipkg.NewRange(g) + return ui.NewRange(g) } func NewUiText(vp Setter[string]) *UiText { - return uipkg.NewText(vp) + return ui.NewText(vp) } diff --git a/jaws/auth.go b/jaws/auth.go index 886c03fd..bbaf2852 100644 --- a/jaws/auth.go +++ b/jaws/auth.go @@ -8,8 +8,8 @@ type Auth interface { type MakeAuthFn func(*Request) Auth -type defaultAuth struct{} +type DefaultAuth struct{} -func (defaultAuth) Data() map[string]any { return nil } -func (defaultAuth) Email() string { return "" } -func (defaultAuth) IsAdmin() bool { return true } +func (DefaultAuth) Data() map[string]any { return nil } +func (DefaultAuth) Email() string { return "" } +func (DefaultAuth) IsAdmin() bool { return true } diff --git a/jaws/auth_test.go b/jaws/auth_test.go index 5b9a7438..a1541bb4 100644 --- a/jaws/auth_test.go +++ b/jaws/auth_test.go @@ -5,7 +5,7 @@ import ( ) func Test_defaultAuth(t *testing.T) { - a := defaultAuth{} + a := DefaultAuth{} if a.Data() != nil { t.Fatal() } diff --git a/jaws/clickhandler_test.go b/jaws/clickhandler_test.go index 2015fb44..b47742e9 100644 --- a/jaws/clickhandler_test.go +++ b/jaws/clickhandler_test.go @@ -38,7 +38,7 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { t.Errorf("Request.UI(NewDiv()) = %q, want %q", got, want) } - rq.InCh <- wsMsg{Data: "text", Jid: 1, What: what.Input} + rq.InCh <- WsMsg{Data: "text", Jid: 1, What: what.Input} select { case <-th.C: th.Timeout() @@ -47,7 +47,7 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { default: } - rq.InCh <- wsMsg{Data: "adam", Jid: 1, What: what.Click} + rq.InCh <- WsMsg{Data: "adam", Jid: 1, What: what.Click} select { case <-th.C: th.Timeout() diff --git a/jaws/element.go b/jaws/element.go index 63b3518c..ee5c8498 100644 --- a/jaws/element.go +++ b/jaws/element.go @@ -26,6 +26,13 @@ func (e *Element) String() string { return fmt.Sprintf("Element{%T, id=%q, Tags: %v}", e.Ui(), e.Jid(), e.Request.TagsOf(e)) } +// AddHandler adds the given handlers to the Element. +func (e *Element) AddHandlers(h ...EventHandler) { + if !e.deleted { + e.handlers = append(e.handlers, h...) + } +} + // Tag adds the given tags to the Element. func (e *Element) Tag(tags ...any) { if !e.deleted { @@ -102,7 +109,7 @@ func (e *Element) JawsUpdate() { func (e *Element) queue(wht what.What, data string) { if !e.deleted { - e.Request.queue(wsMsg{ + e.Request.queue(WsMsg{ Data: data, Jid: e.jid, What: wht, diff --git a/jaws/element_test.go b/jaws/element_test.go index 9cfb5eb3..069fd4f4 100644 --- a/jaws/element_test.go +++ b/jaws/element_test.go @@ -112,7 +112,7 @@ func TestElement_Queued(t *testing.T) { e.Order([]jid.Jid{1, 2}) replaceHTML := template.HTML(fmt.Sprintf("
    ", e.Jid().String())) e.Replace(replaceHTML) - th.Equal(rq.wsQueue, []wsMsg{ + th.Equal(rq.wsQueue, []WsMsg{ { Data: "hidden\n", Jid: e.jid, diff --git a/jaws/eventhandler.go b/jaws/eventhandler.go index 4f46d816..bf59483a 100644 --- a/jaws/eventhandler.go +++ b/jaws/eventhandler.go @@ -44,7 +44,7 @@ func callEventHandler(obj any, e *Element, wht what.What, val string) (err error return ErrEventUnhandled } -func callEventHandlers(ui any, e *Element, wht what.What, val string) (err error) { +func CallEventHandlers(ui any, e *Element, wht what.What, val string) (err error) { if err = callEventHandler(ui, e, wht, val); err == ErrEventUnhandled { for _, h := range e.handlers { if err = callEventHandler(h, e, wht, val); err != ErrEventUnhandled { diff --git a/jaws/getter.go b/jaws/getter.go index d61c4db3..8d37cb4f 100644 --- a/jaws/getter.go +++ b/jaws/getter.go @@ -31,7 +31,7 @@ func makeStaticGetter[T comparable](v T) Getter[T] { return getterStatic[T]{v} } -func makeGetter[T comparable](v any) Getter[T] { +func MakeGetter[T comparable](v any) Getter[T] { switch v := v.(type) { case Getter[T]: return v diff --git a/jaws/getter_test.go b/jaws/getter_test.go index 61adcadc..9193a1ee 100644 --- a/jaws/getter_test.go +++ b/jaws/getter_test.go @@ -8,12 +8,12 @@ func Test_makeGetter_panic(t *testing.T) { t.Fail() } }() - setter2 := makeGetter[string](123) + setter2 := MakeGetter[string](123) t.Error(setter2) } func Test_makeGetter(t *testing.T) { - setter := makeGetter[string]("foo") + setter := MakeGetter[string]("foo") if err := setter.(Setter[string]).JawsSet(nil, "bar"); err != ErrValueNotSettable { t.Error(err) } diff --git a/jaws/jawsevent_test.go b/jaws/jawsevent_test.go index be2e881b..5c07f532 100644 --- a/jaws/jawsevent_test.go +++ b/jaws/jawsevent_test.go @@ -68,7 +68,7 @@ func Test_JawsEvent_ClickUnhandled(t *testing.T) { id := rq.Register(zomgItem, je, "attr1", []string{"attr2"}, template.HTMLAttr("attr3"), []template.HTMLAttr{"attr4"}) je.clickerr = ErrEventUnhandled - rq.InCh <- wsMsg{Data: "name", Jid: id, What: what.Click} + rq.InCh <- WsMsg{Data: "name", Jid: id, What: what.Click} select { case <-th.C: th.Timeout() @@ -93,7 +93,7 @@ func Test_JawsEvent_AllUnhandled(t *testing.T) { je.clickerr = ErrEventUnhandled je.eventerr = ErrEventUnhandled - rq.InCh <- wsMsg{Data: "name", Jid: id, What: what.Click} + rq.InCh <- WsMsg{Data: "name", Jid: id, What: what.Click} select { case <-th.C: th.Timeout() @@ -139,7 +139,7 @@ func Test_JawsEvent_ExtraHandler(t *testing.T) { th.NoErr(elem.JawsRender(&sb, []any{je})) th.Equal(sb.String(), "
    tjEH
    ") - rq.InCh <- wsMsg{Data: "name", Jid: 1, What: what.Click} + rq.InCh <- WsMsg{Data: "name", Jid: 1, What: what.Click} select { case <-th.C: th.Timeout() diff --git a/jaws/register.go b/jaws/register.go deleted file mode 100644 index 010e28f3..00000000 --- a/jaws/register.go +++ /dev/null @@ -1,31 +0,0 @@ -package jaws - -import ( - "io" - - "github.com/linkdata/jaws/jid" -) - -type registerUI struct { - Updater -} - -func (registerUI) JawsRender(*Element, io.Writer, []any) error { - return nil -} - -// Register creates a new Element with the given Updater as a tag -// for dynamic updates. Additional tags may be provided in params. -// The updaters JawsUpdate method will be called immediately to -// ensure the initial rendering is correct. -// -// Returns a Jid, suitable for including as a HTML "id" attribute: -// -//
    ...
    -func (rq RequestWriter) Register(updater Updater, params ...any) jid.Jid { - elem := rq.rq.NewElement(registerUI{Updater: updater}) - elem.Tag(updater) - elem.ApplyParams(params) - updater.JawsUpdate(elem) - return elem.Jid() -} diff --git a/jaws/request.go b/jaws/request.go index 267f81ac..ec720f42 100644 --- a/jaws/request.go +++ b/jaws/request.go @@ -34,7 +34,7 @@ type Request struct { Jaws *Jaws // (read-only) the JaWS instance the Request belongs to JawsKey uint64 // (read-only) a random number used in the WebSocket URI to identify this Request remoteIP netip.Addr // (read-only) remote IP, or nil - rendering atomic.Bool // set to true by RequestWriter.Write() + Rendering atomic.Bool // set to true by RequestWriter.Write() running atomic.Bool // if ServeHTTP() is running claimed atomic.Bool // if UseRequest() has been called for it mu deadlock.RWMutex // protects following @@ -49,7 +49,7 @@ type Request struct { elems []*Element // our Elements tagMap map[any][]*Element // maps tags to Elements muQueue deadlock.Mutex // protects wsQueue - wsQueue []wsMsg // queued messages to send + wsQueue []WsMsg // queued messages to send } type eventFnCall struct { @@ -257,7 +257,7 @@ func (rq *Request) SetContext(fn func(oldctx context.Context) (newctx context.Co func (rq *Request) maintenance(now time.Time, requestTimeout time.Duration) bool { if !rq.running.Load() { - if rq.rendering.Swap(false) { + if rq.Rendering.Swap(false) { rq.mu.Lock() rq.lastWrite = now rq.mu.Unlock() @@ -435,7 +435,7 @@ func (rq *Request) appendDirtyTags(tags []any) { } // Tag adds the given tags to the given Element. -func (rq *Request) tagExpanded(elem *Element, expandedtags []any) { +func (rq *Request) TagExpanded(elem *Element, expandedtags []any) { rq.mu.Lock() defer rq.mu.Unlock() for _, tag := range expandedtags { @@ -448,7 +448,7 @@ func (rq *Request) tagExpanded(elem *Element, expandedtags []any) { // Tag adds the given tags to the given Element. func (rq *Request) Tag(elem *Element, tags ...any) { if elem != nil && len(tags) > 0 && elem.Request == rq { - rq.tagExpanded(elem, MustTagExpand(elem.Request, tags)) + rq.TagExpanded(elem, MustTagExpand(elem.Request, tags)) } } @@ -472,7 +472,7 @@ func (rq *Request) GetElements(tagitem any) (elems []*Element) { } // process is the main message processing loop. Will unsubscribe broadcastMsgCh and close outboundMsgCh on exit. -func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsMsg, outboundMsgCh chan<- wsMsg) { +func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsMsg, outboundMsgCh chan<- WsMsg) { jawsDoneCh := rq.Jaws.Done() httpDoneCh := rq.httpDoneCh eventDoneCh := make(chan struct{}) @@ -504,7 +504,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM for { var tagmsg Message - var wsmsg wsMsg + var wsmsg WsMsg var ok bool rq.sendQueue(outboundMsgCh) @@ -551,7 +551,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM case *Request: case string: // target is a regular HTML ID - rq.queue(wsMsg{ + rq.queue(WsMsg{ Data: v + "\t" + strconv.Quote(tagmsg.Data), What: tagmsg.What, Jid: -1, @@ -562,7 +562,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM switch tagmsg.What { case what.Reload, what.Redirect, what.Order, what.Alert: - rq.queue(wsMsg{ + rq.queue(WsMsg{ Jid: 0, Data: tagmsg.Data, What: tagmsg.What, @@ -571,7 +571,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM for _, elem := range todo { switch tagmsg.What { case what.Delete: - rq.queue(wsMsg{ + rq.queue(WsMsg{ Jid: elem.Jid(), What: what.Delete, }) @@ -588,7 +588,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM // an error to be sent out as an alert message. // primary usecase is tests. if err := rq.Jaws.Log(rq.callAllEventHandlers(elem.Jid(), tagmsg.What, tagmsg.Data)); err != nil { - rq.queue(wsMsg{ + rq.queue(WsMsg{ Data: tagmsg.Data, Jid: elem.Jid(), What: what.Alert, @@ -597,7 +597,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM case what.Update: elem.JawsUpdate() default: - rq.queue(wsMsg{ + rq.queue(WsMsg{ Data: tagmsg.Data, Jid: elem.Jid(), What: tagmsg.What, @@ -618,7 +618,7 @@ func (rq *Request) handleRemove(data string) { } } -func (rq *Request) queue(msg wsMsg) { +func (rq *Request) queue(msg WsMsg) { rq.muQueue.Lock() rq.wsQueue = append(rq.wsQueue, msg) rq.muQueue.Unlock() @@ -650,7 +650,7 @@ func (rq *Request) callAllEventHandlers(id Jid, wht what.What, val string) (err rq.mu.RUnlock() for _, e := range elems { - if err = callEventHandlers(e.Ui(), e, wht, val); err != ErrEventUnhandled { + if err = CallEventHandlers(e.Ui(), e, wht, val); err != ErrEventUnhandled { return } } @@ -669,7 +669,7 @@ func (rq *Request) queueEvent(eventCallCh chan eventFnCall, call eventFnCall) { } } -func (rq *Request) getSendMsgs() (toSend []wsMsg) { +func (rq *Request) getSendMsgs() (toSend []WsMsg) { rq.mu.RLock() defer rq.mu.RUnlock() @@ -695,11 +695,11 @@ func (rq *Request) getSendMsgs() (toSend []wsMsg) { rq.wsQueue = rq.wsQueue[:0] } - slices.SortStableFunc(toSend, func(a, b wsMsg) int { return int(a.Jid - b.Jid) }) + slices.SortStableFunc(toSend, func(a, b WsMsg) int { return int(a.Jid - b.Jid) }) return } -func (rq *Request) sendQueue(outboundMsgCh chan<- wsMsg) { +func (rq *Request) sendQueue(outboundMsgCh chan<- WsMsg) { for _, msg := range rq.getSendMsgs() { select { case <-rq.Context().Done(): @@ -770,11 +770,11 @@ func (rq *Request) makeUpdateList() (todo []*Element) { } // eventCaller calls event functions -func (rq *Request) eventCaller(eventCallCh <-chan eventFnCall, outboundMsgCh chan<- wsMsg, eventDoneCh chan<- struct{}) { +func (rq *Request) eventCaller(eventCallCh <-chan eventFnCall, outboundMsgCh chan<- WsMsg, eventDoneCh chan<- struct{}) { defer close(eventDoneCh) for call := range eventCallCh { if err := rq.callAllEventHandlers(call.jid, call.wht, call.data); err != nil { - var m wsMsg + var m WsMsg m.FillAlert(err) select { case outboundMsgCh <- m: @@ -797,11 +797,6 @@ func (rq *Request) onConnect() (err error) { return } -// Writer returns a RequestWriter with this Request and the given Writer. -func (rq *Request) Writer(w io.Writer) RequestWriter { - return RequestWriter{rq: rq, Writer: w} -} - func (rq *Request) validateWebSocketOrigin(r *http.Request) (err error) { err = ErrWebsocketOriginMissing if origin := r.Header.Get("Origin"); origin != "" { diff --git a/jaws/request_test.go b/jaws/request_test.go index fed8fd69..28e089cd 100644 --- a/jaws/request_test.go +++ b/jaws/request_test.go @@ -19,10 +19,10 @@ import ( const testTimeout = time.Second * 3 -func fillWsCh(ch chan wsMsg) { +func fillWsCh(ch chan WsMsg) { for { select { - case ch <- wsMsg{}: + case ch <- WsMsg{}: default: return } @@ -107,7 +107,7 @@ func TestRequest_SendArrivesOk(t *testing.T) { elem := rq.GetElementByJid(jid) is.True(elem != nil) if elem != nil { - is.Equal(msg, wsMsg{Jid: elem.jid, Data: "bar", What: what.Inner}) + is.Equal(msg, WsMsg{Jid: elem.jid, Data: "bar", What: what.Inner}) } } } @@ -205,7 +205,7 @@ func TestRequest_Trigger(t *testing.T) { case <-th.C: th.Timeout() case msg := <-rq.OutCh: - th.Equal(msg.Format(), (&wsMsg{ + th.Equal(msg.Format(), (&WsMsg{ Data: "danger\nomg", Jid: jid.Jid(0), What: what.Alert, @@ -312,7 +312,7 @@ func TestRequest_EventFnQueueOverflowPanicsWithNoLogger(t *testing.T) { return case <-th.C: th.Timeout() - case rq.InCh <- wsMsg{Jid: jid, What: what.Input}: + case rq.InCh <- WsMsg{Jid: jid, What: what.Input}: } } } @@ -380,7 +380,7 @@ func TestRequest_IgnoresIncomingMsgsDuringShutdown(t *testing.T) { rq.Jaws.Broadcast(Message{Dest: rq}) } select { - case rq.InCh <- wsMsg{}: + case rq.InCh <- WsMsg{}: case <-rq.DoneCh: th.Fatal() case <-th.C: @@ -659,7 +659,7 @@ func TestRequest_IncomingRemove(t *testing.T) { select { case <-th.C: th.Timeout() - case rq.InCh <- wsMsg{What: what.Remove, Data: "Jid.1"}: + case rq.InCh <- WsMsg{What: what.Remove, Data: "Jid.1"}: } elem := rq.GetElementByJid(1) @@ -696,7 +696,7 @@ func TestRequest_IncomingClick(t *testing.T) { select { case <-th.C: th.Timeout() - case rq.InCh <- wsMsg{What: what.Click, Data: "name\tJid.1\tJid.2"}: + case rq.InCh <- WsMsg{What: what.Click, Data: "name\tJid.1\tJid.2"}: } select { diff --git a/jaws/requestwriter.go b/jaws/requestwriter.go deleted file mode 100644 index aba67c7e..00000000 --- a/jaws/requestwriter.go +++ /dev/null @@ -1,56 +0,0 @@ -package jaws - -import ( - "io" - "net/http" -) - -type RequestWriter struct { - rq *Request - io.Writer -} - -func (rw RequestWriter) UI(ui UI, params ...any) error { - return rw.rq.NewElement(ui).JawsRender(rw, params) -} - -func (rw RequestWriter) Write(p []byte) (n int, err error) { - rw.rq.rendering.Store(true) - return rw.Writer.Write(p) -} - -// Request returns the current jaws.Request. -func (rw RequestWriter) Request() *Request { - return rw.rq -} - -// Initial returns the initial http.Request. -func (rw RequestWriter) Initial() *http.Request { - return rw.Request().Initial() -} - -// HeadHTML outputs the HTML code needed in the HEAD section. -func (rw RequestWriter) HeadHTML() error { - return rw.Request().HeadHTML(rw) -} - -// TailHTML writes optional HTML code at the end of the page's BODY section that -// will immediately apply updates made during initial rendering. -func (rw RequestWriter) TailHTML() error { - return rw.Request().TailHTML(rw) -} - -// Session returns the Requests's Session, or nil. -func (rw RequestWriter) Session() *Session { - return rw.Request().Session() -} - -// Get calls Request().Get() -func (rw RequestWriter) Get(key string) (val any) { - return rw.Request().Get(key) -} - -// Set calls Request().Set() -func (rw RequestWriter) Set(key string, val any) { - rw.Request().Set(key, val) -} diff --git a/jaws/requestwriter_widgets.go b/jaws/requestwriter_widgets.go deleted file mode 100644 index d1c9ba16..00000000 --- a/jaws/requestwriter_widgets.go +++ /dev/null @@ -1,167 +0,0 @@ -package jaws - -import "time" - -// RequestWriterWidgetFactory provides widget constructors used by -// RequestWriter helper methods (A, Span, Text, ...). -// -// The ui package registers the canonical implementation via init(), so core -// RequestWriter keeps its legacy API without duplicating widget logic. -type RequestWriterWidgetFactory struct { - A func(HTMLGetter) UI - Button func(HTMLGetter) UI - Checkbox func(Setter[bool]) UI - Container func(string, Container) UI - Date func(Setter[time.Time]) UI - Div func(HTMLGetter) UI - Img func(Getter[string]) UI - Label func(HTMLGetter) UI - Li func(HTMLGetter) UI - Number func(Setter[float64]) UI - Password func(Setter[string]) UI - Radio func(Setter[bool]) UI - Range func(Setter[float64]) UI - Select func(SelectHandler) UI - Span func(HTMLGetter) UI - Tbody func(Container) UI - Td func(HTMLGetter) UI - Text func(Setter[string]) UI - Textarea func(Setter[string]) UI - Tr func(HTMLGetter) UI -} - -var requestWriterWidgets RequestWriterWidgetFactory - -// RegisterRequestWriterWidgets installs constructor hooks for RequestWriter -// helper methods. This is called by package ui during init(). -func RegisterRequestWriterWidgets(f RequestWriterWidgetFactory) { - requestWriterWidgets = f -} - -func mustRequestWriterWidgets() RequestWriterWidgetFactory { - f := requestWriterWidgets - if f.Span == nil { - panic("jaws: RequestWriter widget helpers are not registered; import github.com/linkdata/jaws/ui or github.com/linkdata/jaws") - } - return f -} - -// A writes an element. -func (rw RequestWriter) A(innerHTML any, params ...any) error { - f := mustRequestWriterWidgets() - return rw.UI(f.A(MakeHTMLGetter(innerHTML)), params...) -} - -// Button writes a