Skip to content

Commit 2a13128

Browse files
committed
Improve Oqtane navigation, filter, and sort UX
- Add SKILL.md for returnurl navigation best practices in Oqtane modules - Refactor Edit.razor Back/Cancel to preserve filter/sort state and avoid Blazor 404s - Redesign Grower and Mentor Index filter/sort bars with flex layout - Add client-side sorting (fields, direction) for growers and mentors - Pass filter/sort state in Edit links and restore from URL - Use Open Iconic icons for navigation buttons throughout
1 parent c50c434 commit 2a13128

4 files changed

Lines changed: 323 additions & 68 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
---
2+
name: oqtane-navigation-returnurl
3+
description: |
4+
How to implement Back/Cancel navigation in Oqtane module Edit pages that
5+
correctly returns to the Index with filter and sort state preserved.
6+
Use when: adding a Back link to an Edit.razor, passing filter/sort state
7+
through to Edit and back, or debugging 404 errors caused by returnurl with
8+
module-action path segments (*/moduleId) in the URL.
9+
author: Skiller
10+
version: 1.0.0
11+
date: 2026-06-10
12+
---
13+
14+
# Oqtane Navigation & Return URLs
15+
16+
## Problem
17+
18+
Oqtane module-action URLs contain `*/moduleId` path segments (e.g.
19+
`/grower/*/34/Edit`). If you try to navigate back using a stored `returnurl`
20+
that contains this segment — via `NavigationManager.NavigateTo` or an `href`
21+
— Blazor's client-side router sees `*` as a literal wildcard and 404s before
22+
Oqtane's `SiteRouter` ever parses it.
23+
24+
## Context / Trigger Conditions
25+
26+
- Adding a Back/Cancel button to an Edit.razor that should return to Index
27+
- Passing filter or sort state from Index → Edit → back to Index
28+
- Seeing 404 when using `NavigationManager.NavigateTo(returnurl)` where
29+
`returnurl` was built from `EditUrl(...)` or `PageState.Route.PathAndQuery`
30+
- URLs like `%2Fgrower%2F*%2F34%3Fstatus%3DActive` always 404 when navigated to
31+
32+
## Solution
33+
34+
### Back / Cancel navigation in Edit.razor
35+
36+
**Never store or navigate to a `returnurl` that contains `*/moduleId`.**
37+
38+
Use `NavigateUrl(PageState.Page.Path, PageState.QueryString)` instead:
39+
40+
```razor
41+
<!-- Back link (href) -->
42+
<a href="@NavigateUrl(PageState.Page.Path, PageState.QueryString)"
43+
class="btn btn-sm btn-outline-secondary">
44+
<span class="oi oi-arrow-left" aria-hidden="true"></span> Back
45+
</a>
46+
```
47+
48+
```csharp
49+
// Cancel button / NavigateBack() method
50+
private void NavigateBack()
51+
{
52+
NavigationManager.NavigateTo(NavigateUrl(PageState.Page.Path, PageState.QueryString));
53+
}
54+
```
55+
56+
This produces a clean page URL like `/grower?status=Active&villageId=2` — no
57+
action segment — which routes correctly every time. `PageState.QueryString` is
58+
the dictionary Oqtane already decoded from the current URL, so all params
59+
(filters, sort, etc.) come along for free.
60+
61+
### Passing filter/sort state from Index to Edit
62+
63+
Append the filter/sort query string to the `ActionLink` `Parameters`:
64+
65+
```razor
66+
<ActionLink Action="Edit"
67+
Parameters="@($"id={grower.GrowerId}&{BuildFilterQueryString()}")"
68+
... />
69+
```
70+
71+
Where `BuildFilterQueryString()` emits only non-default params:
72+
73+
```csharp
74+
private string BuildFilterQueryString()
75+
{
76+
var parts = new List<string>();
77+
if (!string.IsNullOrEmpty(_statusFilter)) parts.Add($"status={Uri.EscapeDataString(_statusFilter)}");
78+
if (!string.IsNullOrEmpty(_villageFilter)) parts.Add($"villageId={Uri.EscapeDataString(_villageFilter)}");
79+
if (_sortBy != "lastname") parts.Add($"sortBy={Uri.EscapeDataString(_sortBy)}");
80+
if (!_sortAsc) parts.Add("sortDesc=1");
81+
return string.Join("&", parts);
82+
}
83+
```
84+
85+
Oqtane appends these to the Edit URL query string, so on the Edit page
86+
`PageState.QueryString` contains all of them. `NavigateUrl(PageState.Page.Path,
87+
PageState.QueryString)` then carries them back to Index automatically.
88+
89+
### Keeping the URL in sync with filter/sort state
90+
91+
Wire every filter and sort control to call `NavigateWithFilters()` via
92+
`@bind:after` or a wrapper method, so the browser URL always reflects current
93+
state. This means `PageState.QueryString` is always current when Edit is opened.
94+
95+
```razor
96+
<select @bind="_sortBy" @bind:after="NavigateWithFilters"> ... </select>
97+
<button @onclick="ToggleSortDirAndNavigate"> ... </button>
98+
```
99+
100+
```csharp
101+
private void NavigateWithFilters()
102+
{
103+
NavigationManager.NavigateTo(EditUrl("", BuildFilterQueryString()));
104+
}
105+
```
106+
107+
> **Note:** `EditUrl("", qs)` is correct for navigating within the same module
108+
> on the same page. It embeds the module ID: `/grower/*/34?status=Active`.
109+
110+
### Restoring state from the URL on load
111+
112+
```csharp
113+
protected override async Task OnParametersSetAsync()
114+
{
115+
_statusFilter = PageState.QueryString.GetValueOrDefault("status", "");
116+
_villageFilter = PageState.QueryString.GetValueOrDefault("villageId", "");
117+
_sortBy = PageState.QueryString.GetValueOrDefault("sortBy", "lastname");
118+
_sortAsc = !PageState.QueryString.ContainsKey("sortDesc");
119+
// ...load data...
120+
}
121+
```
122+
123+
## What Was Tried (and why it failed)
124+
125+
| Approach | Why it fails |
126+
|---|---|
127+
| `NavigationManager.NavigateTo(PageState.QueryString["returnurl"])` | `PageState.QueryString["returnurl"]` is decoded; `/grower/*/34` causes Blazor router 404 |
128+
| `href="@PageState.QueryString["returnurl"]"` | Same — `*` in path is a Blazor wildcard |
129+
| `PageState.Route.PathAndQuery` as returnurl | Captures `/grower/*/34/Edit?id=1` — contains the action segment, still 404s on back-nav |
130+
| `EditUrl("")` as fallback | Goes to module index but loses all filter/sort state |
131+
| `Uri.EscapeDataString` for encoding returnurl | Should use `WebUtility.UrlEncode` to match Oqtane's own convention |
132+
133+
## Verification
134+
135+
1. Apply filters and sort on Index — URL updates in address bar
136+
2. Click Edit on a row — Edit URL contains all filter/sort params in query string
137+
3. Click Back — lands on Index with same filters and sort active
138+
4. No 404 at any step
139+
140+
## Notes
141+
142+
- `NavigateUrl()` (no args) = current page, no query string — loses filter state
143+
- `NavigateUrl(PageState.Page.Path, PageState.QueryString)` = current page + all
144+
current query params — the correct pattern for Back/Cancel
145+
- Oqtane's own admin modules (Users, Files, etc.) use `NavigateUrl()` with no
146+
state to preserve because they don't have filter state to restore
147+
- `PageState.QueryString` is already decoded by `SiteRouter` via
148+
`WebUtility.UrlDecode` — never decode it again before use
149+
150+
## References
151+
152+
- `.oqtane-ref/Oqtane.Client/UI/SiteRouter.razor` — returnurl decode logic (line ~117)
153+
- `.oqtane-ref/Oqtane.Shared/Models/Route.cs` — how `*/moduleId` is parsed from path
154+
- `.oqtane-ref/Oqtane.Client/Modules/ModuleBase.cs``NavigateUrl` overloads
155+
- `.oqtane-ref/Oqtane.Client/Themes/Controls/Theme/LoginBase.cs``WebUtility.UrlEncode` pattern
156+
157+
## Activation History
158+
159+
- 2026-06-10 Grower Index/Edit — filter+sort state preservation with Back link

Client/Modules/Grower/Edit.razor

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ else
2525
<!-- Grower Info -->
2626
<div class="card mb-3">
2727
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
28-
<span>@(_growerId == 0 ? "Add Grower" : "Edit Grower")</span>
28+
<div class="d-flex align-items-center gap-2">
29+
<a href="@NavigateUrl(PageState.Page.Path, PageState.QueryString)"
30+
class="btn btn-sm btn-outline-secondary"
31+
aria-label="Back to grower list">
32+
<span class="oi oi-arrow-left" aria-hidden="true"></span> Back
33+
</a>
34+
<span>@(_growerId == 0 ? "Add Grower" : "Edit Grower")</span>
35+
</div>
2936
@if (_growerId > 0)
3037
{
3138
<div class="d-flex align-items-center gap-2 flex-wrap">
@@ -636,10 +643,7 @@ else
636643

637644
private void NavigateBack()
638645
{
639-
if (PageState.QueryString.ContainsKey("returnurl"))
640-
NavigationManager.NavigateTo(PageState.QueryString["returnurl"]);
641-
else
642-
NavigationManager.NavigateTo(NavigateUrl());
646+
NavigationManager.NavigateTo(NavigateUrl(PageState.Page.Path, PageState.QueryString));
643647
}
644648

645649
private string GetStatusBadgeClass(GrowerStatus status) => status switch

Client/Modules/Grower/Index.razor

Lines changed: 91 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,43 +13,62 @@
1313
@inject IStringLocalizer<Index> Localizer
1414

1515
<div class="container">
16-
<!-- Filter Section -->
17-
<div class="row mb-3">
18-
<div class="col-md-4">
19-
<label for="statusFilter">@Localizer["Filter.Status"]</label>
20-
<select id="statusFilter" class="form-select" @bind="_statusFilter" @bind:after="NavigateWithFilters">
21-
<option value="">@Localizer["Filter.AllStatuses"]</option>
22-
<option value="@GrowerStatus.Active">@Localizer["Status.Active"]</option>
23-
<option value="@GrowerStatus.Inactive">@Localizer["Status.Inactive"]</option>
24-
<option value="@GrowerStatus.Exited">@Localizer["Status.Exited"]</option>
25-
</select>
26-
</div>
27-
<div class="col-md-3">
28-
<label for="villageFilter">@Localizer["Filter.Village"]</label>
29-
<select id="villageFilter" class="form-select" @bind="_villageFilter" @bind:after="NavigateWithFilters">
30-
<option value="">@Localizer["Filter.AllVillages"]</option>
31-
@if (_villages != null)
32-
{
33-
@foreach (var village in _villages)
16+
<!-- Filter / Sort bar -->
17+
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-3">
18+
<!-- Filters (left) -->
19+
<div class="d-flex flex-wrap gap-2">
20+
<div>
21+
<label for="statusFilter" class="form-label">@Localizer["Filter.Status"]</label>
22+
<select id="statusFilter" class="form-select" @bind="_statusFilter" @bind:after="NavigateWithFilters">
23+
<option value="">@Localizer["Filter.AllStatuses"]</option>
24+
<option value="@GrowerStatus.Active">@Localizer["Status.Active"]</option>
25+
<option value="@GrowerStatus.Inactive">@Localizer["Status.Inactive"]</option>
26+
<option value="@GrowerStatus.Exited">@Localizer["Status.Exited"]</option>
27+
</select>
28+
</div>
29+
<div>
30+
<label for="villageFilter" class="form-label">@Localizer["Filter.Village"]</label>
31+
<select id="villageFilter" class="form-select" @bind="_villageFilter" @bind:after="NavigateWithFilters">
32+
<option value="">@Localizer["Filter.AllVillages"]</option>
33+
@if (_villages != null)
3434
{
35-
<option value="@village.VillageId">@village.VillageName</option>
35+
@foreach (var village in _villages)
36+
{
37+
<option value="@village.VillageId">@village.VillageName</option>
38+
}
3639
}
37-
}
38-
</select>
39-
</div>
40-
<div class="col-md-3">
41-
<label for="cohortFilter">Cohort</label>
42-
<select id="cohortFilter" class="form-select" @bind="_cohortFilter" @bind:after="LoadGrowersAsync">
43-
<option value="">All Cohorts</option>
44-
@foreach (var cohort in _cohorts)
45-
{
46-
<option value="@cohort.CohortId">@cohort.Name</option>
47-
}
48-
</select>
40+
</select>
41+
</div>
42+
<div>
43+
<label for="cohortFilter" class="form-label">Cohort</label>
44+
<select id="cohortFilter" class="form-select" @bind="_cohortFilter" @bind:after="LoadGrowersAsync">
45+
<option value="">All Cohorts</option>
46+
@foreach (var cohort in _cohorts)
47+
{
48+
<option value="@cohort.CohortId">@cohort.Name</option>
49+
}
50+
</select>
51+
</div>
52+
<div class="d-flex align-items-end gap-2">
53+
<button class="btn btn-primary" @onclick="ApplyFilters">Filter</button>
54+
<button class="btn btn-secondary" @onclick="ClearFilters">@Localizer["Action.ClearFilters"]</button>
55+
</div>
4956
</div>
50-
<div class="col-md-2 d-flex align-items-end gap-2">
51-
<button class="btn btn-primary w-100" @onclick="ApplyFilters">Filter</button>
52-
<button class="btn btn-secondary w-100" @onclick="ClearFilters">@Localizer["Action.ClearFilters"]</button>
57+
<!-- Sort (right) -->
58+
<div class="d-flex align-items-end gap-2">
59+
<div>
60+
<label for="sortBy" class="form-label">Sort by</label>
61+
<select id="sortBy" class="form-select" @bind="_sortBy" @bind:after="NavigateWithFilters">
62+
<option value="lastname">Last Name</option>
63+
<option value="firstname">First Name</option>
64+
<option value="village">Village</option>
65+
<option value="status">Status</option>
66+
<option value="exitdate">Exit Date</option>
67+
</select>
68+
</div>
69+
<button class="btn btn-outline-secondary" @onclick="ToggleSortDirAndNavigate" title="@(_sortAsc ? "Ascending" : "Descending")" aria-label="Toggle sort direction">
70+
<span class="oi @(_sortAsc ? "oi-sort-ascending" : "oi-sort-descending")" aria-hidden="true"></span>
71+
</button>
5372
</div>
5473
</div>
5574

@@ -114,7 +133,7 @@
114133
</tr>
115134
</thead>
116135
<tbody>
117-
@foreach (var grower in _growers)
136+
@foreach (var grower in SortedGrowers())
118137
{
119138
var rowClass = grower.Status == GrowerStatus.Inactive ? "table-warning" :
120139
grower.Status == GrowerStatus.Exited ? "table-secondary" : "";
@@ -131,7 +150,7 @@
131150
<td>
132151
<div class="d-flex gap-1">
133152
<ActionLink Action="Edit"
134-
Parameters="@($"id={grower.GrowerId}&returnurl={Uri.EscapeDataString(GetReturnUrl())}")"
153+
Parameters="@($"id={grower.GrowerId}&{BuildFilterQueryString()}")"
135154
Text="Edit"
136155
ResourceKey="EditGrower"
137156
Class="btn btn-sm btn-primary" />
@@ -162,6 +181,16 @@
162181
private string _villageFilter = "";
163182
private string _cohortFilter = "";
164183
private bool _isLoading = false;
184+
private string _sortBy = "lastname";
185+
private bool _sortAsc = true;
186+
187+
private void ToggleSortDir() => _sortAsc = !_sortAsc;
188+
189+
private void ToggleSortDirAndNavigate()
190+
{
191+
_sortAsc = !_sortAsc;
192+
NavigateWithFilters();
193+
}
165194

166195
protected override async Task OnParametersSetAsync()
167196
{
@@ -177,6 +206,8 @@
177206
{
178207
_statusFilter = PageState.QueryString.GetValueOrDefault("status", "");
179208
_villageFilter = PageState.QueryString.GetValueOrDefault("villageId", "");
209+
_sortBy = PageState.QueryString.GetValueOrDefault("sortBy", "lastname");
210+
_sortAsc = !PageState.QueryString.ContainsKey("sortDesc");
180211
await LoadVillagesAsync();
181212
await LoadCohortsForMentorAsync();
182213
await LoadSummaryAsync();
@@ -279,6 +310,27 @@
279310
StateHasChanged();
280311
}
281312

313+
private static (string Last, string First) SplitName(string fullName)
314+
{
315+
var trimmed = fullName?.Trim() ?? string.Empty;
316+
var idx = trimmed.LastIndexOf(' ');
317+
return idx < 0 ? (trimmed, string.Empty) : (trimmed[(idx + 1)..], trimmed[..idx]);
318+
}
319+
320+
private IEnumerable<Models.Grower> SortedGrowers()
321+
{
322+
IOrderedEnumerable<Models.Grower> sorted = _sortBy switch
323+
{
324+
"firstname" => _growers.OrderBy(g => SplitName(g.GrowerName).First),
325+
"village" => _growers.OrderBy(g => GetVillageName(g.VillageId)),
326+
"status" => _growers.OrderBy(g => g.Status),
327+
"exitdate" => _growers.OrderBy(g => g.ExitDate),
328+
_ => _growers.OrderBy(g => SplitName(g.GrowerName).Last)
329+
.ThenBy(g => SplitName(g.GrowerName).First),
330+
};
331+
return _sortAsc ? sorted : sorted.Reverse();
332+
}
333+
282334
private void ClearFilters()
283335
{
284336
NavigationManager.NavigateTo(NavigateUrl());
@@ -300,19 +352,15 @@
300352
var parts = new List<string>();
301353
if (!string.IsNullOrEmpty(_statusFilter)) parts.Add($"status={Uri.EscapeDataString(_statusFilter)}");
302354
if (!string.IsNullOrEmpty(_villageFilter)) parts.Add($"villageId={Uri.EscapeDataString(_villageFilter)}");
355+
if (_sortBy != "lastname") parts.Add($"sortBy={Uri.EscapeDataString(_sortBy)}");
356+
if (!_sortAsc) parts.Add("sortDesc=1");
303357
return string.Join("&", parts);
304358
}
305359

306360
private void NavigateWithFilters()
307361
{
308362
var queryString = BuildFilterQueryString();
309-
NavigationManager.NavigateTo(NavigateUrl(PageState.Page.Path, queryString));
310-
}
311-
312-
private string GetReturnUrl()
313-
{
314-
var queryString = BuildFilterQueryString();
315-
return NavigateUrl(PageState.Page.Path, queryString);
363+
NavigationManager.NavigateTo(EditUrl("", queryString));
316364
}
317365

318366
private string GetVillageName(int villageId)

0 commit comments

Comments
 (0)