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

inventree / inventree-app / 10299786653

08 Aug 2024 09:44AM UTC coverage: 9.204% (+0.01%) from 9.192%
10299786653

push

github

web-flow
Change from fontawesome to tabler icons (#516)

* Change from fontawesome to tabler icons

- Consistent with the frontend

* Cleanup conflicts

* Use double quotes

* remove unused import

* Update release notes

* Migrate some google icons to tabler icons

* Icon update

* Properly support display of custom icons

* Fix lookup

1 of 254 new or added lines in 43 files covered. (0.39%)

4 existing lines in 4 files now uncovered.

754 of 8192 relevant lines covered (9.2%)

0.31 hits per line

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

0.0
/lib/api_form.dart
1

2
import "dart:io";
3

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

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

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

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

28

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

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

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

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

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

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

50
  dynamic initial_data;
51

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

165
    }
166

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

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

180
    return _filters;
181

182
  }
183

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

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

189
    dynamic errors;
190

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

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

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

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

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

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

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

222
    errors = errors as List<dynamic>;
223

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

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

230
    return messages;
231
  }
232

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

314
    TextEditingController controller = TextEditingController();
×
315

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

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

322
    controller.text = barcode;
×
323

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

344
              barcodeSuccess(L10().barcodeAssigned);
×
345
            });
346

347
            scanBarcode(context, handler: handler);
×
348
          },
349
        ),
350
      )
351
    );
352

353
  }
354

355
  // Field for displaying and selecting dates
356
  Widget _constructDateField() {
×
357

358
    DateTime? currentDate = DateTime.tryParse((value ?? "")as String);
×
359

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

386
          return time;
387
        },
388
      )
389
    );
390

391
  }
392

393

394
  // Field for selecting and uploading files
395
  Widget _constructFileField() {
×
396

397
    TextEditingController controller = TextEditingController();
×
398

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

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

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

430
  // Field for selecting from multiple choice options
431
  Widget _constructChoiceField() {
×
432

433
    dynamic initial;
434

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

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

471
  // Construct a floating point numerical input field
472
  Widget _constructFloatField() {
×
473

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

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

488
        double? quantity = double.tryParse(value.toString());
×
489

490
        if (quantity == null) {
491
          return L10().numberInvalid;
×
492
        }
493

494
        return null;
495
      },
496
      onSaved: (val) {
×
497
        data["value"] = val;
×
498
      },
499
    );
500

501
  }
502

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

527
        _filters["search"] = filter;
×
528
        _filters["offset"] = "0";
×
529
        _filters["limit"] = "25";
×
530

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

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

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

575
        if (item == null || selectedItem == null) {
576
          return false;
577
        }
578

579
        bool result = false;
580

581
        try {
582
          result = item["pk"].toString() == selectedItem["pk"].toString();
×
583
        } catch (error) {
584
          // Catch any conversion errors
585
          result = false;
586
        }
587

588
        return result;
589
      });
590
  }
591

592
  // Construct a set of custom filters for the dropdown search
593
  Map<String, String> _relatedFieldFilters() {
×
594

595
    switch (model) {
×
596
      case "supplierpart":
×
597
        return InvenTreeSupplierPart().defaultListFilters();
×
598
      case "stockitem":
×
599
        return InvenTreeStockItem().defaultListFilters();
×
600
    }
601

602
    return {};
×
603
  }
604

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

608
    // Convert to JSON
609
    Map<String, dynamic> data = {};
×
610

611
    try {
612
      if (item is Map<String, dynamic>) {
×
613
        data = Map<String, dynamic>.from(item);
×
614
      } else {
615
        data = {};
×
616
      }
617
    } catch (error, stackTrace) {
618
      data = {};
×
619

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

632
    switch (model) {
×
633
      case "part":
×
634
        var part = InvenTreePart.fromJson(data);
×
635

636
        return ListTile(
×
637
          title: Text(
×
638
              part.fullname,
×
639
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
640
          ),
641
          subtitle: extended ? Text(
×
642
            part.description,
×
643
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
644
          ) : null,
645
          leading: extended ? InvenTreeAPI().getThumbnail(part.thumbnail) : null,
×
646
        );
647

648
      case "supplierpart":
×
649
        var part = InvenTreeSupplierPart.fromJson(data);
×
650

651
        return ListTile(
×
652
          title: Text(part.SKU),
×
653
          subtitle: Text(part.partName),
×
654
          leading: extended ? InvenTreeAPI().getThumbnail(part.partImage) : null,
×
655
          trailing: extended && part.supplierImage.isNotEmpty ? InvenTreeAPI().getThumbnail(part.supplierImage) : null,
×
656
        );
657
      case "partcategory":
×
658

659
        var cat = InvenTreePartCategory.fromJson(data);
×
660

661
        return ListTile(
×
662
          title: Text(
×
663
              cat.pathstring,
×
664
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
665
          ),
666
          subtitle: extended ? Text(
×
667
            cat.description,
×
668
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
669
          ) : null,
670
        );
671
      case "stockitem":
×
672
        var item = InvenTreeStockItem.fromJson(data);
×
673

674
        return ListTile(
×
675
          title: Text(
×
676
            item.partName,
×
677
          ),
678
          leading: InvenTreeAPI().getThumbnail(item.partThumbnail),
×
679
          trailing: Text(item.quantityString()),
×
680
        );
681
      case "stocklocation":
×
682

683
        var loc = InvenTreeStockLocation.fromJson(data);
×
684

685
        return ListTile(
×
686
          title: Text(
×
687
              loc.pathstring,
×
688
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
689
          ),
690
          subtitle: extended ? Text(
×
691
            loc.description,
×
692
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
693
          ) : null,
694
        );
695
      case "salesordershipment":
×
696
        var shipment = InvenTreeSalesOrderShipment.fromJson(data);
×
697

698
        return ListTile(
×
699
          title: Text(shipment.reference),
×
700
          subtitle: Text(shipment.tracking_number),
×
701
          trailing: shipment.shipped ? Text(shipment.shipment_date!) : null,
×
702
        );
703
      case "owner":
×
704
        String name = (data["name"] ?? "") as String;
×
705
        bool isGroup = (data["label"] ?? "") == "group";
×
706
        return ListTile(
×
707
          title: Text(name),
×
NEW
708
          leading: Icon(isGroup ? TablerIcons.users : TablerIcons.user),
×
709
        );
710
      case "contact":
×
711
        String name = (data["name"] ?? "") as String;
×
712
        String role = (data["role"] ?? "") as String;
×
713
        return ListTile(
×
714
          title: Text(name),
×
715
          subtitle: Text(role),
×
716
        );
717
      case "company":
×
718
        var company = InvenTreeCompany.fromJson(data);
×
719
        return ListTile(
×
720
            title: Text(company.name),
×
721
            subtitle: extended ? Text(company.description) : null,
×
722
            leading: InvenTreeAPI().getThumbnail(company.thumbnail)
×
723
        );
724
      case "projectcode":
×
725
        var project_code = InvenTreeProjectCode.fromJson(data);
×
726
        return ListTile(
×
727
            title: Text(project_code.code),
×
728
            subtitle: Text(project_code.description),
×
NEW
729
            leading: Icon(TablerIcons.list)
×
730
        );
731
      default:
732
        return ListTile(
×
733
          title: Text(
×
734
              "Unsupported model",
735
              style: TextStyle(
×
736
                  fontWeight: FontWeight.bold,
737
                  color: COLOR_DANGER
738
              )
739
          ),
740
          subtitle: Text("Model '${model}' rendering not supported"),
×
741
        );
742
    }
743
  }
744

745
  // Construct a widget to instruct the user that no results were found
746
  Widget _renderEmptyResult() {
×
747
    return ListTile(
×
NEW
748
      leading: Icon(TablerIcons.search),
×
749
      title: Text(L10().noResults),
×
750
      subtitle: Text(
×
751
        L10().queryNoResults,
×
752
        style: TextStyle(fontStyle: FontStyle.italic),
×
753
      ),
754
    );
755
  }
756

757

758
  // Construct a string input element
759
  Widget _constructString() {
×
760

761
    if (readOnly) {
×
762
      return ListTile(
×
763
        title: Text(label),
×
764
        subtitle: Text(helpText),
×
765
        trailing: Text(value.toString()),
×
766
      );
767
    }
768

769
    return TextFormField(
×
770
      decoration: InputDecoration(
×
771
        labelText: required ? label + "*" : label,
×
772
        labelStyle: _labelStyle(),
×
773
        helperText: helpText,
×
774
        helperStyle: _helperStyle(),
×
775
        hintText: placeholderText,
×
776
      ),
777
      readOnly: readOnly,
×
778
      maxLines: multiline ? null : 1,
×
779
      expands: false,
780
      initialValue: (value ?? "") as String,
×
781
      onSaved: (val) {
×
782
        data["value"] = val;
×
783
      },
784
      validator: (value) {
×
785
        if (required && (value == null || value.isEmpty)) {
×
786
          // return L10().valueCannotBeEmpty;
787
        }
788

789
        return null;
790
      },
791
    );
792
  }
793

794
  // Construct a boolean input element
795
  Widget _constructBoolean() {
×
796

797
    bool? initial_value;
798

799
    if (value is bool || value == null) {
×
800
      initial_value = value as bool?;
×
801
    } else {
802
      String vs = value.toString().toLowerCase();
×
803
      initial_value = ["1", "true", "yes"].contains(vs);
×
804
    }
805

806
    return CheckBoxField(
×
807
      label: label,
×
808
      labelStyle: _labelStyle(),
×
809
      helperText: helpText,
×
810
      helperStyle: _helperStyle(),
×
811
      initial: initial_value,
812
      tristate: (getParameter("tristate") ?? false) as bool,
×
813
      onSaved: (val) {
×
814
        data["value"] = val;
×
815
      },
816
    );
817
  }
818

819
  TextStyle _labelStyle() {
×
820
    return TextStyle(
×
821
      fontWeight: FontWeight.bold,
822
      fontSize: 18,
823
      fontFamily: "arial",
824
      color: hasErrors() ? COLOR_DANGER : null,
×
825
      fontStyle: FontStyle.normal,
826
    );
827
  }
828

829
  TextStyle _helperStyle() {
×
830
    return TextStyle(
×
831
      fontStyle: FontStyle.italic,
832
      color: hasErrors() ? COLOR_DANGER : null,
×
833
    );
834
  }
835

836
}
837

838

839
/*
840
 * Extract field options from a returned OPTIONS request
841
 */
842
Map<String, dynamic> extractFields(APIResponse response) {
×
843

844
  if (!response.isValid()) {
×
845
    return {};
×
846
  }
847

848
  var data = response.asMap();
×
849

850
  if (!data.containsKey("actions")) {
×
851
    return {};
×
852
  }
853

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

856
  dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
×
857

858
  return result as Map<String, dynamic>;
859
}
860

861
/*
862
 * Extract a field definition (map) from the provided JSON data.
863
 *
864
 * Notes:
865
 * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"),
866
 * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity"
867
 *
868
 * The map "tree" is traversed based on the provided lookup string, which can use dotted notation.
869
 * This allows complex paths to be used to lookup field information.
870
 */
871
Map<String, dynamic> extractFieldDefinition(Map<String, dynamic> data, String lookup) {
×
872

873
  List<String> path = lookup.split(".");
×
874

875
  // Shadow copy the data for path traversal
876
  Map<String, dynamic> _data = data;
877

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

881
    String el = path[ii];
×
882

883
    if (!_data.containsKey(el)) {
×
884
      print("Could not find field definition for ${lookup}:");
×
885
      print("- Key ${el} missing at index ${ii}");
×
886
      return {};
×
887
    }
888

889
    try {
890
      _data = _data[el] as Map<String, dynamic>;
×
891
    } catch (error, stackTrace) {
892
      print("Could not find sub-field element '${el}' for ${lookup}:");
×
893
      print(error.toString());
×
894

895
      // Report the error
896
      sentryReportError(
×
897
        "apiForm.extractFieldDefinition : path traversal",
898
        error, stackTrace,
899
        context: {
×
900
          "path": path.toString(),
×
901
          "el": el,
902
        }
903
      );
904
      return {};
×
905
    }
906
  }
907

908
  String el = path.last;
×
909

910
  if (!_data.containsKey(el)) {
×
911
    return {};
×
912
  } else {
913

914
    try {
915
      Map<String, dynamic> definition = _data[el] as Map<String, dynamic>;
×
916

917
      return definition;
918
    } catch (error, stacktrace) {
919
      print("Could not find field definition for ${lookup}");
×
920
      print(error.toString());
×
921

922
      // Report the error
923
      sentryReportError(
×
924
        "apiForm.extractFieldDefinition : as map",
925
        error, stacktrace,
926
        context: {
×
927
          "el": el.toString(),
×
928
        }
929
      );
930

931
      return {};
×
932
    }
933

934
  }
935
}
936

937

938
/*
939
 * Launch an API-driven form,
940
 * which uses the OPTIONS metadata (at the provided URL)
941
 * to determine how the form elements should be rendered!
942
 *
943
 * @param title is the title text to display on the form
944
 * @param url is the API URl to make the OPTIONS request to
945
 * @param fields is a map of fields to display (with optional overrides)
946
 * @param modelData is the (optional) existing modelData
947
 * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH)
948
 */
949

950
Future<void> launchApiForm(
×
951
    BuildContext context, String title, String url, Map<String, dynamic> fields,
952
    {
953
      String fileField = "",
954
      Map<String, dynamic> modelData = const {},
955
      String method = "PATCH",
956
      Function(Map<String, dynamic>)? onSuccess,
957
      Function? onCancel,
958
      IconData icon = TablerIcons.device_floppy
959
    }) async {
960

961
  showLoadingOverlay(context);
×
962

963
  // List of fields defined by the server
964
  Map<String, dynamic> serverFields = {};
×
965

966
  if (url.isNotEmpty) {
×
967

968
    var options = await InvenTreeAPI().options(url);
×
969

970
    // Invalid response from server
971
    if (!options.isValid()) {
×
972
      hideLoadingOverlay();
×
973
      return;
974
    }
975

976
    serverFields = extractFields(options);
×
977

978
    if (serverFields.isEmpty) {
×
979
      // User does not have permission to perform this action
980
      showSnackIcon(
×
981
        L10().response403,
×
982
        icon: TablerIcons.user_x,
983
      );
984

985
      hideLoadingOverlay();
×
986
      return;
987
    }
988
  }
989

990
  // Construct a list of APIFormField objects
991
  List<APIFormField> formFields = [];
×
992

993
  APIFormField field;
994

995
  for (String fieldName in fields.keys) {
×
996

997
    dynamic data = fields[fieldName];
×
998

999
    Map<String, dynamic> fieldData = {};
×
1000

1001
    if (data is Map) {
×
1002
      fieldData = Map<String, dynamic>.from(data);
×
1003
    }
1004

1005
    // Iterate through the provided fields we wish to display
1006

1007
    field = APIFormField(fieldName, fieldData);
×
1008

1009
    // Extract the definition of this field from the data received from the server
1010
    field.definition = extractFieldDefinition(serverFields, field.lookupPath);
×
1011

1012
    // Skip fields with empty definitions
1013
    if (url.isNotEmpty && field.definition.isEmpty) {
×
1014
      print("Warning: Empty field definition for field '${fieldName}'");
×
1015
    }
1016

1017
    // Add instance value to the field
1018
    dynamic model_value = modelData[fieldName];
×
1019

1020
    if (model_value != null) {
1021
      field.data["instance_value"] = model_value;
×
1022

1023
      if (field.data["value"] == null) {
×
1024
        field.data["value"] = model_value;
×
1025
      }
1026
    }
1027
    formFields.add(field);
×
1028
  }
1029

1030
  // Grab existing data for each form field
1031
  for (var field in formFields) {
×
1032
    await field.loadInitialData();
×
1033
  }
1034

1035
  hideLoadingOverlay();
×
1036

1037
  // Now, launch a new widget!
1038
  Navigator.push(
×
1039
    context,
1040
    MaterialPageRoute(builder: (context) => APIFormWidget(
×
1041
      title,
1042
      url,
1043
      formFields,
1044
      method,
1045
      onSuccess: onSuccess,
1046
      fileField: fileField,
1047
      icon: icon,
1048
    ))
1049
  );
1050
}
1051

1052

1053
class APIFormWidget extends StatefulWidget {
1054

1055
  const APIFormWidget(
×
1056
      this.title,
1057
      this.url,
1058
      this.fields,
1059
      this.method,
1060
      {
1061
        Key? key,
1062
        this.onSuccess,
1063
        this.fileField = "",
1064
        this.icon = TablerIcons.device_floppy,
1065
      }
1066
      ) : super(key: key);
×
1067

1068
  //! Form title to display
1069
  final String title;
1070

1071
  //! API URL
1072
  final String url;
1073

1074
  //! API method
1075
  final String method;
1076

1077
  final String fileField;
1078

1079
  // Icon
1080
  final IconData icon;
1081

1082
  final List<APIFormField> fields;
1083

1084
  final Function(Map<String, dynamic>)? onSuccess;
1085

1086
  @override
×
1087
  _APIFormWidgetState createState() => _APIFormWidgetState();
×
1088

1089
}
1090

1091

1092
class _APIFormWidgetState extends State<APIFormWidget> {
1093

1094
  _APIFormWidgetState() : super();
×
1095

1096
  final _formKey = GlobalKey<FormState>();
1097

1098
  List<String> nonFieldErrors = [];
1099

1100
  bool spacerRequired = false;
1101

1102
  List<Widget> _buildForm() {
×
1103

1104
    List<Widget> widgets = [];
×
1105

1106
    // Display non-field errors first
1107
    if (nonFieldErrors.isNotEmpty) {
×
1108
      for (String error in nonFieldErrors) {
×
1109
        widgets.add(
×
1110
          ListTile(
×
1111
            title: Text(
×
1112
              error,
1113
              style: TextStyle(
×
1114
                color: COLOR_DANGER,
1115
              ),
1116
            ),
NEW
1117
            leading: Icon(
×
1118
              TablerIcons.exclamation_circle,
1119
              color: COLOR_DANGER
1120
            ),
1121
          )
1122
        );
1123
      }
1124

1125
      widgets.add(Divider(height: 5));
×
1126

1127
    }
1128

1129
    for (var field in widget.fields) {
×
1130

1131
      if (field.hidden) {
×
1132
        continue;
1133
      }
1134

1135
      // Add divider before some widgets
1136
      if (spacerRequired) {
×
1137
        switch (field.type) {
×
1138
          case "related field":
×
1139
          case "choice":
×
1140
            widgets.add(Divider(height: 15));
×
1141
            break;
1142
          default:
1143
            break;
1144
        }
1145
      }
1146

1147
      widgets.add(field.constructField(context));
×
1148

1149
      if (field.hasErrors()) {
×
1150
        for (String error in field.errorMessages()) {
×
1151
          widgets.add(
×
1152
            ListTile(
×
1153
              title: Text(
×
1154
                error,
1155
                style: TextStyle(
×
1156
                  color: COLOR_DANGER,
1157
                  fontStyle: FontStyle.italic,
1158
                  fontSize: 16,
1159
                ),
1160
              )
1161
            )
1162
          );
1163
        }
1164
      }
1165

1166
      // Add divider after some widgets
1167
      switch (field.type) {
×
1168
        case "related field":
×
1169
        case "choice":
×
1170
          widgets.add(Divider(height: 15));
×
1171
          spacerRequired = false;
×
1172
          break;
1173
        default:
1174
          spacerRequired = true;
×
1175
          break;
1176
      }
1177
    }
1178

1179
    return widgets;
1180
  }
1181

1182
  Future<APIResponse> _submit(Map<String, dynamic> data) async {
×
1183

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

1187
      // Pop the "file" field
1188
      data.remove(widget.fileField);
×
1189

1190
      for (var field in widget.fields) {
×
1191
        if (field.name == widget.fileField) {
×
1192

1193
          File? file = field.attachedfile;
×
1194

1195
          if (file != null) {
1196

1197
            // A valid file has been supplied
1198
            final response = await InvenTreeAPI().uploadFile(
×
1199
              widget.url,
×
1200
              file,
1201
              name: widget.fileField,
×
1202
              fields: data,
1203
            );
1204

1205
            return response;
1206
          }
1207
        }
1208
      }
1209
    }
1210

1211
    if (widget.method == "POST") {
×
1212

1213
      showLoadingOverlay(context);
×
1214
      final response =  await InvenTreeAPI().post(
×
1215
        widget.url,
×
1216
        body: data,
1217
        expectedStatusCode: null
1218
      );
1219
      hideLoadingOverlay();
×
1220

1221
      return response;
1222

1223
    } else {
1224
      showLoadingOverlay(context);
×
1225
      final response = await InvenTreeAPI().patch(
×
1226
        widget.url,
×
1227
        body: data,
1228
        expectedStatusCode: null
1229
      );
1230
      hideLoadingOverlay();
×
1231

1232
      return response;
1233
    }
1234
  }
1235

1236
  void extractNonFieldErrors(APIResponse response) {
×
1237

1238
    List<String> errors = [];
×
1239

1240
    Map<String, dynamic> data = response.asMap();
×
1241

1242
    // Potential keys representing non-field errors
1243
    List<String> keys = [
×
1244
      "__all__",
1245
      "non_field_errors",
1246
      "errors",
1247
    ];
1248

1249
    for (String key in keys) {
×
1250
      if (data.containsKey(key)) {
×
1251
        dynamic result = data[key];
×
1252

1253
        if (result is String) {
×
1254
          errors.add(result);
×
1255
        } else if (result is List) {
×
1256
          for (dynamic element in result) {
×
1257
            errors.add(element.toString());
×
1258
          }
1259
        }
1260
      }
1261
    }
1262

1263
    nonFieldErrors = errors;
×
1264
  }
1265

1266
  /* Check for errors relating to an *unhandled* field name
1267
  * These errors will not be displayed and potentially confuse the user
1268
  * So, we need to know if these are ever happening
1269
  */
1270
  void checkInvalidErrors(APIResponse response) {
×
1271
    var errors = response.asMap();
×
1272

1273
    for (String fieldName in errors.keys) {
×
1274

1275
      bool match = false;
1276

1277
      switch (fieldName) {
1278
        case "__all__":
×
1279
        case "non_field_errors":
×
1280
        case "errors":
×
1281
          // ignore these global fields
1282
          match = true;
1283
          continue;
1284
        default:
1285
          for (var field in widget.fields) {
×
1286

1287
            // Hidden fields can't display errors, so we won't match
1288
            if (field.hidden) {
×
1289
              continue;
1290
            }
1291

1292
            if (field.name == fieldName) {
×
1293
              // Direct Match found!
1294
              match = true;
1295
              break;
1296
            } else if (field.parent == fieldName) {
×
1297

1298
              var error = errors[fieldName];
×
1299

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

1314
          break;
1315
      }
1316

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

1332
  /*
1333
   * Submit the form data to the server, and handle the results
1334
   */
1335
  Future<void> _save(BuildContext context) async {
×
1336

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

1344
      if (field.readOnly) {
×
1345
        continue;
1346
      }
1347

1348
      if (field.isSimple) {
×
1349
        // Simple top-level field data
1350
        data[field.name] = field.data["value"];
×
1351
      } else {
1352
        // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?)
1353
        if (field.parent.isNotEmpty) {
×
1354

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
    // Run custom onSuccess function
1378
    var successFunc = widget.onSuccess;
×
1379

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

1386
      if (successFunc != null) {
1387
        // Return the raw "submitted" data, rather than the server response
1388
        successFunc(data);
×
1389
      }
1390

1391
      return;
1392
    }
1393

1394
    final response = await _submit(data);
×
1395

1396
    if (!response.isValid()) {
×
1397
      showServerError(widget.url, L10().serverError, L10().responseInvalid);
×
1398
      return;
1399
    }
1400

1401
    switch (response.statusCode) {
×
1402
      case 200:
×
1403
      case 201:
×
1404
        // Form was successfully validated by the server
1405

1406
        // Hide this form
1407
        Navigator.pop(context);
×
1408

1409
        if (successFunc != null) {
1410

1411
          // Ensure the response is a valid JSON structure
1412
          Map<String, dynamic> json = {};
×
1413

1414
          var data = response.asMap();
×
1415

1416
          for (String key in data.keys) {
×
1417
            json[key.toString()] = data[key];
×
1418
          }
1419

1420
          successFunc(json);
×
1421
        }
1422
        return;
1423
      case 400:
×
1424
        // Form submission / validation error
1425
        showSnackIcon(
×
1426
          L10().formError,
×
1427
          success: false
1428
        );
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
        break;
1438
      case 401:
×
1439
        showSnackIcon(
×
1440
          "401: " + L10().response401,
×
1441
          success: false
1442
        );
1443
        break;
1444
      case 403:
×
1445
        showSnackIcon(
×
1446
          "403: " + L10().response403,
×
1447
          success: false,
1448
        );
1449
        break;
1450
      case 404:
×
1451
        showSnackIcon(
×
1452
          "404: " + L10().response404,
×
1453
          success: false,
1454
        );
1455
        break;
1456
      case 405:
×
1457
        showSnackIcon(
×
1458
          "405: " + L10().response405,
×
1459
          success: false,
1460
        );
1461
        break;
1462
      case 500:
×
1463
        showSnackIcon(
×
1464
          "500: " + L10().response500,
×
1465
          success: false,
1466
        );
1467
        break;
1468
      default:
1469
        showSnackIcon(
×
1470
          "${response.statusCode}: " + L10().responseInvalid,
×
1471
          success: false,
1472
        );
1473
        break;
1474
    }
1475

1476
    setState(() {
×
1477
      // Refresh the form
1478
    });
1479

1480
  }
1481

1482
  @override
×
1483
  Widget build(BuildContext context) {
1484

1485
    return Scaffold(
×
1486
      appBar: AppBar(
×
1487
        title: Text(widget.title),
×
1488
        actions: [
×
1489
          IconButton(
×
NEW
1490
            icon: Icon(widget.icon),
×
1491
            onPressed: () {
×
1492

1493
              if (_formKey.currentState!.validate()) {
×
1494
                _formKey.currentState!.save();
×
1495

1496
                _save(context);
×
1497
              }
1498
            },
1499
          )
1500
        ]
1501
      ),
1502
      body: Form(
×
1503
        key: _formKey,
×
1504
        child: SingleChildScrollView(
×
1505
          child: Column(
×
1506
            mainAxisAlignment: MainAxisAlignment.start,
1507
            mainAxisSize: MainAxisSize.min,
1508
            crossAxisAlignment: CrossAxisAlignment.start,
1509
            children: _buildForm(),
×
1510
          ),
1511
          padding: EdgeInsets.all(16),
×
1512
        )
1513
      )
1514
    );
1515

1516
  }
1517
}
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