I have a list of pages, each using the same Blazor page component, of which the content is generated dynamically based on the page name. The content is split across different tabs. Due to some Blazor rendering issue, when navigating between pages, not everything is updated, resulting in the update of my tab content, but my tab headers are not.
Everything is done in .NET Core 3.1 LTS, as I am not able to upgrade to .NET 5 yet due to various other constraints.
Given, as an example, the following page content:
pageContent.Add("page-1", new Dictionary<string, string>
{
{ "tab a", "This is page 1, tab a" },
{ "tab b", "This is page 1, tab b" },
{ "tab c", "This is page 1, tab c" }
});
pageContent.Add("page-2", new Dictionary<string, string>
{
{ "tab d", "This is page 2, tab d" },
{ "tab e", "This is page 2, tab e" }
});
The result is the following:
As you can see, on page 2 the content is updated, but the tab headers are not. However, if I move back from page 2 to page 1, the content of page 1 is displayed, but now with the tab headers of page 2 are shown.
My tab control is based on various samples found online, and although it contains a lot more functionality, I tried to reduce it to a very basic working example which reproduces the problem.
PageContentService.cs
public Dictionary<string, string> GetPageContent(string pageName)
=> pageContent.ContainsKey(pageName) ? pageContent[pageName] : new Dictionary<string, string>();
DynamicPage.razor
#page "/dynamic/{PageName}"
<MyDynamicContent PageName="#PageName"/>
#code {
[Parameter]
public string PageName { get; set; }
}
MyDynamicContent.razor
#if (isLoading)
{
<p>Loading...</p>
}
else
{
<MyTabs SelectedTab="#selectedTabTitle" OnSelectedTabChanged="OnSelectedTabChanged">
#foreach (var (tabId, tabContent) in currentPage)
{
<MyTabItem Title="#tabId">
#tabContent
</MyTabItem>
}
</MyTabs>
}
#code {
private bool isLoading;
private string selectedTabTitle;
private Dictionary<string, string> currentPage;
[Parameter]
public string PageName { get; set; }
[Inject]
public IPageContentService PageContentService { get; set; }
protected override void OnParametersSet()
{
base.OnParametersSet();
isLoading = true;
currentPage = PageContentService.GetPageContent(PageName);
if (currentPage != null && currentPage.Count > 0)
{
selectedTabTitle = currentPage.First().Key;
}
isLoading = false;
}
public void OnSelectedTabChanged(string title)
=> selectedTabTitle = title;
}
MyTabs.razor
<CascadingValue Value="#(this)" IsFixed="true">
<CascadingValue Value="#selectedTabName">
<div>
<ul class="nav nav-tabs">
#foreach (var item in tabItems)
{
var aCss = "nav-link";
if (item.Active)
{
aCss += " active show";
}
<li class="nav-item">
<a class="#aCss" tabindex="0" #onclick="async () => await item.OnTabClicked()">
#item.Title
</a>
</li>
}
</ul>
<div class="tab-content">
#ChildContent
</div>
</div>
</CascadingValue>
</CascadingValue>
#code {
private readonly List<MyTabItem> tabItems = new List<MyTabItem>();
private string selectedTabName;
[Parameter]
public string SelectedTab
{
get => selectedTabName;
set
{
if (selectedTabName != value)
{
selectedTabName = value;
OnSelectedTabChanged.InvokeAsync(value);
}
}
}
[Parameter]
public EventCallback<string> OnSelectedTabChanged { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; }
public IReadOnlyList<MyTabItem> TabItems
=> tabItems;
public async Task ChangeSelectedTab(string title)
{
SelectedTab = title;
await InvokeAsync(StateHasChanged);
}
public async Task AddTab(MyTabItem tabItem)
{
if (tabItems.All(x => x.Title != tabItem.Title))
{
tabItems.Add(tabItem);
await InvokeAsync(StateHasChanged);
}
}
public async Task RemoveTab(MyTabItem tabItem)
{
if (tabItems.Any(x => x.Title == tabItem.Title))
{
tabItems.Remove(tabItem);
await InvokeAsync(StateHasChanged);
}
}
}
MyTabItem.razor
#implements IAsyncDisposable
#{
var css = "tab-pane";
if (Active)
{
css += " active show";
}
}
<div class="#css">
#if (Active)
{
#ChildContent
}
</div>
#code {
[Parameter]
public string Title { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; }
[CascadingParameter]
public MyTabs ParentTabs { get; set; }
public bool Active
=> ParentTabs.SelectedTab == Title;
public async Task OnTabClicked()
{
if (ParentTabs != null)
{
await ParentTabs.ChangeSelectedTab(Title);
}
}
protected override async Task OnInitializedAsync()
{
if (ParentTabs != null)
{
await ParentTabs.AddTab(this);
}
await base.OnInitializedAsync();
}
public ValueTask DisposeAsync()
=> ParentTabs != null ? new ValueTask(ParentTabs.RemoveTab(this)) : new ValueTask(Task.CompletedTask);
}
My question is very simple: Why is my tab content updating, but my tab headers are not? And how do I get them to update when navigating between pages?
Although adding the #key property to each list isn't a bad idea, as #MisterMagoo suggested in the comments, it wasn't wat ultimately fixed it.
The trick was to make every async method synchronous.
MyTabs.razor
public void ChangeSelectedTab(string title)
{
SelectedTab = title;
StateHasChanged;
}
public void AddTab(MyTabItem tabItem)
{
if (tabItems.All(x => x.Title != tabItem.Title))
{
tabItems.Add(tabItem);
StateHasChanged;
}
}
public void RemoveTab(MyTabItem tabItem)
{
if (tabItems.Any(x => x.Title == tabItem.Title))
{
tabItems.Remove(tabItem);
StateHasChanged;
}
}
MyTabItem.razor
public void OnTabClicked()
{
ParentTabs?.ChangeSelectedTab(Title);
}
protected override void OnInitialized()
{
ParentTabs?.AddTab(this);
base.OnInitialized();
}
public void Dispose()
{
ParentTabs?.RemoveTab(this);
}
So the async methods seem to really change how the render pipeline behaves, and do things seemingly out of order. I guess that makes a bit sense, and might have some benefits too it, but it isn't what I needed in this scenario.
Related
I am new to blazor and need to create a dynamic treeview where nodes can be created and deleted. All this data is saved in database. After pulling the data from database what kind of object I need to create to bind to Mudtreeview?
If only first level nodes are rendered initially, how can I load the children on node click?
is there any example somewhere? All the sample code on mudblazor site is with static data.
Adding and removeing items is easy if you bind the treeview to a collection in MudBlazor. I made a little demo for you to can play around online: https://try.mudblazor.com/snippet/cuwlvFQcfJTzHuki
Here is the full code of the example:
<MudButton OnClick="AddItems" Variant="Variant.Filled">Add items</MudButton>
<MudButton OnClick="DeleteItems" Variant="Variant.Filled">Delete items</MudButton>
<MudPaper Width="300px" Elevation="0">
<MudTreeView Items="TreeItems" MultiSelection="true" #bind-ActivatedValue="ActivatedValue" #bind-SelectedValues="SelectedValues">
<ItemTemplate>
<MudTreeViewItem #bind-Expanded="#context.IsExpanded" Items="#context.TreeItems" Value="#context"
Icon="#context.Icon" Text="#context.Title" EndText="#context.Number?.ToString()" EndTextTypo="#Typo.caption" />
</ItemTemplate>
</MudTreeView>
</MudPaper>
<div style="width: 100%">
<MudText Typo="#Typo.subtitle1">Activated item: #(ActivatedValue?.Title)</MudText>
<MudText Typo="#Typo.subtitle1">Sum of selected items: #GetSelectedSum()</MudText>
</div>
#code {
private TreeItemData ActivatedValue { get; set; }
private HashSet<TreeItemData> SelectedValues { get; set; }
private HashSet<TreeItemData> TreeItems { get; set; } = new HashSet<TreeItemData>();
public class TreeItemData
{
public string Title { get; set; }
public string Icon { get; set; }
public int? Number { get; set; }
public bool IsExpanded { get; set; }
public HashSet<TreeItemData> TreeItems { get; set; }
public TreeItemData(string title, string icon, int? number = null)
{
Title = title;
Icon = icon;
Number = number;
}
public override bool Equals(object x) {
var other = x as TreeItemData;
if (other==null)
return false;
return other.Title==Title;
}
public override int GetHashCode() {
return Title.GetHashCode();
}
}
protected override void OnInitialized()
{
TreeItems.Add(new TreeItemData("All Mail", Icons.Filled.Email));
TreeItems.Add(new TreeItemData("Trash", Icons.Filled.Delete));
TreeItems.Add(new TreeItemData("Categories", Icons.Filled.Label)
{
IsExpanded = true,
TreeItems = new HashSet<TreeItemData>()
{
new TreeItemData("Social", Icons.Filled.Group, 90),
new TreeItemData("Updates", Icons.Filled.Info, 2294),
new TreeItemData("Forums", Icons.Filled.QuestionAnswer, 3566),
new TreeItemData("Promotions", Icons.Filled.LocalOffer, 733)
}
});
TreeItems.Add(new TreeItemData("History", Icons.Filled.Label));
}
public int GetSelectedSum()
{
return SelectedValues?.Sum(i => i.Number ?? 0) ?? 0;
}
private int i=0;
public void AddItems() {
TreeItems.Add(new TreeItemData("Added Item " + (i++), Icons.Filled.Coronavirus));
}
public void DeleteItems() {
var item=TreeItems.FirstOrDefault(x=>x.Title.StartsWith("Added Item"));
TreeItems.Remove(item);
}
}
Anyone who knows how to initialize a list of checkboxes with only myPicketItemsStoredInDabase items marked as checked and all other items marked as not checked?
When I try the code example below all checkboxes is marked as checked. I have tried with different solutions but non of them ends upp with a list of checkboxes with the right items marked as checked.
public class CheckboxItem
{
public Guid Id { get; set; }
public string Name { get; set; }
public bool Checked { get; set; }
}
public class ItemFromDatabase
{
public Guid Id { get; set; }
public string Name { get; set; }
}
Page.razor
#foreach (var item in itemsToEdit)
{
<CheckBoxItemComponent Items="#itemsToEdit" Value="#item.Name" Item="#item" />
}
#code {
private IEnumerable<ItemFromDatabase> myPickedItemsStoredInDatabase;
private IEnumerable<ItemFromDatabase> allItems;
private List<CheckboxItem> itemsToEdit;
protected override async Task OnInitializedAsync()
{
myPickedItemsStoredInDatabase = await getFromJsonAsync<ItemFromDatabase>.....
allItems= await getFromJsonAsync<ItemFromDatabase>.....
InitCheckboxItems();
}
private void InitCheckboxItems()
{
itemsToEdit = new List<CheckboxItem>();
for (int i = 0; i < allItems.Length; i++)
{
CheckboxItem item = new CheckboxItem()
{
Id = allItems[i].Id,
Name = allItems[i].Name,
Checked = false
};
if (myPickedItemsStoredInDatabase.Any(p => p.Id == item.Id))
{
item.Checked = true;
}
itemsToEdit.Add(item);
}
}
}
CheckBoxItemComponent.razor
Activate multiple checkbox in a loop using Blazor
<input type="checkbox" #bind=isChecked />#Item.Name<br />
#code
{
[Parameter]
public string Value { get; set; }
[Parameter]
public CheckboxItem Item { get; set; }
[Parameter]
public List<CheckboxItem> Items { get; set; }
private bool isChecked
{
get => Items.Any(el => el.Name == Value);
set
{
if (value)
{
if (!Items.Any(el => el.Name == Value))
Items.Add(Item);
}
else
Items.Remove(Item);
}
}
}`
I don't understand what should happen in isChecked.
To set checked you can modify the CheckBoxItemComponent.razor like this:
<input type="checkbox" #bind=isChecked /> #Item.Name
<br />
#code
{
[Parameter]
public CheckboxItem Item { get; set; }
public bool isChecked
{
get => this.Item.Checked;
set => this.Item.Checked = value;
}
}
And use it in Page.razor:
#foreach (var item in itemsToEdit)
{
<CheckBoxItemComponent Item="#item" />
}
You need to be very careful when using:
#foreach (var item in itemsToEdit)
{
<CheckBoxItemComponent Items="#itemsToEdit" Value="#item.Name" Item="#item" />
}
This is Razor, not a C# code file. You can find and examine the C# pre-compiled file in obj/debug/net5.0/razor/pages/page.razor.g.cs if your razor file is called page in the pages directory.
if you consume item during the loop - like setting a value to a primitive type, all works fine. BUT any object references - such as Item="#item" - can easily end up pointing to the last item - the one after the foreach finishes. This may be what's happening - your testing checking on the last item. Try setting a local variable in the loop to item and then using that for the component parameters. See below.
#foreach (var item in itemsToEdit)
{
var thisitem = item;
<CheckBoxItemComponent Items="#itemsToEdit" Value="#thisitem.Name" Item="#thisitem" />
}
I've got a blazor project and created some tab components. I want these tabs to be dynamic so I can add and take away from them when new values get added to the view model.
Adding works fine but once the page has rendered and I try to remove one of the tabs it doesn't update the tab header and I can't work out why as the view model has changed so surely the page should re-render?
The count updates correctly but the page is not re-rendering to remove the tab. I'm sure I'm fundamentally missing something.
I've created a fiddle to show this better than I can explain: https://blazorfiddle.com/s/fduse7v2
Thanks in advance.
EDIT - Added code from fiddle as requested.
//INDEX
#page "/"
<h1>Tab Issue</h1>
Data Count: #View.Data.Count
<button #onclick='(() => Add($"Tab {(View.Data.Count + 1)}"))'>Add Tab</button>
<TabControl>
#foreach (var data in View.Data)
{
<TabPage Text='#data'>
#data <button #onclick="(() => Remove(data))">Remove</button>
</TabPage>
}
</TabControl>
#code{
public class ViewModel{
public List<string> Data = new List<string>{
"Tab 1","Tab 2","Tab 3"
};
}
public ViewModel View { get; set; }
protected override async Task OnInitializedAsync()
{
View = new ViewModel();
}
public void Add(string data)
{
View.Data.Add(data);
StateHasChanged();
}
public void Remove(string data)
{
View.Data.Remove(data);
StateHasChanged();
}
}
//TabPage
#if (Parent.ActivePage == this)
{
<div class='tab-page #($"tab-{this.Text.Replace(" ","-").ToLower()}")'>
#ChildContent
</div>
}
#code {
[CascadingParameter] public TabControl Parent { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Id { get; set; }
[Parameter] public string Text { get; set; }
protected override void OnInitialized()
{
if (Parent == null)
throw new ArgumentNullException(nameof(Parent), "TabPage must exist within a TabControl");
Parent.AddPage(this);
base.OnInitialized();
}
}
//Tab Control
<div style="display:flex;">
<ul style="display:flex;">
#foreach (TabPage tabPage in Pages)
{
<li style="margin-right:20px; #GetActiveClass(tabPage); list-style:none; " #onclick=#(() => ActivatePage(tabPage))>#tabPage.Text</li>
}
</ul>
</div>
<CascadingValue Value="this">
#ChildContent
</CascadingValue>
#code {
[Parameter]
public RenderFragment ChildContent { get; set; }
public TabPage ActivePage { get; set; }
public List<TabPage> Pages { get; } = new List<TabPage>();
public void AddPage(TabPage tabPage)
{
Pages.Add(tabPage);
if (Pages.Count == 1)
ActivePage = tabPage;
StateHasChanged();
}
public string GetActiveClass(TabPage page)
{
return page == ActivePage ? "border-bottom: 2px solid #263238;" : "";
}
public void ActivatePage(TabPage page)
{
System.Console.WriteLine($"Activating Page: {page.Text} - {page.Id}.");
ActivePage = page;
}
}
In my blazor .razor component I have this code...
... stuff ...
#HistoricDeck
... stuff ...
#code
{
protected GameDataModel GameData { get; set; }
protected RenderFragment HistoricDeck { get; set; }
protected async Task LoadDeck(FriendSupportModel deck, DeckType type)
{
HistoricDeck = builder =>
{
var deckFragment = new SupportDeck() {
GameData = GameData,
AssetList = GameData.AssetList,
Deck = deck.normalDeck,
Type = DeckType.Standalone,
};
builder.OpenElement(0, "div");
builder.AddAttribute(1, "SupportDeck", deckFragment);
builder.CloseElement();
};
StateHasChanged();
}
}
LoadDeck is called by an #onclick= event on the page, I wish to dynamically render a new SupportDeck() into the page. How do I do this? The code below is non-functional (breakpoints in SupportDeck.cshtml are not hit, for example).
SupportDeck.cshtml.cs:
public class SupportDeck
{
public FriendServantListModel Deck { get; set; }
public DeckType Type { get; set; }
public GameDataModel GameData { get; set; }
public AssetList AssetList { get; set; }
}
SupportDeck.cshtml:
#model Project.Views.Shared.SupportDeck
... stuff ...
#if (Model.Deck != null) {
<p>Hello world</p>
}
... stuff ...
Here is how I would normally render this view fragment in a non-blazor page:
#{
await Html.RenderPartialAsync("SupportDeck", new SupportDeck
{
Deck = Model.FriendSupport.normalDeck,
Type = DeckType.Normal,
GameData = Model.GameData,
AssetList = Model.AssetList
});
}
Solved by turning the .cshtml into a .razor template, then this:
HistoricDeck = builder =>
{
builder.OpenComponent(0, typeof(SupportDeckItem));
builder.AddMultipleAttributes(1, new Dictionary<string, object>() {
{"Deck", deck},
{"Type", deckType},
{"GameDataItem", gameData},
{"AssetList", gameData.AssetList}
});
builder.CloseComponent();
};
StateHasChanged();
At this time, blazor doesn't support the binding for <select multiple> ..., it returns only the first selected option and not all of them.
https://github.com/aspnet/AspNetCore/issues/5519
So i'm trying to make my own component to workaround this problem.
Here is what I have for now:
<select #bind="optionSelected">
#foreach (KeyValuePair<string, string> option in Options) {
<option value="#option.Key" style="#(Value.Contains(option.Key) ? "background-color: dodgerblue;" : "")">#option.Value</option>
}
</select>
#code {
[Parameter] public List<string> Value { get; set; }
[Parameter] public EventCallback<List<string>> ValueChanged { get; set; }
[Parameter] public Dictionary<string, string> Options { get; set; }
private string _optionSelected;
private string optionSelected {
get { return _optionSelected; }
set {
if (optionSelected != null)
OnOptionSelected(value);
_optionSelected = "";
}
}
protected override void OnInitialized() {
optionSelected = null;
}
private void OnOptionSelected(string value) {
if (!Value.Contains(value))
Value.Add(value);
else
Value.Remove(value);
ValueChanged.InvokeAsync(Value);
}
}
It works well but I'm wondering how I could upgrade this component, because actually I have to click the select again and again and I would like to click it once and then only use the shortcut ctrl + click on each option to add them into the List<string> Value without using Javascript