vanjs-jsf 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,62 +36,69 @@ The currently supported form element types are:
36
36
 
37
37
  1. Install the library:
38
38
 
39
- Install the library from the npm registry:
40
-
41
39
  ```bash
42
40
  npm install vanjs-jsf
43
41
  ```
44
42
 
45
- 2. Import it in your project & define your JSON schema + config:
43
+ 2. Import and define your JSON Schema with `x-jsf-presentation` hints:
46
44
 
47
45
  ```typescript
48
- import { jsform } from 'vanjs-jsf';
46
+ import van from "vanjs-core";
47
+ import { jsform } from "vanjs-jsf";
48
+
49
+ const { div, h1, p, button } = van.tags;
49
50
 
50
51
  const schema = {
51
- type: 'object',
52
+ type: "object",
52
53
  properties: {
53
- userName: { type: 'string', title: 'Name' },
54
- age: { type: 'number', title: 'Age' },
54
+ userName: {
55
+ type: "string",
56
+ title: "Name",
57
+ "x-jsf-presentation": { inputType: "text" },
58
+ },
59
+ age: {
60
+ type: "number",
61
+ title: "Age",
62
+ "x-jsf-presentation": { inputType: "number" },
63
+ },
55
64
  },
65
+ required: ["userName"],
66
+ "x-jsf-order": ["userName", "age"],
56
67
  };
68
+ ```
57
69
 
58
- // Initial values to fill the form
70
+ 3. Create a config with initial values and render the form:
71
+
72
+ ```typescript
59
73
  const initialValues = { userName: "Simon" };
60
- // JSON Schema Form config
61
74
  const config = {
62
75
  strictInputType: false,
63
76
  initialValues: initialValues,
64
77
  formValues: initialValues,
65
78
  };
66
- ```
67
79
 
68
- 3. Render the form using VanJS UI framework & handle form submit.
69
-
70
- This library will call the `onsubmit` handler set using the VanJS `props` passing the original source event.
71
-
72
- ```typescript
73
80
  const handleOnSubmit = (e: Event) => {
74
81
  e.preventDefault();
75
- const values = jsfConfig.formValues;
76
- alert(`Submitted successfully: ${JSON.stringify(values, null, 2)}`);
77
- console.log("Submitted!", values);
82
+ const values = config.formValues;
83
+ alert(`Submitted: ${JSON.stringify(values, null, 2)}`);
78
84
  };
79
85
 
80
- // Add the JSON Schema Form to an VanJS element
81
- div(
86
+ van.add(
87
+ document.body,
88
+ div(
82
89
  h1("json-schema-form + VanJS"),
83
- p("This demo uses VanJS without any other form library."),
90
+ p("Dynamic form generated from JSON Schema."),
84
91
  jsform(
85
92
  {
86
93
  name: "my-jsf-form",
87
- schema: schema, // JSON Schema defined previously
88
- config: config, // JSON Schema Form configuration
94
+ schema: schema,
95
+ config: config,
89
96
  onsubmit: handleOnSubmit,
90
97
  },
91
98
  button({ type: "submit" }, "Submit")
92
99
  )
93
- );
94
-
100
+ )
101
+ );
95
102
  ```
96
103
 
97
104
  ## Development
@@ -14,6 +14,8 @@ export declare class VanJsfField extends VanJSComponent {
14
14
  handleChange: (field: VanJsfField, value: MultiType) => void;
15
15
  isVisibleState: State<boolean>;
16
16
  errorState: State<string>;
17
+ /** Used by file fields to pass the selected arrayPath key to formValues */
18
+ arrayPathValue: string;
17
19
  constructor(field: Record<string, unknown>, initVal: MultiType, handleChange: (field: VanJsfField, value: MultiType) => void);
18
20
  get inputType(): string;
19
21
  get label(): string;
@@ -7,7 +7,7 @@ import { json, jsonParseLinter } from "@codemirror/lang-json";
7
7
  import { lintGutter, linter, forEachDiagnostic } from "@codemirror/lint";
8
8
  import * as eslint from "eslint-linter-browserify";
9
9
  import globals from "globals";
10
- const { div, p, input, label, textarea, legend, link, fieldset, span, select, option } = van.tags;
10
+ const { div, p, input, label, textarea, legend, link, fieldset, span, select, option, button, table, tr, th, td, strong, small } = van.tags;
11
11
  var FieldType;
12
12
  (function (FieldType) {
13
13
  FieldType["text"] = "text";
@@ -18,6 +18,7 @@ var FieldType;
18
18
  FieldType["radio"] = "radio";
19
19
  FieldType["date"] = "date";
20
20
  FieldType["fieldset"] = "fieldset";
21
+ FieldType["file"] = "file";
21
22
  })(FieldType || (FieldType = {}));
22
23
  const eslintConfig = {
23
24
  // eslint configuration
@@ -41,6 +42,8 @@ export class VanJsfField extends VanJSComponent {
41
42
  handleChange;
42
43
  isVisibleState;
43
44
  errorState;
45
+ /** Used by file fields to pass the selected arrayPath key to formValues */
46
+ arrayPathValue = "";
44
47
  constructor(field, initVal, handleChange) {
45
48
  super();
46
49
  this.field = field;
@@ -244,6 +247,312 @@ export class VanJsfField extends VanJSComponent {
244
247
  onchange: (e) => this.handleChange(this, e.target.value),
245
248
  }), opt.label, opt.description))), p({ class: this.errorClass }, () => this.error));
246
249
  break;
250
+ case FieldType.file: {
251
+ const accept = this.field.accept || ".json,.csv,.xlsx";
252
+ const maxSizeMB = this.field.maxSizeMB || 50;
253
+ const previewRows = this.field.previewRows || 5;
254
+ // Reactive states
255
+ const fileNameState = van.state("");
256
+ const fileSizeState = van.state("");
257
+ const parsingState = van.state(false);
258
+ const parsedDataState = van.state(null);
259
+ const arrayPathOptionsState = van.state([]);
260
+ const selectedArrayPathState = van.state("");
261
+ const dragOverState = van.state(false);
262
+ const formatSize = (bytes) => {
263
+ if (bytes < 1024)
264
+ return `${bytes} B`;
265
+ if (bytes < 1024 * 1024)
266
+ return `${(bytes / 1024).toFixed(1)} KB`;
267
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
268
+ };
269
+ const formatNumber = (n) => n.toLocaleString();
270
+ const resolveArrayFromJson = (parsed) => {
271
+ if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "object") {
272
+ return { data: parsed, paths: [] };
273
+ }
274
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
275
+ const candidates = [];
276
+ for (const [key, val] of Object.entries(parsed)) {
277
+ if (Array.isArray(val) && val.length > 0 && typeof val[0] === "object") {
278
+ candidates.push(key);
279
+ }
280
+ }
281
+ if (candidates.length === 1) {
282
+ return { data: parsed[candidates[0]], paths: [] };
283
+ }
284
+ if (candidates.length > 1) {
285
+ return { data: null, paths: candidates };
286
+ }
287
+ }
288
+ return { data: null, paths: [] };
289
+ };
290
+ const setData = (data, arrayPath) => {
291
+ parsedDataState.val = data;
292
+ this.arrayPathValue = arrayPath;
293
+ selectedArrayPathState.val = arrayPath;
294
+ parsingState.val = false;
295
+ this.handleChange(this, JSON.stringify(data));
296
+ };
297
+ const setError = (msg) => {
298
+ parsingState.val = false;
299
+ parsedDataState.val = null;
300
+ this.error = msg;
301
+ };
302
+ const processFile = async (file) => {
303
+ // Reset state
304
+ this.error = "";
305
+ parsedDataState.val = null;
306
+ arrayPathOptionsState.val = [];
307
+ selectedArrayPathState.val = "";
308
+ this.arrayPathValue = "";
309
+ // Validate size
310
+ if (file.size > maxSizeMB * 1024 * 1024) {
311
+ setError(`File exceeds maximum size of ${maxSizeMB} MB`);
312
+ return;
313
+ }
314
+ fileNameState.val = file.name;
315
+ fileSizeState.val = formatSize(file.size);
316
+ parsingState.val = true;
317
+ const ext = file.name.split(".").pop()?.toLowerCase() || "";
318
+ try {
319
+ if (ext === "json") {
320
+ const text = await file.text();
321
+ let parsed;
322
+ try {
323
+ parsed = JSON.parse(text);
324
+ }
325
+ catch {
326
+ setError("Invalid JSON file");
327
+ return;
328
+ }
329
+ const { data, paths } = resolveArrayFromJson(parsed);
330
+ if (data) {
331
+ setData(data, "");
332
+ }
333
+ else if (paths.length > 1) {
334
+ arrayPathOptionsState.val = paths;
335
+ parsingState.val = false;
336
+ }
337
+ else {
338
+ setError("JSON does not contain an array of objects");
339
+ }
340
+ }
341
+ else if (ext === "csv") {
342
+ try {
343
+ const Papa = await import("papaparse");
344
+ const text = await file.text();
345
+ const result = Papa.default.parse(text, { header: true, skipEmptyLines: true });
346
+ if (result.errors.length > 0) {
347
+ setError(`CSV parse error: ${result.errors[0].message}`);
348
+ }
349
+ else if (!result.data || result.data.length === 0) {
350
+ setError("CSV file is empty or has no data rows");
351
+ }
352
+ else {
353
+ setData(result.data, "");
354
+ }
355
+ }
356
+ catch {
357
+ setError("Install papaparse to support CSV files: npm install papaparse");
358
+ }
359
+ }
360
+ else if (ext === "xlsx" || ext === "xls") {
361
+ try {
362
+ const XLSX = await import("xlsx");
363
+ const buffer = await file.arrayBuffer();
364
+ const workbook = XLSX.read(buffer);
365
+ const firstSheetName = workbook.SheetNames[0];
366
+ if (!firstSheetName) {
367
+ setError("XLSX file has no sheets");
368
+ return;
369
+ }
370
+ const sheet = workbook.Sheets[firstSheetName];
371
+ const data = XLSX.utils.sheet_to_json(sheet);
372
+ if (data.length === 0) {
373
+ setError("XLSX sheet is empty");
374
+ return;
375
+ }
376
+ setData(data, "");
377
+ }
378
+ catch (e) {
379
+ if (e instanceof Error && e.message.includes("Failed to fetch dynamically imported module")) {
380
+ setError("Install xlsx to support XLSX files: npm install xlsx");
381
+ }
382
+ else if (e instanceof Error) {
383
+ setError(`XLSX parse error: ${e.message}`);
384
+ }
385
+ else {
386
+ setError("Install xlsx to support XLSX files: npm install xlsx");
387
+ }
388
+ }
389
+ }
390
+ else {
391
+ setError(`Unsupported file extension: .${ext}`);
392
+ }
393
+ }
394
+ catch (e) {
395
+ setError(e instanceof Error ? e.message : "Error processing file");
396
+ }
397
+ };
398
+ const clearFile = () => {
399
+ fileNameState.val = "";
400
+ fileSizeState.val = "";
401
+ parsedDataState.val = null;
402
+ parsingState.val = false;
403
+ arrayPathOptionsState.val = [];
404
+ selectedArrayPathState.val = "";
405
+ this.arrayPathValue = "";
406
+ this.error = "";
407
+ this.handleChange(this, "");
408
+ };
409
+ const fileInput = input({
410
+ type: "file",
411
+ accept,
412
+ style: "display: none;",
413
+ onchange: (e) => {
414
+ const files = e.target.files;
415
+ if (files && files[0])
416
+ processFile(files[0]);
417
+ },
418
+ });
419
+ const dropZone = div({
420
+ style: () => {
421
+ const over = dragOverState.val;
422
+ return `border: 2px dashed ${over ? "#4a90d9" : "#ccc"}; border-radius: 8px; padding: 24px; text-align: center; cursor: pointer; transition: border-color 0.2s; background: ${over ? "#f0f7ff" : "transparent"};`;
423
+ },
424
+ ondragover: (e) => { e.preventDefault(); dragOverState.val = true; },
425
+ ondragleave: () => { dragOverState.val = false; },
426
+ ondrop: (e) => {
427
+ e.preventDefault();
428
+ dragOverState.val = false;
429
+ const files = e.dataTransfer?.files;
430
+ if (files && files[0])
431
+ processFile(files[0]);
432
+ },
433
+ onclick: () => fileInput.click(),
434
+ }, p({ style: "margin: 0; color: #666;" }, `Drop a file here or click to browse (${accept})`));
435
+ // We need to store the raw JSON for arrayPath selection
436
+ const rawJsonState = van.state(null);
437
+ // Override processFile's JSON branch to also store raw JSON
438
+ const originalProcessFile = processFile;
439
+ const processFileWrapped = async (file) => {
440
+ const ext = file.name.split(".").pop()?.toLowerCase() || "";
441
+ if (ext === "json") {
442
+ this.error = "";
443
+ parsedDataState.val = null;
444
+ arrayPathOptionsState.val = [];
445
+ selectedArrayPathState.val = "";
446
+ this.arrayPathValue = "";
447
+ if (file.size > maxSizeMB * 1024 * 1024) {
448
+ setError(`File exceeds maximum size of ${maxSizeMB} MB`);
449
+ return;
450
+ }
451
+ fileNameState.val = file.name;
452
+ fileSizeState.val = formatSize(file.size);
453
+ parsingState.val = true;
454
+ try {
455
+ const text = await file.text();
456
+ let parsed;
457
+ try {
458
+ parsed = JSON.parse(text);
459
+ }
460
+ catch {
461
+ setError("Invalid JSON file");
462
+ return;
463
+ }
464
+ const { data, paths } = resolveArrayFromJson(parsed);
465
+ if (data) {
466
+ setData(data, "");
467
+ }
468
+ else if (paths.length > 1) {
469
+ rawJsonState.val = parsed;
470
+ arrayPathOptionsState.val = paths;
471
+ parsingState.val = false;
472
+ }
473
+ else {
474
+ setError("JSON does not contain an array of objects");
475
+ }
476
+ }
477
+ catch (e) {
478
+ setError(e instanceof Error ? e.message : "Error processing file");
479
+ }
480
+ }
481
+ else {
482
+ await originalProcessFile(file);
483
+ }
484
+ };
485
+ // Re-bind event handlers to use wrapped version
486
+ fileInput.onchange = (e) => {
487
+ const files = e.target.files;
488
+ if (files && files[0])
489
+ processFileWrapped(files[0]);
490
+ };
491
+ dropZone.ondrop = (e) => {
492
+ e.preventDefault();
493
+ dragOverState.val = false;
494
+ const files = e.dataTransfer?.files;
495
+ if (files && files[0])
496
+ processFileWrapped(files[0]);
497
+ };
498
+ // Preview table
499
+ const previewTable = () => {
500
+ return div(() => {
501
+ const data = parsedDataState.val;
502
+ if (!data || data.length === 0)
503
+ return div();
504
+ const columns = Object.keys(data[0]);
505
+ const rows = data.slice(0, previewRows);
506
+ return div({ style: "margin-top: 8px; overflow-x: auto;" }, div({ style: "margin-bottom: 4px; font-size: 0.9em; color: #666;" }, `Showing ${Math.min(previewRows, data.length)} of ${formatNumber(data.length)} rows`), table({ style: "border-collapse: collapse; width: 100%; font-size: 0.85em;" }, tr(...columns.map((col) => th({ style: "border: 1px solid #ddd; padding: 4px 8px; background: #f5f5f5; text-align: left; white-space: nowrap;" }, col))), ...rows.map((row) => tr(...columns.map((col) => td({ style: "border: 1px solid #ddd; padding: 4px 8px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" }, String(row[col] ?? "")))))));
507
+ });
508
+ };
509
+ // arrayPath select handler — when user picks a path, extract data
510
+ const arrayPathSelectorEl = div(() => {
511
+ const options = arrayPathOptionsState.val;
512
+ if (options.length === 0)
513
+ return div();
514
+ return div({ style: "margin-top: 8px;" }, label({ style: "margin-right: 5px;" }, "Select data array:"), select({
515
+ onchange: (e) => {
516
+ const key = e.target.value;
517
+ if (!key)
518
+ return;
519
+ const raw = rawJsonState.val;
520
+ if (raw && Array.isArray(raw[key])) {
521
+ setData(raw[key], key);
522
+ }
523
+ },
524
+ }, option({ value: "" }, "-- choose --"), ...options.map((k) => option({ value: k }, k))));
525
+ });
526
+ // File info bar + clear button
527
+ const fileInfoBar = () => {
528
+ return div(() => {
529
+ const name = fileNameState.val;
530
+ if (!name)
531
+ return div();
532
+ return div({ style: "margin-top: 8px; display: flex; align-items: center; gap: 8px;" }, strong(name), small({ style: "color: #888;" }, `(${fileSizeState.val})`), button({
533
+ type: "button",
534
+ style: "cursor: pointer; background: none; border: 1px solid #ccc; border-radius: 4px; padding: 2px 8px; font-size: 0.85em;",
535
+ onclick: (e) => {
536
+ e.stopPropagation();
537
+ clearFile();
538
+ // Reset the file input so the same file can be re-selected
539
+ fileInput.value = "";
540
+ },
541
+ }, "Clear"));
542
+ });
543
+ };
544
+ // Parsing indicator
545
+ const parsingIndicator = () => {
546
+ return div(() => {
547
+ if (!parsingState.val)
548
+ return div();
549
+ return div({ style: "margin-top: 8px; color: #666;" }, "Parsing file...");
550
+ });
551
+ };
552
+ el = div(props, label({ for: this.name, style: "margin-right: 5px;", class: this.titleClass || '' }, this.label), this.description &&
553
+ div({ id: `${this.name}-description`, class: this.descriptionClass || '' }, this.description), fileInput, dropZone, fileInfoBar(), parsingIndicator(), arrayPathSelectorEl, previewTable(), p({ class: this.errorClass }, () => this.error));
554
+ break;
555
+ }
247
556
  default:
248
557
  el = div({ style: "border: 1px dashed gray; padding: 8px;" }, `Field "${this.name}" unsupported: The type "${this.inputType}" has no UI component built yet.`);
249
558
  }
@@ -60,6 +60,10 @@ class VanJsfForm {
60
60
  }
61
61
  handleFieldChange(field, value) {
62
62
  this.formValues[field.name] = value;
63
+ // For file fields, also store the selected arrayPath key
64
+ if (field.inputType === "file") {
65
+ this.formValues[field.name + "__arrayPath"] = field.arrayPathValue;
66
+ }
63
67
  this.config.formValues = this.formValues;
64
68
  const { formErrors } = this.headlessForm.handleValidation(this.formValues);
65
69
  let extraError = false;