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

inventree / inventree-app / 12327582131

14 Dec 2024 06:22AM UTC coverage: 8.396% (+0.01%) from 8.386%
12327582131

Pull #578

github

web-flow
Merge 0781f4721 into 524c5469f
Pull Request #578: Refactor search widget

6 of 91 new or added lines in 4 files covered. (6.59%)

1 existing line in 1 file now uncovered.

727 of 8659 relevant lines covered (8.4%)

0.29 hits per line

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

0.0
/lib/widget/search.dart
1
import "dart:async";
2
import "package:async/async.dart";
3
import "package:flutter/material.dart";
4
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
5

6
import "package:inventree/api.dart";
7
import "package:inventree/app_colors.dart";
8
import "package:inventree/l10.dart";
9

10
import "package:inventree/inventree/part.dart";
11
import "package:inventree/inventree/company.dart";
12
import "package:inventree/inventree/sales_order.dart";
13
import "package:inventree/inventree/purchase_order.dart";
14
import "package:inventree/inventree/stock.dart";
15

16
import "package:inventree/widget/part/part_list.dart";
17
import "package:inventree/widget/order/purchase_order_list.dart";
18
import "package:inventree/widget/refreshable_state.dart";
19
import "package:inventree/widget/stock/stock_list.dart";
20
import "package:inventree/widget/part/category_list.dart";
21
import "package:inventree/widget/stock/location_list.dart";
22
import "package:inventree/widget/order/sales_order_list.dart";
23
import "package:inventree/widget/company/company_list.dart";
24
import "package:inventree/widget/company/supplier_part_list.dart";
25

26

27
// Widget for performing database-wide search
28
class SearchWidget extends StatefulWidget {
29

30
  const SearchWidget(this.hasAppbar);
×
31

32
  final bool hasAppbar;
33

34
  @override
×
35
  _SearchDisplayState createState() => _SearchDisplayState(hasAppbar);
×
36

37
}
38

39
class _SearchDisplayState extends RefreshableState<SearchWidget> {
40

41
  _SearchDisplayState(this.hasAppBar) : super();
×
42

43
  final _formKey = GlobalKey<FormState>();
44

45
  final bool hasAppBar;
46

47
  CancelableOperation<void>? _search_query;
48

49
  @override
×
50
  void dispose() {
51
    super.dispose();
×
52
  }
53

54
  @override
×
55
  String getAppBarTitle() => L10().search;
×
56

57
  @override
×
58
  AppBar? buildAppBar(BuildContext context, GlobalKey<ScaffoldState> key) {
59
    if (hasAppBar) {
×
60
      return super.buildAppBar(context, key);
×
61
    } else {
62
      return null;
63
    }
64
  }
65

66
  final TextEditingController searchController = TextEditingController();
67

68
  Timer? debounceTimer;
69

70
  /*
71
   * Decrement the number of pending / outstanding search queries
72
   */
73
  void decrementPendingSearches() {
×
74
    if (nPendingSearches > 0) {
×
75
      nPendingSearches--;
×
76
    }
77
  }
78

79
  /*
80
   * Determine if the search is still running
81
   */
82
  bool isSearching() {
×
83

84
    if (searchController.text.isEmpty) {
×
85
      return false;
86
    }
87

88
    return nPendingSearches > 0;
×
89
  }
90

91
  // Individual search result count (for legacy search API)
92
  int nPendingSearches = 0;
93

94
  int nPartResults = 0;
95
  int nCategoryResults = 0;
96
  int nStockResults = 0;
97
  int nLocationResults = 0;
98
  int nPurchaseOrderResults = 0;
99
  int nSalesOrderResults = 0;
100
  int nCompanyResults = 0;
101
  int nSupplierPartResults = 0;
102
  int nManufacturerPartResults = 0;
103

NEW
104
  void resetSearchResults() {
×
NEW
105
    if (mounted) {
×
NEW
106
      setState(() {
×
NEW
107
        nPendingSearches = 0;
×
108

NEW
109
        nPartResults = 0;
×
NEW
110
        nCategoryResults = 0;
×
NEW
111
        nStockResults = 0;
×
NEW
112
        nLocationResults = 0;
×
NEW
113
        nPurchaseOrderResults = 0;
×
NEW
114
        nSalesOrderResults = 0;
×
NEW
115
        nCompanyResults = 0;
×
NEW
116
        nSupplierPartResults = 0;
×
NEW
117
        nManufacturerPartResults = 0;
×
118
      });
119
    }
120
  }
121

122
  // Callback when the text is being edited
123
  // Incorporates a debounce timer to restrict search frequency
124
  void onSearchTextChanged(String text, {bool immediate = false}) {
×
125

126
    if (debounceTimer?.isActive ?? false) {
×
127
      debounceTimer!.cancel();
×
128
    }
129

130
    if (immediate) {
131
      search(text);
×
132
    } else {
NEW
133
      debounceTimer = Timer(Duration(milliseconds: 300), () {
×
134
        search(text);
×
135
      });
136
    }
137
  }
138

139
  /*
140
   * Return the 'result count' for a particular query from the results map
141
   * e.g.
142
   * {
143
   *     "part": {
144
   *         "count": 102,
145
   *     }
146
   * }
147
   */
148
  int getSearchResultCount(Map <String, dynamic> results, String key) {
×
149

150
    dynamic result = results[key];
×
151

152
    if (result == null || result is! Map) {
×
153
      return 0;
154
    }
155

156
    dynamic count = result["count"];
×
157

158
    if (count == null || count is! int) {
×
159
      return 0;
160
    }
161

162
    return count;
163
  }
164

165
  // Actually perform the search query
166
  Future<void> _perform_search(Map<String, dynamic> body) async {
×
167
    InvenTreeAPI().post(
×
168
      "search/",
169
      body: body,
170
      expectedStatusCode: 200).then((APIResponse response) {
×
171

NEW
172
        String searchTerm = (body["search"] ?? "").toString();
×
173

174
        // Only update if the results correspond to the current search term
NEW
175
        if (searchTerm == searchController.text && mounted) {
×
176

NEW
177
          decrementPendingSearches();
×
178

NEW
179
          Map<String, dynamic> results = {};
×
180

NEW
181
          if (response.isValid() && response.data is Map<String, dynamic>) {
×
NEW
182
            results = response.data as Map<String, dynamic>;
×
183

NEW
184
            setState(() {
×
NEW
185
              nPartResults = getSearchResultCount(results, InvenTreePart.MODEL_TYPE);
×
NEW
186
              nCategoryResults = getSearchResultCount(results, InvenTreePartCategory.MODEL_TYPE);
×
NEW
187
              nStockResults = getSearchResultCount(results, InvenTreeStockItem.MODEL_TYPE);
×
NEW
188
              nLocationResults = getSearchResultCount(results, InvenTreeStockLocation.MODEL_TYPE);
×
NEW
189
              nPurchaseOrderResults = getSearchResultCount(results, InvenTreePurchaseOrder.MODEL_TYPE);
×
NEW
190
              nSalesOrderResults = getSearchResultCount(results, InvenTreeSalesOrder.MODEL_TYPE);
×
NEW
191
              nCompanyResults = getSearchResultCount(results, InvenTreeCompany.MODEL_TYPE);
×
NEW
192
              nSupplierPartResults = getSearchResultCount(results, InvenTreeSupplierPart.MODEL_TYPE);
×
NEW
193
              nManufacturerPartResults = getSearchResultCount(results, InvenTreeManufacturerPart.MODEL_TYPE);
×
194
            });
195
          } else {
NEW
196
            resetSearchResults();
×
197
          }
198
        }
199
    });
200
  }
201

202
  /*
203
   * Callback when the search input is changed
204
   */
205
  Future<void> search(String term) async {
×
206
    var api = InvenTreeAPI();
×
207

208
    if (!mounted) {
×
209
      return;
210
    }
211

NEW
212
    resetSearchResults();
×
213

214
    // Cancel the previous search query (if in progress)
215
    if (_search_query != null) {
×
216
      if (!_search_query!.isCanceled) {
×
217
        _search_query!.cancel();
×
218
      }
219
    }
220

221
    _search_query = null;
×
222

223
    if (term.isEmpty) {
×
224
      return;
225
    }
226

227
    // Consolidated search allows us to perform *all* searches in a single query
228
    if (api.supportsConsolidatedSearch) {
×
229

UNCOV
230
      Map<String, dynamic> body = {
×
231
        "limit": 1,
232
        "search": term,
233

NEW
234
        InvenTreePart.MODEL_TYPE: {},
×
NEW
235
        InvenTreePartCategory.MODEL_TYPE: {},
×
NEW
236
        InvenTreeStockItem.MODEL_TYPE: {},
×
NEW
237
        InvenTreeStockLocation.MODEL_TYPE: {},
×
NEW
238
        InvenTreePurchaseOrder.MODEL_TYPE: {},
×
NEW
239
        InvenTreeSalesOrder.MODEL_TYPE: {},
×
NEW
240
        InvenTreeCompany.MODEL_TYPE: {},
×
NEW
241
        InvenTreeSupplierPart.MODEL_TYPE: {},
×
NEW
242
        InvenTreeManufacturerPart.MODEL_TYPE: {},
×
243
      };
244

NEW
245
      if (body.isNotEmpty) {
×
246

NEW
247
        if (mounted) {
×
NEW
248
          setState(() {
×
NEW
249
            nPendingSearches = 1;
×
250
          });
251

NEW
252
          _search_query = CancelableOperation.fromFuture(
×
NEW
253
            _perform_search(body),
×
254
          );
255
        }
256

257
      }
258
    } else {
259
      legacySearch(term);
×
260
    }
261
  }
262

263
  /*
264
   * Perform "legacy" search (without consolidated search API endpoint
265
   */
266
  Future<void> legacySearch(String term) async {
×
267

268
    // Search parts
269
    if (InvenTreePart().canView) {
×
270
      nPendingSearches++;
×
271
      InvenTreePart().count(searchQuery: term).then((int n) {
×
272
        if (term == searchController.text) {
×
273
          if (mounted) {
×
274
            decrementPendingSearches();
×
275
            setState(() {
×
276
              nPartResults = n;
×
277
            });
278
          }
279
        }
280
      });
281
    }
282

283
    // Search part categories
284
    if (InvenTreePartCategory().canView) {
×
285
      nPendingSearches++;
×
286
      InvenTreePartCategory().count(searchQuery: term,).then((int n) {
×
287
        if (term == searchController.text) {
×
288
          if (mounted) {
×
289
            decrementPendingSearches();
×
290
            setState(() {
×
291
              nCategoryResults = n;
×
292
            });
293
          }
294
        }
295
      });
296
    }
297

298
    // Search stock items
299
    if (InvenTreeStockItem().canView) {
×
300
      nPendingSearches++;
×
301
      InvenTreeStockItem().count(searchQuery: term).then((int n) {
×
302
        if (term == searchController.text) {
×
303
          if (mounted) {
×
304
            decrementPendingSearches();
×
305
            setState(() {
×
306
              nStockResults = n;
×
307
            });
308
          }
309
        }
310
      });
311
    }
312

313
    // Search stock locations
314
    if (InvenTreeStockLocation().canView) {
×
315
      nPendingSearches++;
×
316
      InvenTreeStockLocation().count(searchQuery: term).then((int n) {
×
317
        if (term == searchController.text) {
×
318
          if (mounted) {
×
319
            decrementPendingSearches();
×
320
            setState(() {
×
321
              nLocationResults = n;
×
322
            });
323
          }
324
        }
325
      });
326
    }
327

328
    // Search purchase orders
329
    if (InvenTreePurchaseOrder().canView) {
×
330
     nPendingSearches++;
×
331
      InvenTreePurchaseOrder().count(
×
332
          searchQuery: term,
333
          filters: {
×
334
            "outstanding": "true"
335
          }
336
      ).then((int n) {
×
337
        if (term == searchController.text) {
×
338
          if (mounted) {
×
339
            decrementPendingSearches();
×
340
            setState(() {
×
341
              nPurchaseOrderResults = n;
×
342
            });
343
          }
344
        }
345
      });
346
    }
347
  }
348

349
  @override
×
350
  List<Widget> getTiles(BuildContext context) {
351

352
    List<Widget> tiles = [];
×
353

354
    // Search input
355
    tiles.add(
×
356
      ListTile(
×
357
        title: TextFormField(
×
358
          decoration: InputDecoration(
×
359
            hintText: L10().queryEmpty,
×
360
          ),
361
          key: _formKey,
×
362
          readOnly: false,
363
          autofocus: true,
364
          autocorrect: false,
365
          controller: searchController,
×
366
          onChanged: (String text) {
×
367
            onSearchTextChanged(text);
×
368
          },
369
          onFieldSubmitted: (String text) {
×
370
          },
371
        ),
372
        trailing: GestureDetector(
×
373
          child: Icon(
×
374
            searchController.text.isEmpty ? TablerIcons.search : TablerIcons.backspace,
×
375
            color: searchController.text.isEmpty ? COLOR_ACTION : COLOR_DANGER,
×
376
          ),
377
          onTap: () {
×
378
            searchController.clear();
×
379
            onSearchTextChanged("", immediate: true);
×
380
          },
381
        ),
382
      )
383

384
    );
385

386
    String query = searchController.text;
×
387

388
    List<Widget> results = [];
×
389

390
    // Part Results
391
    if (nPartResults > 0) {
×
392
      results.add(
×
393
        ListTile(
×
394
          title: Text(L10().parts),
×
395
          leading: Icon(TablerIcons.box),
×
396
          trailing: Text("${nPartResults}"),
×
397
          onTap: () {
×
398
            Navigator.push(
×
399
                context,
400
                MaterialPageRoute(
×
401
                    builder: (context) => PartList(
×
402
                        {
×
403
                          "original_search": query
404
                        }
405
                    )
406
                )
407
            );
408
          }
409
        )
410
      );
411
    }
412

413
    // Part Category Results
414
    if (nCategoryResults > 0) {
×
415
      results.add(
×
416
        ListTile(
×
417
          title: Text(L10().partCategories),
×
418
          leading: Icon(TablerIcons.sitemap),
×
419
          trailing: Text("${nCategoryResults}"),
×
420
          onTap: () {
×
421
            Navigator.push(
×
422
              context,
423
              MaterialPageRoute(
×
424
                builder: (context) => PartCategoryList(
×
425
                  {
×
426
                    "original_search": query
427
                  }
428
                )
429
              )
430
            );
431
          },
432
        )
433
      );
434
    }
435

436
    // Stock Item Results
437
    if (nStockResults > 0) {
×
438
      results.add(
×
439
        ListTile(
×
440
          title: Text(L10().stockItems),
×
441
          leading: Icon(TablerIcons.package),
×
442
          trailing: Text("${nStockResults}"),
×
443
          onTap: () {
×
444
            Navigator.push(
×
445
              context,
446
              MaterialPageRoute(
×
447
                builder: (context) => StockItemList(
×
448
                  {
×
449
                    "original_search": query,
450
                  }
451
                )
452
              )
453
            );
454
          },
455
        )
456
      );
457
    }
458

459
    // Stock location results
460
    if (nLocationResults > 0) {
×
461
      results.add(
×
462
        ListTile(
×
463
          title: Text(L10().stockLocations),
×
464
          leading: Icon(TablerIcons.location),
×
465
          trailing: Text("${nLocationResults}"),
×
466
          onTap: () {
×
467
            Navigator.push(
×
468
              context,
469
              MaterialPageRoute(
×
470
                builder: (context) => StockLocationList(
×
471
                  {
×
472
                    "original_search": query
473
                  }
474
                )
475
              )
476
            );
477
          },
478
        )
479
      );
480
    }
481

482
    // Purchase orders
NEW
483
    if (nPurchaseOrderResults > 0) {
×
484
      results.add(
×
485
        ListTile(
×
NEW
486
          title: Text(L10().purchaseOrders),
×
NEW
487
          leading: Icon(TablerIcons.shopping_cart),
×
NEW
488
          trailing: Text("${nPurchaseOrderResults}"),
×
NEW
489
          onTap: () {
×
NEW
490
            Navigator.push(
×
491
              context,
NEW
492
              MaterialPageRoute(
×
NEW
493
                builder: (context) => PurchaseOrderListWidget(
×
NEW
494
                  filters: {
×
495
                    "original_search": query
496
                  }
497
                )
498
              )
499
            );
500
          },
501
        )
502
      );
503
    }
504

505
    // Sales orders
NEW
506
    if (nSalesOrderResults > 0) {
×
NEW
507
      results.add(
×
NEW
508
        ListTile(
×
NEW
509
          title: Text(L10().salesOrders),
×
NEW
510
          leading: Icon(TablerIcons.shopping_cart),
×
NEW
511
          trailing: Text("${nSalesOrderResults}"),
×
NEW
512
          onTap: () {
×
NEW
513
            Navigator.push(
×
514
              context,
NEW
515
              MaterialPageRoute(
×
NEW
516
                builder: (context) => SalesOrderListWidget(
×
NEW
517
                  filters: {
×
518
                    "original_search": query
519
                  }
520
                )
521
              )
522
            );
523
          },
524
        )
525
      );
526
    }
527

528
    // Company results
NEW
529
    if (nCompanyResults > 0) {
×
NEW
530
      results.add(
×
NEW
531
        ListTile(
×
NEW
532
          title: Text(L10().companies),
×
533
          leading: Icon(TablerIcons.building),
×
NEW
534
          trailing: Text("${nCompanyResults}"),
×
535
          onTap: () {
×
536
            Navigator.push(
×
537
              context,
538
              MaterialPageRoute(
×
539
                builder: (context) => CompanyListWidget(
×
NEW
540
                  L10().companies,
×
541
                  {
×
542
                    "original_search": query
543
                  }
544
                )
545
              )
546
            );
547
          },
548
        )
549
      );
550
    }
551

552
    // Supplier part results
NEW
553
    if (nSupplierPartResults > 0) {
×
554
      results.add(
×
555
        ListTile(
×
NEW
556
          title: Text(L10().supplierParts),
×
NEW
557
          leading: Icon(TablerIcons.box),
×
NEW
558
          trailing: Text("${nSupplierPartResults}"),
×
559
          onTap: () {
×
560
            Navigator.push(
×
561
              context,
562
              MaterialPageRoute(
×
NEW
563
                builder: (context) => SupplierPartList(
×
NEW
564
                  {
×
565
                    "original_search": query
566
                  }
567
                )
568
              )
569
            );
570
          },
571
        )
572
      );
573
    }
574

575
    if (isSearching()) {
×
576
      tiles.add(
×
577
        ListTile(
×
578
          title: Text(L10().searching),
×
579
          leading: Icon(TablerIcons.search),
×
580
          trailing: CircularProgressIndicator(),
×
581
        )
582
      );
583
    }
584

585
    if (!isSearching() && results.isEmpty && searchController.text.isNotEmpty) {
×
586
      tiles.add(
×
587
        ListTile(
×
588
          title: Text(
×
589
            L10().queryNoResults,
×
590
            style: TextStyle(fontStyle: FontStyle.italic),
×
591
          ),
592
          leading: Icon(TablerIcons.zoom_cancel),
×
593
        )
594
      );
595
    } else {
596
      for (Widget result in results) {
×
597
        tiles.add(result);
×
598
      }
599
    }
600

601
    return tiles;
602
  }
603

604
}
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

© 2025 Coveralls, Inc