diff --git a/doc/browser.md b/doc/browser.md index 415d92ee..4903b7c8 100644 --- a/doc/browser.md +++ b/doc/browser.md @@ -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, diff --git a/doc/ui/README.md b/doc/ui/README.md index 46601031..c4a71d83 100644 --- a/doc/ui/README.md +++ b/doc/ui/README.md @@ -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) | @@ -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 @@ -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 diff --git a/doc/ui/controls.md b/doc/ui/controls.md index 4a19e2b2..bd1676e0 100644 --- a/doc/ui/controls.md +++ b/doc/ui/controls.md @@ -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 +
+``` + +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: @@ -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: diff --git a/lib/assets/jaws.js b/lib/assets/jaws.js index 55f93169..4772ac2f 100644 --- a/lib/assets/jaws.js +++ b/lib/assets/jaws.js @@ -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 + '"]'); } @@ -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'; diff --git a/lib/assets/js_test.go b/lib/assets/js_test.go index 7fd374d8..31526fef 100644 --- a/lib/assets/js_test.go +++ b/lib/assets/js_test.go @@ -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("")); +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"); diff --git a/lib/ui/constructors_test.go b/lib/ui/constructors_test.go index 4e7fa654..429a0a45 100644 --- a/lib/ui/constructors_test.go +++ b/lib/ui/constructors_test.go @@ -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), diff --git a/lib/ui/doc.go b/lib/ui/doc.go index cd8a4711..fa137088 100644 --- a/lib/ui/doc.go +++ b/lib/ui/doc.go @@ -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 diff --git a/lib/ui/file.go b/lib/ui/file.go new file mode 100644 index 00000000..e26b5907 --- /dev/null +++ b/lib/ui/file.go @@ -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...) +} diff --git a/lib/ui/file_test.go b/lib/ui/file_test.go new file mode 100644 index 00000000..14b77dfe --- /dev/null +++ b/lib/ui/file_test.go @@ -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: `^$`, + }, + { + name: "empty name", + want: `^$`, + }, + { + name: "submit on change and accept mask", + field: "photo", + submitOnChange: true, + params: []any{htmlio.Attr("accept", ".png,.jpg,image/*"), "multiple"}, + want: `^$`, + }, + { + name: "escaped field and mask", + field: `a"&`, + params: []any{htmlio.Attr("accept", `.png,"&`)}, + want: `^$`, + }, + } + 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(``)) + if err := tmpl.Execute(&output, With{RequestWriter: rw}); err != nil { + t.Fatal(err) + } + mustMatch(t, `^$`, 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) + } +} diff --git a/lib/ui/requestwriter_test.go b/lib/ui/requestwriter_test.go index 1277d373..c6bcc90f 100644 --- a/lib/ui/requestwriter_test.go +++ b/lib/ui/requestwriter_test.go @@ -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") },