• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inventree / inventree-app / 5376905903

pending completion
5376905903

Pull #382

github

web-flow
Merge 372f996e6 into 925966c62
Pull Request #382: Bump version to 0.12.3

5 of 5 new or added lines in 2 files covered. (100.0%)

549 of 6734 relevant lines covered (8.15%)

0.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/lib/api_form.dart
1

2
import "dart:io";
3

4
import "package:intl/intl.dart";
5
import "package:font_awesome_flutter/font_awesome_flutter.dart";
6
import "package:dropdown_search/dropdown_search.dart";
7
import "package:datetime_picker_formfield/datetime_picker_formfield.dart";
8
import "package:flutter/material.dart";
9

10
import "package:inventree/api.dart";
11
import "package:inventree/app_colors.dart";
12
import "package:inventree/barcode/barcode.dart";
13
import "package:inventree/barcode/tones.dart";
14
import "package:inventree/helpers.dart";
15
import "package:inventree/l10.dart";
16

17
import "package:inventree/inventree/company.dart";
18
import "package:inventree/inventree/part.dart";
19
import "package:inventree/inventree/project_code.dart";
20
import "package:inventree/inventree/sentry.dart";
21
import "package:inventree/inventree/stock.dart";
22

23
import "package:inventree/widget/dialogs.dart";
24
import "package:inventree/widget/fields.dart";
25
import "package:inventree/widget/progress.dart";
26
import "package:inventree/widget/snacks.dart";
27

28

29
/*
30
 * Class that represents a single "form field",
31
 * defined by the InvenTree API
32
 */
33
class APIFormField {
34

35
  // Constructor
36
  APIFormField(this.name, this.data);
×
37

38
  // File to be uploaded for this filed
39
  File? attachedfile;
40

41
  // Name of this field
42
  final String name;
43

44
  // JSON data which defines the field
45
  final Map<String, dynamic> data;
46

47
  // JSON field definition provided by the server
48
  Map<String, dynamic> definition = {};
49

50
  dynamic initial_data;
51

52
  // Return the "lookup path" for this field, within the server data
53
  String get lookupPath {
×
54

55
    // Simple top-level case
56
    if (parent.isEmpty && !nested) {
×
57
      return name;
×
58
    }
59

60
    List<String> path = [];
×
61

62
    if (parent.isNotEmpty) {
×
63
      path.add(parent);
×
64
      path.add("child");
×
65
    }
66

67
    if (nested) {
×
68
      path.add("children");
×
69
      path.add(name);
×
70
    }
71

72
    return path.join(".");
×
73
  }
74

75
  /*
76
   * Extract a field parameter from the provided field definition.
77
   *
78
   * - First the user-provided data is checked
79
   * - Second, the server-provided definition is checked
80
   *
81
   * - Finally, return null
82
   */
83
  dynamic getParameter(String key) {
×
84
    if (data.containsKey(key)) {
×
85
      return data[key];
×
86
    } else if (definition.containsKey(key)) {
×
87
      return definition[key];
×
88
    } else {
89
      return null;
90
    }
91
  }
92

93
  // Get the "api_url" associated with a related field
94
  String get api_url => (getParameter("api_url") ?? "") as String;
×
95

96
  // Get the "model" associated with a related field
97
  String get model => (getParameter("model") ?? "") as String;
×
98

99
  // Is this field hidden?
100
  bool get hidden => (getParameter("hidden") ?? false) as bool;
×
101

102
  // Is this field nested? (Nested means part of an array)
103
  // Note: This parameter is only defined locally
104
  bool get nested => (data["nested"] ?? false) as bool;
×
105

106
  // What is the "parent" field of this field?
107
  // Note: This parameter is only defined locally
108
  String get parent => (data["parent"] ?? "") as String;
×
109

110
  bool get isSimple => !nested && parent.isEmpty;
×
111

112
  // Is this field read only?
113
  bool get readOnly => (getParameter("read_only") ?? false) as bool;
×
114

115
  bool get multiline => (getParameter("multiline") ?? false) as bool;
×
116

117
  // Get the "value" as a string (look for "default" if not available)
118
  dynamic get value => data["value"] ?? data["instance_value"] ?? defaultValue;
×
119

120
  // Render value to string (for form submission)
121
  String renderValueToString() {
×
122
    if (data["value"] == null) {
×
123
      return "";
124
    } else {
125
      return data["value"].toString();
×
126
    }
127
  }
128

129
  // Get the "default" as a string
130
  dynamic get defaultValue => getParameter("default");
×
131

132
  // Construct a set of "filters" for this field (e.g. related field)
133
  Map<String, String> get filters {
×
134

135
    Map<String, String> _filters = {};
×
136

137
    // Start with the field "definition" (provided by the server)
138
    if (definition.containsKey("filters")) {
×
139

140
      try {
141
        var fDef = definition["filters"] as Map<String, dynamic>;
×
142

143
        fDef.forEach((String key, dynamic value) {
×
144
          _filters[key] = value.toString();
×
145
        });
146

147
      } catch (error) {
148
        // pass
149
      }
150
    }
151

152
    // Next, look at any "instance_filters" provided by the server
153
    if (definition.containsKey("instance_filters")) {
×
154

155
      try {
156
        var fIns = definition["instance_filters"] as Map<String, dynamic>;
×
157

158
        fIns.forEach((String key, dynamic value) {
×
159
          _filters[key] = value.toString();
×
160
        });
161
      } catch (error) {
162
        // pass
163
      }
164

165
    }
166

167
    // Finally, augment or override with any filters provided by the calling function
168
    if (data.containsKey("filters")) {
×
169
      try {
170
        var fDat = data["filters"] as Map<String, dynamic>;
×
171

172
        fDat.forEach((String key, dynamic value) {
×
173
          _filters[key] = value.toString();
×
174
        });
175
      } catch (error) {
176
        // pass
177
      }
178
    }
179

180
    return _filters;
181

182
  }
183

184
  bool hasErrors() => errorMessages().isNotEmpty;
×
185

186
  // Extract error messages from the server response
187
  void extractErrorMessages(APIResponse response) {
×
188

189
    dynamic errors;
190

191
    if (isSimple) {
×
192
      // Simple fields are easily handled
193
      errors = response.data[name];
×
194
    } else {
195
      if (parent.isNotEmpty) {
×
196
        dynamic parentElement = response.data[parent];
×
197

198
        // Extract from list
199
        if (parentElement is List) {
×
200
          parentElement = parentElement[0];
×
201
        }
202

203
        if (parentElement is Map) {
×
204
          errors = parentElement[name];
×
205
        }
206
      }
207
    }
208

209
    data["errors"] = errors;
×
210
  }
211

212
  // Return the error message associated with this field
213
  List<String> errorMessages() {
×
214

215
    dynamic errors = data["errors"] ?? [];
×
216

217
    // Handle the case where a single error message is returned
218
    if (errors is String) {
×
219
      errors = [errors];
×
220
    }
221

222
    errors = errors as List<dynamic>;
223

224
    List<String> messages = [];
×
225

226
    for (dynamic error in errors) {
×
227
      messages.add(error.toString());
×
228
    }
229

230
    return messages;
231
  }
232

233
  // Is this field required?
234
  bool get required => (getParameter("required") ?? false) as bool;
×
235

236
  String get type => (getParameter("type") ?? "").toString();
×
237

238
  String get label => (getParameter("label") ?? "").toString();
×
239

240
  String get helpText => (getParameter("help_text") ?? "").toString();
×
241

242
  String get placeholderText => (getParameter("placeholder") ?? "").toString();
×
243

244
  List<dynamic> get choices => (getParameter("choices") ?? []) as List<dynamic>;
×
245

246
  Future<void> loadInitialData() async {
×
247

248
    // Only for "related fields"
249
    if (type != "related field") {
×
250
      return;
251
    }
252

253
    // Null value? No point!
254
    if (value == null) {
×
255
      return;
256
    }
257

258
    int? pk = int.tryParse(value.toString());
×
259

260
    if (pk == null) {
261
      return;
262
    }
263

264
    String url = api_url + "/" + pk.toString() + "/";
×
265

266
    final APIResponse response = await InvenTreeAPI().get(
×
267
      url,
268
      params: filters,
×
269
    );
270

271
    if (response.successful()) {
×
272
      initial_data = response.data;
×
273
    }
274
  }
275

276
  // Construct a widget for this input
277
  Widget constructField(BuildContext context) {
×
278

279
    switch (type) {
×
280
      case "string":
×
281
      case "url":
×
282
        return _constructString();
×
283
      case "boolean":
×
284
        return _constructBoolean();
×
285
      case "related field":
×
286
        return _constructRelatedField();
×
287
      case "float":
×
288
      case "decimal":
×
289
        return _constructFloatField();
×
290
      case "choice":
×
291
        return _constructChoiceField();
×
292
      case "file upload":
×
293
      case "image upload":
×
294
        return _constructFileField();
×
295
      case "date":
×
296
        return _constructDateField();
×
297
      case "barcode":
×
298
        return _constructBarcodeField(context);
×
299
      default:
300
        return ListTile(
×
301
          title: Text(
×
302
            "Unsupported field type: '${type}'",
×
303
            style: TextStyle(
×
304
                color: COLOR_DANGER,
305
                fontStyle: FontStyle.italic),
306
          )
307
        );
308
    }
309
  }
310

311
  // Field for capturing a barcode
312
  Widget _constructBarcodeField(BuildContext context) {
×
313

314
    TextEditingController controller = TextEditingController();
×
315

316
    String barcode = (value ?? "").toString();
×
317

318
    if (barcode.isEmpty) {
×
319
      barcode = L10().barcodeNotAssigned;
×
320
    }
321

322
    controller.text = barcode;
×
323

324
    return InputDecorator(
×
325
      decoration: InputDecoration(
×
326
        labelText: required ? label + "*" : label,
×
327
        labelStyle: _labelStyle(),
×
328
        helperText: helpText,
×
329
        helperStyle: _helperStyle(),
×
330
        hintText: placeholderText,
×
331
      ),
332
      child: ListTile(
×
333
        title: TextField(
×
334
          readOnly: true,
335
          controller: controller,
336
        ),
337
        trailing: IconButton(
×
338
          icon: Icon(Icons.qr_code),
×
339
          onPressed: () async {
×
340
            var handler = UniqueBarcodeHandler((String hash) {
×
341
              controller.text = hash;
×
342
              data["value"] = hash;
×
343

344
              barcodeSuccessTone();
×
345

346
              showSnackIcon(
×
347
                  L10().barcodeAssigned,
×
348
                  success: true
349
              );
350
            });
351

352
            scanBarcode(context, handler: handler);
×
353
          },
354
        ),
355
      )
356
    );
357

358
  }
359

360
  // Field for displaying and selecting dates
361
  Widget _constructDateField() {
×
362

363
    DateTime? currentDate = DateTime.tryParse((value ?? "")as String);
×
364

365
    return InputDecorator(
×
366
      decoration: InputDecoration(
×
367
        labelText: label,
×
368
        labelStyle: _labelStyle(),
×
369
        helperStyle: _helperStyle(),
×
370
        helperText: helpText,
×
371
      ),
372
      child: DateTimeField(
×
373
        format: DateFormat("yyyy-MM-dd"),
×
374
        initialValue: currentDate,
375
        onChanged: (DateTime? time) {
×
376
          // Save the time string
377
          if (time == null) {
378
            data["value"] = null;
×
379
          } else {
380
            data["value"] = time.toString().split(" ").first;
×
381
          }
382
        },
383
        onShowPicker: (context, value) async {
×
384
          final time = await showDatePicker(
×
385
            context: context,
386
            initialDate: currentDate ?? DateTime.now(),
×
387
            firstDate: DateTime(1900),
×
388
            lastDate: DateTime(2100),
×
389
          );
390

391
          return time;
392
        },
393
      )
394
    );
395

396
  }
397

398

399
  // Field for selecting and uploading files
400
  Widget _constructFileField() {
×
401

402
    TextEditingController controller = TextEditingController();
×
403

404
    controller.text = (attachedfile?.path ?? L10().attachmentSelect).split("/").last;
×
405

406
    return InputDecorator(
×
407
      decoration: InputDecoration(
×
408
        labelText: label,
×
409
        labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
×
410
      ),
411
      child: ListTile(
×
412
        title: TextField(
×
413
          readOnly: true,
414
          controller: controller,
415
        ),
416
        trailing: IconButton(
×
417
          icon: FaIcon(FontAwesomeIcons.circlePlus),
×
418
          onPressed: () async {
×
419
            FilePickerDialog.pickFile(
×
420
              message: L10().attachmentSelect,
×
421
              onPicked: (file) {
×
422
                // Display the filename
423
                controller.text = file.path.split("/").last;
×
424

425
                // Save the file
426
                attachedfile = file;
×
427
              }
428
            );
429
          },
430
        )
431
      )
432
    );
433
  }
434

435
  // Field for selecting from multiple choice options
436
  Widget _constructChoiceField() {
×
437

438
    dynamic initial;
439

440
    // Check if the current value is within the allowed values
441
    for (var opt in choices) {
×
442
      if (opt["value"] == value) {
×
443
        initial = opt;
444
        break;
445
      }
446
    }
447

448
    return DropdownSearch<dynamic>(
×
449
      popupProps: PopupProps.bottomSheet(
×
450
        showSelectedItems: false,
451
        searchFieldProps: TextFieldProps(
×
452
          autofocus: true
453
        )
454
      ),
455
      selectedItem: initial,
456
      items: choices,
×
457
      dropdownDecoratorProps: DropDownDecoratorProps(
×
458
          dropdownSearchDecoration: InputDecoration(
×
459
        labelText: label,
×
460
        hintText: helpText,
×
461
      )),
462
      onChanged: null,
463
      clearButtonProps: ClearButtonProps(isVisible: !required),
×
464
      itemAsString: (dynamic item) {
×
465
        return (item["display_name"] ?? "") as String;
×
466
      },
467
      onSaved: (item) {
×
468
        if (item == null) {
469
          data["value"] = null;
×
470
        } else {
471
          data["value"] = item["value"];
×
472
        }
473
      });
474
  }
475

476
  // Construct a floating point numerical input field
477
  Widget _constructFloatField() {
×
478

479
    double initial = double.tryParse(value.toString()) ?? 0;
×
480

481
    return TextFormField(
×
482
      decoration: InputDecoration(
×
483
        labelText: required ? label + "*" : label,
×
484
        labelStyle: _labelStyle(),
×
485
        helperText: helpText,
×
486
        helperStyle: _helperStyle(),
×
487
        hintText: placeholderText,
×
488
      ),
489
      initialValue: simpleNumberString(initial),
×
490
      keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
×
491
      validator: (value) {
×
492

493
        double? quantity = double.tryParse(value.toString());
×
494

495
        if (quantity == null) {
496
          return L10().numberInvalid;
×
497
        }
498

499
        return null;
500
      },
501
      onSaved: (val) {
×
502
        data["value"] = val;
×
503
      },
504
    );
505

506
  }
507

508
  // Construct an input for a related field
509
  Widget _constructRelatedField() {
×
510
    return DropdownSearch<dynamic>(
×
511
      popupProps: PopupProps.bottomSheet(
×
512
        showSelectedItems: true,
513
        isFilterOnline: true,
514
        showSearchBox: true,
515
        itemBuilder: (context, item, isSelected) {
×
516
          return _renderRelatedField(item, isSelected, true);
×
517
        },
518
        emptyBuilder: (context, item) {
×
519
          return _renderEmptyResult();
×
520
        },
521
        searchFieldProps: TextFieldProps(
×
522
          autofocus: true
523
        )
524
      ),
525
      selectedItem: initial_data,
×
526
      asyncItems: (String filter) async {
×
527
        Map<String, String> _filters = {};
×
528

529
        filters.forEach((key, value) {
×
530
          _filters[key] = value;
×
531
        });
532

533
        _filters["search"] = filter;
×
534
        _filters["offset"] = "0";
×
535
        _filters["limit"] = "25";
×
536

537
        final APIResponse response = await InvenTreeAPI().get(api_url, params: _filters);
×
538

539
        if (response.isValid()) {
×
540
          return response.resultsList();
×
541
        } else {
542
          return [];
×
543
        }
544
      },
545
      clearButtonProps: ClearButtonProps(
×
546
        isVisible: !required
×
547
      ),
548
      dropdownDecoratorProps: DropDownDecoratorProps(
×
549
          dropdownSearchDecoration: InputDecoration(
×
550
        labelText: label,
×
551
        hintText: helpText,
×
552
      )),
553
      onChanged: null,
554
      itemAsString: (dynamic item) {
×
555
        Map<String, dynamic> data = item as Map<String, dynamic>;
556

557
        switch (model) {
×
558
          case "part":
×
559
            return InvenTreePart.fromJson(data).fullname;
×
560
          case "partcategory":
×
561
            return InvenTreePartCategory.fromJson(data).pathstring;
×
562
          case "stocklocation":
×
563
            return InvenTreeStockLocation.fromJson(data).pathstring;
×
564
          default:
565
            return "itemAsString not implemented for '${model}'";
×
566
        }
567
      },
568
      dropdownBuilder: (context, item) {
×
569
        return _renderRelatedField(item, true, false);
×
570
      },
571
      onSaved: (item) {
×
572
        if (item != null) {
573
          data["value"] = item["pk"];
×
574
        } else {
575
          data["value"] = null;
×
576
        }
577
      },
578
      compareFn: (dynamic item, dynamic selectedItem) {
×
579
        // Comparison is based on the PK value
580

581
        if (item == null || selectedItem == null) {
582
          return false;
583
        }
584

585
        return item["pk"] == selectedItem["pk"];
×
586
      });
587
  }
588

589
  Widget _renderRelatedField(dynamic item, bool selected, bool extended) {
×
590
    // Render a "related field" based on the "model" type
591

592
    // Convert to JSON
593
    var data = Map<String, dynamic>.from((item ?? {}) as Map);
×
594

595
    switch (model) {
×
596
      case "part":
×
597

598
        var part = InvenTreePart.fromJson(data);
×
599

600
        return ListTile(
×
601
          title: Text(
×
602
            part.fullname,
×
603
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
604
          ),
605
          subtitle: extended ? Text(
×
606
            part.description,
×
607
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
608
          ) : null,
609
          leading: extended ? InvenTreeAPI().getThumbnail(part.thumbnail) : null,
×
610
        );
611

612
      case "partcategory":
×
613

614
        var cat = InvenTreePartCategory.fromJson(data);
×
615

616
        return ListTile(
×
617
          title: Text(
×
618
            cat.pathstring,
×
619
            style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
620
          ),
621
          subtitle: extended ? Text(
×
622
            cat.description,
×
623
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
624
          ) : null,
625
        );
626
      case "stocklocation":
×
627

628
        var loc = InvenTreeStockLocation.fromJson(data);
×
629

630
        return ListTile(
×
631
          title: Text(
×
632
            loc.pathstring,
×
633
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
634
          ),
635
          subtitle: extended ? Text(
×
636
            loc.description,
×
637
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
638
          ) : null,
639
        );
640
      case "owner":
×
641
        String name = (data["name"] ?? "") as String;
×
642
        bool isGroup = (data["label"] ?? "") == "group";
×
643
        return ListTile(
×
644
          title: Text(name),
×
645
          leading: FaIcon(isGroup ? FontAwesomeIcons.users : FontAwesomeIcons.user),
×
646
        );
647
      case "contact":
×
648
        String name = (data["name"] ?? "") as String;
×
649
        String role = (data["role"] ?? "") as String;
×
650
        return ListTile(
×
651
          title: Text(name),
×
652
          subtitle: Text(role),
×
653
        );
654
      case "company":
×
655
        var company = InvenTreeCompany.fromJson(data);
×
656
        return ListTile(
×
657
          title: Text(company.name),
×
658
          subtitle: extended ? Text(company.description) : null,
×
659
          leading: InvenTreeAPI().getThumbnail(company.thumbnail)
×
660
        );
661
      case "projectcode":
×
662
        var project_code = InvenTreeProjectCode.fromJson(data);
×
663
        return ListTile(
×
664
          title: Text(project_code.code),
×
665
          subtitle: Text(project_code.description),
×
666
          leading: FaIcon(FontAwesomeIcons.list)
×
667
        );
668
      default:
669
        return ListTile(
×
670
          title: Text(
×
671
            "Unsupported model",
672
            style: TextStyle(
×
673
              fontWeight: FontWeight.bold,
674
              color: COLOR_DANGER
675
            )
676
          ),
677
          subtitle: Text("Model '${model}' rendering not supported"),
×
678
        );
679
    }
680
  }
681

682
  // Construct a widget to instruct the user that no results were found
683
  Widget _renderEmptyResult() {
×
684
    return ListTile(
×
685
      leading: FaIcon(FontAwesomeIcons.magnifyingGlass),
×
686
      title: Text(L10().noResults),
×
687
      subtitle: Text(
×
688
        L10().queryNoResults,
×
689
        style: TextStyle(fontStyle: FontStyle.italic),
×
690
      ),
691
    );
692
  }
693

694

695
  // Construct a string input element
696
  Widget _constructString() {
×
697

698
    if (readOnly) {
×
699
      return ListTile(
×
700
        title: Text(label),
×
701
        subtitle: Text(helpText),
×
702
        trailing: Text(value.toString()),
×
703
      );
704
    }
705

706
    return TextFormField(
×
707
      decoration: InputDecoration(
×
708
        labelText: required ? label + "*" : label,
×
709
        labelStyle: _labelStyle(),
×
710
        helperText: helpText,
×
711
        helperStyle: _helperStyle(),
×
712
        hintText: placeholderText,
×
713
      ),
714
      readOnly: readOnly,
×
715
      maxLines: multiline ? null : 1,
×
716
      expands: false,
717
      initialValue: (value ?? "") as String,
×
718
      onSaved: (val) {
×
719
        data["value"] = val;
×
720
      },
721
      validator: (value) {
×
722
        if (required && (value == null || value.isEmpty)) {
×
723
          // return L10().valueCannotBeEmpty;
724
        }
725

726
        return null;
727
      },
728
    );
729
  }
730

731
  // Construct a boolean input element
732
  Widget _constructBoolean() {
×
733

734
    bool? initial_value;
735

736
    if (value is bool || value == null) {
×
737
      initial_value = value as bool?;
×
738
    } else {
739
      String vs = value.toString().toLowerCase();
×
740
      initial_value = ["1", "true", "yes"].contains(vs);
×
741
    }
742

743
    return CheckBoxField(
×
744
      label: label,
×
745
      labelStyle: _labelStyle(),
×
746
      helperText: helpText,
×
747
      helperStyle: _helperStyle(),
×
748
      initial: initial_value,
749
      tristate: (getParameter("tristate") ?? false) as bool,
×
750
      onSaved: (val) {
×
751
        data["value"] = val;
×
752
      },
753
    );
754
  }
755

756
  TextStyle _labelStyle() {
×
757
    return TextStyle(
×
758
      fontWeight: FontWeight.bold,
759
      fontSize: 18,
760
      fontFamily: "arial",
761
      color: hasErrors() ? COLOR_DANGER : null,
×
762
      fontStyle: FontStyle.normal,
763
    );
764
  }
765

766
  TextStyle _helperStyle() {
×
767
    return TextStyle(
×
768
      fontStyle: FontStyle.italic,
769
      color: hasErrors() ? COLOR_DANGER : null,
×
770
    );
771
  }
772

773
}
774

775

776
/*
777
 * Extract field options from a returned OPTIONS request
778
 */
779
Map<String, dynamic> extractFields(APIResponse response) {
×
780

781
  if (!response.isValid()) {
×
782
    return {};
×
783
  }
784

785
  var data = response.asMap();
×
786

787
  if (!data.containsKey("actions")) {
×
788
    return {};
×
789
  }
790

791
  var actions = response.data["actions"] as Map<String, dynamic>;
×
792

793
  dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
×
794

795
  return result as Map<String, dynamic>;
796
}
797

798
/*
799
 * Extract a field definition (map) from the provided JSON data.
800
 *
801
 * Notes:
802
 * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"),
803
 * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity"
804
 *
805
 * The map "tree" is traversed based on the provided lookup string, which can use dotted notation.
806
 * This allows complex paths to be used to lookup field information.
807
 */
808
Map<String, dynamic> extractFieldDefinition(Map<String, dynamic> data, String lookup) {
×
809

810
  List<String> path = lookup.split(".");
×
811

812
  // Shadow copy the data for path traversal
813
  Map<String, dynamic> _data = data;
814

815
  // Iterate through all but the last element of the path
816
  for (int ii = 0; ii < (path.length - 1); ii++) {
×
817

818
    String el = path[ii];
×
819

820
    if (!_data.containsKey(el)) {
×
821
      print("Could not find field definition for ${lookup}:");
×
822
      print("- Key ${el} missing at index ${ii}");
×
823
      return {};
×
824
    }
825

826
    try {
827
      _data = _data[el] as Map<String, dynamic>;
×
828
    } catch (error, stackTrace) {
829
      print("Could not find sub-field element '${el}' for ${lookup}:");
×
830
      print(error.toString());
×
831

832
      // Report the error
833
      sentryReportError(
×
834
        "apiForm.extractFieldDefinition : path traversal",
835
        error, stackTrace,
836
        context: {
×
837
          "path": path.toString(),
×
838
          "el": el,
839
        }
840
      );
841
      return {};
×
842
    }
843
  }
844

845
  String el = path.last;
×
846

847
  if (!_data.containsKey(el)) {
×
848
    return {};
×
849
  } else {
850

851
    try {
852
      Map<String, dynamic> definition = _data[el] as Map<String, dynamic>;
×
853

854
      return definition;
855
    } catch (error, stacktrace) {
856
      print("Could not find field definition for ${lookup}");
×
857
      print(error.toString());
×
858

859
      // Report the error
860
      sentryReportError(
×
861
        "apiForm.extractFieldDefinition : as map",
862
        error, stacktrace,
863
        context: {
×
864
          "el": el.toString(),
×
865
        }
866
      );
867

868
      return {};
×
869
    }
870

871
  }
872
}
873

874

875
/*
876
 * Launch an API-driven form,
877
 * which uses the OPTIONS metadata (at the provided URL)
878
 * to determine how the form elements should be rendered!
879
 *
880
 * @param title is the title text to display on the form
881
 * @param url is the API URl to make the OPTIONS request to
882
 * @param fields is a map of fields to display (with optional overrides)
883
 * @param modelData is the (optional) existing modelData
884
 * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH)
885
 */
886

887
Future<void> launchApiForm(
×
888
    BuildContext context, String title, String url, Map<String, dynamic> fields,
889
    {
890
      String fileField = "",
891
      Map<String, dynamic> modelData = const {},
892
      String method = "PATCH",
893
      Function(Map<String, dynamic>)? onSuccess,
894
      Function? onCancel,
895
      IconData icon = FontAwesomeIcons.floppyDisk,
896
    }) async {
897

898
  showLoadingOverlay(context);
×
899

900
  // List of fields defined by the server
901
  Map<String, dynamic> serverFields = {};
×
902

903
  if (url.isNotEmpty) {
×
904

905
    var options = await InvenTreeAPI().options(url);
×
906

907
    // Invalid response from server
908
    if (!options.isValid()) {
×
909
      hideLoadingOverlay();
×
910
      return;
911
    }
912

913
    serverFields = extractFields(options);
×
914

915
    if (serverFields.isEmpty) {
×
916
      // User does not have permission to perform this action
917
      showSnackIcon(
×
918
        L10().response403,
×
919
        icon: FontAwesomeIcons.userXmark,
920
      );
921

922
      hideLoadingOverlay();
×
923
      return;
924
    }
925
  }
926

927
  // Construct a list of APIFormField objects
928
  List<APIFormField> formFields = [];
×
929

930
  APIFormField field;
931

932
  for (String fieldName in fields.keys) {
×
933

934
    dynamic data = fields[fieldName];
×
935

936
    Map<String, dynamic> fieldData = {};
×
937

938
    if (data is Map) {
×
939
      fieldData = Map<String, dynamic>.from(data);
×
940
    }
941

942
    // Iterate through the provided fields we wish to display
943

944
    field = APIFormField(fieldName, fieldData);
×
945

946
    // Extract the definition of this field from the data received from the server
947
    field.definition = extractFieldDefinition(serverFields, field.lookupPath);
×
948

949
    // Skip fields with empty definitions
950
    if (url.isNotEmpty && field.definition.isEmpty) {
×
951
      print("Warning: Empty field definition for field '${fieldName}'");
×
952
    }
953

954
    // Add instance value to the field
955
    dynamic model_value = modelData[fieldName];
×
956

957
    if (model_value != null) {
958
      field.data["instance_value"] = model_value;
×
959

960
      if (field.data["value"] == null) {
×
961
        field.data["value"] = model_value;
×
962
      }
963
    }
964
    formFields.add(field);
×
965
  }
966

967
  // Grab existing data for each form field
968
  for (var field in formFields) {
×
969
    await field.loadInitialData();
×
970
  }
971

972
  hideLoadingOverlay();
×
973

974
  // Now, launch a new widget!
975
  Navigator.push(
×
976
    context,
977
    MaterialPageRoute(builder: (context) => APIFormWidget(
×
978
      title,
979
      url,
980
      formFields,
981
      method,
982
      onSuccess: onSuccess,
983
      fileField: fileField,
984
      icon: icon,
985
    ))
986
  );
987
}
988

989

990
class APIFormWidget extends StatefulWidget {
991

992
  const APIFormWidget(
×
993
      this.title,
994
      this.url,
995
      this.fields,
996
      this.method,
997
      {
998
        Key? key,
999
        this.onSuccess,
1000
        this.fileField = "",
1001
        this.icon = FontAwesomeIcons.floppyDisk,
1002
      }
1003
      ) : super(key: key);
×
1004

1005
  //! Form title to display
1006
  final String title;
1007

1008
  //! API URL
1009
  final String url;
1010

1011
  //! API method
1012
  final String method;
1013

1014
  final String fileField;
1015

1016
  // Icon
1017
  final IconData icon;
1018

1019
  final List<APIFormField> fields;
1020

1021
  final Function(Map<String, dynamic>)? onSuccess;
1022

1023
  @override
×
1024
  _APIFormWidgetState createState() => _APIFormWidgetState();
×
1025

1026
}
1027

1028

1029
class _APIFormWidgetState extends State<APIFormWidget> {
1030

1031
  _APIFormWidgetState() : super();
×
1032

1033
  final _formKey = GlobalKey<FormState>();
1034

1035
  List<String> nonFieldErrors = [];
1036

1037
  bool spacerRequired = false;
1038

1039
  List<Widget> _buildForm() {
×
1040

1041
    List<Widget> widgets = [];
×
1042

1043
    // Display non-field errors first
1044
    if (nonFieldErrors.isNotEmpty) {
×
1045
      for (String error in nonFieldErrors) {
×
1046
        widgets.add(
×
1047
          ListTile(
×
1048
            title: Text(
×
1049
              error,
1050
              style: TextStyle(
×
1051
                color: COLOR_DANGER,
1052
              ),
1053
            ),
1054
            leading: FaIcon(
×
1055
              FontAwesomeIcons.circleExclamation,
1056
              color: COLOR_DANGER
1057
            ),
1058
          )
1059
        );
1060
      }
1061

1062
      widgets.add(Divider(height: 5));
×
1063

1064
    }
1065

1066
    for (var field in widget.fields) {
×
1067

1068
      if (field.hidden) {
×
1069
        continue;
1070
      }
1071

1072
      // Add divider before some widgets
1073
      if (spacerRequired) {
×
1074
        switch (field.type) {
×
1075
          case "related field":
×
1076
          case "choice":
×
1077
            widgets.add(Divider(height: 15));
×
1078
            break;
1079
          default:
1080
            break;
1081
        }
1082
      }
1083

1084
      widgets.add(field.constructField(context));
×
1085

1086
      if (field.hasErrors()) {
×
1087
        for (String error in field.errorMessages()) {
×
1088
          widgets.add(
×
1089
            ListTile(
×
1090
              title: Text(
×
1091
                error,
1092
                style: TextStyle(
×
1093
                  color: COLOR_DANGER,
1094
                  fontStyle: FontStyle.italic,
1095
                  fontSize: 16,
1096
                ),
1097
              )
1098
            )
1099
          );
1100
        }
1101
      }
1102

1103
      // Add divider after some widgets
1104
      switch (field.type) {
×
1105
        case "related field":
×
1106
        case "choice":
×
1107
          widgets.add(Divider(height: 15));
×
1108
          spacerRequired = false;
×
1109
          break;
1110
        default:
1111
          spacerRequired = true;
×
1112
          break;
1113
      }
1114
    }
1115

1116
    return widgets;
1117
  }
1118

1119
  Future<APIResponse> _submit(Map<String, dynamic> data) async {
×
1120

1121
    // If a file upload is required, we have to handle the submission differently
1122
    if (widget.fileField.isNotEmpty) {
×
1123

1124
      // Pop the "file" field
1125
      data.remove(widget.fileField);
×
1126

1127
      for (var field in widget.fields) {
×
1128
        if (field.name == widget.fileField) {
×
1129

1130
          File? file = field.attachedfile;
×
1131

1132
          if (file != null) {
1133

1134
            // A valid file has been supplied
1135
            final response = await InvenTreeAPI().uploadFile(
×
1136
              widget.url,
×
1137
              file,
1138
              name: widget.fileField,
×
1139
              fields: data,
1140
            );
1141

1142
            return response;
1143
          }
1144
        }
1145
      }
1146
    }
1147

1148
    if (widget.method == "POST") {
×
1149

1150
      showLoadingOverlay(context);
×
1151
      final response =  await InvenTreeAPI().post(
×
1152
        widget.url,
×
1153
        body: data,
1154
        expectedStatusCode: null
1155
      );
1156
      hideLoadingOverlay();
×
1157

1158
      return response;
1159

1160
    } else {
1161
      showLoadingOverlay(context);
×
1162
      final response = await InvenTreeAPI().patch(
×
1163
        widget.url,
×
1164
        body: data,
1165
        expectedStatusCode: null
1166
      );
1167
      hideLoadingOverlay();
×
1168

1169
      return response;
1170
    }
1171
  }
1172

1173
  void extractNonFieldErrors(APIResponse response) {
×
1174

1175
    List<String> errors = [];
×
1176

1177
    Map<String, dynamic> data = response.asMap();
×
1178

1179
    // Potential keys representing non-field errors
1180
    List<String> keys = [
×
1181
      "__all__",
1182
      "non_field_errors",
1183
      "errors",
1184
    ];
1185

1186
    for (String key in keys) {
×
1187
      if (data.containsKey(key)) {
×
1188
        dynamic result = data[key];
×
1189

1190
        if (result is String) {
×
1191
          errors.add(result);
×
1192
        } else if (result is List) {
×
1193
          for (dynamic element in result) {
×
1194
            errors.add(element.toString());
×
1195
          }
1196
        }
1197
      }
1198
    }
1199

1200
    nonFieldErrors = errors;
×
1201
  }
1202

1203
  /* Check for errors relating to an *unhandled* field name
1204
  * These errors will not be displayed and potentially confuse the user
1205
  * So, we need to know if these are ever happening
1206
  */
1207
  void checkInvalidErrors(APIResponse response) {
×
1208
    var errors = response.asMap();
×
1209

1210
    for (String fieldName in errors.keys) {
×
1211

1212
      bool match = false;
1213

1214
      switch (fieldName) {
1215
        case "__all__":
×
1216
        case "non_field_errors":
×
1217
        case "errors":
×
1218
          // ignore these global fields
1219
          match = true;
1220
          continue;
1221
        default:
1222
          for (var field in widget.fields) {
×
1223

1224
            // Hidden fields can't display errors, so we won't match
1225
            if (field.hidden) {
×
1226
              continue;
1227
            }
1228

1229
            if (field.name == fieldName) {
×
1230
              // Direct Match found!
1231
              match = true;
1232
              break;
1233
            } else if (field.parent == fieldName) {
×
1234

1235
              var error = errors[fieldName];
×
1236

1237
              if (error is List) {
×
1238
                for (var el in error) {
×
1239
                  if (el is Map && el.containsKey(field.name)) {
×
1240
                    match = true;
1241
                    break;
1242
                  }
1243
                }
1244
              } else if (error is Map && error.containsKey(field.name)) {
×
1245
                match = true;
1246
                break;
1247
              }
1248
            }
1249
          }
1250

1251
          break;
1252
      }
1253

1254
      if (!match) {
1255
        // Match for an unknown / unsupported field
1256
        sentryReportMessage(
×
1257
          "API form returned error for unsupported field",
1258
          context: {
×
1259
            "url": response.url,
×
1260
            "status_code": response.statusCode.toString(),
×
1261
            "field": fieldName,
1262
            "error_message": response.data.toString(),
×
1263
          }
1264
        );
1265
      }
1266
    }
1267
  }
1268

1269
  /*
1270
   * Submit the form data to the server, and handle the results
1271
   */
1272
  Future<void> _save(BuildContext context) async {
×
1273

1274
    // Package up the form data
1275
    Map<String, dynamic> data = {};
×
1276

1277
    // Iterate through and find "simple" top-level fields
1278

1279
    for (var field in widget.fields) {
×
1280

1281
      if (field.readOnly) {
×
1282
        continue;
1283
      }
1284

1285
      if (field.isSimple) {
×
1286
        // Simple top-level field data
1287
        data[field.name] = field.data["value"];
×
1288
      } else {
1289
        // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?)
1290
        if (field.parent.isNotEmpty) {
×
1291

1292
          // TODO: This is a dirty hack, there *must* be a cleaner way?!
1293

1294
          dynamic parent = data[field.parent] ?? {};
×
1295

1296
          // In the case of a "nested" object, we need to extract the first item
1297
          if (parent is List) {
×
1298
            parent = parent.first;
×
1299
          }
1300

1301
          parent[field.name] = field.data["value"];
×
1302

1303
          // Nested fields must be handled as an array!
1304
          // For now, we only allow single length nested fields
1305
          if (field.nested) {
×
1306
            parent = [parent];
×
1307
          }
1308

1309
          data[field.parent] = parent;
×
1310
        }
1311
      }
1312
    }
1313

1314
    // Run custom onSuccess function
1315
    var successFunc = widget.onSuccess;
×
1316

1317
    // An "empty" URL means we don't want to submit the form anywhere
1318
    // Perhaps we just want to process the data?
1319
    if (widget.url.isEmpty) {
×
1320
      // Hide the form
1321
      Navigator.pop(context);
×
1322

1323
      if (successFunc != null) {
1324
        // Return the raw "submitted" data, rather than the server response
1325
        successFunc(data);
×
1326
      }
1327

1328
      return;
1329
    }
1330

1331
    final response = await _submit(data);
×
1332

1333
    if (!response.isValid()) {
×
1334
      showServerError(widget.url, L10().serverError, L10().responseInvalid);
×
1335
      return;
1336
    }
1337

1338
    switch (response.statusCode) {
×
1339
      case 200:
×
1340
      case 201:
×
1341
        // Form was successfully validated by the server
1342

1343
        // Hide this form
1344
        Navigator.pop(context);
×
1345

1346
        if (successFunc != null) {
1347

1348
          // Ensure the response is a valid JSON structure
1349
          Map<String, dynamic> json = {};
×
1350

1351
          var data = response.asMap();
×
1352

1353
          for (String key in data.keys) {
×
1354
            json[key.toString()] = data[key];
×
1355
          }
1356

1357
          successFunc(json);
×
1358
        }
1359
        return;
1360
      case 400:
×
1361
        // Form submission / validation error
1362
        showSnackIcon(
×
1363
          L10().formError,
×
1364
          success: false
1365
        );
1366

1367
        // Update field errors
1368
        for (var field in widget.fields) {
×
1369
          field.extractErrorMessages(response);
×
1370
        }
1371

1372
        extractNonFieldErrors(response);
×
1373
        checkInvalidErrors(response);
×
1374
        break;
1375
      case 401:
×
1376
        showSnackIcon(
×
1377
          "401: " + L10().response401,
×
1378
          success: false
1379
        );
1380
        break;
1381
      case 403:
×
1382
        showSnackIcon(
×
1383
          "403: " + L10().response403,
×
1384
          success: false,
1385
        );
1386
        break;
1387
      case 404:
×
1388
        showSnackIcon(
×
1389
          "404: " + L10().response404,
×
1390
          success: false,
1391
        );
1392
        break;
1393
      case 405:
×
1394
        showSnackIcon(
×
1395
          "405: " + L10().response405,
×
1396
          success: false,
1397
        );
1398
        break;
1399
      case 500:
×
1400
        showSnackIcon(
×
1401
          "500: " + L10().response500,
×
1402
          success: false,
1403
        );
1404
        break;
1405
      default:
1406
        showSnackIcon(
×
1407
          "${response.statusCode}: " + L10().responseInvalid,
×
1408
          success: false,
1409
        );
1410
        break;
1411
    }
1412

1413
    setState(() {
×
1414
      // Refresh the form
1415
    });
1416

1417
  }
1418

1419
  @override
×
1420
  Widget build(BuildContext context) {
1421

1422
    return Scaffold(
×
1423
      appBar: AppBar(
×
1424
        title: Text(widget.title),
×
1425
        actions: [
×
1426
          IconButton(
×
1427
            icon: FaIcon(widget.icon),
×
1428
            onPressed: () {
×
1429

1430
              if (_formKey.currentState!.validate()) {
×
1431
                _formKey.currentState!.save();
×
1432

1433
                _save(context);
×
1434
              }
1435
            },
1436
          )
1437
        ]
1438
      ),
1439
      body: Form(
×
1440
        key: _formKey,
×
1441
        child: SingleChildScrollView(
×
1442
          child: Column(
×
1443
            mainAxisAlignment: MainAxisAlignment.start,
1444
            mainAxisSize: MainAxisSize.min,
1445
            crossAxisAlignment: CrossAxisAlignment.start,
1446
            children: _buildForm(),
×
1447
          ),
1448
          padding: EdgeInsets.all(16),
×
1449
        )
1450
      )
1451
    );
1452

1453
  }
1454
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc