maxence-charriere / go-app

A package to build progressive web apps with Go programming language and WebAssembly.
https://go-app.dev
MIT License
7.96k stars 367 forks source link

Can't clear HTML <input> value #447

Closed pojntfx closed 4 years ago

pojntfx commented 4 years ago

Hi!

First of all, thanks for this library! I've had a lot of fun using it so far and I plan to use it for some serious frontends in the future. Sadly, I can't seem to be able to set the value of a HTML <input> element, even when calling Update() afterwards:

package components

import (
    "context"
    "log"

    "github.com/maxence-charriere/go-app/v7/pkg/app"
    proto "github.com/pojntfx/go-app-grpc-chat-frontend-web/pkg/proto/generated"
)

type App struct {
    app.Compo
    client            proto.ChatServiceClient
    receivedMessages  []proto.ChatMessage
    newMessageContent string
}

func NewApp(client proto.ChatServiceClient) *App {
    return &App{client: client, receivedMessages: []proto.ChatMessage{}, newMessageContent: ""}
}

func (c *App) HandleMessageSend(ctx app.Context, e app.Event) {
    log.Println("Sending message with content", c.newMessageContent)

    message := proto.ChatMessage{Content: c.newMessageContent}
    outMessage, err := c.client.CreateMessage(context.TODO(), &message)
    if err != nil {
        log.Println("could not send message", err)
    }

    c.receivedMessages = append(c.receivedMessages, *outMessage)

    log.Println("Received from server message", outMessage)

    c.newMessageContent = ""

    c.Update()
}

func (c *App) Render() app.UI {
    return app.Main().Body(
        app.Div().Class("container").Body(
            app.H1().Class("mt-3").Body(
                app.Text("go-app gRPC Chat Frontend"),
            ),
            app.U().Class("list-group mt-3").Body(
                app.Range(c.receivedMessages).Slice(func(i int) app.UI {
                    return app.Li().Class("list-group-item").Body(
                        app.Text(c.receivedMessages[i].GetContent()),
                    )
                }),
            ),
            app.Div().Class("input-group mt-3").Body(
                app.Input().Type("text").Class("form-control").Value(c.newMessageContent).Placeholder("Message content").OnInput(func(ctx app.Context, e app.Event) {
                    c.newMessageContent = e.Get("target").Get("value").String()

                    c.Update()
                }).OnChange(c.HandleMessageSend),
                app.Div().Class("input-group-append").Body(
                    app.Button().Class("btn btn-primary").Body(app.Text("Send Message")).OnClick(c.HandleMessageSend),
                ),
            ),
        ),
    )
}

I'd expect to the be able to clear the input in the OnChange handler, but nothing happens. newMessageContent gets set, but the input still shows that last value. Any ideas?

maxence-charriere commented 4 years ago

Look like you are setting back the value by the previous content

c.newMessageContent = e.Get("target").Get("value").String()
pojntfx commented 4 years ago

@maxence-charriere Yeah sure, in the OnInput handler; in the OnChange handler (c.HandleMessageSend) however I'm clearing the value (when I "send a message"). No matter what I do, even if I call c.HandleMessageSend from the button, the text field isn't being cleared 🤷

pojntfx commented 4 years ago

Just in case you're still looking into this; I've found a solution after a few hours of troubleshooting. I'll post it here; the gist of it is that there is a difference between the HTML value (for a text input) and the DOM value - noticed this when working with checkboxes. Using component-local state I currently create the equivalent of a React ref and update the DOM attributes manually on every render ;)

pojntfx commented 4 years ago

Code repo with the implementation: https://github.com/pojntfx/liwasc-frontend-web

The implementation (syncing the checked DOM property with the go-app state):

package components

import (
    "github.com/maxence-charriere/go-app/v7/pkg/app"
)

type OnOffSwitchComponent struct {
    app.Compo
    On       bool
    OnToggle func(ctx app.Context, e app.Event)

    ref app.HTMLInput
}

func (c *OnOffSwitchComponent) Render() app.UI {
    c.Sync()

    return c.ref
}

func (c *OnOffSwitchComponent) OnMount(ctx app.Context) {
    c.Sync()
}

func (c *OnOffSwitchComponent) Sync() {
    if c.ref == nil {
        c.ref = app.Input().Type("checkbox").Checked(c.On).OnChange(c.OnToggle)
    } else {
        c.ref.JSValue().Set("checked", c.On)
    }
}

And a small example of the DOM Sync (unidirectional dataflow; there is no two-way bindings magic here) as a video:

ezgif-3-3f3d06163b23

@maxence-charriere If you don't mind I'd add this to the documentation wiki ;)

pojntfx commented 4 years ago

Just one more small issue: With this approach, I can't pass components as "props" anymore because they won't be updated. I tried to implement the behaviour like so:

package components

import (
    "fmt"
    "log"

    "github.com/maxence-charriere/go-app/v7/pkg/app"
)

type ExpandableSectionComponent struct {
    app.Compo
    Open     bool
    OnToggle func(ctx app.Context, e app.Event)
    Title    string
    Content  app.UI
}

func (c *ExpandableSectionComponent) Render() app.UI {
    ref := app.Div().Class("pf-c-expandable-section__content").Hidden(!c.Open).Body(
        c.Content,
    )

    app.Dispatch(func() {
        if ref.JSValue() != nil {
            log.Println("Setting hidden")

            ref.JSValue().Set("hidden", !c.Open)
        }
    })

    return app.Div().Class(fmt.Sprintf("pf-c-expandable-section pf-u-mb-md %v", func() string {
        if c.Open {
            return "pf-m-expanded"
        }

        return ""
    }())).Body(
        app.Button().Class("pf-c-expandable-section__toggle").Body(
            app.Span().Class("pf-c-expandable-section__toggle-icon").Body(
                app.I().Class("fas fa-angle-right"),
            ),
            app.Span().Class("pf-c-expandable-section__toggle-text").Body(
                app.Text(c.Title),
            ),
        ).OnClick(c.OnToggle),
        ref,
    )
}

I would expect the code in app.Dispatch to be called after the render, and thus I'd expect ref.JSValue to be !nil - however it is. Is there some way I could access the JSValue of an element that I'm rendering in the Render function? I need to access the JSValue of a child component. I'd use the OnUpdate callback but that seems to have been removed in v7 ...

pojntfx commented 4 years ago

Alright, one night later I actually got this to work. Even forked the repo to add a OnPostRender callback, only to find out that it's actually possible in an elegant way with the current implementation ;) The issue with my code above is that ref, even once I tried to access it's JSValue in my OnPostRender callback, might not be rendered - it's a child component after all, so that makes sense; this led to the following solution:

package components

import (
    "fmt"

    "github.com/maxence-charriere/go-app/v7/pkg/app"
)

type ExpandableSectionComponent struct {
    app.Compo

    Open     bool
    OnToggle func(ctx app.Context, e app.Event)
    Title    string
    Content  app.UI
}

func (c *ExpandableSectionComponent) Render() app.UI {
    return app.Div().Class(fmt.Sprintf("pf-c-expandable-section pf-u-mb-md %v", func() string {
        if c.Open {
            return "pf-m-expanded"
        }

        return ""
    }())).Body(
        app.Button().Class("pf-c-expandable-section__toggle").Body(
            app.Span().Class("pf-c-expandable-section__toggle-icon").Body(
                app.I().Class("fas fa-angle-right"),
            ),
            app.Span().Class("pf-c-expandable-section__toggle-text").Body(
                app.Text(c.Title),
            ),
        ).OnClick(c.OnToggle),
        &ExpandableSectionComponentContent{Content: c.Content, Open: c.Open},
    )
}

type ExpandableSectionComponentContent struct {
    app.Compo

    Content app.UI
    Open    bool
}

func (c *ExpandableSectionComponentContent) Render() app.UI {
    app.Dispatch(func() {
        c.JSValue().Set("hidden", !c.Open)
    })

    return app.Div().Class("pf-c-expandable-section__content").Hidden(!c.Open).Body(
        c.Content,
    )
}

Pretty simple actually. app.Dispatch is actually called right after the component of the Render func - not the child component, so I simply created a nested component with the div which's JSValue I want to access as the root component and access it in with the standard JSValue func of app.Compo. Now, using this approach, it is possible to modify JS attributes and take child components without anything going out of sync ;)

@maxence-charriere Would it be possible to add this to the Wiki, in an article like "Syncing DOM properties"? The wiki isn't editable directly but I could write the article and send it to you. Or maybe even adopt the defaultValue and defaultChecked prop conventions of React (which change the HTML attributes value and checked) and use the Value and Checked functions to edit the DOM properties instead? Using the latter, go-app would be a bit more intuitive; however I might be biased as I work with React on the daily ;) I could create a PR if you'd like me to.

maxence-charriere commented 4 years ago

Inm gonna takena look to make the wiki editable.