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

inventree / inventree-app / 20982125739

14 Jan 2026 04:15AM UTC coverage: 1.46% (-0.004%) from 1.464%
20982125739

push

github

web-flow
List filtering fix (#746)

* Bug fix for API forms without URL

- Ensure submitted data is returned

* Translate search fields

* Update release notes

* Remove debug message

* dart format

0 of 8 new or added lines in 2 files covered. (0.0%)

5045 existing lines in 38 files now uncovered.

768 of 52602 relevant lines covered (1.46%)

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

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

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

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

29
/*
30
 * Extract field options from a returned OPTIONS request
31
 */
32
Map<String, dynamic> extractFields(APIResponse response) {
×
33
  if (!response.isValid()) {
×
34
    return {};
×
35
  }
36

37
  var data = response.asMap();
×
38

39
  if (!data.containsKey("actions")) {
×
40
    return {};
×
41
  }
42

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

45
  dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
×
46

47
  return result as Map<String, dynamic>;
48
}
49

50
/*
51
 * Class that represents a single "form field",
52
 * defined by the InvenTree API
53
 */
54
class APIFormField {
55
  // Constructor
56
  APIFormField(this.name, this.data, {this.formHandler});
×
57

58
  // File to be uploaded for this filed
59
  File? attachedfile;
60

61
  APIFormWidgetState? formHandler;
62

63
  // Name of this field
64
  final String name;
65

66
  // JSON data which defines the field
67
  final Map<String, dynamic> data;
68

69
  // Function to update the value of this field
70
  void setFieldValue(dynamic val) {
×
71
    data["value"] = val;
×
72
    formHandler?.onValueChanged(name, value);
×
73
  }
74

75
  // JSON field definition provided by the server
76
  Map<String, dynamic> definition = {};
77

78
  dynamic initial_data;
79

80
  // Return the "lookup path" for this field, within the server data
81
  String get lookupPath {
×
82
    // Simple top-level case
83
    if (parent.isEmpty && !nested) {
×
84
      return name;
×
85
    }
86

87
    List<String> path = [];
×
88

89
    if (parent.isNotEmpty) {
×
90
      path.add(parent);
×
91
      path.add("child");
×
92
    }
93

94
    if (nested) {
×
95
      path.add("children");
×
96
      path.add(name);
×
97
    }
98

99
    return path.join(".");
×
100
  }
101

102
  /*
103
   * Extract a field parameter from the provided field definition.
104
   *
105
   * - First the user-provided data is checked
106
   * - Second, the server-provided definition is checked
107
   *
108
   * - Finally, return null
109
   */
110
  dynamic getParameter(String key) {
×
111
    if (data.containsKey(key)) {
×
112
      return data[key];
×
113
    } else if (definition.containsKey(key)) {
×
114
      return definition[key];
×
115
    } else {
116
      return null;
117
    }
118
  }
119

120
  String get pk_field => (getParameter("pk_field") ?? "pk") as String;
×
121

122
  // Get the "api_url" associated with a related field
123
  String get api_url => (getParameter("api_url") ?? "") as String;
×
124

125
  // Get the "model" associated with a related field
126
  String get model => (getParameter("model") ?? "") as String;
×
127

128
  // Is this field hidden?
129
  bool get hidden => (getParameter("hidden") ?? false) as bool;
×
130

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

135
  // What is the "parent" field of this field?
136
  // Note: This parameter is only defined locally
137
  String get parent => (data["parent"] ?? "") as String;
×
138

139
  bool get isSimple => !nested && parent.isEmpty;
×
140

141
  // Is this field read only?
142
  bool get readOnly => (getParameter("read_only") ?? false) as bool;
×
143

144
  bool get multiline => (getParameter("multiline") ?? false) as bool;
×
145

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

149
  // Render value to string (for form submission)
150
  String renderValueToString() {
×
151
    if (data["value"] == null) {
×
152
      return "";
153
    } else {
154
      return data["value"].toString();
×
155
    }
156
  }
157

158
  // Get the "default" as a string
159
  dynamic get defaultValue => getParameter("default");
×
160

161
  // Construct a set of "filters" for this field (e.g. related field)
162
  Map<String, String> get filters {
×
163
    Map<String, String> _filters = {};
×
164

165
    // Start with the field "definition" (provided by the server)
166
    if (definition.containsKey("filters")) {
×
167
      try {
168
        var fDef = definition["filters"] as Map<String, dynamic>;
×
169

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

178
    // Next, look at any "instance_filters" provided by the server
179
    if (definition.containsKey("instance_filters")) {
×
180
      try {
181
        var fIns = definition["instance_filters"] as Map<String, dynamic>;
×
182

183
        fIns.forEach((String key, dynamic value) {
×
184
          _filters[key] = value.toString();
×
185
        });
186
      } catch (error) {
187
        // pass
188
      }
189
    }
190

191
    // Finally, augment or override with any filters provided by the calling function
192
    if (data.containsKey("filters")) {
×
193
      try {
194
        var fDat = data["filters"] as Map<String, dynamic>;
×
195

196
        fDat.forEach((String key, dynamic value) {
×
197
          _filters[key] = value.toString();
×
198
        });
199
      } catch (error) {
200
        // pass
201
      }
202
    }
203

204
    return _filters;
205
  }
206

207
  bool hasErrors() => errorMessages().isNotEmpty;
×
208

209
  // Extract error messages from the server response
210
  void extractErrorMessages(APIResponse response) {
×
211
    dynamic errors;
212

213
    if (isSimple) {
×
214
      // Simple fields are easily handled
215
      errors = response.data[name];
×
216
    } else {
217
      if (parent.isNotEmpty) {
×
218
        dynamic parentElement = response.data[parent];
×
219

220
        // Extract from list
221
        if (parentElement is List) {
×
222
          parentElement = parentElement[0];
×
223
        }
224

225
        if (parentElement is Map) {
×
226
          errors = parentElement[name];
×
227
        }
228
      }
229
    }
230

231
    data["errors"] = errors;
×
232
  }
233

234
  // Return the error message associated with this field
235
  List<String> errorMessages() {
×
236
    dynamic errors = data["errors"] ?? [];
×
237

238
    // Handle the case where a single error message is returned
239
    if (errors is String) {
×
240
      errors = [errors];
×
241
    }
242

243
    errors = errors as List<dynamic>;
244

245
    List<String> messages = [];
×
246

247
    for (dynamic error in errors) {
×
248
      messages.add(error.toString());
×
249
    }
250

251
    return messages;
252
  }
253

254
  // Is this field required?
255
  bool get required => (getParameter("required") ?? false) as bool;
×
256

257
  String get type => (getParameter("type") ?? "").toString();
×
258

259
  String get label => (getParameter("label") ?? "").toString();
×
260

261
  String get helpText => (getParameter("help_text") ?? "").toString();
×
262

263
  String get placeholderText => (getParameter("placeholder") ?? "").toString();
×
264

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

267
  Future<void> loadInitialData() async {
×
268
    // Only for "related fields"
269
    if (type != "related field") {
×
270
      return;
271
    }
272

273
    // Null value? No point!
274
    if (value == null) {
×
275
      return;
276
    }
277

278
    String url = api_url + "/" + value.toString() + "/";
×
279

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

282
    if (response.successful()) {
×
283
      initial_data = response.data;
×
284
      formHandler?.onValueChanged(name, value);
×
285
    }
286
  }
287

288
  // Construct a widget for this input
289
  Widget constructField(BuildContext context) {
×
290
    switch (type) {
×
291
      case "string":
×
292
      case "url":
×
293
        return _constructString();
×
294
      case "boolean":
×
295
        return _constructBoolean();
×
296
      case "related field":
×
297
        return _constructRelatedField();
×
298
      case "integer":
×
299
      case "float":
×
300
      case "decimal":
×
301
        return _constructFloatField();
×
302
      case "choice":
×
303
        return _constructChoiceField();
×
304
      case "file upload":
×
305
      case "image upload":
×
306
        return _constructFileField();
×
307
      case "date":
×
308
        return _constructDateField();
×
309
      case "barcode":
×
310
        return _constructBarcodeField(context);
×
311
      default:
312
        return ListTile(
×
313
          title: Text(
×
314
            "Unsupported field type: '${type}' for field '${name}'",
×
315
            style: TextStyle(color: COLOR_DANGER, fontStyle: FontStyle.italic),
×
316
          ),
317
        );
318
    }
319
  }
320

321
  // Field for capturing a barcode
322
  Widget _constructBarcodeField(BuildContext context) {
×
323
    TextEditingController controller = TextEditingController();
×
324

325
    String barcode = (value ?? "").toString();
×
326

327
    if (barcode.isEmpty) {
×
328
      barcode = L10().barcodeNotAssigned;
×
329
    }
330

331
    controller.text = barcode;
×
332

333
    return InputDecorator(
×
334
      decoration: InputDecoration(
×
335
        labelText: required ? label + "*" : label,
×
336
        labelStyle: _labelStyle(),
×
337
        helperText: helpText,
×
338
        helperStyle: _helperStyle(),
×
339
        hintText: placeholderText,
×
340
      ),
341
      child: ListTile(
×
342
        title: TextField(readOnly: true, controller: controller),
×
343
        trailing: IconButton(
×
344
          icon: Icon(TablerIcons.qrcode),
×
345
          onPressed: () async {
×
346
            var handler = UniqueBarcodeHandler((String hash) {
×
347
              controller.text = hash;
×
348
              setFieldValue(hash);
×
349
              barcodeSuccess(L10().barcodeAssigned);
×
350
            });
351

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

359
  // Field for displaying and selecting dates
360
  Widget _constructDateField() {
×
361
    DateTime? currentDate = DateTime.tryParse((value ?? "") as String);
×
362

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

389
          return time;
390
        },
391
      ),
392
    );
393
  }
394

395
  // Field for selecting and uploading files
396
  Widget _constructFileField() {
×
397
    TextEditingController controller = TextEditingController();
×
398

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

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

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

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

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

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

469
  // Construct a floating point numerical input field
470
  Widget _constructFloatField() {
×
471
    // Initial value: try to cast to a valid number
472
    String initial = "";
473

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

476
    if (initialNumber != null) {
477
      initial = simpleNumberString(initialNumber);
×
478
    }
479

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

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

501
        double? quantity = double.tryParse(value.toString());
×
502

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

507
        return null;
508
      },
509
      onSaved: (val) {
×
510
        setFieldValue(val);
×
511
      },
512
    );
513
  }
514

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

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

538
        final APIResponse response = await InvenTreeAPI().get(
×
539
          api_url,
×
540
          params: _filters,
541
        );
542

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

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

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

600
        bool result = false;
601

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

610
        return result;
611
      },
612
    );
613
  }
614

615
  // Construct a set of custom filters for the dropdown search
616
  Map<String, String> _relatedFieldFilters() {
×
617
    switch (model) {
×
618
      case InvenTreeSupplierPart.MODEL_TYPE:
×
619
        return InvenTreeSupplierPart().defaultListFilters();
×
620
      case InvenTreeStockItem.MODEL_TYPE:
×
621
        return InvenTreeStockItem().defaultListFilters();
×
622
      case InvenTreeSalesOrder.MODEL_TYPE:
×
623
        return InvenTreeSalesOrder().defaultListFilters();
×
624
      default:
625
        break;
626
    }
627

628
    return {};
×
629
  }
630

631
  // Render a "related field" based on the "model" type
632
  Widget _renderRelatedField(
×
633
    String fieldName,
634
    dynamic item,
635
    bool selected,
636
    bool extended,
637
  ) {
638
    // Convert to JSON
639
    Map<String, dynamic> data = {};
×
640

641
    try {
642
      if (item is Map<String, dynamic>) {
×
643
        data = Map<String, dynamic>.from(item);
×
644
      } else {
645
        data = {};
×
646
      }
647
    } catch (error, stackTrace) {
648
      data = {};
×
649

650
      sentryReportError(
×
651
        "_renderRelatedField",
652
        error,
653
        stackTrace,
654
        context: {
×
655
          "method": "_renderRelateField",
656
          "field_name": fieldName,
657
          "item": item.toString(),
×
658
          "selected": selected.toString(),
×
659
          "extended": extended.toString(),
×
660
        },
661
      );
662
    }
663

664
    switch (model) {
×
665
      case InvenTreePart.MODEL_TYPE:
×
666
        var part = InvenTreePart.fromJson(data);
×
667

668
        return ListTile(
×
669
          title: Text(
×
670
            part.fullname,
×
671
            style: TextStyle(
×
672
              fontWeight: selected && extended
673
                  ? FontWeight.bold
674
                  : FontWeight.normal,
675
            ),
676
          ),
677
          subtitle: extended
678
              ? Text(
×
679
                  part.description,
×
680
                  style: TextStyle(
×
681
                    fontWeight: selected ? FontWeight.bold : FontWeight.normal,
682
                  ),
683
                )
684
              : null,
685
          leading: extended
686
              ? InvenTreeAPI().getThumbnail(part.thumbnail)
×
687
              : null,
688
        );
689
      case InvenTreePartTestTemplate.MODEL_TYPE:
×
690
        var template = InvenTreePartTestTemplate.fromJson(data);
×
691

692
        return ListTile(
×
693
          title: Text(template.testName),
×
694
          subtitle: Text(template.description),
×
695
        );
696
      case InvenTreeSupplierPart.MODEL_TYPE:
×
697
        var part = InvenTreeSupplierPart.fromJson(data);
×
698

699
        return ListTile(
×
700
          title: Text(part.SKU),
×
701
          subtitle: Text(part.partName),
×
702
          leading: extended
703
              ? InvenTreeAPI().getThumbnail(part.partImage)
×
704
              : null,
705
          trailing: extended && part.supplierImage.isNotEmpty
×
706
              ? InvenTreeAPI().getThumbnail(part.supplierImage)
×
707
              : null,
708
        );
709
      case InvenTreePartCategory.MODEL_TYPE:
×
710
        var cat = InvenTreePartCategory.fromJson(data);
×
711

712
        return ListTile(
×
713
          title: Text(
×
714
            cat.pathstring,
×
715
            style: TextStyle(
×
716
              fontWeight: selected && extended
717
                  ? FontWeight.bold
718
                  : FontWeight.normal,
719
            ),
720
          ),
721
          subtitle: extended
722
              ? Text(
×
723
                  cat.description,
×
724
                  style: TextStyle(
×
725
                    fontWeight: selected ? FontWeight.bold : FontWeight.normal,
726
                  ),
727
                )
728
              : null,
729
        );
730
      case InvenTreeStockItem.MODEL_TYPE:
×
731
        var item = InvenTreeStockItem.fromJson(data);
×
732

733
        return ListTile(
×
734
          title: Text(item.partName),
×
735
          leading: InvenTreeAPI().getThumbnail(item.partThumbnail),
×
736
          trailing: Text(item.quantityString()),
×
737
        );
738
      case InvenTreeStockLocation.MODEL_TYPE:
×
739
        var loc = InvenTreeStockLocation.fromJson(data);
×
740

741
        return ListTile(
×
742
          title: Text(
×
743
            loc.pathstring,
×
744
            style: TextStyle(
×
745
              fontWeight: selected && extended
746
                  ? FontWeight.bold
747
                  : FontWeight.normal,
748
            ),
749
          ),
750
          subtitle: extended
751
              ? Text(
×
752
                  loc.description,
×
753
                  style: TextStyle(
×
754
                    fontWeight: selected ? FontWeight.bold : FontWeight.normal,
755
                  ),
756
                )
757
              : null,
758
        );
759
      case InvenTreeSalesOrderShipment.MODEL_TYPE:
×
760
        var shipment = InvenTreeSalesOrderShipment.fromJson(data);
×
761

762
        return ListTile(
×
763
          title: Text(shipment.reference),
×
764
          subtitle: Text(shipment.tracking_number),
×
765
          trailing: shipment.isShipped ? Text(shipment.shipment_date!) : null,
×
766
        );
767
      case "owner":
×
768
        String name = (data["name"] ?? "") as String;
×
769
        bool isGroup = (data["label"] ?? "") == "group";
×
770
        return ListTile(
×
771
          title: Text(name),
×
772
          leading: Icon(isGroup ? TablerIcons.users : TablerIcons.user),
×
773
        );
774
      case "contact":
×
775
        String name = (data["name"] ?? "") as String;
×
776
        String role = (data["role"] ?? "") as String;
×
777
        return ListTile(title: Text(name), subtitle: Text(role));
×
778
      case InvenTreeCompany.MODEL_TYPE:
×
779
        var company = InvenTreeCompany.fromJson(data);
×
780
        return ListTile(
×
781
          title: Text(company.name),
×
782
          subtitle: extended ? Text(company.description) : null,
×
783
          leading: InvenTreeAPI().getThumbnail(company.thumbnail),
×
784
        );
785
      case InvenTreeProjectCode.MODEL_TYPE:
×
786
        var project_code = InvenTreeProjectCode.fromJson(data);
×
787
        return ListTile(
×
788
          title: Text(project_code.code),
×
789
          subtitle: Text(project_code.description),
×
790
          leading: Icon(TablerIcons.list),
×
791
        );
792
      case InvenTreeSalesOrder.MODEL_TYPE:
×
793
        var so = InvenTreeSalesOrder.fromJson(data);
×
794
        return ListTile(
×
795
          title: Text(so.reference),
×
796
          subtitle: Text(so.description),
×
797
          leading: InvenTreeAPI().getThumbnail(
×
798
            so.customer?.thumbnail ?? so.customer?.image ?? "",
×
799
          ),
800
        );
801
      case "labeltemplate":
×
802
        return ListTile(
×
803
          title: Text((data["name"] ?? "").toString()),
×
804
          subtitle: Text((data["description"] ?? "").toString()),
×
805
        );
806
      case "pluginconfig":
×
807
        return ListTile(
×
808
          title: Text(
×
809
            (data["meta"]?["human_name"] ?? data["name"] ?? "").toString(),
×
810
          ),
811
          subtitle: Text((data["meta"]?["description"] ?? "").toString()),
×
812
        );
813
      default:
814
        return ListTile(
×
815
          title: Text(
×
816
            "Unsupported model",
817
            style: TextStyle(fontWeight: FontWeight.bold, color: COLOR_DANGER),
×
818
          ),
819
          subtitle: Text("Model '${model}' rendering not supported"),
×
820
        );
821
    }
822
  }
823

824
  // Construct a widget to instruct the user that no results were found
825
  Widget _renderEmptyResult() {
×
826
    return ListTile(
×
827
      leading: Icon(TablerIcons.search),
×
828
      title: Text(L10().noResults),
×
829
      subtitle: Text(
×
830
        L10().queryNoResults,
×
831
        style: TextStyle(fontStyle: FontStyle.italic),
×
832
      ),
833
    );
834
  }
835

836
  // Construct a string input element
837
  Widget _constructString() {
×
838
    if (readOnly) {
×
839
      return ListTile(
×
840
        title: Text(label),
×
841
        subtitle: Text(helpText),
×
842
        trailing: Text(value.toString()),
×
843
      );
844
    }
845

846
    return TextFormField(
×
847
      decoration: InputDecoration(
×
848
        labelText: required ? label + "*" : label,
×
849
        labelStyle: _labelStyle(),
×
850
        helperText: helpText,
×
851
        helperStyle: _helperStyle(),
×
852
        hintText: placeholderText,
×
853
      ),
854
      readOnly: readOnly,
×
855
      maxLines: multiline ? null : 1,
×
856
      expands: false,
857
      initialValue: (value ?? "") as String,
×
858
      onChanged: (val) {
×
859
        setFieldValue(val);
×
860
      },
861
      onSaved: (val) {
×
862
        setFieldValue(val);
×
863
      },
864
      validator: (value) {
×
865
        if (required && (value == null || value.isEmpty)) {
×
866
          // return L10().valueCannotBeEmpty;
867
        }
868

869
        return null;
870
      },
871
    );
872
  }
873

874
  // Construct a boolean input element
875
  Widget _constructBoolean() {
×
876
    bool? initial_value;
877

878
    if (value is bool || value == null) {
×
879
      initial_value = value as bool?;
×
880
    } else {
881
      String vs = value.toString().toLowerCase();
×
882
      initial_value = ["1", "true", "yes"].contains(vs);
×
883
    }
884

885
    return CheckBoxField(
×
886
      label: label,
×
887
      labelStyle: _labelStyle(),
×
888
      helperText: helpText,
×
889
      helperStyle: _helperStyle(),
×
890
      initial: initial_value,
891
      tristate: (getParameter("tristate") ?? false) as bool,
×
892
      onSaved: (val) {
×
893
        setFieldValue(val);
×
894
      },
895
    );
896
  }
897

898
  TextStyle _labelStyle() {
×
899
    return TextStyle(
×
900
      fontWeight: FontWeight.bold,
901
      fontSize: 18,
902
      fontFamily: "arial",
903
      color: hasErrors() ? COLOR_DANGER : null,
×
904
      fontStyle: FontStyle.normal,
905
    );
906
  }
907

908
  TextStyle _helperStyle() {
×
909
    return TextStyle(
×
910
      fontStyle: FontStyle.italic,
911
      color: hasErrors() ? COLOR_DANGER : null,
×
912
    );
913
  }
914
}
915

916
/*
917
 * Extract a field definition (map) from the provided JSON data.
918
 *
919
 * Notes:
920
 * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"),
921
 * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity"
922
 *
923
 * The map "tree" is traversed based on the provided lookup string, which can use dotted notation.
924
 * This allows complex paths to be used to lookup field information.
925
 */
926
Map<String, dynamic> extractFieldDefinition(
×
927
  Map<String, dynamic> data,
928
  String lookup,
929
) {
930
  List<String> path = lookup.split(".");
×
931

932
  // Shadow copy the data for path traversal
933
  Map<String, dynamic> _data = data;
934

935
  // Iterate through all but the last element of the path
936
  for (int ii = 0; ii < (path.length - 1); ii++) {
×
937
    String el = path[ii];
×
938

939
    if (!_data.containsKey(el)) {
×
940
      print("Could not find field definition for ${lookup}:");
×
941
      print("- Key ${el} missing at index ${ii}");
×
942
      return {};
×
943
    }
944

945
    try {
946
      _data = _data[el] as Map<String, dynamic>;
×
947
    } catch (error, stackTrace) {
948
      print("Could not find sub-field element '${el}' for ${lookup}:");
×
949
      print(error.toString());
×
950

951
      // Report the error
952
      sentryReportError(
×
953
        "apiForm.extractFieldDefinition : path traversal",
954
        error,
955
        stackTrace,
956
        context: {"path": path.toString(), "el": el},
×
957
      );
958
      return {};
×
959
    }
960
  }
961

962
  String el = path.last;
×
963

964
  if (!_data.containsKey(el)) {
×
965
    return {};
×
966
  } else {
967
    try {
968
      Map<String, dynamic> definition = _data[el] as Map<String, dynamic>;
×
969

970
      return definition;
971
    } catch (error, stacktrace) {
972
      print("Could not find field definition for ${lookup}");
×
973
      print(error.toString());
×
974

975
      // Report the error
976
      sentryReportError(
×
977
        "apiForm.extractFieldDefinition : as map",
978
        error,
979
        stacktrace,
980
        context: {"el": el.toString()},
×
981
      );
982

983
      return {};
×
984
    }
985
  }
986
}
987

988
/*
989
 * Launch an API-driven form,
990
 * which uses the OPTIONS metadata (at the provided URL)
991
 * to determine how the form elements should be rendered!
992
 *
993
 * @param title is the title text to display on the form
994
 * @param url is the API URl to make the OPTIONS request to
995
 * @param fields is a map of fields to display (with optional overrides)
996
 * @param modelData is the (optional) existing modelData
997
 * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH)
998
 */
999

1000
Future<void> launchApiForm(
×
1001
  BuildContext context,
1002
  String title,
1003
  String url,
1004
  Map<String, dynamic> fields, {
1005
  String fileField = "",
1006
  Map<String, dynamic> modelData = const {},
1007
  String method = "PATCH",
1008
  Function(Map<String, dynamic>)? onSuccess,
1009
  bool Function(Map<String, dynamic>)? validate,
1010
  Function? onCancel,
1011
  APIFormWidgetState? formHandler,
1012
  IconData icon = TablerIcons.device_floppy,
1013
}) async {
1014
  // List of fields defined by the server
UNCOV
1015
  Map<String, dynamic> serverFields = {};
×
1016

1017
  if (url.isNotEmpty) {
×
NEW
1018
    showLoadingOverlay();
×
UNCOV
1019
    var options = await InvenTreeAPI().options(url);
×
NEW
1020
    hideLoadingOverlay();
×
1021

1022
    // Invalid response from server
1023
    if (!options.isValid()) {
×
1024
      return;
1025
    }
1026

1027
    serverFields = extractFields(options);
×
1028

1029
    if (serverFields.isEmpty) {
×
1030
      // User does not have permission to perform this action
1031
      showSnackIcon(L10().response403, icon: TablerIcons.user_x);
×
1032
      return;
1033
    }
1034
  }
1035

1036
  // Construct a list of APIFormField objects
1037
  List<APIFormField> formFields = [];
×
1038

1039
  APIFormField field;
1040

1041
  for (String fieldName in fields.keys) {
×
1042
    dynamic data = fields[fieldName];
×
1043

1044
    Map<String, dynamic> fieldData = {};
×
1045

1046
    if (data is Map) {
×
1047
      fieldData = Map<String, dynamic>.from(data);
×
1048
    }
1049

1050
    // Iterate through the provided fields we wish to display
1051

1052
    field = APIFormField(fieldName, fieldData);
×
1053

1054
    // Extract the definition of this field from the data received from the server
1055
    field.definition = extractFieldDefinition(serverFields, field.lookupPath);
×
1056

1057
    // Skip fields with empty definitions
1058
    if (url.isNotEmpty && field.definition.isEmpty) {
×
1059
      print("Warning: Empty field definition for field '${fieldName}'");
×
1060
    }
1061

1062
    // Add instance value to the field
1063
    dynamic model_value = modelData[fieldName];
×
1064

1065
    if (model_value != null) {
1066
      field.data["instance_value"] = model_value;
×
1067

1068
      if (field.data["value"] == null) {
×
1069
        field.setFieldValue(model_value);
×
1070
      }
1071
    }
1072
    formFields.add(field);
×
1073
  }
1074

NEW
1075
  showLoadingOverlay();
×
1076

1077
  // Grab existing data for each form field
1078
  for (var field in formFields) {
×
1079
    await field.loadInitialData();
×
1080
  }
1081

1082
  hideLoadingOverlay();
×
1083

1084
  // Now, launch a new widget!
1085
  Navigator.push(
×
1086
    context,
1087
    MaterialPageRoute(
×
1088
      builder: (context) => APIFormWidget(
×
1089
        title,
1090
        url,
1091
        formFields,
1092
        method,
1093
        onSuccess: onSuccess,
1094
        validate: validate,
1095
        fileField: fileField,
1096
        state: formHandler,
1097
        icon: icon,
1098
      ),
1099
    ),
1100
  );
1101
}
1102

1103
class APIFormWidget extends StatefulWidget {
1104
  const APIFormWidget(
×
1105
    this.title,
1106
    this.url,
1107
    this.fields,
1108
    this.method, {
1109
    Key? key,
1110
    this.state,
1111
    this.onSuccess,
1112
    this.validate,
1113
    this.fileField = "",
1114
    this.icon = TablerIcons.device_floppy,
1115
  }) : super(key: key);
×
1116

1117
  //! Form title to display
1118
  final String title;
1119

1120
  //! API URL
1121
  final String url;
1122

1123
  //! API method
1124
  final String method;
1125

1126
  final String fileField;
1127

1128
  // Icon
1129
  final IconData icon;
1130

1131
  final List<APIFormField> fields;
1132

1133
  final Function(Map<String, dynamic>)? onSuccess;
1134

1135
  final bool Function(Map<String, dynamic>)? validate;
1136

1137
  final APIFormWidgetState? state;
1138

1139
  // Default form handler is constructed if none is provided
1140
  @override
×
1141
  APIFormWidgetState createState() => state ?? APIFormWidgetState();
×
1142
}
1143

1144
class APIFormWidgetState extends State<APIFormWidget> {
1145
  APIFormWidgetState() : super();
×
1146

1147
  final _formKey = GlobalKey<FormState>();
1148

1149
  List<String> nonFieldErrors = [];
1150

1151
  bool spacerRequired = false;
1152

1153
  // Return a list of all fields used for this form
1154
  // The default implementation just returns the fields provided to the widget
1155
  // However, custom form implementations may override this function
1156
  List<APIFormField> get formFields {
×
1157
    final List<APIFormField> fields = widget.fields;
×
1158

1159
    // Ensure each field has access to this form handler
1160
    for (var field in fields) {
×
1161
      field.formHandler ??= this;
×
1162
    }
1163

1164
    return fields;
1165
  }
1166

1167
  // Callback for when a field value is changed
1168
  // Default implementation does nothing,
1169
  // but custom form implementations may override this function
1170
  void onValueChanged(String field, dynamic value) {}
×
1171

1172
  Future<void> handleSuccess(
×
1173
    Map<String, dynamic> submittedData,
1174
    Map<String, dynamic> responseData,
1175
  ) async {
1176
    Navigator.pop(context);
×
1177
    widget.onSuccess?.call(responseData);
×
1178
  }
1179

1180
  List<Widget> _buildForm() {
×
1181
    List<Widget> widgets = [];
×
1182

1183
    // Display non-field errors first
1184
    if (nonFieldErrors.isNotEmpty) {
×
1185
      for (String error in nonFieldErrors) {
×
1186
        widgets.add(
×
1187
          ListTile(
×
1188
            title: Text(error, style: TextStyle(color: COLOR_DANGER)),
×
1189
            leading: Icon(TablerIcons.exclamation_circle, color: COLOR_DANGER),
×
1190
          ),
1191
        );
1192
      }
1193

1194
      widgets.add(Divider(height: 5));
×
1195
    }
1196

1197
    for (var field in formFields) {
×
1198
      if (field.hidden) {
×
1199
        continue;
1200
      }
1201

1202
      // Add divider before some widgets
1203
      if (spacerRequired) {
×
1204
        switch (field.type) {
×
1205
          case "related field":
×
1206
          case "choice":
×
1207
            widgets.add(Divider(height: 15));
×
1208
          default:
1209
            break;
1210
        }
1211
      }
1212

1213
      widgets.add(field.constructField(context));
×
1214

1215
      if (field.hasErrors()) {
×
1216
        for (String error in field.errorMessages()) {
×
1217
          widgets.add(
×
1218
            ListTile(
×
1219
              title: Text(
×
1220
                error,
1221
                style: TextStyle(
×
1222
                  color: COLOR_DANGER,
1223
                  fontStyle: FontStyle.italic,
1224
                  fontSize: 16,
1225
                ),
1226
              ),
1227
            ),
1228
          );
1229
        }
1230
      }
1231

1232
      // Add divider after some widgets
1233
      switch (field.type) {
×
1234
        case "related field":
×
1235
        case "choice":
×
1236
          widgets.add(Divider(height: 15));
×
1237
          spacerRequired = false;
×
1238
        default:
1239
          spacerRequired = true;
×
1240
      }
1241
    }
1242

1243
    return widgets;
1244
  }
1245

1246
  Future<APIResponse> _submit(Map<String, dynamic> data) async {
×
1247
    // If a file upload is required, we have to handle the submission differently
1248
    if (widget.fileField.isNotEmpty) {
×
1249
      // Pop the "file" field
1250
      data.remove(widget.fileField);
×
1251

1252
      for (var field in formFields) {
×
1253
        if (field.name == widget.fileField) {
×
1254
          File? file = field.attachedfile;
×
1255

1256
          if (file != null) {
1257
            // A valid file has been supplied
1258
            final response = await InvenTreeAPI().uploadFile(
×
1259
              widget.url,
×
1260
              file,
1261
              name: widget.fileField,
×
1262
              fields: data,
1263
            );
1264

1265
            return response;
1266
          }
1267
        }
1268
      }
1269
    }
1270

1271
    if (widget.method == "POST") {
×
1272
      showLoadingOverlay();
×
1273
      final response = await InvenTreeAPI().post(
×
1274
        widget.url,
×
1275
        body: data,
1276
        expectedStatusCode: null,
1277
      );
1278
      hideLoadingOverlay();
×
1279

1280
      return response;
1281
    } else {
1282
      showLoadingOverlay();
×
1283
      final response = await InvenTreeAPI().patch(
×
1284
        widget.url,
×
1285
        body: data,
1286
        expectedStatusCode: null,
1287
      );
1288
      hideLoadingOverlay();
×
1289

1290
      return response;
1291
    }
1292
  }
1293

1294
  void extractNonFieldErrors(APIResponse response) {
×
1295
    List<String> errors = [];
×
1296

1297
    Map<String, dynamic> data = response.asMap();
×
1298

1299
    // Potential keys representing non-field errors
1300
    List<String> keys = ["__all__", "non_field_errors", "errors"];
×
1301

1302
    for (String key in keys) {
×
1303
      if (data.containsKey(key)) {
×
1304
        dynamic result = data[key];
×
1305

1306
        if (result is String) {
×
1307
          errors.add(result);
×
1308
        } else if (result is List) {
×
1309
          for (dynamic element in result) {
×
1310
            errors.add(element.toString());
×
1311
          }
1312
        }
1313
      }
1314
    }
1315

1316
    nonFieldErrors = errors;
×
1317
  }
1318

1319
  /* Check for errors relating to an *unhandled* field name
1320
  * These errors will not be displayed and potentially confuse the user
1321
  * So, we need to know if these are ever happening
1322
  */
1323
  void checkInvalidErrors(APIResponse response) {
×
1324
    var errors = response.asMap();
×
1325

1326
    for (String fieldName in errors.keys) {
×
1327
      bool match = false;
1328

1329
      switch (fieldName) {
1330
        case "__all__":
×
1331
        case "non_field_errors":
×
1332
        case "errors":
×
1333
          // ignore these global fields
1334
          match = true;
1335
          continue;
1336
        default:
1337
          for (var field in formFields) {
×
1338
            // Hidden fields can't display errors, so we won't match
1339
            if (field.hidden) {
×
1340
              continue;
1341
            }
1342

1343
            if (field.name == fieldName) {
×
1344
              // Direct Match found!
1345
              match = true;
1346
              break;
1347
            } else if (field.parent == fieldName) {
×
1348
              var error = errors[fieldName];
×
1349

1350
              if (error is List) {
×
1351
                for (var el in error) {
×
1352
                  if (el is Map && el.containsKey(field.name)) {
×
1353
                    match = true;
1354
                    break;
1355
                  }
1356
                }
1357
              } else if (error is Map && error.containsKey(field.name)) {
×
1358
                match = true;
1359
                break;
1360
              }
1361
            }
1362
          }
1363
      }
1364

1365
      if (!match) {
1366
        // Match for an unknown / unsupported field
1367
        sentryReportMessage(
×
1368
          "API form returned error for unsupported field",
1369
          context: {
×
1370
            "url": response.url,
×
1371
            "status_code": response.statusCode.toString(),
×
1372
            "field": fieldName,
1373
            "error_message": response.data.toString(),
×
1374
          },
1375
        );
1376
      }
1377
    }
1378
  }
1379

1380
  /*
1381
   * Submit the form data to the server, and handle the results
1382
   */
1383
  Future<void> _save(BuildContext context) async {
×
1384
    // Package up the form data
1385
    Map<String, dynamic> data = {};
×
1386

1387
    // Iterate through and find "simple" top-level fields
1388

1389
    for (var field in formFields) {
×
1390
      if (field.readOnly) {
×
1391
        continue;
1392
      }
1393

1394
      if (field.isSimple) {
×
1395
        // Simple top-level field data
1396
        data[field.name] = field.data["value"];
×
1397
      } else {
1398
        // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?)
1399
        if (field.parent.isNotEmpty) {
×
1400
          // TODO: This is a dirty hack, there *must* be a cleaner way?!
1401

1402
          dynamic parent = data[field.parent] ?? {};
×
1403

1404
          // In the case of a "nested" object, we need to extract the first item
1405
          if (parent is List) {
×
1406
            parent = parent.first;
×
1407
          }
1408

1409
          parent[field.name] = field.data["value"];
×
1410

1411
          // Nested fields must be handled as an array!
1412
          // For now, we only allow single length nested fields
1413
          if (field.nested) {
×
1414
            parent = [parent];
×
1415
          }
1416

1417
          data[field.parent] = parent;
×
1418
        }
1419
      }
1420
    }
1421

1422
    final bool isValid = widget.validate?.call(data) ?? true;
×
1423

1424
    if (!isValid) {
1425
      return;
1426
    }
1427

1428
    // An "empty" URL means we don't want to submit the form anywhere
1429
    // Perhaps we just want to process the data?
1430
    if (widget.url.isEmpty) {
×
1431
      // Hide the form
NEW
1432
      handleSuccess(data, data);
×
1433
      return;
1434
    }
1435

1436
    final response = await _submit(data);
×
1437

1438
    if (!response.isValid()) {
×
1439
      showServerError(widget.url, L10().serverError, L10().responseInvalid);
×
1440
      return;
1441
    }
1442

1443
    switch (response.statusCode) {
×
1444
      case 200:
×
1445
      case 201:
×
1446
        // Form was successfully validated by the server
1447
        // Ensure the response is a valid JSON structure
1448
        Map<String, dynamic> json = {};
×
1449

1450
        var responseData = response.asMap();
×
1451

1452
        for (String key in responseData.keys) {
×
1453
          json[key.toString()] = responseData[key];
×
1454
        }
1455

1456
        handleSuccess(data, json);
×
1457

1458
        return;
1459
      case 400:
×
1460
        // Form submission / validation error
1461
        showSnackIcon(L10().formError, success: false);
×
1462

1463
        // Update field errors
1464
        for (var field in formFields) {
×
1465
          field.extractErrorMessages(response);
×
1466
        }
1467

1468
        extractNonFieldErrors(response);
×
1469
        checkInvalidErrors(response);
×
1470
      case 401:
×
1471
        showSnackIcon("401: " + L10().response401, success: false);
×
1472
      case 403:
×
1473
        showSnackIcon("403: " + L10().response403, success: false);
×
1474
      case 404:
×
1475
        showSnackIcon("404: " + L10().response404, success: false);
×
1476
      case 405:
×
1477
        showSnackIcon("405: " + L10().response405, success: false);
×
1478
      case 500:
×
1479
        showSnackIcon("500: " + L10().response500, success: false);
×
1480
      default:
1481
        showSnackIcon(
×
1482
          "${response.statusCode}: " + L10().responseInvalid,
×
1483
          success: false,
1484
        );
1485
    }
1486

1487
    setState(() {
×
1488
      // Refresh the form
1489
    });
1490
  }
1491

1492
  // Construct the internal form widget, based on the provided fields
1493
  Widget buildForm(BuildContext context) {
×
1494
    return Form(
×
1495
      key: _formKey,
×
1496
      child: SingleChildScrollView(
×
1497
        child: Column(
×
1498
          mainAxisAlignment: MainAxisAlignment.start,
1499
          mainAxisSize: MainAxisSize.min,
1500
          crossAxisAlignment: CrossAxisAlignment.start,
1501
          children: _buildForm(),
×
1502
        ),
1503
        padding: EdgeInsets.all(16),
×
1504
      ),
1505
    );
1506
  }
1507

1508
  @override
×
1509
  Widget build(BuildContext context) {
1510
    return Scaffold(
×
1511
      appBar: AppBar(
×
1512
        title: Text(widget.title),
×
1513
        backgroundColor: COLOR_APP_BAR,
1514
        actions: [
×
1515
          IconButton(
×
1516
            icon: Icon(widget.icon),
×
1517
            onPressed: () {
×
1518
              if (_formKey.currentState!.validate()) {
×
1519
                _formKey.currentState!.save();
×
1520

1521
                _save(context);
×
1522
              }
1523
            },
1524
          ),
1525
        ],
1526
      ),
1527
      body: buildForm(context),
×
1528
    );
1529
  }
1530
}
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