Skip to content

Commit 7955bf6

Browse files
authored
tsunami -- handle onSubmit and onChange for file inputs (#2541)
new VDomFormData and VDomFileData (and an async path for event handling on the FE)
1 parent 34062ad commit 7955bf6

File tree

4 files changed

+203
-11
lines changed

4 files changed

+203
-11
lines changed

tsunami/frontend/src/model/tsunami-model.tsx

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import debug from "debug";
55
import * as jotai from "jotai";
66

7+
import { arrayBufferToBase64 } from "@/util/base64";
78
import { getOrCreateClientId } from "@/util/clientid";
89
import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
910
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
@@ -38,6 +39,28 @@ function isBlank(v: string): boolean {
3839
return v == null || v === "";
3940
}
4041

42+
async function fileToVDomFileData(file: File, fieldname: string): Promise<VDomFileData> {
43+
const maxSize = 5 * 1024 * 1024;
44+
if (file.size > maxSize) {
45+
return {
46+
fieldname: fieldname,
47+
name: file.name,
48+
size: file.size,
49+
type: file.type,
50+
error: "File size exceeds 5MB limit",
51+
};
52+
}
53+
const buffer = await file.arrayBuffer();
54+
const data64 = arrayBufferToBase64(buffer);
55+
return {
56+
fieldname: fieldname,
57+
name: file.name,
58+
size: file.size,
59+
type: file.type,
60+
data64: data64,
61+
};
62+
}
63+
4164
function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) {
4265
if (reactEvent == null) {
4366
return;
@@ -47,7 +70,7 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn
4770
event.targetvalue = changeEvent.target?.value;
4871
event.targetchecked = changeEvent.target?.checked;
4972
}
50-
if (propName == "onClick" || propName == "onMouseDown") {
73+
if (propName == "onClick" || propName == "onMouseDown" || propName == "onMouseUp" || propName == "onDoubleClick") {
5174
const mouseEvent = reactEvent as React.MouseEvent<any>;
5275
event.mousedata = {
5376
button: mouseEvent.button,
@@ -79,6 +102,69 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn
79102
}
80103
}
81104

105+
async function asyncAnnotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) {
106+
if (propName == "onSubmit") {
107+
const formEvent = reactEvent as React.FormEvent<HTMLFormElement>;
108+
const form = formEvent.currentTarget;
109+
110+
event.targetname = form.name;
111+
event.targetid = form.id;
112+
113+
const formData: VDomFormData = {
114+
method: (form.method || "get").toUpperCase(),
115+
enctype: form.enctype || "application/x-www-form-urlencoded",
116+
fields: {},
117+
files: {},
118+
};
119+
120+
if (form.action) {
121+
formData.action = form.action;
122+
}
123+
if (form.id) {
124+
formData.formid = form.id;
125+
}
126+
if (form.name) {
127+
formData.formname = form.name;
128+
}
129+
130+
const formDataObj = new FormData(form);
131+
132+
for (const [key, value] of formDataObj.entries()) {
133+
if (value instanceof File) {
134+
if (!value.name && value.size === 0) {
135+
continue;
136+
}
137+
if (!formData.files[key]) {
138+
formData.files[key] = [];
139+
}
140+
formData.files[key].push(await fileToVDomFileData(value, key));
141+
} else {
142+
if (!formData.fields[key]) {
143+
formData.fields[key] = [];
144+
}
145+
formData.fields[key].push(value.toString());
146+
}
147+
}
148+
149+
event.formdata = formData;
150+
}
151+
if (propName == "onChange") {
152+
const changeEvent = reactEvent as React.ChangeEvent<HTMLInputElement>;
153+
if (changeEvent.target?.type === "file" && changeEvent.target.files) {
154+
event.targetname = changeEvent.target.name;
155+
event.targetid = changeEvent.target.id;
156+
157+
const files: VDomFileData[] = [];
158+
const fieldname = changeEvent.target.name || changeEvent.target.id || "file";
159+
for (let i = 0; i < changeEvent.target.files.length; i++) {
160+
const file = changeEvent.target.files[i];
161+
files.push(await fileToVDomFileData(file, fieldname));
162+
}
163+
event.targetfiles = files;
164+
}
165+
}
166+
}
167+
82168
export class TsunamiModel {
83169
clientId: string;
84170
serverId: string;
@@ -109,7 +195,7 @@ export class TsunamiModel {
109195
cachedTitle: string | null = null;
110196
cachedShortDesc: string | null = null;
111197
reason: string | null = null;
112-
currentModal: jotai.PrimitiveAtom<ModalConfig | null> = jotai.atom(null);
198+
currentModal: jotai.PrimitiveAtom<ModalConfig | null> = jotai.atom(null) as jotai.PrimitiveAtom<ModalConfig | null>;
113199

114200
constructor() {
115201
this.clientId = getOrCreateClientId();
@@ -631,9 +717,23 @@ export class TsunamiModel {
631717
if (fnDecl.globalevent) {
632718
vdomEvent.globaleventtype = fnDecl.globalevent;
633719
}
634-
annotateEvent(vdomEvent, propName, e);
635-
this.batchedEvents.push(vdomEvent);
636-
this.queueUpdate(true, "event");
720+
const needsAsync =
721+
propName == "onSubmit" ||
722+
(propName == "onChange" && (e.target as HTMLInputElement)?.type === "file");
723+
if (needsAsync) {
724+
asyncAnnotateEvent(vdomEvent, propName, e)
725+
.then(() => {
726+
this.batchedEvents.push(vdomEvent);
727+
this.queueUpdate(true, "event");
728+
})
729+
.catch((err) => {
730+
console.error("Error processing event:", err);
731+
});
732+
} else {
733+
annotateEvent(vdomEvent, propName, e);
734+
this.batchedEvents.push(vdomEvent);
735+
this.queueUpdate(true, "event");
736+
}
637737
}
638738

639739
createFeUpdate(): VDomFrontendUpdate {

tsunami/frontend/src/types/vdom.d.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ type VDomEvent = {
4242
targetchecked?: boolean;
4343
targetname?: string;
4444
targetid?: string;
45+
targetfiles?: VDomFileData[];
4546
keydata?: VDomKeyboardEvent;
4647
mousedata?: VDomPointerData;
48+
formdata?: VDomFormData;
4749
};
4850

4951
// vdom.VDomFrontendUpdate
@@ -204,3 +206,24 @@ type VDomPointerData = {
204206
cmd?: boolean;
205207
option?: boolean;
206208
};
209+
210+
// vdom.VDomFormData
211+
type VDomFormData = {
212+
action?: string;
213+
method: string;
214+
enctype: string;
215+
formid?: string;
216+
formname?: string;
217+
fields: { [key: string]: string[] };
218+
files: { [key: string]: VDomFileData[] };
219+
};
220+
221+
// vdom.VDomFileData
222+
type VDomFileData = {
223+
fieldname: string;
224+
name: string;
225+
size: number;
226+
type: string;
227+
data64?: string;
228+
error?: string;
229+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import base64 from "base64-js";
5+
6+
export function base64ToString(b64: string): string {
7+
if (b64 == null) {
8+
return null;
9+
}
10+
if (b64 == "") {
11+
return "";
12+
}
13+
const stringBytes = base64.toByteArray(b64);
14+
return new TextDecoder().decode(stringBytes);
15+
}
16+
17+
export function stringToBase64(input: string): string {
18+
const stringBytes = new TextEncoder().encode(input);
19+
return base64.fromByteArray(stringBytes);
20+
}
21+
22+
export function base64ToArray(b64: string): Uint8Array<ArrayBufferLike> {
23+
const cleanB64 = b64.replace(/\s+/g, "");
24+
return base64.toByteArray(cleanB64);
25+
}
26+
27+
export function base64ToArrayBuffer(b64: string): ArrayBuffer {
28+
const cleanB64 = b64.replace(/\s+/g, "");
29+
const u8 = base64.toByteArray(cleanB64); // Uint8Array<ArrayBufferLike>
30+
// Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues)
31+
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
32+
}
33+
34+
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
35+
const u8 = new Uint8Array(buffer);
36+
return base64.fromByteArray(u8);
37+
}

tsunami/vdom/vdom_types.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ type VDomEvent struct {
6666
WaveId string `json:"waveid"`
6767
EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown)
6868
GlobalEventType string `json:"globaleventtype,omitempty"`
69-
TargetValue string `json:"targetvalue,omitempty"`
70-
TargetChecked bool `json:"targetchecked,omitempty"`
71-
TargetName string `json:"targetname,omitempty"`
72-
TargetId string `json:"targetid,omitempty"`
73-
KeyData *VDomKeyboardEvent `json:"keydata,omitempty"`
74-
MouseData *VDomPointerData `json:"mousedata,omitempty"`
69+
TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select
70+
TargetChecked bool `json:"targetchecked,omitempty"` // set for onChange events on checkbox/radio inputs
71+
TargetName string `json:"targetname,omitempty"` // target element's name attribute
72+
TargetId string `json:"targetid,omitempty"` // target element's id attribute
73+
TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs
74+
KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events
75+
MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events
76+
FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms
7577
}
7678

7779
type VDomKeyboardEvent struct {
@@ -112,6 +114,36 @@ type VDomPointerData struct {
112114
Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta)
113115
}
114116

117+
type VDomFormData struct {
118+
Action string `json:"action,omitempty"`
119+
Method string `json:"method"`
120+
Enctype string `json:"enctype"`
121+
FormId string `json:"formid,omitempty"`
122+
FormName string `json:"formname,omitempty"`
123+
Fields map[string][]string `json:"fields"`
124+
Files map[string][]VDomFileData `json:"files"`
125+
}
126+
127+
func (f *VDomFormData) GetField(fieldName string) string {
128+
if f.Fields == nil {
129+
return ""
130+
}
131+
values := f.Fields[fieldName]
132+
if len(values) == 0 {
133+
return ""
134+
}
135+
return values[0]
136+
}
137+
138+
type VDomFileData struct {
139+
FieldName string `json:"fieldname"`
140+
Name string `json:"name"`
141+
Size int64 `json:"size"`
142+
Type string `json:"type"`
143+
Data64 []byte `json:"data64,omitempty"`
144+
Error string `json:"error,omitempty"`
145+
}
146+
115147
type VDomRefOperation struct {
116148
RefId string `json:"refid"`
117149
Op string `json:"op"`

0 commit comments

Comments
 (0)