Repository: btcpayserver/btcpayserver (csharp)

Issue:
[Bug][UI]: Invoices could be permanently filtered via SearchTerm stored in cookies
### What is your BTCPay version?

I'm running BTCPay Server v1.13.1+ca55e1f30

### How did you deploy BTCPay Server?

I'm running BTCPay Server on dedicated VPS deployed with Docker

### What happened?

If you are filtering out Invoices list by clicking "Invoices" in Requests list - it goes into cookies and Invoices list became filtered forever

### How did you encounter this bug?

1. Install BTCPay server
2. Create store and wallet
3. Make some invoices
4. Make a Payment Request
5. Open that request and try to pay it, that should create invoices with Order Id linked to request - e.g. PAY_REQUEST_ID
6. Observe that now we have different invoices in Invoices list
7. Go to Requests page, click three dots at the end of the request row and click "Invoices"
8. Observe Invoices list which only contain invoices that linked to the request
9. Click anywhere else on the web UI and click again on the Invoices - you should return to the Invoices list which will be still filtered by the request with no obvious way to reset this filter

### Relevant log output

_No response_

### What browser do you use?

Chrome 124.0.6367.119

### Additional information

There are three ways to reset filter:
1. LogOut-LogIn
2. Put focus on search input in Invoices page, press Enter, that will bring params into URL, delete SearchTerm content in URL, press Enter to reload page
3. Delete SearchTerm field from cookies

### Are you sure this is a bug report?

- [X] I confirm this is a bug report

Pull request:
This changes the search text input to also display the filters, which don't have a special UI (e.g. dropdown). Those filters (e.g. orderid) were not displayed before and hence could not be reset.

Fixes #5984.

![grafik](https://github.com/btcpayserver/btcpayserver/assets/886/942ed045-acbe-4c26-ba96-80da026d86f6)

Patch:
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index af1e6c66ce..f99d71f2e7 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -1111,7 +1111,7 @@ public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
                     storeIds.Add(i);
             }
             model.Search = fs;
-            model.SearchText = fs.TextSearch;
+            model.SearchText = fs.TextCombined;
 
             var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
             InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps, timezoneOffset);
diff --git a/BTCPayServer/SearchString.cs b/BTCPayServer/SearchString.cs
index 004084bc86..03d9348f32 100644
--- a/BTCPayServer/SearchString.cs
+++ b/BTCPayServer/SearchString.cs
@@ -9,7 +9,8 @@ public class SearchString
     {
         private const char FilterSeparator = ',';
         private const char ValueSeparator = ':';
-        
+        private static readonly string[] StripFilters = ["status", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate"];
+
         private readonly string _originalString;
         private readonly int _timezoneOffset;
 
@@ -27,12 +28,18 @@ public SearchString(string str, int timezoneOffset = 0)
                     .Where(kv => kv.Length == 2)
                     .Select(kv => new KeyValuePair<string, string>(UnifyKey(kv[0]), kv[1]))
                     .ToMultiValueDictionary(o => o.Key, o => o.Value);
-
-            var val = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1);
-            TextSearch = val != null ? val.Trim() : string.Empty;
+            // combine raw search term and filters which don't have a special UI (e.g. orderid)
+            var textFilters = Filters
+                .Where(f => !StripFilters.Contains(f.Key))
+                .Select(f => string.Join(FilterSeparator, f.Value.Select(v => $"{f.Key}{ValueSeparator}{v}"))).ToList();
+            TextFilters = textFilters.Any() ? string.Join(FilterSeparator, textFilters) : null;
+            TextSearch = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1)?.Trim();
         }
 
         public string TextSearch { get; private set; }
+        public string TextFilters { get; private set; }
+
+        public string TextCombined => string.Join(FilterSeparator, new []{ TextFilters, TextSearch }.Where(x => !string.IsNullOrEmpty(x)));
 
         public MultiValueDictionary<string, string> Filters { get; }
 
@@ -82,9 +89,10 @@ public string Toggle(string key, string value)
 
         public string WithoutSearchText()
         {
-            return string.IsNullOrEmpty(TextSearch)
-                ? Finalize(ToString())
-                : Finalize(ToString()).Replace(TextSearch, string.Empty);
+            var txt = ToString();
+            if (!string.IsNullOrEmpty(TextSearch)) txt = Finalize(txt.Replace(TextSearch, string.Empty));
+            if (!string.IsNullOrEmpty(TextFilters)) txt = Finalize(txt.Replace(TextFilters, string.Empty));
+            return Finalize(txt).Trim();
         }
 
         public string[] GetFilterArray(string key)
@@ -144,7 +152,7 @@ private string UnifyKey(string key)
         
         private static string Finalize(string str)
         {
-            var value = str.TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
+            var value = str.Trim().TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
             return string.IsNullOrEmpty(value) ? " " : value;
         }
     }


Test patch:
diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs
index 58cc75cfe5..d5d38e26c5 100644
--- a/BTCPayServer.Tests/FastTests.cs
+++ b/BTCPayServer.Tests/FastTests.cs
@@ -1299,17 +1299,22 @@ public void CanParseFilter()
 
             filter = "status:abed, status:abed2";
             search = new SearchString(filter);
-            Assert.Equal("", search.TextSearch);
+            Assert.Null(search.TextSearch);
+            Assert.Null(search.TextFilters);
             Assert.Equal("status:abed, status:abed2", search.ToString());
             Assert.Throws<KeyNotFoundException>(() => search.Filters["test"]);
             Assert.Equal(2, search.Filters["status"].Count);
             Assert.Equal("abed", search.Filters["status"].First());
             Assert.Equal("abed2", search.Filters["status"].Skip(1).First());
 
-            filter = "StartDate:2019-04-25 01:00 AM, hekki";
+            filter = "StartDate:2019-04-25 01:00 AM, hekki,orderid:MYORDERID,orderid:MYORDERID_2";
             search = new SearchString(filter);
             Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
             Assert.Equal("hekki", search.TextSearch);
+            Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2", search.TextFilters);
+            Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2,hekki", search.TextCombined);
+            Assert.Equal("StartDate:2019-04-25 01:00 AM", search.WithoutSearchText());
+            Assert.Equal(filter, search.ToString());
 
             // modify search
             filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";