diff --git a/h/base.go b/h/base.go index eddefc9..be7423b 100644 --- a/h/base.go +++ b/h/base.go @@ -19,10 +19,6 @@ func (p *Partial) Render() *Node { return p.Root } -func (p *Partial) ToNode() *Node { - return p.Root -} - type Page struct { Root Renderable HttpMethod string @@ -42,10 +38,6 @@ func NewPageWithHttpMethod(httpMethod string, root Renderable) *Page { } } -func WrapPartial(ctx *fiber.Ctx, cb func(ctx *fiber.Ctx) *Partial) *Node { - return cb(ctx).Root -} - func NewPartialWithHeaders(headers *Headers, root Renderable) *Partial { return &Partial{ Headers: headers, diff --git a/h/livereload.go b/h/livereload.go index f30375b..0dc910d 100644 --- a/h/livereload.go +++ b/h/livereload.go @@ -25,7 +25,7 @@ func LiveReloadHandler(c *fiber.Ctx) error { } func LiveReload() Renderable { - return Div(Get("/livereload"), Trigger("every 200ms")) + return Div(Get("/livereload"), Trigger("every 2s")) } func AddLiveReloadHandler(path string, app *fiber.App) { diff --git a/h/render.go b/h/render.go index e4aae9e..fdd579e 100644 --- a/h/render.go +++ b/h/render.go @@ -57,11 +57,12 @@ func (page Builder) renderNode(node *Node) { for _, child := range node.children { - c := child.Render() if child == nil { continue } + c := child.Render() + if c.tag == "class" { insertAttribute(node, "class", c.value) c.tag = FlagSkip @@ -140,6 +141,6 @@ func Render(node Renderable) string { page.render() d := page.builder.String() duration := time.Since(start) - fmt.Printf("render took %s\n", duration) + fmt.Printf("render took %d\n", duration.Microseconds()) return d } diff --git a/h/tag.go b/h/tag.go index 5fcd07a..5c14ba7 100644 --- a/h/tag.go +++ b/h/tag.go @@ -81,6 +81,10 @@ func Attribute(key string, value string) Renderable { return Attributes(map[string]string{key: value}) } +func TriggerChildren() Renderable { + return Attribute("hx-trigger-children", "") +} + func Disabled() Renderable { return Attribute("disabled", "") } @@ -103,6 +107,7 @@ func CreateTriggers(triggers ...string) []string { type ReloadParams struct { Triggers []string + Target string } func ViewOnLoad(partial func(ctx *fiber.Ctx) *Partial) Renderable { @@ -115,10 +120,11 @@ func View(partial func(ctx *fiber.Ctx) *Partial, params ReloadParams) Renderable return Div(Attributes(map[string]string{ "hx-get": GetPartialPath(partial), "hx-trigger": strings.Join(params.Triggers, ", "), + "hx-target": params.Target, })) } -func ViewWithTriggers(partial func(ctx *fiber.Ctx) *Partial, triggers ...string) Renderable { +func PartialWithTriggers(partial func(ctx *fiber.Ctx) *Partial, triggers ...string) Renderable { return Div(Attributes(map[string]string{ "hx-get": GetPartialPath(partial), "hx-trigger": strings.Join(triggers, ", "), @@ -126,20 +132,7 @@ func ViewWithTriggers(partial func(ctx *fiber.Ctx) *Partial, triggers ...string) } func GetWithQs(path string, qs map[string]string) Renderable { - u, err := url.Parse(path) - if err != nil { - return Empty() - } - - q := u.Query() - - for s := range qs { - q.Add(s, qs[s]) - } - - u.RawQuery = q.Encode() - - return Get(u.String()) + return Get(SetQueryParams(path, qs)) } func Post(url string) Renderable { @@ -239,6 +232,49 @@ func Div(children ...Renderable) Renderable { return Tag("div", children...) } +func PushUrlHeader(url string) *Headers { + return NewHeaders("HX-Push-Url", url) +} + +func CombineHeaders(headers ...*Headers) *Headers { + m := make(Headers) + for _, h := range headers { + for k, v := range *h { + m[k] = v + } + } + return &m +} + +func CurrentPath(ctx *fiber.Ctx) string { + current := ctx.Get("Hx-Current-Url") + parsed, err := url.Parse(current) + if err != nil { + return "" + } + return parsed.Path +} + +func PushQsHeader(ctx *fiber.Ctx, key string, value string) *Headers { + current := ctx.Get("Hx-Current-Url") + parsed, err := url.Parse(current) + if err != nil { + return NewHeaders() + } + return NewHeaders("HX-Push-Url", SetQueryParams(parsed.Path, map[string]string{ + key: value, + })) +} + +func NewHeaders(headers ...string) *Headers { + m := make(Headers) + for i := 0; i < len(headers); i++ { + m[headers[i]] = headers[i+1] + i++ + } + return &m +} + func Input(inputType string, children ...Renderable) Renderable { return &Node{ tag: "input", @@ -331,6 +367,10 @@ func BeforeRequestSetText(text string) Renderable { return Attribute("hx-on::before-request", `this.innerText = '`+text+`'`) } +func AfterRequestSetText(text string) Renderable { + return Attribute("hx-on::after-request", `this.innerText = '`+text+`'`) +} + func AfterRequestRemoveAttribute(key string, value string) Renderable { return Attribute("hx-on::after-request", `this.removeAttribute('`+key+`')`) } diff --git a/h/util.go b/h/util.go index cdc7a4c..38f0fe3 100644 --- a/h/util.go +++ b/h/util.go @@ -46,3 +46,20 @@ func GetQueryParam(ctx *fiber.Ctx, key string) string { } return value } + +func SetQueryParams(href string, qs map[string]string) string { + u, err := url.Parse(href) + if err != nil { + return href + } + q := u.Query() + for key, value := range qs { + if value == "" { + q.Del(key) + } else { + q.Set(key, value) + } + } + u.RawQuery = q.Encode() + return u.String() +} diff --git a/js/mhtml.js b/js/mhtml.js index e492b3f..92fe8e8 100644 --- a/js/mhtml.js +++ b/js/mhtml.js @@ -1,12 +1,30 @@ window.onload = function () { // htmx.logger = function(elt, event, data) { // if(console) { - // console.log(event); + // console.log(elt, event, data); // } // } // onUrlChange(window.location.href); + function triggerChildren(event) { + const target = event.detail.target + const type = event.type + if(target && target.children && target.hasAttribute('hx-trigger-children')) { + Array.from(target.children).forEach(function(element) { + htmx.trigger(element, type); + }); + } + } + + const events = ['htmx:beforeRequest', 'htmx:afterRequest', 'htmx:responseError', 'htmx:sendError', + 'htmx:timeout', 'htmx:xhr:abort', + 'htmx:xhr:loadstart', 'htmx:xhr:loadend', 'htmx:xhr:progress'] + + events.forEach(function(event) { + document.addEventListener(event, triggerChildren) + }) + window.history.pushState = new Proxy(window.history.pushState, { apply: (target, thisArg, argArray) => { if(argArray.length > 2) { diff --git a/k6.js b/k6.js new file mode 100644 index 0000000..0efa8e5 --- /dev/null +++ b/k6.js @@ -0,0 +1,18 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +export let options = { + stages: [ + { duration: '1m', target: 100 }, // Ramp-up to 100 RPS over 1 minute + { duration: '10m', target: 100 }, // Stay at 100 RPS for 10 minutes + { duration: '1m', target: 0 }, // Ramp-down to 0 RPS + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests should be below 500ms + }, +}; + +export default function () { + http.get('http://localhost:3000/patients'); + sleep(1 / 100); // Make 100 requests per second +} diff --git a/main.go b/main.go index 8ca7be5..5584e17 100644 --- a/main.go +++ b/main.go @@ -36,19 +36,13 @@ func main() { duration := time.Since(now) ctx.Set("X-Response-Time", duration.String()) // Log or print the request method, URL, and duration - log.Printf("Request: %s %s took %v", ctx.Method(), ctx.OriginalURL(), duration) + log.Printf("Request: %s %s took %dms", ctx.Method(), ctx.OriginalURL(), duration.Milliseconds()) return err }) - f.All("/mhtml/partials*", func(ctx *fiber.Ctx) error { - partial := partials.GetPartialFromContext(ctx) - if partial == nil { - return ctx.SendStatus(404) - } - return h.PartialView(ctx, partial) - }) - + partials.RegisterPartials(f) pages.RegisterPages(f) + h.Start(f, h.App{ LiveReload: true, }) diff --git a/pages/patients.index.go b/pages/patients.index.go index 7a48920..ad36dff 100644 --- a/pages/patients.index.go +++ b/pages/patients.index.go @@ -5,6 +5,7 @@ import ( "mhtml/h" "mhtml/pages/base" "mhtml/partials/patient" + "mhtml/partials/sheet" ) func PatientsIndex(ctx *fiber.Ctx) *h.Page { @@ -17,7 +18,13 @@ func PatientsIndex(ctx *fiber.Ctx) *h.Page { h.P("Manage Patients", h.Class("text-lg font-bold")), patient.AddPatientButton(), ), - h.ViewWithTriggers(patient.List, "load", "patient-added from:body"), + h.PartialWithTriggers(patient.List, "load", "patient-added from:body", "every 5s"), + h.If( + h.GetQueryParam(ctx, "adding") == "true", + h.View(patient.AddPatientSheetPartial, h.ReloadParams{ + Triggers: h.CreateTriggers("load"), + Target: sheet.Id, + })), ), ), )) diff --git a/partials/base.go b/partials/base.go index 4feeaa0..5adc860 100644 --- a/partials/base.go +++ b/partials/base.go @@ -1 +1,16 @@ package partials + +import ( + "github.com/gofiber/fiber/v2" + "mhtml/h" +) + +func RegisterPartials(f *fiber.App) { + f.All("/mhtml/partials*", func(ctx *fiber.Ctx) error { + partial := GetPartialFromContext(ctx) + if partial == nil { + return ctx.SendStatus(404) + } + return h.PartialView(ctx, partial) + }) +} diff --git a/partials/generated.go b/partials/generated.go index 18772d8..9627b4a 100644 --- a/partials/generated.go +++ b/partials/generated.go @@ -20,8 +20,8 @@ func GetPartialFromContext(ctx *fiber.Ctx) *h.Partial { if path == "List" || path == "/mhtml/partials/patient.List" { return patient.List(ctx) } - if path == "AddPatientSheet" || path == "/mhtml/partials/patient.AddPatientSheet" { - return patient.AddPatientSheet(ctx) + if path == "AddPatientSheetPartial" || path == "/mhtml/partials/patient.AddPatientSheetPartial" { + return patient.AddPatientSheetPartial(ctx) } if path == "Close" || path == "/mhtml/partials/sheet.Close" { return sheet.Close(ctx) diff --git a/partials/patient/create.go b/partials/patient/create.go index b2f0884..c5e6dc7 100644 --- a/partials/patient/create.go +++ b/partials/patient/create.go @@ -21,9 +21,11 @@ func Create(ctx *fiber.Ctx) *h.Partial { LocationName: location, }) - headers := &map[string]string{ + headers := h.CombineHeaders(h.PushQsHeader(ctx, "adding", ""), &map[string]string{ "HX-Trigger": "patient-added", - } + }) - return h.NewPartialWithHeaders(headers, h.WrapPartial(ctx, sheet.Close)) + return h.NewPartialWithHeaders( + headers, + sheet.Close(ctx)) } diff --git a/partials/patient/patient.go b/partials/patient/patient.go index 42ce5a3..bab1893 100644 --- a/partials/patient/patient.go +++ b/partials/patient/patient.go @@ -40,26 +40,36 @@ func List(ctx *fiber.Ctx) *h.Partial { )) } -func AddPatientSheet(ctx *fiber.Ctx) *h.Partial { - return h.NewPartial(sheet.Opened( +func AddPatientSheetPartial(ctx *fiber.Ctx) *h.Partial { + return h.NewPartialWithHeaders( + h.PushQsHeader(ctx, "adding", "true"), + AddPatientSheet(h.CurrentPath(ctx)), + ) +} + +func AddPatientSheet(onClosePath string) h.Renderable { + return sheet.Opened( sheet.Props{ - ClassName: "w-[400px] bg-gray-100 p-4", + OnClosePath: onClosePath, + ClassName: "w-[400px] bg-gray-100 p-4", Root: h.Div( h.Class("flex flex-col gap-4"), h.P("Add Patient", h.Class("text-lg font-bold")), addPatientForm(), ), - })) + }) } func addPatientForm() h.Renderable { return h.Form( + h.TriggerChildren(), h.Post(h.GetPartialPath(Create)), h.Class("flex flex-col gap-2"), ui.Input(ui.InputProps{ - Type: "text", - Label: "Name", - Name: "name", + Type: "text", + Label: "Name", + Name: "name", + DefaultValue: "fart", }), ui.Input(ui.InputProps{ Type: "text", @@ -98,6 +108,6 @@ func AddPatientButton() h.Renderable { Text: "Add Patient", Class: "bg-blue-700 text-white rounded p-2 h-12", Target: sheet.Id, - Get: h.GetPartialPath(AddPatientSheet), + Get: h.GetPartialPath(AddPatientSheetPartial), }) } diff --git a/partials/sheet/sheet.go b/partials/sheet/sheet.go index 57be992..f8663f5 100644 --- a/partials/sheet/sheet.go +++ b/partials/sheet/sheet.go @@ -1,13 +1,15 @@ package sheet import ( + "fmt" "github.com/gofiber/fiber/v2" "mhtml/h" ) type Props struct { - ClassName string - Root h.Renderable + ClassName string + Root h.Renderable + OnClosePath string } var Id = "#active-modal" @@ -16,7 +18,7 @@ func Opened(props Props) h.Renderable { return h.Fragment(h.Div( h.Class(`fixed top-0 right-0 h-full shadow-lg z-50`, h.Ternary(props.ClassName != "", props.ClassName, "w-96 bg-gray-100")), - closeButton(), + closeButton(props), h.Div( props.Root, ))) @@ -27,17 +29,18 @@ func Closed() h.Renderable { } func Close(ctx *fiber.Ctx) *h.Partial { - return h.NewPartial( + return h.NewPartialWithHeaders( + h.Ternary(ctx.Query("path") != "", h.PushUrlHeader(ctx.Query("path")), h.NewHeaders()), h.Swap(ctx, Closed()), ) } -func closeButton() h.Renderable { +func closeButton(props Props) h.Renderable { return h.Div( h.Class("absolute top-0 right-0 p-3"), h.Button( h.Class("text-gray-500"), - h.GetPartial(Close), + h.GetPartialWithQs(Close, fmt.Sprintf("path=%s", props.OnClosePath)), h.Text("X"), ), ) diff --git a/ui/button.go b/ui/button.go index 14498de..73864ff 100644 --- a/ui/button.go +++ b/ui/button.go @@ -36,7 +36,10 @@ func Button(props ButtonProps) h.Renderable { h.Class("flex gap-1 items-center border p-4 rounded cursor-hover", props.Class), h.If(props.Get != "", h.Get(props.Get)), h.If(props.Target != "", h.Target(props.Target)), + //h.Attribute("hx-indicator", "#spinner"), h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")), + h.BeforeRequestSetText("Loading..."), + h.AfterRequestSetText(props.Text), text, ) diff --git a/ui/input.go b/ui/input.go index e1d5b2f..7364ced 100644 --- a/ui/input.go +++ b/ui/input.go @@ -16,7 +16,7 @@ func Input(props InputProps) h.Renderable { h.Class("border p-2 rounded"), h.If(props.Id != "", h.Id(props.Id)), h.If(props.Name != "", h.Name(props.Name)), - h.If(props.DefaultValue != "", h.Attribute("defaultValue", props.DefaultValue)), + h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)), ) if props.Label != "" { return h.Div(