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

inventree / inventree-app / 19571043730

21 Nov 2025 12:50PM UTC coverage: 1.419% (-0.05%) from 1.469%
19571043730

Pull #673

github

web-flow
Merge 4377499a4 into e41842a31
Pull Request #673: Build Order

0 of 849 new or added lines in 15 files covered. (0.0%)

30794 existing lines in 37 files now uncovered.

768 of 54115 relevant lines covered (1.42%)

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
      case InvenTreeSalesOrder.MODEL_TYPE:
×
589
        return InvenTreeSalesOrder().defaultListFilters();
×
590
      default:
591
        break;
592
    }
593

594
    return {};
×
595
  }
596

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

835
        return null;
836
      },
837
    );
838
  }
839

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

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

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

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

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

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

890
  var data = response.asMap();
×
891

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

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

898
  dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
×
899

900
  return result as Map<String, dynamic>;
901
}
902

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

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

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

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

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

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

949
  String el = path.last;
×
950

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

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

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

970
      return {};
×
971
    }
972
  }
973
}
974

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

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

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

1005
  if (url.isNotEmpty) {
×
1006
    var options = await InvenTreeAPI().options(url);
×
1007

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

1014
    serverFields = extractFields(options);
×
1015

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

1020
      hideLoadingOverlay();
×
1021
      return;
1022
    }
1023
  }
1024

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

1028
  APIFormField field;
1029

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

1033
    Map<String, dynamic> fieldData = {};
×
1034

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

1039
    // Iterate through the provided fields we wish to display
1040

1041
    field = APIFormField(fieldName, fieldData);
×
1042

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

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

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

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

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

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

1069
  hideLoadingOverlay();
×
1070

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

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

1102
  //! Form title to display
1103
  final String title;
1104

1105
  //! API URL
1106
  final String url;
1107

1108
  //! API method
1109
  final String method;
1110

1111
  final String fileField;
1112

1113
  // Icon
1114
  final IconData icon;
1115

1116
  final List<APIFormField> fields;
1117

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

1120
  final bool Function(Map<String, dynamic>)? validate;
1121

1122
  @override
×
1123
  _APIFormWidgetState createState() => _APIFormWidgetState();
×
1124
}
1125

1126
class _APIFormWidgetState extends State<APIFormWidget> {
1127
  _APIFormWidgetState() : super();
×
1128

1129
  final _formKey = GlobalKey<FormState>();
1130

1131
  List<String> nonFieldErrors = [];
1132

1133
  bool spacerRequired = false;
1134

1135
  List<Widget> _buildForm() {
×
1136
    List<Widget> widgets = [];
×
1137

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

1149
      widgets.add(Divider(height: 5));
×
1150
    }
1151

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

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

1168
      widgets.add(field.constructField(context));
×
1169

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

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

1198
    return widgets;
1199
  }
1200

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

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

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

1220
            return response;
1221
          }
1222
        }
1223
      }
1224
    }
1225

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

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

1245
      return response;
1246
    }
1247
  }
1248

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

1252
    Map<String, dynamic> data = response.asMap();
×
1253

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

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

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

1271
    nonFieldErrors = errors;
×
1272
  }
1273

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

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

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

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

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

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

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

1342
    // Iterate through and find "simple" top-level fields
1343

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

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

1357
          dynamic parent = data[field.parent] ?? {};
×
1358

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

1364
          parent[field.name] = field.data["value"];
×
1365

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

1372
          data[field.parent] = parent;
×
1373
        }
1374
      }
1375
    }
1376

1377
    final bool isValid = widget.validate?.call(data) ?? true;
×
1378

1379
    if (!isValid) {
1380
      return;
1381
    }
1382

1383
    // Run custom onSuccess function
1384
    var successFunc = widget.onSuccess;
×
1385

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

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

1397
      return;
1398
    }
1399

1400
    final response = await _submit(data);
×
1401

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

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

1412
        // Hide this form
1413
        Navigator.pop(context);
×
1414

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

1419
          var data = response.asMap();
×
1420

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

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

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

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

1456
    setState(() {
×
1457
      // Refresh the form
1458
    });
1459
  }
1460

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

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