diff --git a/Radzen.Blazor.Tests/ChartTests.cs b/Radzen.Blazor.Tests/ChartTests.cs index 62aa9844a..2796865cb 100644 --- a/Radzen.Blazor.Tests/ChartTests.cs +++ b/Radzen.Blazor.Tests/ChartTests.cs @@ -117,4 +117,27 @@ public class ChartTests var (barHovered, _) = instance.FindClosestSeries(linePoint.X, linePoint.Y + 80, 25); Assert.Same(column, barHovered); } + + [Fact] + public void Chart_Renders_AllowZoom_DataAttribute_And_Updates_On_Runtime_Change() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(parameters => parameters + .AddChildContent>(series => series + .Add(p => p.CategoryProperty, nameof(MultiAxisItem.Month)) + .Add(p => p.ValueProperty, nameof(MultiAxisItem.Revenue)) + .Add(p => p.Data, TallBarData))); + + var root = chart.Find("[data-allow-zoom]"); + Assert.Equal("false", root.GetAttribute("data-allow-zoom")); + Assert.Equal("0", root.GetAttribute("data-zoom-start")); + Assert.Equal("1", root.GetAttribute("data-zoom-end")); + + chart.SetParametersAndRender(parameters => parameters.Add(p => p.AllowZoom, true)); + Assert.Equal("true", chart.Find("[data-allow-zoom]").GetAttribute("data-allow-zoom")); + + chart.SetParametersAndRender(parameters => parameters.Add(p => p.AllowZoom, false)); + Assert.Equal("false", chart.Find("[data-allow-zoom]").GetAttribute("data-allow-zoom")); + } } diff --git a/Radzen.Blazor.Tests/ChatTests.cs b/Radzen.Blazor.Tests/ChatTests.cs index f83e106b9..78f924afa 100644 --- a/Radzen.Blazor.Tests/ChatTests.cs +++ b/Radzen.Blazor.Tests/ChatTests.cs @@ -1,10 +1,12 @@ using Bunit; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using Radzen.Blazor; using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Xunit; @@ -490,5 +492,415 @@ namespace Radzen.Blazor.Tests Assert.Empty(component.FindAll(".rz-chat-date-separator")); } + + // Baseline tests for mention feature backwards compatibility + [Fact] + public void RadzenChat_ShouldRenderNormallyWithoutMentionCharacter() + { + var messages = new List + { + new ChatMessage { Content = "Hello @user2", UserId = "user1", Timestamp = DateTime.Now } + }; + + var users = new List + { + new ChatUser { Id = "user1", Name = "John" }, + new ChatUser { Id = "user2", Name = "Jane" } + }; + + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.Title, "Test Chat") + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, users) + .Add(p => p.Messages, messages) + ); + + Assert.Contains("Hello @user2", component.Markup); + } + + [Fact] + public void RadzenChat_ShouldNotShowMentionPopupWhenMentionCharacterIsNull() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.Title, "Test Chat") + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, null) + ); + + var mentionPopup = component.FindAll(".rz-chat-mention-popup"); + Assert.Empty(mentionPopup); + } + + [Fact] + public void RadzenChat_ShouldSendMessagesWithoutMentionFeature() + { + var messages = new List(); + var messageSent = false; + + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.Title, "Test Chat") + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, messages) + .Add(p => p.MessageSent, EventCallback.Factory.Create(this, (msg) => + { + messageSent = true; + })) + ); + + Assert.False(messageSent); + } + + // Feature tests for mention functionality + [Fact] + public void RadzenChat_ShouldParseMentionFormatCorrectly() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + ); + + var chat = component.Instance; + var text = "Hello @[user-123] how are you?"; + var mentions = chat.ParseMentions(text); + + Assert.Single(mentions); + Assert.Equal("user-123", mentions[0].userId); + } + + [Fact] + public void RadzenChat_ShouldParseMutipleMentionsCorrectly() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + ); + + var chat = component.Instance; + var text = "Hey @[user-123] and @[user-456] check this out!"; + var mentions = chat.ParseMentions(text); + + Assert.Equal(2, mentions.Count); + Assert.Equal("user-123", mentions[0].userId); + Assert.Equal("user-456", mentions[1].userId); + } + + [Fact] + public void RadzenChat_ShouldNotParseMentionsWhenMentionCharacterIsNull() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, null) + ); + + var chat = component.Instance; + var text = "Hello @[user-123] how are you?"; + var mentions = chat.ParseMentions(text); + + Assert.Empty(mentions); + } + + [Fact] + public async Task RadzenChat_ShouldSetMentionSearchResults() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + ); + + var chat = component.Instance; + var results = new List + { + new MentionUserContext { UserId = "user-1", UserName = "Alice", IsInChat = true }, + new MentionUserContext { UserId = "user-2", UserName = "Bob", IsInChat = false } + }; + + await chat.SetMentionSearchResults(results); + + Assert.Equal(2, results.Count); + } + + [Fact] + public void RadzenChat_ShouldHaveMentionPropertiesOptional() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + // MentionCharacter not set - should default to null (disabled) + ); + + var chat = component.Instance; + Assert.Null(chat.MentionCharacter); + } + + [Fact] + public void RadzenChat_ShouldAcceptMentionCharacter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + ); + + var chat = component.Instance; + Assert.Equal('@', chat.MentionCharacter); + } + + [Fact] + public void RadzenChat_ShouldAcceptMentionItemTemplate() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + .Add(p => p.MentionItemTemplate, (RenderFragment)((context) => builder => { + builder.OpenElement(0, "div"); + builder.AddContent(1, context.UserName ?? ""); + builder.CloseElement(); + })) + ); + + var chat = component.Instance; + Assert.NotNull(chat.MentionItemTemplate); + } + + [Fact] + public void RadzenChat_ShouldAcceptMentionDisplayTemplate() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, new List()) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + .Add(p => p.MentionDisplayTemplate, (RenderFragment)((userId) => builder => { + builder.OpenElement(0, "span"); + builder.AddContent(1, $"@{userId}"); + builder.CloseElement(); + })) + ); + + var chat = component.Instance; + Assert.NotNull(chat.MentionDisplayTemplate); + } + + [Fact] + public void RadzenChat_ShouldDisplayMessageWithMentionUsingTemplate() + { + var messages = new List + { + new ChatMessage { Content = "Hi @[user-123] how are you?", UserId = "user1", Timestamp = DateTime.Now } + }; + + var users = new List + { + new ChatUser { Id = "user1", Name = "John" }, + new ChatUser { Id = "user-123", Name = "Alice" } + }; + + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, users) + .Add(p => p.Messages, messages) + .Add(p => p.MentionCharacter, '@') + .Add(p => p.MentionDisplayTemplate, (RenderFragment)((userId) => builder => { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "rz-mention-badge"); + builder.AddContent(2, $"@{userId}"); + builder.CloseElement(); + })) + ); + + var mentionBadge = component.FindAll(".rz-mention-badge"); + Assert.NotEmpty(mentionBadge); + } + + [Fact] + public async Task RadzenChat_ShouldRenderSelectedMentionWithUserNameInInput() + { + var users = new List + { + new ChatUser { Id = "user1", Name = "John" }, + new ChatUser { Id = "user2", Name = "Jane Smith" } + }; + + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, users) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + .Add(p => p.MentionDisplayTemplate, (RenderFragment)((userId) => builder => + { + builder.OpenElement(0, "span"); + builder.AddContent(1, $"@{users.First(u => u.Id == userId).Name}"); + builder.CloseElement(); + })) + ); + + SetPrivateProperty(component.Instance, "CurrentInput", "@ja"); + SetPrivateField(component.Instance, "mentionStartPosition", 0); + SetPrivateField(component.Instance, "mentionSearchText", "ja"); + await InvokePrivateAsync(component.Instance, "InsertMention", new MentionUserContext { UserId = "user2", UserName = "Jane Smith", IsInChat = true }); + + component.WaitForAssertion(() => + { + var updatedTextarea = component.Find(".rz-chat-textarea"); + Assert.Equal("@Jane Smith", updatedTextarea.GetAttribute("value")); + }); + } + + [Fact] + public async Task RadzenChat_ShouldSendCanonicalMentionFormatWhenInputShowsUserName() + { + var users = new List + { + new ChatUser { Id = "user1", Name = "John" }, + new ChatUser { Id = "user2", Name = "Jane Smith" } + }; + + ChatMessage sentMessage = null; + + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, users) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + .Add(p => p.MentionDisplayTemplate, (RenderFragment)((userId) => builder => + { + builder.OpenElement(0, "span"); + builder.AddContent(1, $"@{users.First(u => u.Id == userId).Name}"); + builder.CloseElement(); + })) + .Add(p => p.MessageSent, EventCallback.Factory.Create(this, message => sentMessage = message)) + ); + + SetPrivateProperty(component.Instance, "CurrentInput", "Hi @ja"); + SetPrivateField(component.Instance, "mentionStartPosition", 3); + SetPrivateField(component.Instance, "mentionSearchText", "ja"); + await InvokePrivateAsync(component.Instance, "InsertMention", new MentionUserContext { UserId = "user2", UserName = "Jane Smith", IsInChat = true }); + + component.WaitForAssertion(() => + { + var updatedTextarea = component.Find(".rz-chat-textarea"); + Assert.Equal("Hi @Jane Smith", updatedTextarea.GetAttribute("value")); + }); + + var textarea = component.Find(".rz-chat-textarea"); + textarea.KeyDown(new KeyboardEventArgs { Key = "Enter" }); + + component.WaitForAssertion(() => + { + Assert.NotNull(sentMessage); + Assert.Equal("Hi @[user2]", sentMessage!.Content); + }); + } + + [Fact] + public async Task RadzenChat_ShouldDeleteSelectedMentionInOneStep() + { + var users = new List + { + new ChatUser { Id = "user1", Name = "John" }, + new ChatUser { Id = "user2", Name = "Jane Smith" } + }; + + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.Setup("Radzen.getSelectionRange", _ => true).SetResult(new[] { 11, 11 }); + + var component = ctx.RenderComponent(parameters => parameters + .Add(p => p.CurrentUserId, "user1") + .Add(p => p.Users, users) + .Add(p => p.Messages, new List()) + .Add(p => p.MentionCharacter, '@') + .Add(p => p.MentionDisplayTemplate, (RenderFragment)((userId) => builder => + { + builder.OpenElement(0, "span"); + builder.AddContent(1, $"@{users.First(u => u.Id == userId).Name}"); + builder.CloseElement(); + })) + ); + + SetPrivateProperty(component.Instance, "CurrentInput", "@ja"); + SetPrivateField(component.Instance, "mentionStartPosition", 0); + SetPrivateField(component.Instance, "mentionSearchText", "ja"); + await InvokePrivateAsync(component.Instance, "InsertMention", new MentionUserContext { UserId = "user2", UserName = "Jane Smith", IsInChat = true }); + + component.WaitForAssertion(() => + { + var updatedTextarea = component.Find(".rz-chat-textarea"); + Assert.Equal("@Jane Smith", updatedTextarea.GetAttribute("value")); + }); + + var textarea = component.Find(".rz-chat-textarea"); + textarea.KeyDown(new KeyboardEventArgs { Key = "Backspace" }); + + component.WaitForAssertion(() => + { + var updatedTextarea = component.Find(".rz-chat-textarea"); + Assert.Equal(string.Empty, updatedTextarea.GetAttribute("value")); + }); + } + + static void SetPrivateField(object instance, string fieldName, object value) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + field.SetValue(instance, value); + } + + static void SetPrivateProperty(object instance, string propertyName, object value) + { + var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.NonPublic); + property.SetValue(instance, value); + } + + static async Task InvokePrivateAsync(object instance, string methodName, params object[] args) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + var task = (Task)method.Invoke(instance, args); + await task; + } } } diff --git a/Radzen.Blazor.Tests/FunnelSeriesTests.cs b/Radzen.Blazor.Tests/FunnelSeriesTests.cs index 7ec7f6664..0ddaec7c5 100644 --- a/Radzen.Blazor.Tests/FunnelSeriesTests.cs +++ b/Radzen.Blazor.Tests/FunnelSeriesTests.cs @@ -1,3 +1,7 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; using Bunit; using Xunit; using static Radzen.Blazor.Tests.ChartTestHelper; @@ -6,6 +10,80 @@ namespace Radzen.Blazor.Tests { public class FunnelSeriesTests { + private static readonly DataItem[] SkewedData = new[] + { + new DataItem { Category = "Visitors", Value = 179 }, + new DataItem { Category = "Leads", Value = 31 }, + new DataItem { Category = "Prospects", Value = 15 }, + new DataItem { Category = "Negotiations", Value = 9 }, + }; + + private static List<(double Y, double Width)> ParseEdgeWidths(string markup) + { + var edges = new List<(double, double)>(); + + foreach (Match match in Regex.Matches(markup, "--rz-segment-order:.*?d=\"M ([^\"]+)\"", RegexOptions.Singleline)) + { + var numbers = Regex.Matches(match.Groups[1].Value, "-?[0-9]+(\\.[0-9]+)?") + .Select(m => double.Parse(m.Value, CultureInfo.InvariantCulture)) + .ToArray(); + + if (numbers.Length != 8) + { + continue; + } + + var topWidth = System.Math.Abs(numbers[2] - numbers[0]); + var bottomWidth = System.Math.Abs(numbers[4] - numbers[6]); + + edges.Add((numbers[1], topWidth)); + edges.Add((numbers[5], bottomWidth)); + } + + return edges.OrderBy(e => e.Item1).ToList(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FunnelSeries_SkewedValues_OutlineDoesNotFlareBack(bool inverted) + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(DataItem.Category)) + .Add(x => x.ValueProperty, nameof(DataItem.Value)) + .Add(x => x.Inverted, inverted) + .Add(x => x.Data, SkewedData))); + + var edges = ParseEdgeWidths(chart.Markup); + + Assert.NotEmpty(edges); + + var widths = edges.Select(e => e.Width).ToList(); + const double tolerance = 0.01; + + var nonDecreasing = true; + var nonIncreasing = true; + + for (var i = 1; i < widths.Count; i++) + { + if (widths[i] < widths[i - 1] - tolerance) + { + nonDecreasing = false; + } + + if (widths[i] > widths[i - 1] + tolerance) + { + nonIncreasing = false; + } + } + + Assert.True(nonDecreasing || nonIncreasing, + $"Funnel outline reverses direction (flares back out): [{string.Join(", ", widths)}]."); + } + [Fact] public void FunnelSeries_Renders_FunnelClass() { diff --git a/Radzen.Blazor/MentionSearchArgs.cs b/Radzen.Blazor/MentionSearchArgs.cs new file mode 100644 index 000000000..40bbb99d5 --- /dev/null +++ b/Radzen.Blazor/MentionSearchArgs.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace Radzen.Blazor; + +/// +/// Supplies information about a mention search event in the RadzenChat component. +/// +public class MentionSearchArgs +{ + /// + /// Gets or sets the search filter text (text typed after the mention character). + /// + public string? Filter { get; set; } + + /// + /// Gets or sets how many items to skip. Related to pagination. Usually used with the LINQ method. + /// + public int? Skip { get; set; } + + /// + /// Gets or sets how many items to take. Related to pagination and page size. Usually used with the LINQ method. + /// + public int? Top { get; set; } +} diff --git a/Radzen.Blazor/MentionUserContext.cs b/Radzen.Blazor/MentionUserContext.cs new file mode 100644 index 000000000..8c5dccbc9 --- /dev/null +++ b/Radzen.Blazor/MentionUserContext.cs @@ -0,0 +1,24 @@ +using System; + +namespace Radzen.Blazor; + +/// +/// Represents context information for a user in a mention search result in the RadzenChat component. +/// +public class MentionUserContext +{ + /// + /// Gets or sets the unique identifier of the user. + /// + public string? UserId { get; set; } + + /// + /// Gets or sets the display name of the user. + /// + public string? UserName { get; set; } + + /// + /// Gets or sets whether the user is currently in the chat. + /// + public bool IsInChat { get; set; } +} diff --git a/Radzen.Blazor/Radzen.Blazor.csproj b/Radzen.Blazor/Radzen.Blazor.csproj index 6358c4534..3f8e3f1aa 100644 --- a/Radzen.Blazor/Radzen.Blazor.csproj +++ b/Radzen.Blazor/Radzen.Blazor.csproj @@ -13,7 +13,7 @@ true Radzen.Blazor Radzen.Blazor - 11.0.3 + 11.0.4 Radzen Ltd. Radzen Ltd. Radzen Blazor is the most sophisticated free UI component library for Blazor, featuring 110+ native components including DataGrid, Scheduler, Charts, and advanced theming with full support for Material Design and Fluent UI. diff --git a/Radzen.Blazor/RadzenChart.razor b/Radzen.Blazor/RadzenChart.razor index 4e484f695..1f6784e6e 100644 --- a/Radzen.Blazor/RadzenChart.razor +++ b/Radzen.Blazor/RadzenChart.razor @@ -8,7 +8,7 @@ @ChildContent -
+
@if (Width.HasValue && Height.HasValue) { diff --git a/Radzen.Blazor/RadzenChat.razor b/Radzen.Blazor/RadzenChat.razor index 5a26d6bf9..d36266370 100644 --- a/Radzen.Blazor/RadzenChat.razor +++ b/Radzen.Blazor/RadzenChat.razor @@ -126,7 +126,15 @@ } else { - + @if (MentionCharacter.HasValue && MentionDisplayTemplate != null && message.Content.Contains($"{MentionCharacter}[")) + { + // Parse and render mentions + @RenderMessageWithMentions(message.Content) + } + else + { + + } }
@@ -184,6 +192,27 @@ Title="Send message" class="rz-chat-send-btn" />
+ + @if (isMentionPopupOpen && MentionCharacter.HasValue && mentionSearchResults.Count > 0) + { +
+ @foreach (var (user, index) in mentionSearchResults.Select((u, i) => (u, i))) + { + var isSelected = index == selectedMentionIndex; +
+ @if (MentionItemTemplate != null) + { + @MentionItemTemplate(user) + } + else + { + @user.UserName + } +
+ } +
+ } } diff --git a/Radzen.Blazor/RadzenChat.razor.cs b/Radzen.Blazor/RadzenChat.razor.cs index cca941739..af49a761f 100644 --- a/Radzen.Blazor/RadzenChat.razor.cs +++ b/Radzen.Blazor/RadzenChat.razor.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -125,6 +126,25 @@ namespace Radzen.Blazor private bool currentUserIsTyping; private CancellationTokenSource? typingCts; + // Mention-related fields + private List mentionSearchResults = new(); + private bool isMentionPopupOpen; + private string mentionSearchText = string.Empty; + private int selectedMentionIndex = -1; + private int mentionStartPosition = -1; + private CancellationTokenSource? mentionSearchCts; + private readonly List mentionInputSegments = new(); + + private sealed class MentionInputSegment + { + public int Start { get; set; } + public int Length { get; set; } + public string UserId { get; set; } = string.Empty; + public string DisplayText { get; set; } = string.Empty; + + public int End => Start + Length; + } + /// /// Gets or sets the message template. /// @@ -353,6 +373,65 @@ namespace Radzen.Blazor [Parameter] public EventCallback UserRemoved { get; set; } + /// + /// Gets or sets the character that triggers the mention search popup (e.g., '@'). When null, mention feature is disabled. + /// + [Parameter] + public char? MentionCharacter { get; set; } + + /// + /// Event callback that is invoked when a mention search is triggered. + /// The callback receives a with the search filter and pagination info. + /// The app should populate the search results which will be displayed in the mention popup. + /// + [Parameter] + public EventCallback MentionSearch { get; set; } + + /// + /// Gets or sets the template for rendering individual items in the mention search popup. + /// Receives a containing the user ID, name, and chat status. + /// + [Parameter] + public RenderFragment? MentionItemTemplate { get; set; } + + /// + /// Gets or sets the template for rendering a stored mention in the chat message. + /// Receives the user ID string (from @[userid] format) and should render how the mention is displayed (e.g., as a badge). + /// + [Parameter] + public RenderFragment? MentionDisplayTemplate { get; set; } + + /// + /// Gets or sets the maximum number of mention search results to display in the popup. Defaults to 10. + /// + [Parameter] + public int MentionMaxResults { get; set; } = 10; + + /// + /// Gets or sets the event callback that stores mention search results from the MentionSearch callback. + /// + [Parameter] + public EventCallback> MentionSearchResultsChanged { get; set; } + + /// + /// Updates the mention search results and refreshes the popup. + /// Call this from within your MentionSearch event callback handler. + /// + /// The search results to display. + public async Task SetMentionSearchResults(IEnumerable? results) + { + mentionSearchResults = results?.ToList() ?? new(); + selectedMentionIndex = mentionSearchResults.Count > 0 ? 0 : -1; + isMentionPopupOpen = mentionSearchResults.Count > 0; + + if (MentionSearchResultsChanged.HasDelegate) + { + await MentionSearchResultsChanged.InvokeAsync(mentionSearchResults); + } + + await InvokeAsync(StateHasChanged); + } + /// /// Gets the current list of messages. /// @@ -468,6 +547,7 @@ namespace Radzen.Blazor // Clear input CurrentInput = string.Empty; + mentionInputSegments.Clear(); await SetCurrentUserTyping(false); await InvokeAsync(StateHasChanged); } @@ -557,8 +637,11 @@ namespace Radzen.Blazor private async Task OnInput(ChangeEventArgs e) { + var previousInput = CurrentInput; CurrentInput = e.Value?.ToString() ?? ""; + ReconcileMentionInputSegments(previousInput, CurrentInput); await NotifyCurrentUserTyping(); + await DetectMentionTrigger(CurrentInput); await InvokeAsync(StateHasChanged); } @@ -627,6 +710,50 @@ namespace Radzen.Blazor private async Task OnKeyDown(KeyboardEventArgs e) { + // Handle mention popup navigation + if (isMentionPopupOpen && MentionCharacter.HasValue) + { + if (e.Key == "ArrowDown") + { + selectedMentionIndex = Math.Min(selectedMentionIndex + 1, mentionSearchResults.Count - 1); + preventDefault = true; + await InvokeAsync(StateHasChanged); + return; + } + else if (e.Key == "ArrowUp") + { + selectedMentionIndex = Math.Max(selectedMentionIndex - 1, 0); + preventDefault = true; + await InvokeAsync(StateHasChanged); + return; + } + else if (e.Key == "Enter") + { + if (selectedMentionIndex >= 0 && selectedMentionIndex < mentionSearchResults.Count) + { + await InsertMention(mentionSearchResults[selectedMentionIndex]); + preventDefault = true; + return; + } + } + else if (e.Key == "Escape") + { + await CloseMentionPopup(); + preventDefault = true; + return; + } + } + + if (MentionCharacter.HasValue && mentionInputSegments.Count > 0 && (e.Key == "Backspace" || e.Key == "Delete")) + { + if (await HandleMentionDeletion(e.Key)) + { + preventDefault = true; + return; + } + } + + // Handle normal message sending if (e.Key == "Enter" && !e.ShiftKey && JSRuntime != null) { await JSRuntime.InvokeAsync("Radzen.setInputValue", inputElement, ""); @@ -639,12 +766,396 @@ namespace Radzen.Blazor private async Task OnSendMessage() { - if (!string.IsNullOrWhiteSpace(CurrentInput)) + var content = BuildStoredMessageInput(); + if (!string.IsNullOrWhiteSpace(content)) { - await SendMessage(CurrentInput); + await SendMessage(content); } } + private async Task DetectMentionTrigger(string input) + { + if (!MentionCharacter.HasValue || MentionSearch.HasDelegate == false) + { + return; + } + + var char_code = MentionCharacter.Value; + var trimmedInput = input.TrimEnd(); + + // Check if mention character appears at the start or after whitespace + int mentionPos = -1; + + for (int i = trimmedInput.Length - 1; i >= 0; i--) + { + if (trimmedInput[i] == char_code) + { + // Check if it's at the start or after whitespace + if (i == 0 || char.IsWhiteSpace(trimmedInput[i - 1])) + { + mentionPos = i; + } + break; + } + else if (char.IsWhiteSpace(trimmedInput[i])) + { + break; + } + } + + if (mentionPos >= 0) + { + // Extract search text after the mention character + var searchText = trimmedInput.Substring(mentionPos + 1); + + // Check if this is a continuous mention (no spaces in search text) + if (!searchText.Contains(' ', StringComparison.Ordinal)) + { + mentionSearchText = searchText; + mentionStartPosition = mentionPos; + await PerformMentionSearch(searchText); + return; + } + } + + await CloseMentionPopup(); + } + + private async Task PerformMentionSearch(string filter) + { + if (!MentionSearch.HasDelegate) + { + return; + } + + mentionSearchCts?.Cancel(); + mentionSearchCts?.Dispose(); + mentionSearchCts = new CancellationTokenSource(); + + try + { + var searchArgs = new MentionSearchArgs + { + Filter = filter, + Skip = 0, + Top = MentionMaxResults + }; + + await MentionSearch.InvokeAsync(searchArgs); + isMentionPopupOpen = true; + selectedMentionIndex = 0; + + if (MentionSearchResultsChanged.HasDelegate) + { + await MentionSearchResultsChanged.InvokeAsync(mentionSearchResults); + } + + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) + { + // Search was cancelled + } + } + + private async Task InsertMention(MentionUserContext user) + { + if (user?.UserId == null) + { + return; + } + + // Replace the search text with the mention + var beforeMention = CurrentInput.Substring(0, mentionStartPosition); + var afterMention = CurrentInput.Substring(mentionStartPosition + 1 + mentionSearchText.Length); + var displayName = ResolveMentionDisplayName(user); + var displayMention = $"{MentionCharacter}{displayName}"; + var replacedLength = 1 + mentionSearchText.Length; + + ShiftMentionSegmentsAfterEdit(mentionStartPosition, replacedLength, displayMention.Length); + mentionInputSegments.Add(new MentionInputSegment + { + Start = mentionStartPosition, + Length = displayMention.Length, + UserId = user.UserId, + DisplayText = displayMention + }); + + CurrentInput = beforeMention + displayMention + afterMention; + + await CloseMentionPopup(); + await InvokeAsync(StateHasChanged); + } + + private async Task CloseMentionPopup() + { + isMentionPopupOpen = false; + mentionSearchResults.Clear(); + mentionSearchText = string.Empty; + selectedMentionIndex = -1; + mentionStartPosition = -1; + + mentionSearchCts?.Cancel(); + mentionSearchCts?.Dispose(); + mentionSearchCts = null; + + if (MentionSearchResultsChanged.HasDelegate) + { + await MentionSearchResultsChanged.InvokeAsync(mentionSearchResults); + } + + await InvokeAsync(StateHasChanged); + } + + private async Task OnMentionItemClick(MentionUserContext user) + { + await InsertMention(user); + } + + private string BuildStoredMessageInput() + { + if (mentionInputSegments.Count == 0 || !MentionCharacter.HasValue) + { + return CurrentInput; + } + + var orderedSegments = mentionInputSegments.OrderBy(segment => segment.Start).ToList(); + var builder = new StringBuilder(); + var currentIndex = 0; + + foreach (var segment in orderedSegments) + { + if (segment.Start < currentIndex || segment.End > CurrentInput.Length) + { + continue; + } + + var segmentText = CurrentInput.Substring(segment.Start, segment.Length); + if (!string.Equals(segmentText, segment.DisplayText, StringComparison.Ordinal)) + { + continue; + } + + builder.Append(CurrentInput.AsSpan(currentIndex, segment.Start - currentIndex)); + builder.Append(MentionCharacter.Value); + builder.Append('['); + builder.Append(segment.UserId); + builder.Append(']'); + currentIndex = segment.End; + } + + if (currentIndex < CurrentInput.Length) + { + builder.Append(CurrentInput.AsSpan(currentIndex)); + } + + return builder.ToString(); + } + + private string ResolveMentionDisplayName(MentionUserContext user) + { + if (!string.IsNullOrWhiteSpace(user.UserName)) + { + return user.UserName; + } + + var userId = user.UserId ?? string.Empty; + var chatUser = GetUser(userId); + if (!string.IsNullOrWhiteSpace(chatUser?.Name)) + { + return chatUser.Name; + } + + return userId; + } + + private void ReconcileMentionInputSegments(string previousInput, string nextInput) + { + if (mentionInputSegments.Count == 0 || string.Equals(previousInput, nextInput, StringComparison.Ordinal)) + { + return; + } + + var commonPrefixLength = 0; + var maxPrefixLength = Math.Min(previousInput.Length, nextInput.Length); + while (commonPrefixLength < maxPrefixLength && previousInput[commonPrefixLength] == nextInput[commonPrefixLength]) + { + commonPrefixLength++; + } + + var previousSuffixIndex = previousInput.Length; + var nextSuffixIndex = nextInput.Length; + while (previousSuffixIndex > commonPrefixLength && + nextSuffixIndex > commonPrefixLength && + previousInput[previousSuffixIndex - 1] == nextInput[nextSuffixIndex - 1]) + { + previousSuffixIndex--; + nextSuffixIndex--; + } + + var previousChangedEnd = previousSuffixIndex; + var lengthDelta = nextInput.Length - previousInput.Length; + + var updatedSegments = new List(mentionInputSegments.Count); + foreach (var segment in mentionInputSegments.OrderBy(segment => segment.Start)) + { + if (segment.End <= commonPrefixLength) + { + updatedSegments.Add(segment); + continue; + } + + if (segment.Start >= previousChangedEnd) + { + segment.Start += lengthDelta; + updatedSegments.Add(segment); + continue; + } + } + + mentionInputSegments.Clear(); + mentionInputSegments.AddRange(updatedSegments); + } + + private void ShiftMentionSegmentsAfterEdit(int editStart, int replacedLength, int insertedLength) + { + if (mentionInputSegments.Count == 0) + { + return; + } + + var editEnd = editStart + replacedLength; + var delta = insertedLength - replacedLength; + + mentionInputSegments.RemoveAll(segment => segment.Start < editEnd && segment.End > editStart); + + foreach (var segment in mentionInputSegments.Where(segment => segment.Start >= editEnd)) + { + segment.Start += delta; + } + } + + private async Task HandleMentionDeletion(string key) + { + if (JSRuntime == null) + { + return false; + } + + var selection = await JSRuntime.InvokeAsync("Radzen.getSelectionRange", inputElement); + if (selection == null || selection.Length < 2) + { + return false; + } + + var selectionStart = selection[0]; + var selectionEnd = selection[1]; + + MentionInputSegment? segmentToRemove = null; + if (selectionStart != selectionEnd) + { + segmentToRemove = mentionInputSegments.FirstOrDefault(segment => segment.Start < selectionEnd && segment.End > selectionStart); + } + else + { + var targetIndex = key == "Backspace" ? selectionStart - 1 : selectionStart; + if (targetIndex < 0) + { + return false; + } + + segmentToRemove = mentionInputSegments.FirstOrDefault(segment => segment.Start <= targetIndex && segment.End > targetIndex); + } + + if (segmentToRemove == null) + { + return false; + } + + CurrentInput = CurrentInput.Remove(segmentToRemove.Start, segmentToRemove.Length); + mentionInputSegments.Remove(segmentToRemove); + foreach (var segment in mentionInputSegments.Where(segment => segment.Start > segmentToRemove.Start)) + { + segment.Start -= segmentToRemove.Length; + } + + await JSRuntime.InvokeVoidAsync("Radzen.setInputValue", inputElement, CurrentInput); + await JSRuntime.InvokeVoidAsync("Radzen.setSelectionRange", inputElement, segmentToRemove.Start, segmentToRemove.Start); + await DetectMentionTrigger(CurrentInput); + await InvokeAsync(StateHasChanged); + return true; + } + + internal List<(int start, int end, string userId)> ParseMentions(string text) + { + var mentions = new List<(int, int, string)>(); + + if (string.IsNullOrEmpty(text) || !MentionCharacter.HasValue) + { + return mentions; + } + + var pattern = $@"\{MentionCharacter}\[([^\]]+)\]"; + var regex = new System.Text.RegularExpressions.Regex(pattern); + + foreach (System.Text.RegularExpressions.Match match in regex.Matches(text)) + { + var userId = match.Groups[1].Value; + mentions.Add((match.Index, match.Length, userId)); + } + + return mentions; + } + + private RenderFragment RenderMessageWithMentions(string text) + { + return builder => + { + if (string.IsNullOrEmpty(text) || !MentionCharacter.HasValue || MentionDisplayTemplate == null) + { + builder.AddContent(0, new MarkupString($"

{System.Net.WebUtility.HtmlEncode(text)}

")); + return; + } + + var mentions = ParseMentions(text); + if (mentions.Count == 0) + { + builder.AddContent(0, new MarkupString($"

{System.Net.WebUtility.HtmlEncode(text)}

")); + return; + } + + int lastIndex = 0; + int contentIndex = 0; + + builder.OpenElement(contentIndex++, "p"); + + foreach (var mention in mentions) + { + // Add text before mention + if (mention.start > lastIndex) + { + var textBefore = text.Substring(lastIndex, mention.start - lastIndex); + builder.AddContent(contentIndex++, textBefore); + } + + // Add the mention using the template + var userId = mention.userId; + builder.AddContent(contentIndex++, MentionDisplayTemplate(userId)); + + lastIndex = mention.start + mention.end; + } + + // Add remaining text after last mention + if (lastIndex < text.Length) + { + var textAfter = text.Substring(lastIndex); + builder.AddContent(contentIndex++, textAfter); + } + + builder.CloseElement(); + }; + } + private async Task OnClearChat() { await ClearChat(); @@ -730,6 +1241,10 @@ namespace Radzen.Blazor typingCts?.Cancel(); typingCts?.Dispose(); typingCts = null; + + mentionSearchCts?.Cancel(); + mentionSearchCts?.Dispose(); + mentionSearchCts = null; } catch { diff --git a/Radzen.Blazor/RadzenFunnelSeries.razor.cs b/Radzen.Blazor/RadzenFunnelSeries.razor.cs index 8bb85b2e7..c690dfeca 100644 --- a/Radzen.Blazor/RadzenFunnelSeries.razor.cs +++ b/Radzen.Blazor/RadzenFunnelSeries.razor.cs @@ -325,16 +325,16 @@ namespace Radzen.Blazor topWidth = valueWidth; bottomWidth = index < count - 1 ? AvailableWidth * (Value(orderedItems[index + 1]) / maxValue) - : AvailableWidth * 0.15; + : Math.Min(AvailableWidth * 0.15, valueWidth); } else { // Default: wide at visual top, narrow at visual bottom. // In polar coords, index 0 = visual bottom → narrow the first segment's "top" (visual bottom edge). - topWidth = index == 0 ? AvailableWidth * 0.15 : valueWidth; bottomWidth = index < count - 1 ? AvailableWidth * (Value(orderedItems[index + 1]) / maxValue) : valueWidth; + topWidth = index == 0 ? Math.Min(AvailableWidth * 0.15, bottomWidth) : valueWidth; } var topY = this.TopY + index * segHeight; diff --git a/Radzen.Blazor/wwwroot/Radzen.Blazor.js b/Radzen.Blazor/wwwroot/Radzen.Blazor.js index a7d338f09..e8d620909 100644 --- a/Radzen.Blazor/wwwroot/Radzen.Blazor.js +++ b/Radzen.Blazor/wwwroot/Radzen.Blazor.js @@ -3373,11 +3373,14 @@ window.Radzen = { }; ref.wheelHandler = function (e) { - if (!inside) return; + if (!inside || ref.dataset.allowZoom !== 'true') return; + var delta = e.deltaY > 0 ? 1 : -1; + var range = parseFloat(ref.dataset.zoomEnd) - parseFloat(ref.dataset.zoomStart); + if (isNaN(range)) range = 1; + if (delta < 0 ? range <= 0.01 + 1e-6 : range >= 1 - 1e-6) return; e.preventDefault(); var rect = ref.getBoundingClientRect(); var x = e.clientX - rect.left; - var delta = e.deltaY > 0 ? 1 : -1; try { suppressDisposed(instance.invokeMethodAsync('OnWheel', x, delta)); } catch {} }; diff --git a/RadzenBlazorDemos/Pages/ChatMention.razor b/RadzenBlazorDemos/Pages/ChatMention.razor new file mode 100644 index 000000000..4dd12a862 --- /dev/null +++ b/RadzenBlazorDemos/Pages/ChatMention.razor @@ -0,0 +1,175 @@ +@using Radzen +@using Radzen.Blazor +@inherits ComponentBase + + + + + + + + + + Chat with Mentions + + + + + + +@code { + RadzenChat basicChat; + EventConsole console; + + private List users = new(); + private List messages = new(); + private const char MentionCharacter = '@'; + private const string PlaceholderText = "Type your message... (try typing @ to mention someone)"; + + protected override void OnInitialized() + { + // Initialize users + users.AddRange(new[] + { + new ChatUser { Id = "user1", Name = "John Doe", Color = "#1976d2" }, + new ChatUser { Id = "user2", Name = "Jane Smith", Color = "#388e3c" }, + new ChatUser { Id = "user3", Name = "Bob Johnson", Color = "#f57c00" } + }); + + // Add some sample messages + messages.AddRange(new[] + { + new ChatMessage { Content = "Welcome to the team chat! 👋", UserId = "user1", Timestamp = DateTime.Now.AddMinutes(-30) }, + new ChatMessage { Content = "Thanks John! Looking forward to working together.", UserId = "user2", Timestamp = DateTime.Now.AddMinutes(-29) }, + new ChatMessage { Content = "Same here! Let's make this project amazing! 🚀", UserId = "user3", Timestamp = DateTime.Now.AddMinutes(-28) } + }); + } + + // Template for rendering individual mention search results + private RenderFragment MentionItemTemplate => (context) => builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "rz-mention-item"); + builder.AddAttribute(2, "style", "display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem;"); + + // User avatar/initials + var user = users.FirstOrDefault(u => u.Id == context.UserId); + builder.OpenElement(3, "div"); + builder.AddAttribute(4, "style", $"width: 24px; height: 24px; border-radius: 50%; background: {user?.Color ?? "#ccc"}; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: bold;"); + builder.AddContent(5, user?.GetInitials() ?? "?"); + builder.CloseElement(); + + // User name + builder.OpenElement(6, "span"); + builder.AddContent(7, context.UserName ?? ""); + builder.CloseElement(); + + // Online indicator + if (context.IsInChat) + { + builder.OpenElement(8, "span"); + builder.AddAttribute(9, "style", "margin-left: auto; width: 8px; height: 8px; border-radius: 50%; background: #4caf50;"); + builder.CloseElement(); + } + + builder.CloseElement(); + }; + + // Template for rendering stored mentions in chat messages + private RenderFragment MentionDisplayTemplate => (userId) => builder => + { + var mentionedUser = users.FirstOrDefault(u => u.Id == userId); + + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "rz-mention-badge"); + builder.AddAttribute(2, "style", $"background: {mentionedUser?.Color ?? "#ccc"}20; color: {mentionedUser?.Color ?? "#ccc"}; padding: 0.2rem 0.4rem; border-radius: 4px; font-weight: 500;"); + builder.AddAttribute(3, "title", mentionedUser?.Name ?? userId); + builder.AddContent(4, $"@{mentionedUser?.Name ?? userId}"); + builder.CloseElement(); + }; + + async Task LoadMentions(MentionSearchArgs args) + { + // Simulate a small delay for network request + await Task.Delay(100); + + // Filter users based on search text + var searchText = args.Filter ?? string.Empty; + var filteredUsers = users + .Where(u => u.Id != "user1") // Exclude current user + .Where(u => u.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + .Skip(args.Skip ?? 0) + .Take(args.Top ?? 5) + .Select(u => new MentionUserContext + { + UserId = u.Id, + UserName = u.Name, + IsInChat = true + }) + .ToList(); + + // Update search results in the chat component + await basicChat?.SetMentionSearchResults(filteredUsers); + + console.Log($"Mention search: '{searchText}' - Found {filteredUsers.Count} results", AlertStyle.Info); + } + + void OnMessageAdded(ChatMessage message) + { + var participant = users.FirstOrDefault(p => p.Id == message.UserId); + var participantName = participant?.Name ?? "Unknown"; + var contentPreview = message.Content.Substring(0, Math.Min(50, message.Content.Length)); + console.Log($"Message added: {participantName} - {contentPreview}...", + message.UserId == "user1" ? AlertStyle.Info : AlertStyle.Success); + } + + void OnMessageSent(ChatMessage message) + { + console.Log($"Message sent: {message.Content}", AlertStyle.Info); + } + + void OnMessagesChanged(IEnumerable newMessages) + { + messages = newMessages.ToList(); + StateHasChanged(); + } + + void OnChatCleared() + { + console.Log("Chat cleared", AlertStyle.Warning); + } + + async Task SimulateJaneTyping() + { + if (basicChat == null) + { + return; + } + + await basicChat.SetUserTyping("user2", true); + await Task.Delay(1500); + await basicChat.SetUserTyping("user2", false); + } + + void OnTypingChanged(ChatTypingEventArgs args) + { + // In a real app you would broadcast this via SignalR to other clients. + console.Log($"TypingChanged: {args.UserId} => {args.IsTyping}", AlertStyle.Info); + } +} diff --git a/RadzenBlazorDemos/Pages/ChatPage.razor b/RadzenBlazorDemos/Pages/ChatPage.razor index a66250626..b27dd0ccf 100644 --- a/RadzenBlazorDemos/Pages/ChatPage.razor +++ b/RadzenBlazorDemos/Pages/ChatPage.razor @@ -76,3 +76,13 @@ + + + Mention Users + + + Use a Mention Character to define on which character a callback should be triggered and display a search popup. + + + +