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

inventree / inventree-app / 12326848637

14 Dec 2024 04:24AM UTC coverage: 8.386% (-0.1%) from 8.518%
12326848637

push

github

web-flow
[refactor] Scan improvements (#577)

* Handle error on unexpected barcode response

* Add ManufacturerPart detail view

* Support barcode scanning for manufacturer part

* Refactoring for null checks

* Ignore selected errors in sentry

* Fix API implementation for ManufacturerPart

* Update release notes

* More error handling

* Decode quantity betterer

* Refactoring

* Add option to confirm checkin details

* Improve response handlign

* Cleanup

* Remove unused imports

* Fix async function

* Fix for assigning custom barcode

* Handle barcode scan result for company

* Fix

* Adjust scan priority

* Refactoring MODEL_TYPE

- Use instead of duplicated const strings

* @override fix

4 of 241 new or added lines in 17 files covered. (1.66%)

12 existing lines in 9 files now uncovered.

723 of 8621 relevant lines covered (8.39%)

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:flutter_tabler_icons/flutter_tabler_icons.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/helpers.dart";
14
import "package:inventree/l10.dart";
15

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

23
import "package:inventree/inventree/sentry.dart";
24

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

30

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

37
  // Constructor
38
  APIFormField(this.name, this.data);
×
39

40
  // File to be uploaded for this filed
41
  File? attachedfile;
42

43
  // Name of this field
44
  final String name;
45

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

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

52
  dynamic initial_data;
53

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

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

62
    List<String> path = [];
×
63

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

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

74
    return path.join(".");
×
75
  }
76

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

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

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

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

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

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

112
  bool get isSimple => !nested && parent.isEmpty;
×
113

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

117
  bool get multiline => (getParameter("multiline") ?? false) as bool;
×
118

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

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

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

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

137
    Map<String, String> _filters = {};
×
138

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

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

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

149
      } catch (error) {
150
        // pass
151
      }
152
    }
153

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

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

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

167
    }
168

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

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

182
    return _filters;
183

184
  }
185

186
  bool hasErrors() => errorMessages().isNotEmpty;
×
187

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

191
    dynamic errors;
192

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

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

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

211
    data["errors"] = errors;
×
212
  }
213

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

217
    dynamic errors = data["errors"] ?? [];
×
218

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

224
    errors = errors as List<dynamic>;
225

226
    List<String> messages = [];
×
227

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

232
    return messages;
233
  }
234

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

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

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

242
  String get helpText => (getParameter("help_text") ?? "").toString();
×
243

244
  String get placeholderText => (getParameter("placeholder") ?? "").toString();
×
245

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

248
  Future<void> loadInitialData() async {
×
249

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

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

260
    int? pk = int.tryParse(value.toString());
×
261

262
    if (pk == null) {
263
      return;
264
    }
265

266
    String url = api_url + "/" + pk.toString() + "/";
×
267

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

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

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

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

313
  // Field for capturing a barcode
314
  Widget _constructBarcodeField(BuildContext context) {
×
315

316
    TextEditingController controller = TextEditingController();
×
317

318
    String barcode = (value ?? "").toString();
×
319

320
    if (barcode.isEmpty) {
×
321
      barcode = L10().barcodeNotAssigned;
×
322
    }
323

324
    controller.text = barcode;
×
325

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

346
              barcodeSuccess(L10().barcodeAssigned);
×
347
            });
348

349
            scanBarcode(context, handler: handler);
×
350
          },
351
        ),
352
      )
353
    );
354

355
  }
356

357
  // Field for displaying and selecting dates
358
  Widget _constructDateField() {
×
359

360
    DateTime? currentDate = DateTime.tryParse((value ?? "")as String);
×
361

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

388
          return time;
389
        },
390
      )
391
    );
392

393
  }
394

395

396
  // Field for selecting and uploading files
397
  Widget _constructFileField() {
×
398

399
    TextEditingController controller = TextEditingController();
×
400

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

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

422
                // Save the file
423
                attachedfile = file;
×
424
              }
425
            );
426
          },
427
        )
428
      )
429
    );
430
  }
431

432
  // Field for selecting from multiple choice options
433
  Widget _constructChoiceField() {
×
434

435
    dynamic initial;
436

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

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

473
  // Construct a floating point numerical input field
474
  Widget _constructFloatField() {
×
475

476
    // Initial value: try to cast to a valid number
477
    String initial = "";
478

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

481
    if (initialNumber != null) {
482
      initial = simpleNumberString(initialNumber);
×
483
    }
484

485
    return TextFormField(
×
486
      decoration: InputDecoration(
×
487
        labelText: required ? label + "*" : label,
×
488
        labelStyle: _labelStyle(),
×
489
        helperText: helpText,
×
490
        helperStyle: _helperStyle(),
×
491
        hintText: placeholderText,
×
492
      ),
493
      initialValue: initial,
494
      keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
×
495
      validator: (value) {
×
496
        value = value?.trim() ?? "";
×
497

498
        // Allow empty numbers, *if* this field is not required
499
        if (value.isEmpty && !required) {
×
500
          return null;
501
        }
502

503
        double? quantity = double.tryParse(value.toString());
×
504

505
        if (quantity == null) {
506
          return L10().numberInvalid;
×
507
        }
508

509
        return null;
510
      },
511
      onSaved: (val) {
×
512
        data["value"] = val;
×
513
      },
514
    );
515

516
  }
517

518
  // Construct an input for a related field
519
  Widget _constructRelatedField() {
×
520
    return DropdownSearch<dynamic>(
×
521
      popupProps: PopupProps.bottomSheet(
×
522
        showSelectedItems: true,
523
        isFilterOnline: true,
524
        showSearchBox: true,
525
        itemBuilder: (context, item, isSelected) {
×
526
          return _renderRelatedField(name, item, isSelected, true);
×
527
        },
528
        emptyBuilder: (context, item) {
×
529
          return _renderEmptyResult();
×
530
        },
531
        searchFieldProps: TextFieldProps(
×
532
          autofocus: true
533
        )
534
      ),
535
      selectedItem: initial_data,
×
536
      asyncItems: (String filter) async {
×
537
        Map<String, String> _filters = {
×
538
          ..._relatedFieldFilters(),
×
539
          ...filters,
×
540
        };
541

542
        _filters["search"] = filter;
×
543
        _filters["offset"] = "0";
×
544
        _filters["limit"] = "25";
×
545

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

548
        if (response.isValid()) {
×
549
          return response.resultsList();
×
550
        } else {
551
          return [];
×
552
        }
553
      },
554
      clearButtonProps: ClearButtonProps(
×
555
        isVisible: !required
×
556
      ),
557
      dropdownDecoratorProps: DropDownDecoratorProps(
×
558
          dropdownSearchDecoration: InputDecoration(
×
559
        labelText: label,
×
560
        hintText: helpText,
×
561
      )),
562
      onChanged: null,
563
      itemAsString: (dynamic item) {
×
564
        Map<String, dynamic> data = item as Map<String, dynamic>;
565

566
        switch (model) {
×
NEW
567
          case InvenTreePart.MODEL_TYPE:
×
568
            return InvenTreePart.fromJson(data).fullname;
×
NEW
569
          case InvenTreeCompany.MODEL_TYPE:
×
NEW
570
            return InvenTreeCompany.fromJson(data).name;
×
NEW
571
          case InvenTreePurchaseOrder.MODEL_TYPE:
×
NEW
572
            return InvenTreePurchaseOrder.fromJson(data).reference;
×
NEW
573
          case InvenTreeSalesOrder.MODEL_TYPE:
×
NEW
574
            return InvenTreeSalesOrder.fromJson(data).reference;
×
NEW
575
          case InvenTreePartCategory.MODEL_TYPE:
×
576
            return InvenTreePartCategory.fromJson(data).pathstring;
×
NEW
577
          case InvenTreeStockLocation.MODEL_TYPE:
×
578
            return InvenTreeStockLocation.fromJson(data).pathstring;
×
579
          default:
580
            return "itemAsString not implemented for '${model}'";
×
581
        }
582
      },
583
      dropdownBuilder: (context, item) {
×
584
        return _renderRelatedField(name, item, true, false);
×
585
      },
586
      onSaved: (item) {
×
587
        if (item != null) {
588
          data["value"] = item["pk"];
×
589
        } else {
590
          data["value"] = null;
×
591
        }
592
      },
593
      compareFn: (dynamic item, dynamic selectedItem) {
×
594
        // Comparison is based on the PK value
595

596
        if (item == null || selectedItem == null) {
597
          return false;
598
        }
599

600
        bool result = false;
601

602
        try {
603
          result = item["pk"].toString() == selectedItem["pk"].toString();
×
604
        } catch (error) {
605
          // Catch any conversion errors
606
          result = false;
607
        }
608

609
        return result;
610
      });
611
  }
612

613
  // Construct a set of custom filters for the dropdown search
614
  Map<String, String> _relatedFieldFilters() {
×
615

616
    switch (model) {
×
NEW
617
      case InvenTreeSupplierPart.MODEL_TYPE:
×
618
        return InvenTreeSupplierPart().defaultListFilters();
×
NEW
619
      case InvenTreeStockItem.MODEL_TYPE:
×
620
        return InvenTreeStockItem().defaultListFilters();
×
621
      default:
622
        break;
623
    }
624

625
    return {};
×
626
  }
627

628
  // Render a "related field" based on the "model" type
629
  Widget _renderRelatedField(String fieldName, dynamic item, bool selected, bool extended) {
×
630

631
    // Convert to JSON
632
    Map<String, dynamic> data = {};
×
633

634
    try {
635
      if (item is Map<String, dynamic>) {
×
636
        data = Map<String, dynamic>.from(item);
×
637
      } else {
638
        data = {};
×
639
      }
640
    } catch (error, stackTrace) {
641
      data = {};
×
642

643
      sentryReportError(
×
644
        "_renderRelatedField", error, stackTrace,
645
        context: {
×
646
          "method": "_renderRelateField",
647
          "field_name": fieldName,
648
          "item": item.toString(),
×
649
          "selected": selected.toString(),
×
650
          "extended": extended.toString(),
×
651
        }
652
      );
653
    }
654

655
    switch (model) {
×
NEW
656
      case InvenTreePart.MODEL_TYPE:
×
657
        var part = InvenTreePart.fromJson(data);
×
658

659
        return ListTile(
×
660
          title: Text(
×
661
              part.fullname,
×
662
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
663
          ),
664
          subtitle: extended ? Text(
×
665
            part.description,
×
666
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
667
          ) : null,
668
          leading: extended ? InvenTreeAPI().getThumbnail(part.thumbnail) : null,
×
669
        );
NEW
670
      case InvenTreePartTestTemplate.MODEL_TYPE:
×
671
          var template = InvenTreePartTestTemplate.fromJson(data);
×
672

673
          return ListTile(
×
674
            title: Text(template.testName),
×
675
            subtitle: Text(template.description),
×
676
          );
NEW
677
      case InvenTreeSupplierPart.MODEL_TYPE:
×
678
        var part = InvenTreeSupplierPart.fromJson(data);
×
679

680
        return ListTile(
×
681
          title: Text(part.SKU),
×
682
          subtitle: Text(part.partName),
×
683
          leading: extended ? InvenTreeAPI().getThumbnail(part.partImage) : null,
×
684
          trailing: extended && part.supplierImage.isNotEmpty ? InvenTreeAPI().getThumbnail(part.supplierImage) : null,
×
685
        );
NEW
686
      case InvenTreePartCategory.MODEL_TYPE:
×
687

688
        var cat = InvenTreePartCategory.fromJson(data);
×
689

690
        return ListTile(
×
691
          title: Text(
×
692
              cat.pathstring,
×
693
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
694
          ),
695
          subtitle: extended ? Text(
×
696
            cat.description,
×
697
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
698
          ) : null,
699
        );
NEW
700
      case InvenTreeStockItem.MODEL_TYPE:
×
701
        var item = InvenTreeStockItem.fromJson(data);
×
702

703
        return ListTile(
×
704
          title: Text(
×
705
            item.partName,
×
706
          ),
707
          leading: InvenTreeAPI().getThumbnail(item.partThumbnail),
×
708
          trailing: Text(item.quantityString()),
×
709
        );
NEW
710
      case InvenTreeStockLocation.MODEL_TYPE:
×
UNCOV
711
        var loc = InvenTreeStockLocation.fromJson(data);
×
712

713
        return ListTile(
×
714
          title: Text(
×
715
              loc.pathstring,
×
716
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
717
          ),
718
          subtitle: extended ? Text(
×
719
            loc.description,
×
720
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
721
          ) : null,
722
        );
NEW
723
      case InvenTreeSalesOrderShipment.MODEL_TYPE:
×
724
        var shipment = InvenTreeSalesOrderShipment.fromJson(data);
×
725

726
        return ListTile(
×
727
          title: Text(shipment.reference),
×
728
          subtitle: Text(shipment.tracking_number),
×
729
          trailing: shipment.shipped ? Text(shipment.shipment_date!) : null,
×
730
        );
731
      case "owner":
×
732
        String name = (data["name"] ?? "") as String;
×
733
        bool isGroup = (data["label"] ?? "") == "group";
×
734
        return ListTile(
×
735
          title: Text(name),
×
736
          leading: Icon(isGroup ? TablerIcons.users : TablerIcons.user),
×
737
        );
738
      case "contact":
×
739
        String name = (data["name"] ?? "") as String;
×
740
        String role = (data["role"] ?? "") as String;
×
741
        return ListTile(
×
742
          title: Text(name),
×
743
          subtitle: Text(role),
×
744
        );
NEW
745
      case InvenTreeCompany.MODEL_TYPE:
×
746
        var company = InvenTreeCompany.fromJson(data);
×
747
        return ListTile(
×
748
            title: Text(company.name),
×
749
            subtitle: extended ? Text(company.description) : null,
×
750
            leading: InvenTreeAPI().getThumbnail(company.thumbnail)
×
751
        );
NEW
752
      case InvenTreeProjectCode.MODEL_TYPE:
×
753
        var project_code = InvenTreeProjectCode.fromJson(data);
×
754
        return ListTile(
×
755
            title: Text(project_code.code),
×
756
            subtitle: Text(project_code.description),
×
757
            leading: Icon(TablerIcons.list)
×
758
        );
759
      default:
760
        return ListTile(
×
761
          title: Text(
×
762
              "Unsupported model",
763
              style: TextStyle(
×
764
                  fontWeight: FontWeight.bold,
765
                  color: COLOR_DANGER
766
              )
767
          ),
768
          subtitle: Text("Model '${model}' rendering not supported"),
×
769
        );
770
    }
771
  }
772

773
  // Construct a widget to instruct the user that no results were found
774
  Widget _renderEmptyResult() {
×
775
    return ListTile(
×
776
      leading: Icon(TablerIcons.search),
×
777
      title: Text(L10().noResults),
×
778
      subtitle: Text(
×
779
        L10().queryNoResults,
×
780
        style: TextStyle(fontStyle: FontStyle.italic),
×
781
      ),
782
    );
783
  }
784

785

786
  // Construct a string input element
787
  Widget _constructString() {
×
788

789
    if (readOnly) {
×
790
      return ListTile(
×
791
        title: Text(label),
×
792
        subtitle: Text(helpText),
×
793
        trailing: Text(value.toString()),
×
794
      );
795
    }
796

797
    return TextFormField(
×
798
      decoration: InputDecoration(
×
799
        labelText: required ? label + "*" : label,
×
800
        labelStyle: _labelStyle(),
×
801
        helperText: helpText,
×
802
        helperStyle: _helperStyle(),
×
803
        hintText: placeholderText,
×
804
      ),
805
      readOnly: readOnly,
×
806
      maxLines: multiline ? null : 1,
×
807
      expands: false,
808
      initialValue: (value ?? "") as String,
×
809
      onSaved: (val) {
×
810
        data["value"] = val;
×
811
      },
812
      validator: (value) {
×
813
        if (required && (value == null || value.isEmpty)) {
×
814
          // return L10().valueCannotBeEmpty;
815
        }
816

817
        return null;
818
      },
819
    );
820
  }
821

822
  // Construct a boolean input element
823
  Widget _constructBoolean() {
×
824

825
    bool? initial_value;
826

827
    if (value is bool || value == null) {
×
828
      initial_value = value as bool?;
×
829
    } else {
830
      String vs = value.toString().toLowerCase();
×
831
      initial_value = ["1", "true", "yes"].contains(vs);
×
832
    }
833

834
    return CheckBoxField(
×
835
      label: label,
×
836
      labelStyle: _labelStyle(),
×
837
      helperText: helpText,
×
838
      helperStyle: _helperStyle(),
×
839
      initial: initial_value,
840
      tristate: (getParameter("tristate") ?? false) as bool,
×
841
      onSaved: (val) {
×
842
        data["value"] = val;
×
843
      },
844
    );
845
  }
846

847
  TextStyle _labelStyle() {
×
848
    return TextStyle(
×
849
      fontWeight: FontWeight.bold,
850
      fontSize: 18,
851
      fontFamily: "arial",
852
      color: hasErrors() ? COLOR_DANGER : null,
×
853
      fontStyle: FontStyle.normal,
854
    );
855
  }
856

857
  TextStyle _helperStyle() {
×
858
    return TextStyle(
×
859
      fontStyle: FontStyle.italic,
860
      color: hasErrors() ? COLOR_DANGER : null,
×
861
    );
862
  }
863

864
}
865

866

867
/*
868
 * Extract field options from a returned OPTIONS request
869
 */
870
Map<String, dynamic> extractFields(APIResponse response) {
×
871

872
  if (!response.isValid()) {
×
873
    return {};
×
874
  }
875

876
  var data = response.asMap();
×
877

878
  if (!data.containsKey("actions")) {
×
879
    return {};
×
880
  }
881

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

884
  dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
×
885

886
  return result as Map<String, dynamic>;
887
}
888

889
/*
890
 * Extract a field definition (map) from the provided JSON data.
891
 *
892
 * Notes:
893
 * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"),
894
 * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity"
895
 *
896
 * The map "tree" is traversed based on the provided lookup string, which can use dotted notation.
897
 * This allows complex paths to be used to lookup field information.
898
 */
899
Map<String, dynamic> extractFieldDefinition(Map<String, dynamic> data, String lookup) {
×
900

901
  List<String> path = lookup.split(".");
×
902

903
  // Shadow copy the data for path traversal
904
  Map<String, dynamic> _data = data;
905

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

909
    String el = path[ii];
×
910

911
    if (!_data.containsKey(el)) {
×
912
      print("Could not find field definition for ${lookup}:");
×
913
      print("- Key ${el} missing at index ${ii}");
×
914
      return {};
×
915
    }
916

917
    try {
918
      _data = _data[el] as Map<String, dynamic>;
×
919
    } catch (error, stackTrace) {
920
      print("Could not find sub-field element '${el}' for ${lookup}:");
×
921
      print(error.toString());
×
922

923
      // Report the error
924
      sentryReportError(
×
925
        "apiForm.extractFieldDefinition : path traversal",
926
        error, stackTrace,
927
        context: {
×
928
          "path": path.toString(),
×
929
          "el": el,
930
        }
931
      );
932
      return {};
×
933
    }
934
  }
935

936
  String el = path.last;
×
937

938
  if (!_data.containsKey(el)) {
×
939
    return {};
×
940
  } else {
941

942
    try {
943
      Map<String, dynamic> definition = _data[el] as Map<String, dynamic>;
×
944

945
      return definition;
946
    } catch (error, stacktrace) {
947
      print("Could not find field definition for ${lookup}");
×
948
      print(error.toString());
×
949

950
      // Report the error
951
      sentryReportError(
×
952
        "apiForm.extractFieldDefinition : as map",
953
        error, stacktrace,
954
        context: {
×
955
          "el": el.toString(),
×
956
        }
957
      );
958

959
      return {};
×
960
    }
961

962
  }
963
}
964

965

966
/*
967
 * Launch an API-driven form,
968
 * which uses the OPTIONS metadata (at the provided URL)
969
 * to determine how the form elements should be rendered!
970
 *
971
 * @param title is the title text to display on the form
972
 * @param url is the API URl to make the OPTIONS request to
973
 * @param fields is a map of fields to display (with optional overrides)
974
 * @param modelData is the (optional) existing modelData
975
 * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH)
976
 */
977

978
Future<void> launchApiForm(
×
979
    BuildContext context, String title, String url, Map<String, dynamic> fields,
980
    {
981
      String fileField = "",
982
      Map<String, dynamic> modelData = const {},
983
      String method = "PATCH",
984
      Function(Map<String, dynamic>)? onSuccess,
985
      Function? onCancel,
986
      IconData icon = TablerIcons.device_floppy
987
    }) async {
988

989
  showLoadingOverlay();
×
990

991
  // List of fields defined by the server
992
  Map<String, dynamic> serverFields = {};
×
993

994
  if (url.isNotEmpty) {
×
995

996
    var options = await InvenTreeAPI().options(url);
×
997

998
    // Invalid response from server
999
    if (!options.isValid()) {
×
1000
      hideLoadingOverlay();
×
1001
      return;
1002
    }
1003

1004
    serverFields = extractFields(options);
×
1005

1006
    if (serverFields.isEmpty) {
×
1007
      // User does not have permission to perform this action
1008
      showSnackIcon(
×
1009
        L10().response403,
×
1010
        icon: TablerIcons.user_x,
1011
      );
1012

1013
      hideLoadingOverlay();
×
1014
      return;
1015
    }
1016
  }
1017

1018
  // Construct a list of APIFormField objects
1019
  List<APIFormField> formFields = [];
×
1020

1021
  APIFormField field;
1022

1023
  for (String fieldName in fields.keys) {
×
1024

1025
    dynamic data = fields[fieldName];
×
1026

1027
    Map<String, dynamic> fieldData = {};
×
1028

1029
    if (data is Map) {
×
1030
      fieldData = Map<String, dynamic>.from(data);
×
1031
    }
1032

1033
    // Iterate through the provided fields we wish to display
1034

1035
    field = APIFormField(fieldName, fieldData);
×
1036

1037
    // Extract the definition of this field from the data received from the server
1038
    field.definition = extractFieldDefinition(serverFields, field.lookupPath);
×
1039

1040
    // Skip fields with empty definitions
1041
    if (url.isNotEmpty && field.definition.isEmpty) {
×
1042
      print("Warning: Empty field definition for field '${fieldName}'");
×
1043
    }
1044

1045
    // Add instance value to the field
1046
    dynamic model_value = modelData[fieldName];
×
1047

1048
    if (model_value != null) {
1049
      field.data["instance_value"] = model_value;
×
1050

1051
      if (field.data["value"] == null) {
×
1052
        field.data["value"] = model_value;
×
1053
      }
1054
    }
1055
    formFields.add(field);
×
1056
  }
1057

1058
  // Grab existing data for each form field
1059
  for (var field in formFields) {
×
1060
    await field.loadInitialData();
×
1061
  }
1062

1063
  hideLoadingOverlay();
×
1064

1065
  // Now, launch a new widget!
1066
  Navigator.push(
×
1067
    context,
1068
    MaterialPageRoute(builder: (context) => APIFormWidget(
×
1069
      title,
1070
      url,
1071
      formFields,
1072
      method,
1073
      onSuccess: onSuccess,
1074
      fileField: fileField,
1075
      icon: icon,
1076
    ))
1077
  );
1078
}
1079

1080

1081
class APIFormWidget extends StatefulWidget {
1082

1083
  const APIFormWidget(
×
1084
      this.title,
1085
      this.url,
1086
      this.fields,
1087
      this.method,
1088
      {
1089
        Key? key,
1090
        this.onSuccess,
1091
        this.fileField = "",
1092
        this.icon = TablerIcons.device_floppy,
1093
      }
1094
      ) : super(key: key);
×
1095

1096
  //! Form title to display
1097
  final String title;
1098

1099
  //! API URL
1100
  final String url;
1101

1102
  //! API method
1103
  final String method;
1104

1105
  final String fileField;
1106

1107
  // Icon
1108
  final IconData icon;
1109

1110
  final List<APIFormField> fields;
1111

1112
  final Function(Map<String, dynamic>)? onSuccess;
1113

1114
  @override
×
1115
  _APIFormWidgetState createState() => _APIFormWidgetState();
×
1116

1117
}
1118

1119

1120
class _APIFormWidgetState extends State<APIFormWidget> {
1121

1122
  _APIFormWidgetState() : super();
×
1123

1124
  final _formKey = GlobalKey<FormState>();
1125

1126
  List<String> nonFieldErrors = [];
1127

1128
  bool spacerRequired = false;
1129

1130
  List<Widget> _buildForm() {
×
1131

1132
    List<Widget> widgets = [];
×
1133

1134
    // Display non-field errors first
1135
    if (nonFieldErrors.isNotEmpty) {
×
1136
      for (String error in nonFieldErrors) {
×
1137
        widgets.add(
×
1138
          ListTile(
×
1139
            title: Text(
×
1140
              error,
1141
              style: TextStyle(
×
1142
                color: COLOR_DANGER,
1143
              ),
1144
            ),
1145
            leading: Icon(
×
1146
              TablerIcons.exclamation_circle,
1147
              color: COLOR_DANGER
1148
            ),
1149
          )
1150
        );
1151
      }
1152

1153
      widgets.add(Divider(height: 5));
×
1154

1155
    }
1156

1157
    for (var field in widget.fields) {
×
1158

1159
      if (field.hidden) {
×
1160
        continue;
1161
      }
1162

1163
      // Add divider before some widgets
1164
      if (spacerRequired) {
×
1165
        switch (field.type) {
×
1166
          case "related field":
×
1167
          case "choice":
×
1168
            widgets.add(Divider(height: 15));
×
1169
            break;
1170
          default:
1171
            break;
1172
        }
1173
      }
1174

1175
      widgets.add(field.constructField(context));
×
1176

1177
      if (field.hasErrors()) {
×
1178
        for (String error in field.errorMessages()) {
×
1179
          widgets.add(
×
1180
            ListTile(
×
1181
              title: Text(
×
1182
                error,
1183
                style: TextStyle(
×
1184
                  color: COLOR_DANGER,
1185
                  fontStyle: FontStyle.italic,
1186
                  fontSize: 16,
1187
                ),
1188
              )
1189
            )
1190
          );
1191
        }
1192
      }
1193

1194
      // Add divider after some widgets
1195
      switch (field.type) {
×
1196
        case "related field":
×
1197
        case "choice":
×
1198
          widgets.add(Divider(height: 15));
×
1199
          spacerRequired = false;
×
1200
          break;
1201
        default:
1202
          spacerRequired = true;
×
1203
          break;
1204
      }
1205
    }
1206

1207
    return widgets;
1208
  }
1209

1210
  Future<APIResponse> _submit(Map<String, dynamic> data) async {
×
1211

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

1215
      // Pop the "file" field
1216
      data.remove(widget.fileField);
×
1217

1218
      for (var field in widget.fields) {
×
1219
        if (field.name == widget.fileField) {
×
1220

1221
          File? file = field.attachedfile;
×
1222

1223
          if (file != null) {
1224

1225
            // A valid file has been supplied
1226
            final response = await InvenTreeAPI().uploadFile(
×
1227
              widget.url,
×
1228
              file,
1229
              name: widget.fileField,
×
1230
              fields: data,
1231
            );
1232

1233
            return response;
1234
          }
1235
        }
1236
      }
1237
    }
1238

1239
    if (widget.method == "POST") {
×
1240

1241
      showLoadingOverlay();
×
1242
      final response =  await InvenTreeAPI().post(
×
1243
        widget.url,
×
1244
        body: data,
1245
        expectedStatusCode: null
1246
      );
1247
      hideLoadingOverlay();
×
1248

1249
      return response;
1250

1251
    } else {
1252
      showLoadingOverlay();
×
1253
      final response = await InvenTreeAPI().patch(
×
1254
        widget.url,
×
1255
        body: data,
1256
        expectedStatusCode: null
1257
      );
1258
      hideLoadingOverlay();
×
1259

1260
      return response;
1261
    }
1262
  }
1263

1264
  void extractNonFieldErrors(APIResponse response) {
×
1265

1266
    List<String> errors = [];
×
1267

1268
    Map<String, dynamic> data = response.asMap();
×
1269

1270
    // Potential keys representing non-field errors
1271
    List<String> keys = [
×
1272
      "__all__",
1273
      "non_field_errors",
1274
      "errors",
1275
    ];
1276

1277
    for (String key in keys) {
×
1278
      if (data.containsKey(key)) {
×
1279
        dynamic result = data[key];
×
1280

1281
        if (result is String) {
×
1282
          errors.add(result);
×
1283
        } else if (result is List) {
×
1284
          for (dynamic element in result) {
×
1285
            errors.add(element.toString());
×
1286
          }
1287
        }
1288
      }
1289
    }
1290

1291
    nonFieldErrors = errors;
×
1292
  }
1293

1294
  /* Check for errors relating to an *unhandled* field name
1295
  * These errors will not be displayed and potentially confuse the user
1296
  * So, we need to know if these are ever happening
1297
  */
1298
  void checkInvalidErrors(APIResponse response) {
×
1299
    var errors = response.asMap();
×
1300

1301
    for (String fieldName in errors.keys) {
×
1302

1303
      bool match = false;
1304

1305
      switch (fieldName) {
1306
        case "__all__":
×
1307
        case "non_field_errors":
×
1308
        case "errors":
×
1309
          // ignore these global fields
1310
          match = true;
1311
          continue;
1312
        default:
1313
          for (var field in widget.fields) {
×
1314

1315
            // Hidden fields can't display errors, so we won't match
1316
            if (field.hidden) {
×
1317
              continue;
1318
            }
1319

1320
            if (field.name == fieldName) {
×
1321
              // Direct Match found!
1322
              match = true;
1323
              break;
1324
            } else if (field.parent == fieldName) {
×
1325

1326
              var error = errors[fieldName];
×
1327

1328
              if (error is List) {
×
1329
                for (var el in error) {
×
1330
                  if (el is Map && el.containsKey(field.name)) {
×
1331
                    match = true;
1332
                    break;
1333
                  }
1334
                }
1335
              } else if (error is Map && error.containsKey(field.name)) {
×
1336
                match = true;
1337
                break;
1338
              }
1339
            }
1340
          }
1341

1342
          break;
1343
      }
1344

1345
      if (!match) {
1346
        // Match for an unknown / unsupported field
1347
        sentryReportMessage(
×
1348
          "API form returned error for unsupported field",
1349
          context: {
×
1350
            "url": response.url,
×
1351
            "status_code": response.statusCode.toString(),
×
1352
            "field": fieldName,
1353
            "error_message": response.data.toString(),
×
1354
          }
1355
        );
1356
      }
1357
    }
1358
  }
1359

1360
  /*
1361
   * Submit the form data to the server, and handle the results
1362
   */
1363
  Future<void> _save(BuildContext context) async {
×
1364

1365
    // Package up the form data
1366
    Map<String, dynamic> data = {};
×
1367

1368
    // Iterate through and find "simple" top-level fields
1369

1370
    for (var field in widget.fields) {
×
1371

1372
      if (field.readOnly) {
×
1373
        continue;
1374
      }
1375

1376
      if (field.isSimple) {
×
1377
        // Simple top-level field data
1378
        data[field.name] = field.data["value"];
×
1379
      } else {
1380
        // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?)
1381
        if (field.parent.isNotEmpty) {
×
1382

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

1385
          dynamic parent = data[field.parent] ?? {};
×
1386

1387
          // In the case of a "nested" object, we need to extract the first item
1388
          if (parent is List) {
×
1389
            parent = parent.first;
×
1390
          }
1391

1392
          parent[field.name] = field.data["value"];
×
1393

1394
          // Nested fields must be handled as an array!
1395
          // For now, we only allow single length nested fields
1396
          if (field.nested) {
×
1397
            parent = [parent];
×
1398
          }
1399

1400
          data[field.parent] = parent;
×
1401
        }
1402
      }
1403
    }
1404

1405
    // Run custom onSuccess function
1406
    var successFunc = widget.onSuccess;
×
1407

1408
    // An "empty" URL means we don't want to submit the form anywhere
1409
    // Perhaps we just want to process the data?
1410
    if (widget.url.isEmpty) {
×
1411
      // Hide the form
1412
      Navigator.pop(context);
×
1413

1414
      if (successFunc != null) {
1415
        // Return the raw "submitted" data, rather than the server response
1416
        successFunc(data);
×
1417
      }
1418

1419
      return;
1420
    }
1421

1422
    final response = await _submit(data);
×
1423

1424
    if (!response.isValid()) {
×
1425
      showServerError(widget.url, L10().serverError, L10().responseInvalid);
×
1426
      return;
1427
    }
1428

1429
    switch (response.statusCode) {
×
1430
      case 200:
×
1431
      case 201:
×
1432
        // Form was successfully validated by the server
1433

1434
        // Hide this form
1435
        Navigator.pop(context);
×
1436

1437
        if (successFunc != null) {
1438

1439
          // Ensure the response is a valid JSON structure
1440
          Map<String, dynamic> json = {};
×
1441

1442
          var data = response.asMap();
×
1443

1444
          for (String key in data.keys) {
×
1445
            json[key.toString()] = data[key];
×
1446
          }
1447

1448
          successFunc(json);
×
1449
        }
1450
        return;
1451
      case 400:
×
1452
        // Form submission / validation error
1453
        showSnackIcon(
×
1454
          L10().formError,
×
1455
          success: false
1456
        );
1457

1458
        // Update field errors
1459
        for (var field in widget.fields) {
×
1460
          field.extractErrorMessages(response);
×
1461
        }
1462

1463
        extractNonFieldErrors(response);
×
1464
        checkInvalidErrors(response);
×
1465
        break;
1466
      case 401:
×
1467
        showSnackIcon(
×
1468
          "401: " + L10().response401,
×
1469
          success: false
1470
        );
1471
        break;
1472
      case 403:
×
1473
        showSnackIcon(
×
1474
          "403: " + L10().response403,
×
1475
          success: false,
1476
        );
1477
        break;
1478
      case 404:
×
1479
        showSnackIcon(
×
1480
          "404: " + L10().response404,
×
1481
          success: false,
1482
        );
1483
        break;
1484
      case 405:
×
1485
        showSnackIcon(
×
1486
          "405: " + L10().response405,
×
1487
          success: false,
1488
        );
1489
        break;
1490
      case 500:
×
1491
        showSnackIcon(
×
1492
          "500: " + L10().response500,
×
1493
          success: false,
1494
        );
1495
        break;
1496
      default:
1497
        showSnackIcon(
×
1498
          "${response.statusCode}: " + L10().responseInvalid,
×
1499
          success: false,
1500
        );
1501
        break;
1502
    }
1503

1504
    setState(() {
×
1505
      // Refresh the form
1506
    });
1507

1508
  }
1509

1510
  @override
×
1511
  Widget build(BuildContext context) {
1512

1513
    return Scaffold(
×
1514
      appBar: AppBar(
×
1515
        title: Text(widget.title),
×
1516
        backgroundColor: COLOR_APP_BAR,
×
1517
        actions: [
×
1518
          IconButton(
×
1519
            icon: Icon(widget.icon),
×
1520
            onPressed: () {
×
1521

1522
              if (_formKey.currentState!.validate()) {
×
1523
                _formKey.currentState!.save();
×
1524

1525
                _save(context);
×
1526
              }
1527
            },
1528
          )
1529
        ]
1530
      ),
1531
      body: Form(
×
1532
        key: _formKey,
×
1533
        child: SingleChildScrollView(
×
1534
          child: Column(
×
1535
            mainAxisAlignment: MainAxisAlignment.start,
1536
            mainAxisSize: MainAxisSize.min,
1537
            crossAxisAlignment: CrossAxisAlignment.start,
1538
            children: _buildForm(),
×
1539
          ),
1540
          padding: EdgeInsets.all(16),
×
1541
        )
1542
      )
1543
    );
1544

1545
  }
1546
}
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