Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions doc/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ For controls that require a connection, render them disabled or inert and clear
that state from the server after connection. See [request lifecycle](runtime.md)
for connection handlers.

Managed inputs, selects, and textareas send `Input` events. `Number` uses
`change`; `Range` and other managed controls use `input`. Checkboxes and radio
buttons send their checked state. A multiple-select control sends all selected
values as a JSON string array; a single-select control sends one string.
Managed file inputs send no WebSocket `Input` events. With submit-on-change,
they call `requestSubmit()` on their owning form after a nonempty selection.
Other managed inputs, selects, and textareas send `Input` events: `Number` uses
`change`, while `Range` and the rest use `input`. Checkboxes and radio buttons
send their checked state. A multiple-select control sends all selected values
as a JSON string array; a single-select control sends one string.

Managed non-input elements forward clicks and context-menu events through their
managed ancestor route. Events originating inside an input, select, textarea,
Expand Down
11 changes: 6 additions & 5 deletions doc/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ construction. Add JaWS widgets where values or browser events need live handling
| --- | --- |
| Updating text or HTML | [`Span`, `Div`, `Button`, and the other `HTMLInner` widgets](../bindings.md#compute-content-and-attributes) |
| Editable values | [`Text`, `Password`, `Textarea`, `Checkbox`, `Radio`, `Date`, `Number`, `Range`](controls.md#input-controls) |
| Native file upload | [`File`](controls.md#input-controls) |
| Changing child lists | [`Container`, `Tbody`, `Select`, `MultiSelect`](controls.md#lists-and-selections) |
| Go templates | [`Handler`, `Template`, `RequestWriter`](#render-a-page-or-partial) |
| Initial attributes | [Constant template strings and member functions](#choose-initial-attributes) |
Expand Down Expand Up @@ -181,7 +182,7 @@ Render parameters supply trusted attributes (`string` or `template.HTMLAttr`),
input/click/context-menu handlers, and dependency tags. A handler can also
supply tags. See [actions and bindings](../bindings.md).

A widget normally backs one live Element. Input widgets retain per-control
A widget normally backs one live Element. Bound input widgets retain per-control
state and need separate widget instances even when they share a binding:

```go
Expand All @@ -190,10 +191,10 @@ right := ui.NewText(nameBinding)
```

Within one Request, `HTMLInner` widgets, `Img`, and `Option` can back multiple
Elements when their sources support that use. `Template`, `Container`, `Tbody`, `Select`, and
`MultiSelect` can also do so under their provider and child contracts. Their
constructors return definition values: use those values directly, without
taking their addresses.
Elements when their sources support that use. `Template`, `Container`, `Tbody`,
`Select`, and `MultiSelect` can also do so under their provider and child
contracts. Their constructors return definition values: use those values
directly, without taking their addresses.

An Element belongs to its Request. Do not retain Elements or Requests in shared
application state or background work. A nil UI interface passed directly to
Expand Down
33 changes: 29 additions & 4 deletions doc/ui/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,31 @@ in its own location, but browser edits store midnight UTC. An empty edit stores
the zero time, which renders as `0001-01-01`. Use dates in years 1 through 9999
and keep the bound time and location irrelevant or normalized to UTC.

`File` renders a named native file input in an application-owned multipart form.
Selected files go to the form's HTTP POST handler, not through JaWS WebSocket
events. Pass `true` to submit the form when at least one file is selected;
`requestSubmit()` validates the form and, if valid, fires its submit event.
Pass `false` to use a normal submit button. Canceling selection or having no
owning form does not submit.

```gotemplate
<form action="/upload" method="post" enctype="multipart/form-data">
{{$.File "document" true `accept=".pdf,.png"`}}
<button type="submit">Upload</button>
</form>
```

The optional `accept` attribute takes comma-separated file extensions (starting
with `.`) or MIME types, including `image/*`; it is a picker hint, not validation.
For a dynamic mask, build `htmlio.Attr("accept", mask)` in Go and pass the result
as a template parameter; plain render-parameter strings are trusted raw HTML.
Protect the HTTP handler against CSRF (for example with
`http.CrossOriginProtection`), authorize uploads, validate files, enforce size
limits, and write the response. A native POST normally navigates to that
response. Replacing the file input through a
[JaWS Template](README.md#render-a-page-or-partial) or another parent update
clears its browser-held selection.

`Number` and `Range` accept predeclared or named integer and floating-point
types. They preserve the source type for parsing, formatting, and setter calls:

Expand Down Expand Up @@ -115,10 +140,10 @@ to fields. Updating a row Template recreates its nested Elements and can discard
focus or unsent edits; dirty the field's tag when only its value changed.

Equal containers may back several Elements only when the provider and any
reused child widgets support that use. A repeated input widget pointer does not;
construct separate inputs instead. Container, Template, and selection widgets
each claim one Element state slot, so two state-owning renderers cannot share
one Element.
reused child widgets support that use. Construct separate widgets for child
types that support only one live Element. Container, Template, and selection
widgets each claim one Element state slot, so two state-owning renderers cannot
share one Element.

Use [`named.BoolArray`](../utilities.md#named-choices) for standard selection data:

Expand Down
13 changes: 13 additions & 0 deletions lib/assets/jaws.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ function jawsInputHandler(e) {
}
}

function jawsFileChangeHandler(e) {
const elem = e.currentTarget;
if (elem.files?.length > 0) {
elem.form?.requestSubmit();
}
}

function jawsManagedElements(topElem) {
return topElem.querySelectorAll('[id^="' + jawsIdPrefix + '"]');
}
Expand Down Expand Up @@ -196,6 +203,12 @@ function jawsAttach(elem, initial) {
return;
}
if (jawsIsInputTag(elem.tagName)) {
if (String(elem.type).toLowerCase() === "file") {
if (elem.hasAttribute("data-jawsonchangesubmit")) {
elem.addEventListener('change', jawsFileChangeHandler, false);
}
return;
}
let eventName = 'input';
if (String(elem.type).toLowerCase() === "number" && elem.hasAttribute("data-jawsnumber")) {
eventName = 'change';
Expand Down
61 changes: 61 additions & 0 deletions lib/assets/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,67 @@ process.stdout.write(JSON.stringify({
}
}

func TestJawsJS_FileSubmitsNativeFormWithoutWebSocketInput(t *testing.T) {
runJawsJSSnippet(t, `
const assert = require("node:assert/strict");
jaws = { readyState: 1, sent: [], send(msg) { this.sent.push(msg); } };

function fileInput(id, submitOnChange, form) {
const listeners = {};
return {
id, tagName: "INPUT", type: "file", files: [], form, listeners,
hasAttribute(name) { return name === "data-jawsonchangesubmit" && submitOnChange; },
addEventListener(name, handler) { (listeners[name] ||= []).push(handler); }
};
}
function change(input) {
for (const handler of input.listeners.change || []) {
handler({ currentTarget: input });
}
}

const form = { submissions: 0, requestSubmit() { this.submissions++; } };
const automatic = fileInput("Jid.1", true, form);
const manual = fileInput("Jid.2", false, form);
document.querySelectorAll = () => [automatic, manual];
jawsAttachChildren(document);
assert.deepEqual(Object.keys(automatic.listeners), ["change"]);
assert.deepEqual(Object.keys(manual.listeners), []);
manual.files = [{}];
change(manual);
assert.equal(form.submissions, 0);

automatic.files = [{}];
change(automatic);
assert.equal(form.submissions, 1);
automatic.files = [];
change(automatic);
assert.equal(form.submissions, 1);
automatic.files = [{}];
automatic.form = null;
change(automatic);
assert.equal(form.submissions, 1);
assert.deepEqual(jaws.sent, []);

const replacement = fileInput("Jid.3", true, form);
let children = [];
const template = {
id: "Jid.4", html: "before",
get innerHTML() { return this.html; },
set innerHTML(value) { this.html = value; children = [replacement]; },
querySelectorAll() { return children; }
};
document.getElementById = id => id === template.id ? template : null;
jawsPerform("Inner", template.id, JSON.stringify("<input type=file>"));
assert.deepEqual(Object.keys(replacement.listeners), ["change"]);
replacement.files = [{}];
jaws.readyState = 0;
change(replacement);
assert.equal(form.submissions, 2);
assert.deepEqual(jaws.sent, []);
`)
}

func TestJawsJS_MultipleSelect(t *testing.T) {
raw := runJawsJSSnippet(t, `
const assert = require("node:assert/strict");
Expand Down
1 change: 1 addition & 0 deletions lib/ui/constructors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func TestConstructors(t *testing.T) {
NewContainer("div", &tc),
NewDate(timeSetter),
NewDiv(htmlGetter),
NewFile("upload", false),
NewImg(imgGetter),
NewLabel(htmlGetter),
NewLi(htmlGetter),
Expand Down
8 changes: 4 additions & 4 deletions lib/ui/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
//
// Its main building blocks are [HTMLInner] for dynamic inner HTML; [Input],
// [InputText], [InputBool], and [InputDate] for typed controls; [Number] and
// [Range] for numeric controls; [Container], [Tbody], [Select], and [MultiSelect]
// for dynamic children; and [Template], [Handler], and [RequestWriter] for templates.
// [Range] for numeric controls; [File] for native file upload; [Container],
// [Tbody], [Select], and [MultiSelect] for dynamic children; and [Template],
// [Handler], and [RequestWriter] for templates.
//
// Widgets follow [github.com/linkdata/jaws.UI]'s request ownership and equality
// requirements. Input widgets retain state for one live Element; other widgets
// document their supported multiplicity on their concrete types.
// requirements.
//
// [HTMLInner] widgets adapt content with
// [github.com/linkdata/jaws/lib/bind.MakeHTMLGetter]. Plain strings are trusted
Expand Down
57 changes: 57 additions & 0 deletions lib/ui/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package ui

import (
"html/template"
"io"

"github.com/linkdata/jaws"
"github.com/linkdata/jaws/lib/htmlio"
)

// File renders a named native file input for a multipart form.
//
// The zero value renders an input with an empty name, which browsers omit from
// form submission.
//
// The browser submits selected files through the owning form's HTTP request;
// file data and names are not sent through JaWS input events.
// [File.JawsUpdate] leaves the existing input untouched; replacing it clears
// its selection.
type File struct {
name string
submitOnChange bool
}

// NewFile returns a file input with the given form field name.
//
// If submitOnChange is true, selecting at least one file calls the owning
// form's requestSubmit method. The browser validates the form and, if valid,
// fires its submit event.
// An empty selection or an input without an owning form does nothing.
func NewFile(name string, submitOnChange bool) *File {
return &File{name: name, submitOnChange: submitOnChange}
}

// JawsRender renders the file input without a value attribute.
func (u *File) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
attrs := []template.HTMLAttr{htmlio.Attr("name", u.name)}
if u.submitOnChange {
attrs = append(attrs, "data-jawsonchangesubmit")
}
attrs = append(attrs, elem.ApplyParams(params)...)
return htmlio.WriteHTMLInput(w, elem.Jid(), "file", "", attrs)
}

// JawsUpdate leaves the browser's selected files unchanged.
func (u *File) JawsUpdate(*jaws.Element) {}

// File renders a named native file input for a multipart form.
//
// Set submitOnChange to submit the owning form when files are selected.
// Use an accept attribute in params, such as `accept=".png,.jpg"`, to hint
// which file types the picker should show. Use [htmlio.Attr] for dynamic
// attribute values. The HTTP handler must protect against CSRF, authorize the
// request, validate uploaded files, and enforce size limits.
func (rw RequestWriter) File(name string, submitOnChange bool, params ...any) error {
return rw.NewUI(NewFile(name, submitOnChange), params...)
}
84 changes: 84 additions & 0 deletions lib/ui/file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package ui

import (
"bytes"
"errors"
"html/template"
"strings"
"testing"

"github.com/linkdata/jaws/lib/htmlio"
)

func TestFile_Render(t *testing.T) {
_, rq := newCoreRequest(t)
tests := []struct {
name string
field string
submitOnChange bool
params []any
want string
}{
{
name: "manual submit",
field: "document",
want: `^<input id="Jid\.[0-9]+" type="file" name="document">$`,
},
{
name: "empty name",
want: `^<input id="Jid\.[0-9]+" type="file" name="">$`,
},
{
name: "submit on change and accept mask",
field: "photo",
submitOnChange: true,
params: []any{htmlio.Attr("accept", ".png,.jpg,image/*"), "multiple"},
want: `^<input id="Jid\.[0-9]+" type="file" name="photo" data-jawsonchangesubmit accept="\.png,\.jpg,image/\*" multiple>$`,
},
{
name: "escaped field and mask",
field: `a"&<b>`,
params: []any{htmlio.Attr("accept", `.png,"&<b>`)},
want: `^<input id="Jid\.[0-9]+" type="file" name="a&#34;&amp;&lt;b&gt;" accept="\.png,&#34;&amp;&lt;b&gt;">$`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, got := renderUI(t, rq, NewFile(tt.field, tt.submitOnChange), tt.params...)
mustMatch(t, tt.want, got)
if strings.Contains(got, " value=") {
t.Fatalf("file input has value attribute: %q", got)
}
})
}
}

func TestFile_TemplateHelper(t *testing.T) {
_, rq := newCoreRequest(t)
var output bytes.Buffer
rw := RequestWriter{Request: rq, Writer: &output}
tmpl := template.Must(template.New("file").Parse(`<form method="post" enctype="multipart/form-data">{{$.File "upload" true "accept=\".pdf\""}}</form>`))
if err := tmpl.Execute(&output, With{RequestWriter: rw}); err != nil {
t.Fatal(err)
}
mustMatch(t, `^<form method="post" enctype="multipart/form-data"><input id="Jid\.[0-9]+" type="file" name="upload" data-jawsonchangesubmit accept="\.pdf"></form>$`, output.String())
}

func TestFile_UpdateLeavesSelectionAlone(t *testing.T) {
tr := valueTestNewLoopRequest(t, nil)
u := NewFile("upload", true)
elem, _ := renderUI(t, tr.Request, u)
u.JawsUpdate(elem)
if got := nestedReorderDrainWire(t, tr, "file update"); len(got) != 0 {
t.Fatalf("file update commands = %+v, want none", got)
}
}

func TestFile_RenderWriteError(t *testing.T) {
_, rq := newCoreRequest(t)
wantErr := errors.New("write failed")
elem := rq.NewElement(NewFile("upload", false))
if err := elem.JawsRender(&failNthWrite{n: 1, err: wantErr}, nil); !errors.Is(err, wantErr) {
t.Fatalf("JawsRender error = %v, want %v", err, wantErr)
}
}
1 change: 1 addition & 0 deletions lib/ui/requestwriter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ func TestRequestWriter_MethodsAndWidgetHelpers(t *testing.T) {
func() error { return rw.Container("section", tc) },
func() error { return rw.Date(date) },
func() error { return rw.Div("d") },
func() error { return rw.File("upload", false) },
func() error { return rw.Img("img.png") },
func() error { return rw.Label("l") },
func() error { return rw.Li("li") },
Expand Down
Loading