mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
Merge remote-tracking branch 'origin/master' into trim
This commit is contained in:
@@ -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<RadzenChart>(parameters => parameters
|
||||
.AddChildContent<RadzenColumnSeries<MultiAxisItem>>(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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatMessage>
|
||||
{
|
||||
new ChatMessage { Content = "Hello @user2", UserId = "user1", Timestamp = DateTime.Now }
|
||||
};
|
||||
|
||||
var users = new List<ChatUser>
|
||||
{
|
||||
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<RadzenChat>(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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.Title, "Test Chat")
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.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<ChatMessage>();
|
||||
var messageSent = false;
|
||||
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.Title, "Test Chat")
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, messages)
|
||||
.Add(p => p.MessageSent, EventCallback.Factory.Create<ChatMessage>(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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.Add(p => p.MentionCharacter, '@')
|
||||
);
|
||||
|
||||
var chat = component.Instance;
|
||||
var results = new List<MentionUserContext>
|
||||
{
|
||||
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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
// 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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.Add(p => p.MentionCharacter, '@')
|
||||
.Add(p => p.MentionItemTemplate, (RenderFragment<MentionUserContext>)((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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, new List<ChatUser>())
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.Add(p => p.MentionCharacter, '@')
|
||||
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((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<ChatMessage>
|
||||
{
|
||||
new ChatMessage { Content = "Hi @[user-123] how are you?", UserId = "user1", Timestamp = DateTime.Now }
|
||||
};
|
||||
|
||||
var users = new List<ChatUser>
|
||||
{
|
||||
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<RadzenChat>(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<string>)((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<ChatUser>
|
||||
{
|
||||
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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, users)
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.Add(p => p.MentionCharacter, '@')
|
||||
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((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<ChatUser>
|
||||
{
|
||||
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<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, users)
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.Add(p => p.MentionCharacter, '@')
|
||||
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((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<ChatMessage>(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<ChatUser>
|
||||
{
|
||||
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<int[]>("Radzen.getSelectionRange", _ => true).SetResult(new[] { 11, 11 });
|
||||
|
||||
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
|
||||
.Add(p => p.CurrentUserId, "user1")
|
||||
.Add(p => p.Users, users)
|
||||
.Add(p => p.Messages, new List<ChatMessage>())
|
||||
.Add(p => p.MentionCharacter, '@')
|
||||
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RadzenChart>(p => p
|
||||
.AddChildContent<RadzenFunnelSeries<DataItem>>(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()
|
||||
{
|
||||
|
||||
25
Radzen.Blazor/MentionSearchArgs.cs
Normal file
25
Radzen.Blazor/MentionSearchArgs.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies information about a mention search event in the RadzenChat component.
|
||||
/// </summary>
|
||||
public class MentionSearchArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the search filter text (text typed after the mention character).
|
||||
/// </summary>
|
||||
public string? Filter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many items to skip. Related to pagination. Usually used with the <see cref="System.Linq.Enumerable.Skip{TSource}(IEnumerable{TSource}, int)"/> LINQ method.
|
||||
/// </summary>
|
||||
public int? Skip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many items to take. Related to pagination and page size. Usually used with the <see cref="System.Linq.Enumerable.Take{TSource}(IEnumerable{TSource}, int)"/> LINQ method.
|
||||
/// </summary>
|
||||
public int? Top { get; set; }
|
||||
}
|
||||
24
Radzen.Blazor/MentionUserContext.cs
Normal file
24
Radzen.Blazor/MentionUserContext.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
|
||||
namespace Radzen.Blazor;
|
||||
|
||||
/// <summary>
|
||||
/// Represents context information for a user in a mention search result in the RadzenChat component.
|
||||
/// </summary>
|
||||
public class MentionUserContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the user.
|
||||
/// </summary>
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the display name of the user.
|
||||
/// </summary>
|
||||
public string? UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the user is currently in the chat.
|
||||
/// </summary>
|
||||
public bool IsInChat { get; set; }
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>Radzen.Blazor</PackageId>
|
||||
<Product>Radzen.Blazor</Product>
|
||||
<Version>11.0.3</Version>
|
||||
<Version>11.0.4</Version>
|
||||
<Copyright>Radzen Ltd.</Copyright>
|
||||
<Authors>Radzen Ltd.</Authors>
|
||||
<Description>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.</Description>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<CascadingValue Value="@this">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
<div @ref="Element" @attributes="@Attributes" class="@GetCssClass()" style="@GetChartStyle()" id="@GetId()">
|
||||
<div @ref="Element" @attributes="@Attributes" class="@GetCssClass()" style="@GetChartStyle()" id="@GetId()" data-allow-zoom="@(AllowZoom ? "true" : "false")" data-zoom-start="@ZoomStart.ToInvariantString()" data-zoom-end="@ZoomEnd.ToInvariantString()">
|
||||
@if (Width.HasValue && Height.HasValue)
|
||||
{
|
||||
<CascadingValue Value="@this">
|
||||
|
||||
@@ -126,7 +126,15 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<RadzenMarkdown Text=@message.Content />
|
||||
@if (MentionCharacter.HasValue && MentionDisplayTemplate != null && message.Content.Contains($"{MentionCharacter}["))
|
||||
{
|
||||
// Parse and render mentions
|
||||
@RenderMessageWithMentions(message.Content)
|
||||
}
|
||||
else
|
||||
{
|
||||
<RadzenMarkdown Text=@message.Content />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -184,6 +192,27 @@
|
||||
Title="Send message"
|
||||
class="rz-chat-send-btn" />
|
||||
</div>
|
||||
|
||||
@if (isMentionPopupOpen && MentionCharacter.HasValue && mentionSearchResults.Count > 0)
|
||||
{
|
||||
<div class="rz-chat-mention-popup">
|
||||
@foreach (var (user, index) in mentionSearchResults.Select((u, i) => (u, i)))
|
||||
{
|
||||
var isSelected = index == selectedMentionIndex;
|
||||
<div class="rz-chat-mention-item @(isSelected ? "rz-chat-mention-item-selected" : "")"
|
||||
@onclick="@((MouseEventArgs e) => OnMentionItemClick(user))">
|
||||
@if (MentionItemTemplate != null)
|
||||
{
|
||||
@MentionItemTemplate(user)
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@user.UserName</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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<MentionUserContext> mentionSearchResults = new();
|
||||
private bool isMentionPopupOpen;
|
||||
private string mentionSearchText = string.Empty;
|
||||
private int selectedMentionIndex = -1;
|
||||
private int mentionStartPosition = -1;
|
||||
private CancellationTokenSource? mentionSearchCts;
|
||||
private readonly List<MentionInputSegment> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message template.
|
||||
/// </summary>
|
||||
@@ -353,6 +373,65 @@ namespace Radzen.Blazor
|
||||
[Parameter]
|
||||
public EventCallback<ChatUser> UserRemoved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the character that triggers the mention search popup (e.g., '@'). When null, mention feature is disabled.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public char? MentionCharacter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event callback that is invoked when a mention search is triggered.
|
||||
/// The callback receives a <see cref="MentionSearchArgs"/> with the search filter and pagination info.
|
||||
/// The app should populate the search results which will be displayed in the mention popup.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<MentionSearchArgs> MentionSearch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the template for rendering individual items in the mention search popup.
|
||||
/// Receives a <see cref="MentionUserContext"/> containing the user ID, name, and chat status.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment<MentionUserContext>? MentionItemTemplate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment<string>? MentionDisplayTemplate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of mention search results to display in the popup. Defaults to 10.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int MentionMaxResults { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the event callback that stores mention search results from the MentionSearch callback.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<IEnumerable<MentionUserContext>> MentionSearchResultsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the mention search results and refreshes the popup.
|
||||
/// Call this from within your MentionSearch event callback handler.
|
||||
/// </summary>
|
||||
/// <param name="results">The search results to display.</param>
|
||||
public async Task SetMentionSearchResults(IEnumerable<MentionUserContext>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current list of messages.
|
||||
/// </summary>
|
||||
@@ -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<string>("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<MentionInputSegment>(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<bool> HandleMentionDeletion(string key)
|
||||
{
|
||||
if (JSRuntime == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var selection = await JSRuntime.InvokeAsync<int[]?>("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($"<p>{System.Net.WebUtility.HtmlEncode(text)}</p>"));
|
||||
return;
|
||||
}
|
||||
|
||||
var mentions = ParseMentions(text);
|
||||
if (mentions.Count == 0)
|
||||
{
|
||||
builder.AddContent(0, new MarkupString($"<p>{System.Net.WebUtility.HtmlEncode(text)}</p>"));
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
};
|
||||
|
||||
|
||||
175
RadzenBlazorDemos/Pages/ChatMention.razor
Normal file
175
RadzenBlazorDemos/Pages/ChatMention.razor
Normal file
@@ -0,0 +1,175 @@
|
||||
@using Radzen
|
||||
@using Radzen.Blazor
|
||||
@inherits ComponentBase
|
||||
|
||||
<RadzenStack class="rz-p-0 rz-p-md-12">
|
||||
<RadzenCard class="rz-p-4" Variant="Variant.Outlined">
|
||||
<RadzenStack Orientation="Orientation.Vertical" Gap="0.5rem">
|
||||
<RadzenLabel Text="Mention Users in Chat" />
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
<RadzenChat @ref="basicChat"
|
||||
CurrentUserId="user1"
|
||||
Users="@users"
|
||||
Messages="@messages"
|
||||
MessagesChanged="@OnMessagesChanged"
|
||||
Placeholder="@PlaceholderText"
|
||||
Style="height: 500px;"
|
||||
ShowTypingIndicator="true"
|
||||
MessageAdded="@OnMessageAdded"
|
||||
MessageSent="@OnMessageSent"
|
||||
ChatCleared="@OnChatCleared"
|
||||
TypingChanged="@OnTypingChanged"
|
||||
MentionCharacter="@MentionCharacter"
|
||||
MentionMaxResults="5"
|
||||
MentionSearch="@LoadMentions"
|
||||
MentionItemTemplate="@MentionItemTemplate"
|
||||
MentionDisplayTemplate="@MentionDisplayTemplate">
|
||||
<TitleContent>
|
||||
<RadzenBadge>Chat with Mentions</RadzenBadge>
|
||||
</TitleContent>
|
||||
</RadzenChat>
|
||||
</RadzenStack>
|
||||
|
||||
<EventConsole @ref="console" Style="min-height: 230px;" />
|
||||
|
||||
@code {
|
||||
RadzenChat basicChat;
|
||||
EventConsole console;
|
||||
|
||||
private List<ChatUser> users = new();
|
||||
private List<ChatMessage> 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<MentionUserContext> 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<string> 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<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
@@ -76,3 +76,13 @@
|
||||
<RadzenExample ComponentName="Chat" Example="ChatTitle">
|
||||
<ChatTitle />
|
||||
</RadzenExample>
|
||||
|
||||
<RadzenText Anchor="chat#mention" TextStyle="TextStyle.DisplayH5" TagName="TagName.H2" class="rt-pt-12">
|
||||
Mention Users
|
||||
</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-8">
|
||||
Use a Mention Character to define on which character a callback should be triggered and display a search popup.
|
||||
</RadzenText>
|
||||
<RadzenExample ComponentName="Chat" Example="ChatMention">
|
||||
<ChatMention />
|
||||
</RadzenExample>
|
||||
|
||||
Reference in New Issue
Block a user