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
2 changes: 1 addition & 1 deletion AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The module contains 16 Go packages, each with one package-local guide:
* [`github.com/linkdata/jaws/lib/key`](./lib/key/AI.md) -- request-key encoding
and parsing.
* [`github.com/linkdata/jaws/lib/named`](./lib/named/AI.md) -- named inputs and
single-select collections.
selection collections.
* [`github.com/linkdata/jaws/lib/tag`](./lib/tag/AI.md) -- tag expansion,
registration, targeting, and rendering.
* [`github.com/linkdata/jaws/lib/templatereloader`](./lib/templatereloader/AI.md)
Expand Down
6 changes: 6 additions & 0 deletions lib/assets/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ Value updates avoid writes when possible and preserve text selection when a
textual value changes by insertion or removal. Managed native form reset is not
implemented: it does not generate the per-control events JaWS transports.

Native multiple-selection selects send all `selectedOptions` values as a JSON
string array inside the ordinary Input payload. Their Value payload uses the
same encoding and reconciles every option's live `selected` property, including
clearing all options for an empty array. Single-selection selects retain their
plain string payload. Use `ui.MultiSelect` for the matching server-side API.

`JavascriptText` and `JawsCSS` are immutable embedded strings. `ISO8601` is the
browser date-input format. `DefaultCookieName` is computed once at package
initialization; `MakeCookieName` keeps ASCII letters and digits from the
Expand Down
12 changes: 12 additions & 0 deletions lib/assets/jaws.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ function jawsInputHandler(e) {
e.stopPropagation();
if (jawsIsCheckable(elem.getAttribute('type'))) {
val = elem.checked;
} else if (elem.type === 'select-multiple') {
val = JSON.stringify(Array.from(elem.selectedOptions, option => option.value));
} else {
val = elem.value;
}
Expand Down Expand Up @@ -267,6 +269,16 @@ function jawsSetValue(elem, str) {
}
return;
}
if (elemtype === 'select-multiple') {
const values = new Set(JSON.parse(str));
for (const option of elem.options) {
const selected = values.has(option.value);
if (option.selected !== selected) {
option.selected = selected;
}
}
return;
}
if (elem.value === str) {
return;
}
Expand Down
75 changes: 75 additions & 0 deletions lib/assets/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,81 @@ process.stdout.write(JSON.stringify({
}
}

func TestJawsJS_MultipleSelect(t *testing.T) {
raw := runJawsJSSnippet(t, `
const assert = require("node:assert/strict");
jaws = { readyState: 1, sent: [], send(msg) { this.sent.push(msg); } };
const options = [
{ value: "first", selected: true },
{ value: "second", selected: false },
{ value: "third\t\n\"\\", selected: true }
];
const listeners = {};
const multiple = {
id: "Jid.1",
tagName: "SELECT",
type: "select-multiple",
multiple: true,
options,
get selectedOptions() { return this.options.filter(option => option.selected); },
get value() { return this.selectedOptions[0]?.value || ""; },
set value(value) { throw new Error("multiple select must update option.selected"); },
hasAttribute() { return false; },
getAttribute() { return null; },
addEventListener(name, handler) { listeners[name] = handler; }
};
jawsAttach(multiple);
assert.deepEqual(Object.keys(listeners), ["input"]);
let stopped = 0;
const event = { currentTarget: multiple, stopPropagation() { stopped++; } };
listeners.input(event);
for (const option of options) option.selected = false;
listeners.input(event);
assert.equal(stopped, 2);

for (const readyState of [0, 2, 3]) {
jaws.readyState = readyState;
listeners.input(event);
}
assert.equal(jaws.sent.length, 2);
assert.equal(stopped, 2);
jaws.readyState = 1;

const single = {
id: "Jid.2", tagName: "SELECT", type: "select-one", multiple: false,
value: "second", getAttribute() { return null; }
};
jawsInputHandler({ currentTarget: single, stopPropagation() {} });
document.getElementById = id => id === multiple.id ? multiple : single;
jawsPerform("Value", multiple.id, JSON.stringify(JSON.stringify(["second", options[2].value])));
assert.deepEqual(options.map(option => option.selected), [false, true, true]);
jawsPerform("Value", multiple.id, JSON.stringify(JSON.stringify(["first"])));
assert.deepEqual(options.map(option => option.selected), [true, false, false]);
jawsPerform("Value", multiple.id, JSON.stringify("[]"));
assert.deepEqual(options.map(option => option.selected), [false, false, false]);
jawsPerform("Value", single.id, JSON.stringify("first"));
assert.equal(single.value, "first");
process.stdout.write(JSON.stringify(jaws.sent));
`)
var frames []string
if err := json.Unmarshal([]byte(raw), &frames); err != nil {
t.Fatal(err)
}
want := []string{`["first","third\t\n\"\\"]`, `[]`, "second"}
if len(frames) != len(want) {
t.Fatalf("frames = %q, want %d frames", frames, len(want))
}
for i, frame := range frames {
msg, ok := wire.Parse([]byte(frame))
if !ok || msg.What != what.Input || msg.Data != want[i] {
t.Errorf("frame %d = %+v, parseable %t, want Input %q", i, msg, ok, want[i])
}
if (i < 2 && msg.Jid != 1) || (i == 2 && msg.Jid != 2) {
t.Errorf("frame %d targets unexpected Jid %s", i, msg.Jid)
}
}
}

func TestJawsJS_ClickAndInputRoutesRejectNoncanonicalJids(t *testing.T) {
raw := runJawsJSSnippet(t, `
function FakeSocket() { this.readyState = 1; this.sent = []; }
Expand Down
12 changes: 9 additions & 3 deletions lib/named/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ exported symbols.

## Values and trust boundaries

`Bool` and `BoolArray` model named choices used by Select, Option, Checkbox,
Radio, and RadioGroup widgets. A name is a browser form value and must be a
non-empty, valid UTF-8 string without U+0000. The zero `Bool` is therefore not
`Bool` and `BoolArray` model named choices used by Select, MultiSelect, Option,
Checkbox, Radio, and RadioGroup widgets. A name is a browser form value and must
be a non-empty, valid UTF-8 string without U+0000. The zero `Bool` is therefore not
ready for use; construct entries with `NewBool` or `BoolArray.Add`.

Labels use `template.HTML` and are trusted HTML. Escape user-controlled text
Expand All @@ -24,6 +24,12 @@ in [bind](../bind/AI.md), [htmlio](../htmlio/AI.md), and [ui](../ui/AI.md).
Construct it with `NewBoolArray(false)` or use its zero value. Give every
option a distinct name. Multi-select arrays and duplicate `Bool` names are
unsupported by `ui.Select` and `ui.RadioGroup`.
- Use `NewBoolArray(true)` for `ui.MultiSelect`, with distinct, non-empty option
names. `JawsGetValues` returns all checked names in array order.
`JawsSetValues` replaces the entire selection, ignores unknown names, and
clears absent names, including all names for nil or empty input. On a
single-select array it selects only the first matching name in array order.
It dirties only changed Bools and their array after releasing value locks.
- `Bool.JawsSet` acquires the owning array lock before the value lock, changes
the selected value, clears peers when needed, releases value locks, and then
dirties the affected `Bool` values and the array. Preserve that order.
Expand Down
2 changes: 2 additions & 0 deletions lib/named/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
// A single-select [BoolArray] is the standard shared selection model for
// [github.com/linkdata/jaws/lib/ui.Select] and
// [github.com/linkdata/jaws/lib/ui.RequestWriter.RadioGroup].
// A [BoolArray] created with NewBoolArray(true) implements [MultiSelectHandler]
// for [github.com/linkdata/jaws/lib/ui.MultiSelect]. Use distinct option names.
package named
4 changes: 2 additions & 2 deletions lib/named/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func ExampleBoolArray_multiSelect() {

choices.Set("red", true)
choices.Set("green", true)
fmt.Println(choices.IsChecked("red"), choices.IsChecked("green"))
fmt.Println(choices.JawsGetValues(nil))

// Output: true true
// Output: [red green]
}
100 changes: 100 additions & 0 deletions lib/named/multiselect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package named

import (
"errors"
"slices"
"sync/atomic"
"testing"
"testing/synctest"

"github.com/linkdata/jaws"
)

func TestBoolArray_JawsSetValues(t *testing.T) {
for _, tt := range []struct {
name string
names []string
want []string
unchanged bool
}{
{name: "replace", names: []string{"three", "two"}, want: []string{"two", "three"}},
{name: "nil clears"},
{name: "empty clears", names: []string{}},
{name: "unknown clears", names: []string{"missing"}},
{name: "unknown ignored", names: []string{"missing", "two"}, want: []string{"two"}},
{name: "unchanged set", names: []string{"two", "one", "two", "missing"}, want: []string{"one", "two"}, unchanged: true},
} {
t.Run(tt.name, func(t *testing.T) {
nba := NewBoolArray(true).Add("one", "One").Add("two", "Two").Add("three", "Three")
nba.Set("one", true)
nba.Set("two", true)
before := nba.JawsGetValues(nil)
if !slices.Equal(before, []string{"one", "two"}) {
t.Fatalf("initial values = %v", before)
}

_, rq := newCoreRequest(t)
err := nba.JawsSetValues(rq.NewElement(noopUI{}), tt.names)
if tt.unchanged {
if !errors.Is(err, jaws.ErrValueUnchanged) {
t.Fatalf("error = %v, want ErrValueUnchanged", err)
}
} else if err != nil {
t.Fatal(err)
}
if got := nba.JawsGetValues(nil); !slices.Equal(got, tt.want) {
t.Fatalf("selected values = %v, want %v", got, tt.want)
}
if !slices.Equal(before, []string{"one", "two"}) {
t.Fatalf("getter result changed: %v", before)
}
})
}
}

func TestBoolArray_JawsSetValuesSingleSelect(t *testing.T) {
nba := NewBoolArray(false).Add("one", "One").Add("two", "Two").Add("one", "Another one")
nba.Set("two", true)
_, rq := newCoreRequest(t)
elem := rq.NewElement(noopUI{})
if err := nba.JawsSetValues(elem, []string{"missing", "two", "one"}); err != nil {
t.Fatal(err)
}
if got := nba.JawsGetValues(nil); !slices.Equal(got, []string{"one", "one"}) {
t.Fatalf("selected values = %v, want both Bools named one", got)
}
if err := nba.JawsSetValues(elem, nil); err != nil {
t.Fatal(err)
}
if got := nba.JawsGetValues(nil); got != nil {
t.Fatalf("cleared values = %v, want nil", got)
}
}

func TestBoolArray_JawsSetValuesDirtiesOnlyChangedBoolsAndArray(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
jw, rq := newTestRequest(t)
defer closeBubbleRequest(jw, rq)
nba := NewBoolArray(true).Add("one", "One").Add("two", "Two").Add("three", "Three")
nba.Set("one", true)
nba.Set("two", true)
var oneHits, twoHits, threeHits, groupHits atomic.Int32
registerDirtyProbe(rq, nba.data[0], &oneHits)
registerDirtyProbe(rq, nba.data[1], &twoHits)
registerDirtyProbe(rq, nba.data[2], &threeHits)
registerDirtyProbe(rq, nba, &groupHits)
elem := rq.NewElement(noopUI{})
if err := nba.JawsSetValues(elem, []string{"two", "three"}); err != nil {
t.Fatal(err)
}
waitForDirtyProbes(t, func() bool {
return oneHits.Load() == 1 && twoHits.Load() == 0 && threeHits.Load() == 1 && groupHits.Load() == 1
})
if err := nba.JawsSetValues(elem, []string{"three", "two"}); !errors.Is(err, jaws.ErrValueUnchanged) {
t.Fatalf("repeat error = %v, want ErrValueUnchanged", err)
}
waitForDirtyProbes(t, func() bool {
return oneHits.Load() == 1 && twoHits.Load() == 0 && threeHits.Load() == 1 && groupHits.Load() == 1
})
})
}
52 changes: 47 additions & 5 deletions lib/named/namedboolarray.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ type BoolArray struct {
}

var _ SelectHandler = (*BoolArray)(nil)
var _ MultiSelectHandler = (*BoolArray)(nil)

// NewBoolArray returns an empty [BoolArray].
//
// If multi is false, setting one value clears other names in the array. If
// multi is true, multiple values may be checked at the same time. Pass a
// single-select array to [github.com/linkdata/jaws/lib/ui.NewSelect] and
// [github.com/linkdata/jaws/lib/ui.RequestWriter.RadioGroup]; neither supports
// multi-select arrays.
// multi-select arrays. Pass a multi-select array to
// [github.com/linkdata/jaws/lib/ui.NewMultiSelect].
func NewBoolArray(multi bool) *BoolArray {
return &BoolArray{multi: multi}
}
Expand Down Expand Up @@ -100,8 +102,7 @@ func (nba *BoolArray) JawsContains(elem *jaws.Element) (contents []jaws.UI) {
// (e.g. template.HTML(template.HTMLEscapeString(s))) when it is derived from
// untrusted user input. See [NewBool].
//
// Use distinct names for options rendered in a single-selection select or
// radio group.
// Use distinct names for options rendered in a select or radio group.
func (nba *BoolArray) Add(name string, html template.HTML) *BoolArray {
nba.mu.Lock()
nba.data = append(nba.data, NewBool(nba, name, html, false))
Expand Down Expand Up @@ -152,8 +153,8 @@ func (nba *BoolArray) deselectOthersLocked(name string, deselect bool) (changed

// Get returns the name of the first checked [Bool].
//
// It returns an empty string if none are checked. Use [BoolArray.ReadLocked]
// to inspect all checked values when multiple values may be checked.
// It returns an empty string if none are checked. Use [BoolArray.JawsGetValues]
// to read all checked values when multiple values may be checked.
func (nba *BoolArray) Get() (name string) {
nba.mu.RLock()
for _, nb := range nba.data {
Expand Down Expand Up @@ -222,6 +223,47 @@ func (nba *BoolArray) JawsSet(elem *jaws.Element, name string) error {
return dirtyChanged(elem, nba, changed)
}

// JawsGetValues returns the names of all checked Bools in array order.
// The returned slice is independent of nba and is nil when nothing is checked.
func (nba *BoolArray) JawsGetValues(elem *jaws.Element) (names []string) {
nba.mu.RLock()
for _, nb := range nba.data {
if nb.Checked() {
names = append(names, nb.Name())
}
}
nba.mu.RUnlock()
return
}

// JawsSetValues replaces the selected names and updates affected UI.
// Unknown names are ignored; nil or empty names clear the selection. It returns
// [jaws.ErrValueUnchanged] if no checked state changed.
//
// Use NewBoolArray(true) for multiple selections. In single-select mode, only
// the first matching name in array order is selected, including all its Bools.
func (nba *BoolArray) JawsSetValues(elem *jaws.Element, names []string) error {
selected := make(map[string]bool, len(names))
for _, name := range names {
selected[name] = true
}
nba.mu.Lock()
var changed []*Bool
var first string
for _, nb := range nba.data {
name := nb.Name()
checked := selected[name] && (nba.multi || first == "" || first == name)
if checked {
first = name
}
if nb.Set(checked) {
changed = append(changed, nb)
}
}
nba.mu.Unlock()
return dirtyChanged(elem, nba, changed)
}

// dirtyChanged updates changed Bools and their array after value locks are released.
func dirtyChanged(elem *jaws.Element, nba *BoolArray, changed []*Bool) error {
if len(changed) == 0 {
Expand Down
5 changes: 4 additions & 1 deletion lib/named/namedboolarray_race_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func TestBoolArray_ConcurrentLockOrdering(t *testing.T) {
// JawsContains, and reads. The locked callbacks touch
// only the provided slice and Bool methods, honoring the
// non-reentrancy contract.
switch i % 11 {
switch i % 12 {
case 0:
nba.Set(name, true)
case 1:
Expand Down Expand Up @@ -65,6 +65,9 @@ func TestBoolArray_ConcurrentLockOrdering(t *testing.T) {
nba.Add(name, template.HTML(name))
case 10:
_ = nba.JawsContains(elem)
case 11:
_ = nba.JawsSetValues(elem, []string{name})
_ = nba.JawsGetValues(elem)
}
}
}(g)
Expand Down
12 changes: 12 additions & 0 deletions lib/named/selecthandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ type SelectHandler interface {
jaws.Container
bind.Setter[string]
}

// MultiSelectHandler renders multi-select options and stores selected values.
//
// Rendered option values must be distinct and non-empty. JawsGetValues returns
// the selected values; JawsSetValues replaces the entire selection, with nil or
// an empty slice clearing it. [BoolArray] created with NewBoolArray(true) is the
// standard implementation.
type MultiSelectHandler interface {
jaws.Container
JawsGetValues(*jaws.Element) []string
JawsSetValues(*jaws.Element, []string) error
}
Loading
Loading