2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-12-03 18:59:50 +00:00

Modern Label printing (#724)

* Basic widget

* Redirect label printing action

* Refactor label printing code

* Construct form elements

* Refactor to allow re-use of forms

* Basic rendering of label printing form

* Remove dead code

* Pass custom handler through to form context

* Refactoring API forms:

- Allow custom pk field name
- Add callback when values change

* linting

* Dynamically rebuild form

* Handle nested fields

* Handle label printing status

* Run dart format

* Update release notes

* Remove unused var

* Enable close icon

* Handle initial plugin default value

* Store default values:

- Selected template (per label type)
- Selected printing plugin

* Dart format

* Fix dart linting

* use setter

* Just use a public field
This commit is contained in:
Oliver
2025-11-22 07:26:37 +11:00
committed by GitHub
parent e41842a31d
commit 13d95dd1b1
10 changed files with 425 additions and 368 deletions

View File

@@ -26,23 +26,52 @@ import "package:inventree/widget/fields.dart";
import "package:inventree/widget/progress.dart";
import "package:inventree/widget/snacks.dart";
/*
* Extract field options from a returned OPTIONS request
*/
Map<String, dynamic> extractFields(APIResponse response) {
if (!response.isValid()) {
return {};
}
var data = response.asMap();
if (!data.containsKey("actions")) {
return {};
}
var actions = response.data["actions"] as Map<String, dynamic>;
dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
return result as Map<String, dynamic>;
}
/*
* Class that represents a single "form field",
* defined by the InvenTree API
*/
class APIFormField {
// Constructor
APIFormField(this.name, this.data);
APIFormField(this.name, this.data, {this.formHandler});
// File to be uploaded for this filed
File? attachedfile;
APIFormWidgetState? formHandler;
// Name of this field
final String name;
// JSON data which defines the field
final Map<String, dynamic> data;
// Function to update the value of this field
void setFieldValue(dynamic val) {
data["value"] = val;
formHandler?.onValueChanged(name, value);
}
// JSON field definition provided by the server
Map<String, dynamic> definition = {};
@@ -88,6 +117,8 @@ class APIFormField {
}
}
String get pk_field => (getParameter("pk_field") ?? "pk") as String;
// Get the "api_url" associated with a related field
String get api_url => (getParameter("api_url") ?? "") as String;
@@ -244,18 +275,13 @@ class APIFormField {
return;
}
int? pk = int.tryParse(value.toString());
if (pk == null) {
return;
}
String url = api_url + "/" + pk.toString() + "/";
String url = api_url + "/" + value.toString() + "/";
final APIResponse response = await InvenTreeAPI().get(url, params: filters);
if (response.successful()) {
initial_data = response.data;
formHandler?.onValueChanged(name, value);
}
}
@@ -269,6 +295,7 @@ class APIFormField {
return _constructBoolean();
case "related field":
return _constructRelatedField();
case "integer":
case "float":
case "decimal":
return _constructFloatField();
@@ -318,8 +345,7 @@ class APIFormField {
onPressed: () async {
var handler = UniqueBarcodeHandler((String hash) {
controller.text = hash;
data["value"] = hash;
setFieldValue(hash);
barcodeSuccess(L10().barcodeAssigned);
});
@@ -347,9 +373,9 @@ class APIFormField {
onChanged: (DateTime? time) {
// Save the time string
if (time == null) {
data["value"] = null;
setFieldValue(null);
} else {
data["value"] = time.toString().split(" ").first;
setFieldValue(time.toString().split(" ").first);
}
},
onShowPicker: (context, value) async {
@@ -432,9 +458,9 @@ class APIFormField {
},
onSaved: (item) {
if (item == null) {
data["value"] = null;
setFieldValue(null);
} else {
data["value"] = item["value"];
setFieldValue(item["value"]);
}
},
);
@@ -481,7 +507,7 @@ class APIFormField {
return null;
},
onSaved: (val) {
data["value"] = val;
setFieldValue(val);
},
);
}
@@ -527,7 +553,20 @@ class APIFormField {
hintText: helpText,
),
),
onChanged: null,
onChanged: (item) {
if (item != null) {
setFieldValue(item[pk_field]);
} else {
setFieldValue(null);
}
},
onSaved: (item) {
if (item != null) {
setFieldValue(item[pk_field]);
} else {
setFieldValue(null);
}
},
itemAsString: (dynamic item) {
Map<String, dynamic> data = item as Map<String, dynamic>;
@@ -551,13 +590,6 @@ class APIFormField {
dropdownBuilder: (context, item) {
return _renderRelatedField(name, item, true, false);
},
onSaved: (item) {
if (item != null) {
data["value"] = item["pk"];
} else {
data["value"] = null;
}
},
compareFn: (dynamic item, dynamic selectedItem) {
// Comparison is based on the PK value
@@ -568,7 +600,8 @@ class APIFormField {
bool result = false;
try {
result = item["pk"].toString() == selectedItem["pk"].toString();
result =
item[pk_field].toString() == selectedItem[pk_field].toString();
} catch (error) {
// Catch any conversion errors
result = false;
@@ -765,6 +798,18 @@ class APIFormField {
so.customer?.thumbnail ?? so.customer?.image ?? "",
),
);
case "labeltemplate":
return ListTile(
title: Text((data["name"] ?? "").toString()),
subtitle: Text((data["description"] ?? "").toString()),
);
case "pluginconfig":
return ListTile(
title: Text(
(data["meta"]?["human_name"] ?? data["name"] ?? "").toString(),
),
subtitle: Text((data["meta"]?["description"] ?? "").toString()),
);
default:
return ListTile(
title: Text(
@@ -810,8 +855,11 @@ class APIFormField {
maxLines: multiline ? null : 1,
expands: false,
initialValue: (value ?? "") as String,
onChanged: (val) {
setFieldValue(val);
},
onSaved: (val) {
data["value"] = val;
setFieldValue(val);
},
validator: (value) {
if (required && (value == null || value.isEmpty)) {
@@ -842,7 +890,7 @@ class APIFormField {
initial: initial_value,
tristate: (getParameter("tristate") ?? false) as bool,
onSaved: (val) {
data["value"] = val;
setFieldValue(val);
},
);
}
@@ -865,27 +913,6 @@ class APIFormField {
}
}
/*
* Extract field options from a returned OPTIONS request
*/
Map<String, dynamic> extractFields(APIResponse response) {
if (!response.isValid()) {
return {};
}
var data = response.asMap();
if (!data.containsKey("actions")) {
return {};
}
var actions = response.data["actions"] as Map<String, dynamic>;
dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
return result as Map<String, dynamic>;
}
/*
* Extract a field definition (map) from the provided JSON data.
*
@@ -981,6 +1008,7 @@ Future<void> launchApiForm(
Function(Map<String, dynamic>)? onSuccess,
bool Function(Map<String, dynamic>)? validate,
Function? onCancel,
APIFormWidgetState? formHandler,
IconData icon = TablerIcons.device_floppy,
}) async {
showLoadingOverlay();
@@ -1041,7 +1069,7 @@ Future<void> launchApiForm(
field.data["instance_value"] = model_value;
if (field.data["value"] == null) {
field.data["value"] = model_value;
field.setFieldValue(model_value);
}
}
formFields.add(field);
@@ -1066,6 +1094,7 @@ Future<void> launchApiForm(
onSuccess: onSuccess,
validate: validate,
fileField: fileField,
state: formHandler,
icon: icon,
),
),
@@ -1079,6 +1108,7 @@ class APIFormWidget extends StatefulWidget {
this.fields,
this.method, {
Key? key,
this.state,
this.onSuccess,
this.validate,
this.fileField = "",
@@ -1105,12 +1135,15 @@ class APIFormWidget extends StatefulWidget {
final bool Function(Map<String, dynamic>)? validate;
final APIFormWidgetState? state;
// Default form handler is constructed if none is provided
@override
_APIFormWidgetState createState() => _APIFormWidgetState();
APIFormWidgetState createState() => state ?? APIFormWidgetState();
}
class _APIFormWidgetState extends State<APIFormWidget> {
_APIFormWidgetState() : super();
class APIFormWidgetState extends State<APIFormWidget> {
APIFormWidgetState() : super();
final _formKey = GlobalKey<FormState>();
@@ -1118,6 +1151,33 @@ class _APIFormWidgetState extends State<APIFormWidget> {
bool spacerRequired = false;
// Return a list of all fields used for this form
// The default implementation just returns the fields provided to the widget
// However, custom form implementations may override this function
List<APIFormField> get formFields {
final List<APIFormField> fields = widget.fields;
// Ensure each field has access to this form handler
for (var field in fields) {
field.formHandler ??= this;
}
return fields;
}
// Callback for when a field value is changed
// Default implementation does nothing,
// but custom form implementations may override this function
void onValueChanged(String field, dynamic value) {}
Future<void> handleSuccess(
Map<String, dynamic> submittedData,
Map<String, dynamic> responseData,
) async {
widget.onSuccess?.call(responseData);
Navigator.pop(context);
}
List<Widget> _buildForm() {
List<Widget> widgets = [];
@@ -1135,7 +1195,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
widgets.add(Divider(height: 5));
}
for (var field in widget.fields) {
for (var field in formFields) {
if (field.hidden) {
continue;
}
@@ -1190,7 +1250,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
// Pop the "file" field
data.remove(widget.fileField);
for (var field in widget.fields) {
for (var field in formFields) {
if (field.name == widget.fileField) {
File? file = field.attachedfile;
@@ -1275,7 +1335,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
match = true;
continue;
default:
for (var field in widget.fields) {
for (var field in formFields) {
// Hidden fields can't display errors, so we won't match
if (field.hidden) {
continue;
@@ -1327,7 +1387,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
// Iterate through and find "simple" top-level fields
for (var field in widget.fields) {
for (var field in formFields) {
if (field.readOnly) {
continue;
}
@@ -1366,20 +1426,11 @@ class _APIFormWidgetState extends State<APIFormWidget> {
return;
}
// Run custom onSuccess function
var successFunc = widget.onSuccess;
// An "empty" URL means we don't want to submit the form anywhere
// Perhaps we just want to process the data?
if (widget.url.isEmpty) {
// Hide the form
Navigator.pop(context);
if (successFunc != null) {
// Return the raw "submitted" data, rather than the server response
successFunc(data);
}
handleSuccess(data, {});
return;
}
@@ -1394,29 +1445,24 @@ class _APIFormWidgetState extends State<APIFormWidget> {
case 200:
case 201:
// Form was successfully validated by the server
// Ensure the response is a valid JSON structure
Map<String, dynamic> json = {};
// Hide this form
Navigator.pop(context);
var responseData = response.asMap();
if (successFunc != null) {
// Ensure the response is a valid JSON structure
Map<String, dynamic> json = {};
var data = response.asMap();
for (String key in data.keys) {
json[key.toString()] = data[key];
}
successFunc(json);
for (String key in responseData.keys) {
json[key.toString()] = responseData[key];
}
handleSuccess(data, json);
return;
case 400:
// Form submission / validation error
showSnackIcon(L10().formError, success: false);
// Update field errors
for (var field in widget.fields) {
for (var field in formFields) {
field.extractErrorMessages(response);
}
@@ -1444,6 +1490,22 @@ class _APIFormWidgetState extends State<APIFormWidget> {
});
}
// Construct the internal form widget, based on the provided fields
Widget buildForm(BuildContext context) {
return Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildForm(),
),
padding: EdgeInsets.all(16),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -1463,18 +1525,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
),
],
),
body: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildForm(),
),
padding: EdgeInsets.all(16),
),
),
body: buildForm(context),
);
}
}