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

inventree / inventree-app / 17453133907

04 Sep 2025 04:15AM UTC coverage: 1.477% (-0.07%) from 1.544%
17453133907

Pull #673

github

web-flow
Merge fc015ba2d into d237a0e07
Pull Request #673: Build Order

0 of 1047 new or added lines in 42 files covered. (0.0%)

30000 existing lines in 41 files now uncovered.

768 of 51989 relevant lines covered (1.48%)

0.05 hits per line

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

0.0
/lib/api_form.dart
1
import "dart:io";
2

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

9
import "package:inventree/api.dart";
10
import "package:inventree/app_colors.dart";
11
import "package:inventree/helpers.dart";
12
import "package:inventree/l10.dart";
13
import "package:inventree/barcode/barcode.dart";
14
import "package:inventree/inventree/build.dart";
15
import "package:inventree/inventree/company.dart";
16
import "package:inventree/inventree/part.dart";
17
import "package:inventree/inventree/project_code.dart";
18
import "package:inventree/inventree/purchase_order.dart";
19
import "package:inventree/inventree/sales_order.dart";
20
import "package:inventree/inventree/stock.dart";
21
import "package:inventree/inventree/sentry.dart";
22
import "package:inventree/widget/dialogs.dart";
23
import "package:inventree/widget/fields.dart";
24
import "package:inventree/widget/progress.dart";
25
import "package:inventree/widget/snacks.dart";
26

27
/*
28
 * Class that represents a single "form field",
29
 * defined by the InvenTree API
30
 */
31
class APIFormField {
32
  // Constructor
33
  APIFormField(this.name, this.data);
×
34

35
  // File to be uploaded for this filed
36
  File? attachedfile;
37

38
  // Name of this field
39
  final String name;
40

41
  // JSON data which defines the field
42
  final Map<String, dynamic> data;
43

44
  // JSON field definition provided by the server
45
  Map<String, dynamic> definition = {};
46

47
  dynamic initial_data;
48

49
  // Return the "lookup path" for this field, within the server data
50
  String get lookupPath {
×
51
    // Simple top-level case
52
    if (parent.isEmpty && !nested) {
×
53
      return name;
×
54
    }
55

56
    List<String> path = [];
×
57

58
    if (parent.isNotEmpty) {
×
59
      path.add(parent);
×
60
      path.add("child");
×
61
    }
62

63
    if (nested) {
×
64
      path.add("children");
×
65
      path.add(name);
×
66
    }
67

68
    return path.join(".");
×
69
  }
70

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

89
  // Get the "api_url" associated with a related field
90
  String get api_url => (getParameter("api_url") ?? "") as String;
×
91

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

95
  // Is this field hidden?
96
  bool get hidden => (getParameter("hidden") ?? false) as bool;
×
97

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

102
  // What is the "parent" field of this field?
103
  // Note: This parameter is only defined locally
104
  String get parent => (data["parent"] ?? "") as String;
×
105

106
  bool get isSimple => !nested && parent.isEmpty;
×
107

108
  // Is this field read only?
109
  bool get readOnly => (getParameter("read_only") ?? false) as bool;
×
110

111
  bool get multiline => (getParameter("multiline") ?? false) as bool;
×
112

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

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

125
  // Get the "default" as a string
126
  dynamic get defaultValue => getParameter("default");
×
127

128
  // Construct a set of "filters" for this field (e.g. related field)
129
  Map<String, String> get filters {
×
130
    Map<String, String> _filters = {};
×
131

132
    // Start with the field "definition" (provided by the server)
133
    if (definition.containsKey("filters")) {
×
134
      try {
135
        var fDef = definition["filters"] as Map<String, dynamic>;
×
136

137
        fDef.forEach((String key, dynamic value) {
×
138
          _filters[key] = value.toString();
×
139
        });
140
      } catch (error) {
141
        // pass
142
      }
143
    }
144

145
    // Next, look at any "instance_filters" provided by the server
146
    if (definition.containsKey("instance_filters")) {
×
147
      try {
148
        var fIns = definition["instance_filters"] as Map<String, dynamic>;
×
149

150
        fIns.forEach((String key, dynamic value) {
×
151
          _filters[key] = value.toString();
×
152
        });
153
      } catch (error) {
154
        // pass
155
      }
156
    }
157

158
    // Finally, augment or override with any filters provided by the calling function
159
    if (data.containsKey("filters")) {
×
160
      try {
161
        var fDat = data["filters"] as Map<String, dynamic>;
×
162

163
        fDat.forEach((String key, dynamic value) {
×
164
          _filters[key] = value.toString();
×
165
        });
166
      } catch (error) {
167
        // pass
168
      }
169
    }
170

171
    return _filters;
172
  }
173

174
  bool hasErrors() => errorMessages().isNotEmpty;
×
175

176
  // Extract error messages from the server response
177
  void extractErrorMessages(APIResponse response) {
×
178
    dynamic errors;
179

180
    if (isSimple) {
×
181
      // Simple fields are easily handled
182
      errors = response.data[name];
×
183
    } else {
184
      if (parent.isNotEmpty) {
×
185
        dynamic parentElement = response.data[parent];
×
186

187
        // Extract from list
188
        if (parentElement is List) {
×
189
          parentElement = parentElement[0];
×
190
        }
191

192
        if (parentElement is Map) {
×
193
          errors = parentElement[name];
×
194
        }
195
      }
196
    }
197

198
    data["errors"] = errors;
×
199
  }
200

201
  // Return the error message associated with this field
202
  List<String> errorMessages() {
×
203
    dynamic errors = data["errors"] ?? [];
×
204

205
    // Handle the case where a single error message is returned
206
    if (errors is String) {
×
207
      errors = [errors];
×
208
    }
209

210
    errors = errors as List<dynamic>;
211

212
    List<String> messages = [];
×
213

214
    for (dynamic error in errors) {
×
215
      messages.add(error.toString());
×
216
    }
217

218
    return messages;
219
  }
220

221
  // Is this field required?
222
  bool get required => (getParameter("required") ?? false) as bool;
×
223

224
  String get type => (getParameter("type") ?? "").toString();
×
225

226
  String get label => (getParameter("label") ?? "").toString();
×
227

228
  String get helpText => (getParameter("help_text") ?? "").toString();
×
229

230
  String get placeholderText => (getParameter("placeholder") ?? "").toString();
×
231

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

234
  Future<void> loadInitialData() async {
×
235
    // Only for "related fields"
236
    if (type != "related field") {
×
237
      return;
238
    }
239

240
    // Null value? No point!
241
    if (value == null) {
×
242
      return;
243
    }
244

245
    int? pk = int.tryParse(value.toString());
×
246

247
    if (pk == null) {
248
      return;
249
    }
250

251
    String url = api_url + "/" + pk.toString() + "/";
×
252

253
    final APIResponse response = await InvenTreeAPI().get(url, params: filters);
×
254

255
    if (response.successful()) {
×
256
      initial_data = response.data;
×
257
    }
258
  }
259

260
  // Construct a widget for this input
261
  Widget constructField(BuildContext context) {
×
262
    switch (type) {
×
263
      case "string":
×
264
      case "url":
×
265
        return _constructString();
×
266
      case "boolean":
×
267
        return _constructBoolean();
×
268
      case "related field":
×
269
        return _constructRelatedField();
×
270
      case "decimal":
×
NEW
271
      case "float":
×
NEW
272
      case "integer":
×
273
        return _constructFloatField();
×
274
      case "choice":
×
275
        return _constructChoiceField();
×
276
      case "file upload":
×
277
      case "image upload":
×
278
        return _constructFileField();
×
279
      case "date":
×
280
        return _constructDateField();
×
281
      case "barcode":
×
282
        return _constructBarcodeField(context);
×
283
      default:
284
        return ListTile(
×
285
          title: Text(
×
286
            "Unsupported field type: '${type}' for field '${name}'",
×
287
            style: TextStyle(color: COLOR_DANGER, fontStyle: FontStyle.italic),
×
288
          ),
289
        );
290
    }
291
  }
292

293
  // Field for capturing a barcode
294
  Widget _constructBarcodeField(BuildContext context) {
×
295
    TextEditingController controller = TextEditingController();
×
296

297
    String barcode = (value ?? "").toString();
×
298

299
    if (barcode.isEmpty) {
×
300
      barcode = L10().barcodeNotAssigned;
×
301
    }
302

303
    controller.text = barcode;
×
304

305
    return InputDecorator(
×
306
      decoration: InputDecoration(
×
307
        labelText: required ? label + "*" : label,
×
308
        labelStyle: _labelStyle(),
×
309
        helperText: helpText,
×
310
        helperStyle: _helperStyle(),
×
311
        hintText: placeholderText,
×
312
      ),
313
      child: ListTile(
×
314
        title: TextField(readOnly: true, controller: controller),
×
315
        trailing: IconButton(
×
316
          icon: Icon(TablerIcons.qrcode),
×
317
          onPressed: () async {
×
318
            var handler = UniqueBarcodeHandler((String hash) {
×
319
              controller.text = hash;
×
320
              data["value"] = hash;
×
321

322
              barcodeSuccess(L10().barcodeAssigned);
×
323
            });
324

325
            scanBarcode(context, handler: handler);
×
326
          },
327
        ),
328
      ),
329
    );
330
  }
331

332
  // Field for displaying and selecting dates
333
  Widget _constructDateField() {
×
334
    DateTime? currentDate = DateTime.tryParse((value ?? "") as String);
×
335

336
    return InputDecorator(
×
337
      decoration: InputDecoration(
×
338
        labelText: label,
×
339
        labelStyle: _labelStyle(),
×
340
        helperStyle: _helperStyle(),
×
341
        helperText: helpText,
×
342
      ),
343
      child: DateTimeField(
×
344
        format: DateFormat("yyyy-MM-dd"),
×
345
        initialValue: currentDate,
346
        onChanged: (DateTime? time) {
×
347
          // Save the time string
348
          if (time == null) {
349
            data["value"] = null;
×
350
          } else {
351
            data["value"] = time.toString().split(" ").first;
×
352
          }
353
        },
354
        onShowPicker: (context, value) async {
×
355
          final time = await showDatePicker(
×
356
            context: context,
357
            initialDate: currentDate ?? DateTime.now(),
×
358
            firstDate: DateTime(1900),
×
359
            lastDate: DateTime(2100),
×
360
          );
361

362
          return time;
363
        },
364
      ),
365
    );
366
  }
367

368
  // Field for selecting and uploading files
369
  Widget _constructFileField() {
×
370
    TextEditingController controller = TextEditingController();
×
371

372
    controller.text = (attachedfile?.path ?? L10().attachmentSelect)
×
373
        .split("/")
×
374
        .last;
×
375

376
    return InputDecorator(
×
377
      decoration: InputDecoration(
×
378
        labelText: label,
×
379
        labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
×
380
      ),
381
      child: ListTile(
×
382
        title: TextField(readOnly: true, controller: controller),
×
383
        trailing: IconButton(
×
384
          icon: Icon(TablerIcons.circle_plus),
×
385
          onPressed: () async {
×
386
            FilePickerDialog.pickFile(
×
387
              message: L10().attachmentSelect,
×
388
              onPicked: (file) {
×
389
                // Display the filename
390
                controller.text = file.path.split("/").last;
×
391

392
                // Save the file
393
                attachedfile = file;
×
394
              },
395
            );
396
          },
397
        ),
398
      ),
399
    );
400
  }
401

402
  // Field for selecting from multiple choice options
403
  Widget _constructChoiceField() {
×
404
    dynamic initial;
405

406
    // Check if the current value is within the allowed values
407
    for (var opt in choices) {
×
408
      if (opt["value"] == value) {
×
409
        initial = opt;
410
        break;
411
      }
412
    }
413

414
    return DropdownSearch<dynamic>(
×
415
      popupProps: PopupProps.bottomSheet(
×
416
        showSelectedItems: false,
417
        searchFieldProps: TextFieldProps(autofocus: true),
×
418
      ),
419
      selectedItem: initial,
420
      items: choices,
×
421
      dropdownDecoratorProps: DropDownDecoratorProps(
×
422
        dropdownSearchDecoration: InputDecoration(
×
423
          labelText: label,
×
424
          hintText: helpText,
×
425
        ),
426
      ),
427
      onChanged: null,
428
      clearButtonProps: ClearButtonProps(isVisible: !required),
×
429
      itemAsString: (dynamic item) {
×
430
        return (item["display_name"] ?? "") as String;
×
431
      },
432
      onSaved: (item) {
×
433
        if (item == null) {
434
          data["value"] = null;
×
435
        } else {
436
          data["value"] = item["value"];
×
437
        }
438
      },
439
    );
440
  }
441

442
  // Construct a floating point numerical input field
443
  Widget _constructFloatField() {
×
444
    // Initial value: try to cast to a valid number
445
    String initial = "";
446

447
    double? initialNumber = double.tryParse(value.toString());
×
448

449
    if (initialNumber != null) {
450
      initial = simpleNumberString(initialNumber);
×
451
    }
452

453
    return TextFormField(
×
454
      decoration: InputDecoration(
×
455
        labelText: required ? label + "*" : label,
×
456
        labelStyle: _labelStyle(),
×
457
        helperText: helpText,
×
458
        helperStyle: _helperStyle(),
×
459
        hintText: placeholderText,
×
460
      ),
461
      initialValue: initial,
462
      keyboardType: TextInputType.numberWithOptions(
×
463
        signed: true,
464
        decimal: true,
465
      ),
466
      validator: (value) {
×
467
        value = value?.trim() ?? "";
×
468

469
        // Allow empty numbers, *if* this field is not required
470
        if (value.isEmpty && !required) {
×
471
          return null;
472
        }
473

474
        double? quantity = double.tryParse(value.toString());
×
475

476
        if (quantity == null) {
477
          return L10().numberInvalid;
×
478
        }
479

480
        return null;
481
      },
482
      onSaved: (val) {
×
483
        data["value"] = val;
×
484
      },
485
    );
486
  }
487

488
  // Construct an input for a related field
489
  Widget _constructRelatedField() {
×
490
    return DropdownSearch<dynamic>(
×
491
      popupProps: PopupProps.bottomSheet(
×
492
        showSelectedItems: true,
493
        isFilterOnline: true,
494
        showSearchBox: true,
495
        itemBuilder: (context, item, isSelected) {
×
496
          return _renderRelatedField(name, item, isSelected, true);
×
497
        },
498
        emptyBuilder: (context, item) {
×
499
          return _renderEmptyResult();
×
500
        },
501
        searchFieldProps: TextFieldProps(autofocus: true),
×
502
      ),
503
      selectedItem: initial_data,
×
504
      asyncItems: (String filter) async {
×
505
        Map<String, String> _filters = {..._relatedFieldFilters(), ...filters};
×
506

507
        _filters["search"] = filter;
×
508
        _filters["offset"] = "0";
×
509
        _filters["limit"] = "25";
×
510

511
        final APIResponse response = await InvenTreeAPI().get(
×
512
          api_url,
×
513
          params: _filters,
514
        );
515

516
        if (response.isValid()) {
×
517
          return response.resultsList();
×
518
        } else {
519
          return [];
×
520
        }
521
      },
522
      clearButtonProps: ClearButtonProps(isVisible: !required),
×
523
      dropdownDecoratorProps: DropDownDecoratorProps(
×
524
        dropdownSearchDecoration: InputDecoration(
×
525
          labelText: label,
×
526
          hintText: helpText,
×
527
        ),
528
      ),
529
      onChanged: null,
530
      itemAsString: (dynamic item) {
×
531
        Map<String, dynamic> data = item as Map<String, dynamic>;
532

533
        switch (model) {
×
534
          case InvenTreePart.MODEL_TYPE:
×
535
            return InvenTreePart.fromJson(data).fullname;
×
536
          case InvenTreeCompany.MODEL_TYPE:
×
537
            return InvenTreeCompany.fromJson(data).name;
×
538
          case InvenTreePurchaseOrder.MODEL_TYPE:
×
539
            return InvenTreePurchaseOrder.fromJson(data).reference;
×
540
          case InvenTreeSalesOrder.MODEL_TYPE:
×
541
            return InvenTreeSalesOrder.fromJson(data).reference;
×
542
          case InvenTreePartCategory.MODEL_TYPE:
×
543
            return InvenTreePartCategory.fromJson(data).pathstring;
×
544
          case InvenTreeStockLocation.MODEL_TYPE:
×
545
            return InvenTreeStockLocation.fromJson(data).pathstring;
×
546
          default:
547
            return "itemAsString not implemented for '${model}'";
×
548
        }
549
      },
550
      dropdownBuilder: (context, item) {
×
551
        return _renderRelatedField(name, item, true, false);
×
552
      },
553
      onSaved: (item) {
×
554
        if (item != null) {
555
          data["value"] = item["pk"];
×
556
        } else {
557
          data["value"] = null;
×
558
        }
559
      },
560
      compareFn: (dynamic item, dynamic selectedItem) {
×
561
        // Comparison is based on the PK value
562

563
        if (item == null || selectedItem == null) {
564
          return false;
565
        }
566

567
        bool result = false;
568

569
        try {
570
          result = item["pk"].toString() == selectedItem["pk"].toString();
×
571
        } catch (error) {
572
          // Catch any conversion errors
573
          result = false;
574
        }
575

576
        return result;
577
      },
578
    );
579
  }
580

581
  // Construct a set of custom filters for the dropdown search
582
  Map<String, String> _relatedFieldFilters() {
×
583
    switch (model) {
×
584
      case InvenTreeSupplierPart.MODEL_TYPE:
×
585
        return InvenTreeSupplierPart().defaultListFilters();
×
586
      case InvenTreeStockItem.MODEL_TYPE:
×
587
        return InvenTreeStockItem().defaultListFilters();
×
588
      default:
589
        break;
590
    }
591

592
    return {};
×
593
  }
594

595
  // Render a "related field" based on the "model" type
596
  Widget _renderRelatedField(
×
597
    String fieldName,
598
    dynamic item,
599
    bool selected,
600
    bool extended,
601
  ) {
602
    // Convert to JSON
603
    Map<String, dynamic> data = {};
×
604

605
    try {
606
      if (item is Map<String, dynamic>) {
×
607
        data = Map<String, dynamic>.from(item);
×
608
      } else {
609
        data = {};
×
610
      }
611
    } catch (error, stackTrace) {
612
      data = {};
×
613

614
      sentryReportError(
×
615
        "_renderRelatedField",
616
        error,
617
        stackTrace,
618
        context: {
×
619
          "method": "_renderRelateField",
620
          "field_name": fieldName,
621
          "item": item.toString(),
×
622
          "selected": selected.toString(),
×
623
          "extended": extended.toString(),
×
624
        },
625
      );
626
    }
627

628
    switch (model) {
×
629
      case InvenTreePart.MODEL_TYPE:
×
630
        var part = InvenTreePart.fromJson(data);
×
631

632
        return ListTile(
×
633
          title: Text(
×
634
            part.fullname,
×
635
            style: TextStyle(
×
636
              fontWeight: selected && extended
637
                  ? FontWeight.bold
638
                  : FontWeight.normal,
639
            ),
640
          ),
641
          subtitle: extended
642
              ? Text(
×
643
                  part.description,
×
644
                  style: TextStyle(
×
645
                    fontWeight: selected ? FontWeight.bold : FontWeight.normal,
646
                  ),
647
                )
648
              : null,
649
          leading: extended
650
              ? InvenTreeAPI().getThumbnail(part.thumbnail)
×
651
              : null,
652
        );
653
      case InvenTreePartTestTemplate.MODEL_TYPE:
×
654
        var template = InvenTreePartTestTemplate.fromJson(data);
×
655

656
        return ListTile(
×
657
          title: Text(template.testName),
×
658
          subtitle: Text(template.description),
×
659
        );
660
      case InvenTreeSupplierPart.MODEL_TYPE:
×
661
        var part = InvenTreeSupplierPart.fromJson(data);
×
662

663
        return ListTile(
×
664
          title: Text(part.SKU),
×
665
          subtitle: Text(part.partName),
×
666
          leading: extended
667
              ? InvenTreeAPI().getThumbnail(part.partImage)
×
668
              : null,
669
          trailing: extended && part.supplierImage.isNotEmpty
×
670
              ? InvenTreeAPI().getThumbnail(part.supplierImage)
×
671
              : null,
672
        );
673
      case InvenTreePartCategory.MODEL_TYPE:
×
674
        var cat = InvenTreePartCategory.fromJson(data);
×
675

676
        return ListTile(
×
677
          title: Text(
×
678
            cat.pathstring,
×
679
            style: TextStyle(
×
680
              fontWeight: selected && extended
681
                  ? FontWeight.bold
682
                  : FontWeight.normal,
683
            ),
684
          ),
685
          subtitle: extended
686
              ? Text(
×
687
                  cat.description,
×
688
                  style: TextStyle(
×
689
                    fontWeight: selected ? FontWeight.bold : FontWeight.normal,
690
                  ),
691
                )
692
              : null,
693
        );
694
      case InvenTreeStockItem.MODEL_TYPE:
×
695
        var item = InvenTreeStockItem.fromJson(data);
×
696

697
        return ListTile(
×
698
          title: Text(item.partName),
×
699
          leading: InvenTreeAPI().getThumbnail(item.partThumbnail),
×
700
          trailing: Text(item.quantityString()),
×
701
        );
702
      case InvenTreeStockLocation.MODEL_TYPE:
×
703
        var loc = InvenTreeStockLocation.fromJson(data);
×
704

705
        return ListTile(
×
706
          title: Text(
×
707
            loc.pathstring,
×
708
            style: TextStyle(
×
709
              fontWeight: selected && extended
710
                  ? FontWeight.bold
711
                  : FontWeight.normal,
712
            ),
713
          ),
714
          subtitle: extended
715
              ? Text(
×
716
                  loc.description,
×
717
                  style: TextStyle(
×
718
                    fontWeight: selected ? FontWeight.bold : FontWeight.normal,
719
                  ),
720
                )
721
              : null,
722
        );
NEW
723
      case InvenTreeBuildOrder.MODEL_TYPE:
×
NEW
724
        var order = InvenTreeBuildOrder.fromJson(data);
×
725

NEW
726
        return ListTile(
×
NEW
727
          title: Text(order.reference),
×
NEW
728
          subtitle: Text(order.description),
×
729
        );
730

NEW
731
      case InvenTreePurchaseOrder.MODEL_TYPE:
×
NEW
732
        var order = InvenTreePurchaseOrder.fromJson(data);
×
733

NEW
734
        return ListTile(
×
NEW
735
          title: Text(order.reference),
×
NEW
736
          subtitle: Text(order.description),
×
NEW
737
          trailing: Text(order.supplier?.name ?? ""),
×
738
        );
NEW
739
      case InvenTreeSalesOrder.MODEL_TYPE:
×
NEW
740
        var order = InvenTreeSalesOrder.fromJson(data);
×
741

NEW
742
        return ListTile(
×
NEW
743
          title: Text(order.reference),
×
NEW
744
          subtitle: Text(order.description),
×
NEW
745
          trailing: Text(order.customer?.name ?? ""),
×
746
        );
747
      case InvenTreeSalesOrderShipment.MODEL_TYPE:
×
748
        var shipment = InvenTreeSalesOrderShipment.fromJson(data);
×
749

750
        return ListTile(
×
751
          title: Text(shipment.reference),
×
752
          subtitle: Text(shipment.tracking_number),
×
753
          trailing: shipment.shipped ? Text(shipment.shipment_date!) : null,
×
754
        );
755
      case "owner":
×
756
        String name = (data["name"] ?? "") as String;
×
757
        bool isGroup = (data["label"] ?? "") == "group";
×
758
        return ListTile(
×
759
          title: Text(name),
×
760
          leading: Icon(isGroup ? TablerIcons.users : TablerIcons.user),
×
761
        );
762
      case "contact":
×
763
        String name = (data["name"] ?? "") as String;
×
764
        String role = (data["role"] ?? "") as String;
×
765
        return ListTile(title: Text(name), subtitle: Text(role));
×
766
      case InvenTreeCompany.MODEL_TYPE:
×
767
        var company = InvenTreeCompany.fromJson(data);
×
768
        return ListTile(
×
769
          title: Text(company.name),
×
770
          subtitle: extended ? Text(company.description) : null,
×
771
          leading: InvenTreeAPI().getThumbnail(company.thumbnail),
×
772
        );
773
      case InvenTreeProjectCode.MODEL_TYPE:
×
774
        var project_code = InvenTreeProjectCode.fromJson(data);
×
775
        return ListTile(
×
776
          title: Text(project_code.code),
×
777
          subtitle: Text(project_code.description),
×
778
          leading: Icon(TablerIcons.list),
×
779
        );
780
      default:
781
        return ListTile(
×
782
          title: Text(
×
783
            "Unsupported model",
784
            style: TextStyle(fontWeight: FontWeight.bold, color: COLOR_DANGER),
×
785
          ),
786
          subtitle: Text("Model '${model}' rendering not supported"),
×
787
        );
788
    }
789
  }
790

791
  // Construct a widget to instruct the user that no results were found
792
  Widget _renderEmptyResult() {
×
793
    return ListTile(
×
794
      leading: Icon(TablerIcons.search),
×
795
      title: Text(L10().noResults),
×
796
      subtitle: Text(
×
797
        L10().queryNoResults,
×
798
        style: TextStyle(fontStyle: FontStyle.italic),
×
799
      ),
800
    );
801
  }
802

803
  // Construct a string input element
804
  Widget _constructString() {
×
805
    if (readOnly) {
×
806
      return ListTile(
×
807
        title: Text(label),
×
808
        subtitle: Text(helpText),
×
809
        trailing: Text(value.toString()),
×
810
      );
811
    }
812

813
    return TextFormField(
×
814
      decoration: InputDecoration(
×
815
        labelText: required ? label + "*" : label,
×
816
        labelStyle: _labelStyle(),
×
817
        helperText: helpText,
×
818
        helperStyle: _helperStyle(),
×
819
        hintText: placeholderText,
×
820
      ),
821
      readOnly: readOnly,
×
822
      maxLines: multiline ? null : 1,
×
823
      expands: false,
824
      initialValue: (value ?? "") as String,
×
825
      onSaved: (val) {
×
826
        data["value"] = val;
×
827
      },
828
      validator: (value) {
×
829
        if (required && (value == null || value.isEmpty)) {
×
830
          // return L10().valueCannotBeEmpty;
831
        }
832

833
        return null;
834
      },
835
    );
836
  }
837

838
  // Construct a boolean input element
839
  Widget _constructBoolean() {
×
840
    bool? initial_value;
841

842
    if (value is bool || value == null) {
×
843
      initial_value = value as bool?;
×
844
    } else {
845
      String vs = value.toString().toLowerCase();
×
846
      initial_value = ["1", "true", "yes"].contains(vs);
×
847
    }
848

849
    return CheckBoxField(
×
850
      label: label,
×
851
      labelStyle: _labelStyle(),
×
852
      helperText: helpText,
×
853
      helperStyle: _helperStyle(),
×
854
      initial: initial_value,
855
      tristate: (getParameter("tristate") ?? false) as bool,
×
856
      onSaved: (val) {
×
857
        data["value"] = val;
×
858
      },
859
    );
860
  }
861

862
  TextStyle _labelStyle() {
×
863
    return TextStyle(
×
864
      fontWeight: FontWeight.bold,
865
      fontSize: 18,
866
      fontFamily: "arial",
867
      color: hasErrors() ? COLOR_DANGER : null,
×
868
      fontStyle: FontStyle.normal,
869
    );
870
  }
871

872
  TextStyle _helperStyle() {
×
873
    return TextStyle(
×
874
      fontStyle: FontStyle.italic,
875
      color: hasErrors() ? COLOR_DANGER : null,
×
876
    );
877
  }
878
}
879

880
/*
881
 * Extract field options from a returned OPTIONS request
882
 */
883
Map<String, dynamic> extractFields(APIResponse response) {
×
884
  if (!response.isValid()) {
×
885
    return {};
×
886
  }
887

888
  var data = response.asMap();
×
889

890
  if (!data.containsKey("actions")) {
×
891
    return {};
×
892
  }
893

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

896
  dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
×
897

898
  return result as Map<String, dynamic>;
899
}
900

901
/*
902
 * Extract a field definition (map) from the provided JSON data.
903
 *
904
 * Notes:
905
 * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"),
906
 * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity"
907
 *
908
 * The map "tree" is traversed based on the provided lookup string, which can use dotted notation.
909
 * This allows complex paths to be used to lookup field information.
910
 */
911
Map<String, dynamic> extractFieldDefinition(
×
912
  Map<String, dynamic> data,
913
  String lookup,
914
) {
915
  List<String> path = lookup.split(".");
×
916

917
  // Shadow copy the data for path traversal
918
  Map<String, dynamic> _data = data;
919

920
  // Iterate through all but the last element of the path
921
  for (int ii = 0; ii < (path.length - 1); ii++) {
×
922
    String el = path[ii];
×
923

924
    if (!_data.containsKey(el)) {
×
925
      print("Could not find field definition for ${lookup}:");
×
926
      print("- Key ${el} missing at index ${ii}");
×
927
      return {};
×
928
    }
929

930
    try {
931
      _data = _data[el] as Map<String, dynamic>;
×
932
    } catch (error, stackTrace) {
933
      print("Could not find sub-field element '${el}' for ${lookup}:");
×
934
      print(error.toString());
×
935

936
      // Report the error
937
      sentryReportError(
×
938
        "apiForm.extractFieldDefinition : path traversal",
939
        error,
940
        stackTrace,
941
        context: {"path": path.toString(), "el": el},
×
942
      );
943
      return {};
×
944
    }
945
  }
946

947
  String el = path.last;
×
948

949
  if (!_data.containsKey(el)) {
×
950
    return {};
×
951
  } else {
952
    try {
953
      Map<String, dynamic> definition = _data[el] as Map<String, dynamic>;
×
954

955
      return definition;
956
    } catch (error, stacktrace) {
957
      print("Could not find field definition for ${lookup}");
×
958
      print(error.toString());
×
959

960
      // Report the error
961
      sentryReportError(
×
962
        "apiForm.extractFieldDefinition : as map",
963
        error,
964
        stacktrace,
965
        context: {"el": el.toString()},
×
966
      );
967

968
      return {};
×
969
    }
970
  }
971
}
972

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

985
Future<void> launchApiForm(
×
986
  BuildContext context,
987
  String title,
988
  String url,
989
  Map<String, dynamic> fields, {
990
  String fileField = "",
991
  Map<String, dynamic> modelData = const {},
992
  String method = "PATCH",
993
  Function(Map<String, dynamic>)? onSuccess,
994
  bool Function(Map<String, dynamic>)? validate,
995
  Function? onCancel,
996
  IconData icon = TablerIcons.device_floppy,
997
}) async {
998
  showLoadingOverlay();
×
999

1000
  // List of fields defined by the server
1001
  Map<String, dynamic> serverFields = {};
×
1002

1003
  if (url.isNotEmpty) {
×
1004
    var options = await InvenTreeAPI().options(url);
×
1005

1006
    // Invalid response from server
1007
    if (!options.isValid()) {
×
1008
      hideLoadingOverlay();
×
1009
      return;
1010
    }
1011

1012
    serverFields = extractFields(options);
×
1013

1014
    if (serverFields.isEmpty) {
×
1015
      // User does not have permission to perform this action
1016
      showSnackIcon(L10().response403, icon: TablerIcons.user_x);
×
1017

1018
      hideLoadingOverlay();
×
1019
      return;
1020
    }
1021
  }
1022

1023
  // Construct a list of APIFormField objects
1024
  List<APIFormField> formFields = [];
×
1025

1026
  APIFormField field;
1027

1028
  for (String fieldName in fields.keys) {
×
1029
    dynamic data = fields[fieldName];
×
1030

1031
    Map<String, dynamic> fieldData = {};
×
1032

1033
    if (data is Map) {
×
1034
      fieldData = Map<String, dynamic>.from(data);
×
1035
    }
1036

1037
    // Iterate through the provided fields we wish to display
1038

1039
    field = APIFormField(fieldName, fieldData);
×
1040

1041
    // Extract the definition of this field from the data received from the server
1042
    field.definition = extractFieldDefinition(serverFields, field.lookupPath);
×
1043

1044
    // Skip fields with empty definitions
1045
    if (url.isNotEmpty && field.definition.isEmpty) {
×
1046
      print("Warning: Empty field definition for field '${fieldName}'");
×
1047
    }
1048

1049
    // Add instance value to the field
1050
    dynamic model_value = modelData[fieldName];
×
1051

1052
    if (model_value != null) {
1053
      field.data["instance_value"] = model_value;
×
1054

1055
      if (field.data["value"] == null) {
×
1056
        field.data["value"] = model_value;
×
1057
      }
1058
    }
1059
    formFields.add(field);
×
1060
  }
1061

1062
  // Grab existing data for each form field
1063
  for (var field in formFields) {
×
1064
    await field.loadInitialData();
×
1065
  }
1066

1067
  hideLoadingOverlay();
×
1068

1069
  // Now, launch a new widget!
1070
  Navigator.push(
×
1071
    context,
1072
    MaterialPageRoute(
×
1073
      builder: (context) => APIFormWidget(
×
1074
        title,
1075
        url,
1076
        formFields,
1077
        method,
1078
        onSuccess: onSuccess,
1079
        validate: validate,
1080
        fileField: fileField,
1081
        icon: icon,
1082
      ),
1083
    ),
1084
  );
1085
}
1086

1087
class APIFormWidget extends StatefulWidget {
1088
  const APIFormWidget(
×
1089
    this.title,
1090
    this.url,
1091
    this.fields,
1092
    this.method, {
1093
    Key? key,
1094
    this.onSuccess,
1095
    this.validate,
1096
    this.fileField = "",
1097
    this.icon = TablerIcons.device_floppy,
1098
  }) : super(key: key);
×
1099

1100
  //! Form title to display
1101
  final String title;
1102

1103
  //! API URL
1104
  final String url;
1105

1106
  //! API method
1107
  final String method;
1108

1109
  final String fileField;
1110

1111
  // Icon
1112
  final IconData icon;
1113

1114
  final List<APIFormField> fields;
1115

1116
  final Function(Map<String, dynamic>)? onSuccess;
1117

1118
  final bool Function(Map<String, dynamic>)? validate;
1119

1120
  @override
×
1121
  _APIFormWidgetState createState() => _APIFormWidgetState();
×
1122
}
1123

1124
class _APIFormWidgetState extends State<APIFormWidget> {
1125
  _APIFormWidgetState() : super();
×
1126

1127
  final _formKey = GlobalKey<FormState>();
1128

1129
  List<String> nonFieldErrors = [];
1130

1131
  bool spacerRequired = false;
1132

1133
  List<Widget> _buildForm() {
×
1134
    List<Widget> widgets = [];
×
1135

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

1147
      widgets.add(Divider(height: 5));
×
1148
    }
1149

1150
    for (var field in widget.fields) {
×
1151
      if (field.hidden) {
×
1152
        continue;
1153
      }
1154

1155
      // Add divider before some widgets
1156
      if (spacerRequired) {
×
1157
        switch (field.type) {
×
1158
          case "related field":
×
1159
          case "choice":
×
1160
            widgets.add(Divider(height: 15));
×
1161
          default:
1162
            break;
1163
        }
1164
      }
1165

1166
      widgets.add(field.constructField(context));
×
1167

1168
      if (field.hasErrors()) {
×
1169
        for (String error in field.errorMessages()) {
×
1170
          widgets.add(
×
1171
            ListTile(
×
1172
              title: Text(
×
1173
                error,
1174
                style: TextStyle(
×
1175
                  color: COLOR_DANGER,
1176
                  fontStyle: FontStyle.italic,
1177
                  fontSize: 16,
1178
                ),
1179
              ),
1180
            ),
1181
          );
1182
        }
1183
      }
1184

1185
      // Add divider after some widgets
1186
      switch (field.type) {
×
1187
        case "related field":
×
1188
        case "choice":
×
1189
          widgets.add(Divider(height: 15));
×
1190
          spacerRequired = false;
×
1191
        default:
1192
          spacerRequired = true;
×
1193
      }
1194
    }
1195

1196
    return widgets;
1197
  }
1198

1199
  Future<APIResponse> _submit(Map<String, dynamic> data) async {
×
1200
    // If a file upload is required, we have to handle the submission differently
1201
    if (widget.fileField.isNotEmpty) {
×
1202
      // Pop the "file" field
1203
      data.remove(widget.fileField);
×
1204

1205
      for (var field in widget.fields) {
×
1206
        if (field.name == widget.fileField) {
×
1207
          File? file = field.attachedfile;
×
1208

1209
          if (file != null) {
1210
            // A valid file has been supplied
1211
            final response = await InvenTreeAPI().uploadFile(
×
1212
              widget.url,
×
1213
              file,
1214
              name: widget.fileField,
×
1215
              fields: data,
1216
            );
1217

1218
            return response;
1219
          }
1220
        }
1221
      }
1222
    }
1223

1224
    if (widget.method == "POST") {
×
1225
      showLoadingOverlay();
×
1226
      final response = await InvenTreeAPI().post(
×
1227
        widget.url,
×
1228
        body: data,
1229
        expectedStatusCode: null,
1230
      );
1231
      hideLoadingOverlay();
×
1232

1233
      return response;
1234
    } else {
1235
      showLoadingOverlay();
×
1236
      final response = await InvenTreeAPI().patch(
×
1237
        widget.url,
×
1238
        body: data,
1239
        expectedStatusCode: null,
1240
      );
1241
      hideLoadingOverlay();
×
1242

1243
      return response;
1244
    }
1245
  }
1246

1247
  void extractNonFieldErrors(APIResponse response) {
×
1248
    List<String> errors = [];
×
1249

1250
    Map<String, dynamic> data = response.asMap();
×
1251

1252
    // Potential keys representing non-field errors
1253
    List<String> keys = ["__all__", "non_field_errors", "errors"];
×
1254

1255
    for (String key in keys) {
×
1256
      if (data.containsKey(key)) {
×
1257
        dynamic result = data[key];
×
1258

1259
        if (result is String) {
×
1260
          errors.add(result);
×
1261
        } else if (result is List) {
×
1262
          for (dynamic element in result) {
×
1263
            errors.add(element.toString());
×
1264
          }
1265
        }
1266
      }
1267
    }
1268

1269
    nonFieldErrors = errors;
×
1270
  }
1271

1272
  /* Check for errors relating to an *unhandled* field name
1273
  * These errors will not be displayed and potentially confuse the user
1274
  * So, we need to know if these are ever happening
1275
  */
1276
  void checkInvalidErrors(APIResponse response) {
×
1277
    var errors = response.asMap();
×
1278

1279
    for (String fieldName in errors.keys) {
×
1280
      bool match = false;
1281

1282
      switch (fieldName) {
1283
        case "__all__":
×
1284
        case "non_field_errors":
×
1285
        case "errors":
×
1286
          // ignore these global fields
1287
          match = true;
1288
          continue;
1289
        default:
1290
          for (var field in widget.fields) {
×
1291
            // Hidden fields can't display errors, so we won't match
1292
            if (field.hidden) {
×
1293
              continue;
1294
            }
1295

1296
            if (field.name == fieldName) {
×
1297
              // Direct Match found!
1298
              match = true;
1299
              break;
1300
            } else if (field.parent == fieldName) {
×
1301
              var error = errors[fieldName];
×
1302

1303
              if (error is List) {
×
1304
                for (var el in error) {
×
1305
                  if (el is Map && el.containsKey(field.name)) {
×
1306
                    match = true;
1307
                    break;
1308
                  }
1309
                }
1310
              } else if (error is Map && error.containsKey(field.name)) {
×
1311
                match = true;
1312
                break;
1313
              }
1314
            }
1315
          }
1316
      }
1317

1318
      if (!match) {
1319
        // Match for an unknown / unsupported field
1320
        sentryReportMessage(
×
1321
          "API form returned error for unsupported field",
1322
          context: {
×
1323
            "url": response.url,
×
1324
            "status_code": response.statusCode.toString(),
×
1325
            "field": fieldName,
1326
            "error_message": response.data.toString(),
×
1327
          },
1328
        );
1329
      }
1330
    }
1331
  }
1332

1333
  /*
1334
   * Submit the form data to the server, and handle the results
1335
   */
1336
  Future<void> _save(BuildContext context) async {
×
1337
    // Package up the form data
1338
    Map<String, dynamic> data = {};
×
1339

1340
    // Iterate through and find "simple" top-level fields
1341

1342
    for (var field in widget.fields) {
×
1343
      if (field.readOnly) {
×
1344
        continue;
1345
      }
1346

1347
      if (field.isSimple) {
×
1348
        // Simple top-level field data
1349
        data[field.name] = field.data["value"];
×
1350
      } else {
1351
        // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?)
1352
        if (field.parent.isNotEmpty) {
×
1353
          // TODO: This is a dirty hack, there *must* be a cleaner way?!
1354

1355
          dynamic parent = data[field.parent] ?? {};
×
1356

1357
          // In the case of a "nested" object, we need to extract the first item
1358
          if (parent is List) {
×
1359
            parent = parent.first;
×
1360
          }
1361

1362
          parent[field.name] = field.data["value"];
×
1363

1364
          // Nested fields must be handled as an array!
1365
          // For now, we only allow single length nested fields
1366
          if (field.nested) {
×
1367
            parent = [parent];
×
1368
          }
1369

1370
          data[field.parent] = parent;
×
1371
        }
1372
      }
1373
    }
1374

1375
    final bool isValid = widget.validate?.call(data) ?? true;
×
1376

1377
    if (!isValid) {
1378
      return;
1379
    }
1380

1381
    // Run custom onSuccess function
1382
    var successFunc = widget.onSuccess;
×
1383

1384
    // An "empty" URL means we don't want to submit the form anywhere
1385
    // Perhaps we just want to process the data?
1386
    if (widget.url.isEmpty) {
×
1387
      // Hide the form
1388
      Navigator.pop(context);
×
1389

1390
      if (successFunc != null) {
1391
        // Return the raw "submitted" data, rather than the server response
1392
        successFunc(data);
×
1393
      }
1394

1395
      return;
1396
    }
1397

1398
    final response = await _submit(data);
×
1399

1400
    if (!response.isValid()) {
×
1401
      showServerError(widget.url, L10().serverError, L10().responseInvalid);
×
1402
      return;
1403
    }
1404

1405
    switch (response.statusCode) {
×
1406
      case 200:
×
1407
      case 201:
×
1408
        // Form was successfully validated by the server
1409

1410
        // Hide this form
1411
        Navigator.pop(context);
×
1412

1413
        if (successFunc != null) {
1414
          // Ensure the response is a valid JSON structure
1415
          Map<String, dynamic> json = {};
×
1416

1417
          var data = response.asMap();
×
1418

1419
          for (String key in data.keys) {
×
1420
            json[key.toString()] = data[key];
×
1421
          }
1422

1423
          successFunc(json);
×
1424
        }
1425
        return;
1426
      case 400:
×
1427
        // Form submission / validation error
1428
        showSnackIcon(L10().formError, success: false);
×
1429

1430
        // Update field errors
1431
        for (var field in widget.fields) {
×
1432
          field.extractErrorMessages(response);
×
1433
        }
1434

1435
        extractNonFieldErrors(response);
×
1436
        checkInvalidErrors(response);
×
1437
      case 401:
×
1438
        showSnackIcon("401: " + L10().response401, success: false);
×
1439
      case 403:
×
1440
        showSnackIcon("403: " + L10().response403, success: false);
×
1441
      case 404:
×
1442
        showSnackIcon("404: " + L10().response404, success: false);
×
1443
      case 405:
×
1444
        showSnackIcon("405: " + L10().response405, success: false);
×
1445
      case 500:
×
1446
        showSnackIcon("500: " + L10().response500, success: false);
×
1447
      default:
1448
        showSnackIcon(
×
1449
          "${response.statusCode}: " + L10().responseInvalid,
×
1450
          success: false,
1451
        );
1452
    }
1453

1454
    setState(() {
×
1455
      // Refresh the form
1456
    });
1457
  }
1458

1459
  @override
×
1460
  Widget build(BuildContext context) {
1461
    return Scaffold(
×
1462
      appBar: AppBar(
×
1463
        title: Text(widget.title),
×
1464
        backgroundColor: COLOR_APP_BAR,
1465
        actions: [
×
1466
          IconButton(
×
1467
            icon: Icon(widget.icon),
×
1468
            onPressed: () {
×
1469
              if (_formKey.currentState!.validate()) {
×
1470
                _formKey.currentState!.save();
×
1471

1472
                _save(context);
×
1473
              }
1474
            },
1475
          ),
1476
        ],
1477
      ),
1478
      body: Form(
×
1479
        key: _formKey,
×
1480
        child: SingleChildScrollView(
×
1481
          child: Column(
×
1482
            mainAxisAlignment: MainAxisAlignment.start,
1483
            mainAxisSize: MainAxisSize.min,
1484
            crossAxisAlignment: CrossAxisAlignment.start,
1485
            children: _buildForm(),
×
1486
          ),
1487
          padding: EdgeInsets.all(16),
×
1488
        ),
1489
      ),
1490
    );
1491
  }
1492
}
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