Compare commits
2 commits
04a31709a2
...
5e3d361f88
| Author | SHA1 | Date | |
|---|---|---|---|
|
5e3d361f88 |
|||
|
ef59cd1735 |
10 changed files with 928 additions and 44 deletions
118
src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs
Normal file
118
src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PostIt.Services;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View model for the "add a Yavsc user to a circle" modal.
|
||||||
|
///
|
||||||
|
/// <para>Resolves users through <see cref="IUserDirectory"/>
|
||||||
|
/// (which delegates to <c>/api/user-search</c>); the caller
|
||||||
|
/// (CirclesPage) decides whether to add the picked user to
|
||||||
|
/// the circle by calling
|
||||||
|
/// <see cref="AddCircleMemberDialogViewModel.AddCommand"/>
|
||||||
|
/// (which is bound to the dialog's "Ajouter" button).</para>
|
||||||
|
///
|
||||||
|
/// <para>The dialog itself doesn't know the target
|
||||||
|
/// <c>CircleId</c>: that's set by the caller via the
|
||||||
|
/// constructor and the dialog only triggers
|
||||||
|
/// <see cref="IUserDirectory.SearchAsync"/> against the
|
||||||
|
/// <see cref="SearchQuery"/> string. The "Add" command
|
||||||
|
/// returns the picked <see cref="UserSummary"/> via the
|
||||||
|
/// <see cref="Confirmed"/> event, and the hosting
|
||||||
|
/// <c>CirclesPage</c> then calls
|
||||||
|
/// <see cref="CircleApiClient.AddMemberAsync"/>.</para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddCircleMemberDialogViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IUserDirectory _directory;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SearchQuery { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<UserSummary> Results { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial UserSummary? Selected { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string StatusMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user confirms a selection. The hosting
|
||||||
|
/// <c>CirclesPage</c> subscribes to this event and calls
|
||||||
|
/// <c>CircleApiClient.AddMemberAsync</c> with the target
|
||||||
|
/// circle id + the picked user's id. The dialog itself
|
||||||
|
/// does not know the circle id by design: separation of
|
||||||
|
/// concerns — the modal is a user picker, not a
|
||||||
|
/// "circle joiner" form.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<UserSummary>? Confirmed;
|
||||||
|
|
||||||
|
public AddCircleMemberDialogViewModel(IUserDirectory directory)
|
||||||
|
{
|
||||||
|
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||||
|
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search the directory for users matching the current
|
||||||
|
/// <see cref="SearchQuery"/>. Triggered explicitly via the
|
||||||
|
/// "Rechercher" button — no debouncing, so the caller
|
||||||
|
/// stays in control of how often the network is hit.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task SearchAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(SearchQuery))
|
||||||
|
{
|
||||||
|
Results.Clear();
|
||||||
|
StatusMessage = "Tapez un nom ou un email";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
Results = new ObservableCollection<UserSummary>(hits ?? Array.Empty<UserSummary>());
|
||||||
|
StatusMessage = $"{Results.Count} résultat(s)";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raise <see cref="Confirmed"/> for the currently selected
|
||||||
|
/// user. No-op when no selection has been made — keeps the
|
||||||
|
/// UI from firing an event with a null payload.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
public void Add()
|
||||||
|
{
|
||||||
|
if (Selected is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Sélectionnez un utilisateur";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Confirmed?.Invoke(this, Selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PostIt.Services;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Api.Client.Dtos;
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
|
|
@ -11,13 +13,24 @@ namespace PostIt.ViewModels;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// View model for the "Mes cercles" page. CRUD on the caller's own
|
/// View model for the "Mes cercles" page. CRUD on the caller's own
|
||||||
/// circles (the server scopes every endpoint to the caller's uid
|
/// circles (the server scopes every endpoint to the caller's uid
|
||||||
/// since the BlogAcl fix on this branch).
|
/// since the BlogAcl fix on this branch), plus membership
|
||||||
|
/// management on the currently selected circle.
|
||||||
///
|
///
|
||||||
/// <para>The view lists circles in <see cref="Circles"/>, supports
|
/// <para>The view lists circles in <see cref="Circles"/>, supports
|
||||||
/// create / edit via <see cref="DraftName"/>, and exposes
|
/// create / edit via <see cref="DraftName"/>, and exposes
|
||||||
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
|
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
|
||||||
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
|
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
|
||||||
/// surfaces success / error feedback in the view footer.</para>
|
/// surfaces success / error feedback in the view footer.</para>
|
||||||
|
///
|
||||||
|
/// <para>When the user selects a circle in the list,
|
||||||
|
/// <see cref="LoadMembersAsync"/> fetches its members into
|
||||||
|
/// <see cref="Members"/>. The "Add a member" command
|
||||||
|
/// (<see cref="OpenAddMemberAsync"/>) is a UI event the view
|
||||||
|
/// raises to open <c>AddCircleMemberDialog</c>; the dialog
|
||||||
|
/// raises a <c>Confirmed</c> event back, which the page's
|
||||||
|
/// code-behind forwards here via
|
||||||
|
/// <see cref="OnAddMemberConfirmedAsync"/>. The "remove"
|
||||||
|
/// command is per-row and runs inline.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class CirclesPageViewModel : ViewModelBase
|
public partial class CirclesPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
|
|
@ -37,12 +50,26 @@ public partial class CirclesPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool DraftPublic { get; set; }
|
public partial bool DraftPublic { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Members of the currently selected circle. Empty
|
||||||
|
/// when no circle is selected or after a refresh that
|
||||||
|
/// produced an empty list. Updated by
|
||||||
|
/// <see cref="LoadMembersAsync"/>.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<CircleMemberDto> Members { get; set; } = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string StatusMessage { get; set; } = string.Empty;
|
public partial string StatusMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user wants to add a member to the
|
||||||
|
/// currently selected circle. The view listens to this
|
||||||
|
/// event and opens <c>AddCircleMemberDialog</c>.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? AddMemberRequested;
|
||||||
|
|
||||||
public CirclesPageViewModel(CircleApiClient client)
|
public CirclesPageViewModel(CircleApiClient client)
|
||||||
{
|
{
|
||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
|
@ -51,6 +78,24 @@ public partial class CirclesPageViewModel : ViewModelBase
|
||||||
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Partial property setter: when the selected circle
|
||||||
|
/// changes, refresh the members list. The setter is
|
||||||
|
/// invoked by the [ObservableProperty] source generator
|
||||||
|
/// for both user selections and programmatic resets.
|
||||||
|
/// </summary>
|
||||||
|
partial void OnSelectedCircleChanged(CircleDto? value)
|
||||||
|
{
|
||||||
|
Members = new ObservableCollection<CircleMemberDto>();
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
// Fire-and-forget: load members in the background.
|
||||||
|
// Errors are routed to StatusMessage inside
|
||||||
|
// LoadMembersAsync.
|
||||||
|
_ = LoadMembersAsync(value.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task RefreshAsync()
|
public async Task RefreshAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -71,6 +116,33 @@ public partial class CirclesPageViewModel : ViewModelBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load the members of one of the caller's circles. The
|
||||||
|
/// server scopes the endpoint with a 404 when the circle
|
||||||
|
/// doesn't belong to the caller (mirroring the rest of the
|
||||||
|
/// circle API); that case flattens to an empty list here.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task LoadMembersAsync(long circleId)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _client.GetMembersAsync(circleId);
|
||||||
|
Members = new ObservableCollection<CircleMemberDto>(list ?? new());
|
||||||
|
StatusMessage = $"{Members.Count} membre(s)";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
Members = new ObservableCollection<CircleMemberDto>();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void StartCreate()
|
public void StartCreate()
|
||||||
{
|
{
|
||||||
|
|
@ -141,6 +213,12 @@ public partial class CirclesPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
await _client.DeleteCircleAsync(circle.Id);
|
await _client.DeleteCircleAsync(circle.Id);
|
||||||
StatusMessage = $"Cercle « {circle.Name} » supprimé";
|
StatusMessage = $"Cercle « {circle.Name} » supprimé";
|
||||||
|
// If the deleted circle was the selected one,
|
||||||
|
// clear the selection so the Members view goes
|
||||||
|
// empty too (the partial setter on
|
||||||
|
// SelectedCircle will reset Members).
|
||||||
|
if (SelectedCircle?.Id == circle.Id)
|
||||||
|
SelectedCircle = null;
|
||||||
await RefreshAsync();
|
await RefreshAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -152,4 +230,81 @@ public partial class CirclesPageViewModel : ViewModelBase
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fire the <see cref="AddMemberRequested"/> event so
|
||||||
|
/// the view opens <c>AddCircleMemberDialog</c>. The view
|
||||||
|
/// forwards the dialog's <c>Confirmed</c> event back to
|
||||||
|
/// <see cref="OnAddMemberConfirmedAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
public void OpenAddMember()
|
||||||
|
{
|
||||||
|
if (SelectedCircle is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Sélectionnez d'abord un cercle";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AddMemberRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the view when the dialog confirms a
|
||||||
|
/// selection. Adds the picked user to the currently
|
||||||
|
/// selected circle and refreshes the members list.
|
||||||
|
/// </summary>
|
||||||
|
public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked)
|
||||||
|
{
|
||||||
|
if (SelectedCircle is null || picked is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.AddMemberAsync(SelectedCircle.Id, picked.Id);
|
||||||
|
StatusMessage = $"« {picked.DisplayName} » ajouté au cercle";
|
||||||
|
await LoadMembersAsync(SelectedCircle.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 409 (already a member) is a likely race — surface
|
||||||
|
// it as a friendly status, not an error. The
|
||||||
|
// server returns 409 for "already a member";
|
||||||
|
// YavscApiClient surfaces that as an exception
|
||||||
|
// today; future refactors could route 409 into a
|
||||||
|
// typed result, but for now the message string is
|
||||||
|
// distinctive enough.
|
||||||
|
var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict")
|
||||||
|
? "Déjà membre du cercle"
|
||||||
|
: $"Erreur: {ex.Message}";
|
||||||
|
StatusMessage = msg;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-row "remove" command. Updates the local
|
||||||
|
/// collection in place so the UI doesn't flash.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task RemoveMemberAsync(CircleMemberDto? member)
|
||||||
|
{
|
||||||
|
if (member is null || SelectedCircle is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id);
|
||||||
|
Members.Remove(member);
|
||||||
|
StatusMessage = $"« {member.UserName} » retiré du cercle";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml
Normal file
57
src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<ContentPage
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="PostIt.Views.AddCircleMemberDialog"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:services="using:PostIt.Services"
|
||||||
|
x:DataType="vm:AddCircleMemberDialogViewModel"
|
||||||
|
>
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
|
||||||
|
|
||||||
|
<!-- Search box + button -->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
|
||||||
|
IsEnabled="{Binding !IsBusy}">
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
Text="{Binding SearchQuery, Mode=TwoWay}"
|
||||||
|
PlaceholderText="Nom ou email d'un utilisateur Yavsc..."
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<Button Grid.Column="1" Content="Rechercher"
|
||||||
|
Command="{Binding SearchCommand}"
|
||||||
|
Margin="8,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Selection hint -->
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="Sélectionnez un résultat puis cliquez Ajouter."
|
||||||
|
FontSize="11" Opacity="0.6"
|
||||||
|
Margin="0,0,0,8"/>
|
||||||
|
|
||||||
|
<!-- Search results -->
|
||||||
|
<ListBox Grid.Row="2"
|
||||||
|
ItemsSource="{Binding Results}"
|
||||||
|
SelectedItem="{Binding Selected, Mode=TwoWay}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="services:UserSummary">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="{Binding DisplayName}"
|
||||||
|
FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding UserName}"
|
||||||
|
FontSize="11" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" Margin="0,8,0,0">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Content="Ajouter"
|
||||||
|
Command="{Binding AddCommand}"
|
||||||
|
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||||
|
Margin="0,0,8,0"/>
|
||||||
|
<Button Grid.Column="2" Content="Fermer"
|
||||||
|
Click="OnCloseClicked"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
54
src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs
Normal file
54
src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using PostIt.Services;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal "add a member to a circle" page. Hosted by
|
||||||
|
/// <c>CirclesPage</c>; the caller passes the resolved
|
||||||
|
/// <see cref="IUserDirectory"/> via the constructor.
|
||||||
|
///
|
||||||
|
/// <para>The dialog raises <c>Confirmed</c> on its ViewModel
|
||||||
|
/// when the user picks a result and clicks "Ajouter"; the
|
||||||
|
/// hosting page subscribes to that event and calls
|
||||||
|
/// <c>CircleApiClient.AddMemberAsync</c> with the target
|
||||||
|
/// circle id. The dialog itself does not know the circle id
|
||||||
|
/// by design.</para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddCircleMemberDialog : ContentPage
|
||||||
|
{
|
||||||
|
public AddCircleMemberDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AddCircleMemberDialog(IUserDirectory directory)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = new AddCircleMemberDialogViewModel(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribe a handler to be notified when the user
|
||||||
|
/// confirms a selection. Returns the underlying VM so
|
||||||
|
/// the caller can also drive further state (clear the
|
||||||
|
/// selection, close the dialog, refresh its own list).
|
||||||
|
/// </summary>
|
||||||
|
public AddCircleMemberDialogViewModel? ViewModel
|
||||||
|
=> DataContext as AddCircleMemberDialogViewModel;
|
||||||
|
|
||||||
|
private void OnCloseClicked(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Same light-modal pattern as PostAclDialog: rely on
|
||||||
|
// the system back gesture or the navigation host's
|
||||||
|
// "pop" — the ContentPage doesn't own the back stack.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
||||||
x:DataType="vm:CirclesPageViewModel"
|
x:DataType="vm:CirclesPageViewModel"
|
||||||
>
|
>
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- Toolbar: refresh + new -->
|
<!-- Toolbar: refresh + new -->
|
||||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
|
||||||
|
|
@ -16,8 +16,15 @@
|
||||||
Command="{Binding StartCreateCommand}"/>
|
Command="{Binding StartCreateCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- List of circles -->
|
<!-- Two-pane body: circles (left) + members (right) -->
|
||||||
<ListBox Grid.Row="1" Margin="12,0,12,12"
|
<Grid Grid.Row="1" Margin="12,0,12,12"
|
||||||
|
ColumnDefinitions="*,16,*"
|
||||||
|
RowDefinitions="*,Auto">
|
||||||
|
|
||||||
|
<!-- Left column: list of circles + editor -->
|
||||||
|
<Grid Grid.Row="0" Grid.Column="0"
|
||||||
|
RowDefinitions="*,Auto">
|
||||||
|
<ListBox Grid.Row="0"
|
||||||
ItemsSource="{Binding Circles}"
|
ItemsSource="{Binding Circles}"
|
||||||
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
|
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
|
|
@ -40,7 +47,7 @@
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<!-- Editor -->
|
<!-- Editor -->
|
||||||
<Grid Grid.Row="2" Margin="12" RowDefinitions="Auto,Auto,Auto"
|
<Grid Grid.Row="1" Margin="0,12,0,0" RowDefinitions="Auto,Auto,Auto"
|
||||||
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
|
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
|
||||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
|
@ -53,9 +60,47 @@
|
||||||
Command="{Binding SaveCommand}"
|
Command="{Binding SaveCommand}"
|
||||||
HorizontalAlignment="Right" Margin="0,8,0,0"/>
|
HorizontalAlignment="Right" Margin="0,8,0,0"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Right column: members of the selected circle -->
|
||||||
|
<Grid Grid.Row="0" Grid.Column="2"
|
||||||
|
RowDefinitions="Auto,*,Auto">
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Membres"
|
||||||
|
FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Content="Ajouter un membre"
|
||||||
|
Command="{Binding OpenAddMemberCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ListBox Grid.Row="1"
|
||||||
|
ItemsSource="{Binding Members}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="dtos:CircleMemberDto">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
|
<TextBlock Text="{Binding FullName}"
|
||||||
|
FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding UserName}"
|
||||||
|
FontSize="11" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="Retirer"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).RemoveMemberCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
<!-- Empty-state hint -->
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
Text="Sélectionnez un cercle pour voir ses membres."
|
||||||
|
IsVisible="{Binding SelectedCircle, Converter={x:Static ObjectConverters.IsNull}}"
|
||||||
|
FontSize="11" Opacity="0.6"
|
||||||
|
Margin="0,8,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
|
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<ProgressBar Grid.Column="1" IsIndeterminate="True"
|
<ProgressBar Grid.Column="1" IsIndeterminate="True"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,55 @@
|
||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
public partial class CirclesPage : ContentPage
|
public partial class CirclesPage : ContentPage
|
||||||
{
|
{
|
||||||
|
private CirclesPageViewModel? _vm;
|
||||||
|
|
||||||
public CirclesPage()
|
public CirclesPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// Unsubscribe from the previous VM to avoid leaking
|
||||||
|
// handlers across navigation pushes / DataContext resets.
|
||||||
|
if (_vm is not null)
|
||||||
|
_vm.AddMemberRequested -= OnAddMemberRequested;
|
||||||
|
|
||||||
|
_vm = DataContext as CirclesPageViewModel;
|
||||||
|
if (_vm is not null)
|
||||||
|
_vm.AddMemberRequested += OnAddMemberRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAddMemberRequested(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var app = Application.Current as App;
|
||||||
|
var services = app?.ServiceProvider;
|
||||||
|
if (services is null || _vm is null) return;
|
||||||
|
|
||||||
|
// Resolve the directory via DI. The dialog raises its
|
||||||
|
// own Confirmed event; the VM subscribes via the method
|
||||||
|
// below — we pass the VM in so the closure can call
|
||||||
|
// back into it without the dialog needing to know the
|
||||||
|
// type of its caller. EventHandler<UserSummary> wants a
|
||||||
|
// void return, so wrap the async VM method in a fire-
|
||||||
|
// and-forget helper.
|
||||||
|
var directory = services.GetRequiredService<IUserDirectory>();
|
||||||
|
var dialog = new AddCircleMemberDialog(directory);
|
||||||
|
dialog.ViewModel!.Confirmed += async (sender, picked) =>
|
||||||
|
await _vm.OnAddMemberConfirmedAsync(sender, picked);
|
||||||
|
|
||||||
|
if (this.VisualRoot is MainWindow window)
|
||||||
|
_ = window.NavRoot.PushAsync(dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent()
|
private void InitializeComponent()
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,36 @@ public sealed class CircleApiClient
|
||||||
|
|
||||||
public Task DeleteCircleAsync(long id, CancellationToken ct = default)
|
public Task DeleteCircleAsync(long id, CancellationToken ct = default)
|
||||||
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
|
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the members of one of the caller's circles.
|
||||||
|
/// Returns null when the circle does not exist or is not
|
||||||
|
/// owned by the caller (the server scopes the endpoint
|
||||||
|
/// with a 404 in either case to avoid leaking existence
|
||||||
|
/// — this client flattens that into a null result).
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<CircleMemberDto>?> GetMembersAsync(long id, CancellationToken ct = default)
|
||||||
|
=> _api.CallAsync<List<CircleMemberDto>?>(HttpMethod.Get, $"{Path}/{id}/members", ct: ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a Yavsc user (resolved client-side via
|
||||||
|
/// <c>/api/user-search</c>) to one of the caller's
|
||||||
|
/// circles. Returns null when the circle does not exist
|
||||||
|
/// or is not owned by the caller, or when the target
|
||||||
|
/// user does not exist. Throws on 409 (already a
|
||||||
|
/// member) — callers that want idempotent behaviour
|
||||||
|
/// can swallow the exception or dedupe beforehand.
|
||||||
|
/// </summary>
|
||||||
|
public Task AddMemberAsync(long id, string userId, CancellationToken ct = default)
|
||||||
|
=> _api.CallAsync(HttpMethod.Post, $"{Path}/{id}/members",
|
||||||
|
body: new { userId }, ct: ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a user from one of the caller's circles.
|
||||||
|
/// Returns null on success (the server returns 200 OK
|
||||||
|
/// with no body) or when the membership does not
|
||||||
|
/// exist — both treated as success by the caller.
|
||||||
|
/// </summary>
|
||||||
|
public Task RemoveMemberAsync(long id, string userId, CancellationToken ct = default)
|
||||||
|
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}/members/{userId}", ct: ct);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs
Normal file
21
src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire format for <c>GET /api/circle/{id}/members</c>.
|
||||||
|
///
|
||||||
|
/// <para>Mirrors the server-side
|
||||||
|
/// <c>Yavsc.Blogs.Controllers.CircleMemberDto</c>. Intentionally
|
||||||
|
/// stops short of the Email field that
|
||||||
|
/// <see cref="UserSearchResultDto"/> carries — the circle
|
||||||
|
/// membership UI only needs a name and an avatar to render the
|
||||||
|
/// list. If the future ACL UI wants contact details, it can
|
||||||
|
/// fall back to <see cref="IYavscApiClient"/>'s other
|
||||||
|
/// endpoints rather than widening this shape.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CircleMemberDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string? FullName { get; set; }
|
||||||
|
public string? Avatar { get; set; }
|
||||||
|
}
|
||||||
199
src/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
Normal file
199
src/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Relationship;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Behavioural tests for the circle-members endpoints on
|
||||||
|
/// <c>CircleApiController</c>:
|
||||||
|
/// <c>GET /api/circle/{id}/members</c>,
|
||||||
|
/// <c>POST /api/circle/{id}/members</c>,
|
||||||
|
/// <c>DELETE /api/circle/{id}/members/{userId}</c>.
|
||||||
|
///
|
||||||
|
/// <para>Same fixture as <see cref="BlogApiTests"/>:
|
||||||
|
/// <see cref="BlogsWebServerFixture"/> provides an in-memory
|
||||||
|
/// <c>ApplicationDbContext</c>, JWT bearer auth with HS256,
|
||||||
|
/// and the production <c>BlogScope</c> policy. Tests use
|
||||||
|
/// <c>TestTokenIssuer</c> to mint tokens whose <c>sub</c>
|
||||||
|
/// claim identifies the caller.</para>
|
||||||
|
///
|
||||||
|
/// <para>Test users (<c>alice</c>, <c>bob</c>) are seeded
|
||||||
|
/// directly via <see cref="ApplicationDbContext.Users"/>:
|
||||||
|
/// the Blogs fixture doesn't stand up
|
||||||
|
/// <c>UserManager<ApplicationUser></c>, so we go
|
||||||
|
/// through the DbContext the same way the production code
|
||||||
|
/// would.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("JwtClaimMapping")]
|
||||||
|
public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly BlogsWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public CircleMembersApiTests(BlogsWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reset the in-memory database and seed
|
||||||
|
/// <c>alice</c> + <c>bob</c>. <c>UseInMemoryDatabase</c>
|
||||||
|
/// shares its store across the fixture lifetime, so each
|
||||||
|
/// test starts from a clean slate.</summary>
|
||||||
|
private void ResetDatabaseWithUsers()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
db.Database.EnsureDeleted();
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "alice",
|
||||||
|
UserName = "alice",
|
||||||
|
Email = "alice@example.com",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = "Alice Dupont",
|
||||||
|
Avatar = "/avatars/alice.png",
|
||||||
|
});
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "bob",
|
||||||
|
UserName = "bob",
|
||||||
|
Email = "bob@example.com",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = "Bob Martin",
|
||||||
|
Avatar = "/avatars/bob.png",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create a circle owned by <paramref name="ownerId"/>
|
||||||
|
/// directly in the in-memory store and return its server-assigned
|
||||||
|
/// id. The tests below use this to bypass the controller's POST
|
||||||
|
/// (which is already covered by other tests on the branch);
|
||||||
|
/// the focus here is the members endpoints.</summary>
|
||||||
|
private long SeedCircle(string ownerId, string name)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var circle = new Circle { OwnerId = ownerId, Name = name };
|
||||||
|
db.Circle.Add(circle);
|
||||||
|
db.SaveChanges();
|
||||||
|
return circle.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MembersUrl(long circleId)
|
||||||
|
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/circle/{circleId}/members";
|
||||||
|
|
||||||
|
private HttpClient NewClient(string subject)
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||||
|
"Bearer", TestTokenIssuer.Issue(subject));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMembers_returns_200_with_empty_list_when_no_members()
|
||||||
|
{
|
||||||
|
ResetDatabaseWithUsers();
|
||||||
|
var circleId = SeedCircle("alice", "Famille");
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
var response = await http.GetAsync(MembersUrl(circleId));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||||
|
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostMember_returns_201_then_Get_returns_the_member()
|
||||||
|
{
|
||||||
|
ResetDatabaseWithUsers();
|
||||||
|
var circleId = SeedCircle("alice", "Famille");
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
var postResponse = await http.PostAsJsonAsync(
|
||||||
|
MembersUrl(circleId),
|
||||||
|
new { userId = "bob" });
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||||
|
|
||||||
|
var getResponse = await http.GetAsync(MembersUrl(circleId));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||||
|
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||||
|
var member = doc.RootElement[0];
|
||||||
|
Assert.Equal("bob", member.GetProperty("id").GetString());
|
||||||
|
Assert.Equal("bob", member.GetProperty("userName").GetString());
|
||||||
|
Assert.Equal("Bob Martin", member.GetProperty("fullName").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostMember_returns_409_when_user_already_in_circle()
|
||||||
|
{
|
||||||
|
ResetDatabaseWithUsers();
|
||||||
|
var circleId = SeedCircle("alice", "Famille");
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
var first = await http.PostAsJsonAsync(
|
||||||
|
MembersUrl(circleId),
|
||||||
|
new { userId = "bob" });
|
||||||
|
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||||
|
|
||||||
|
var second = await http.PostAsJsonAsync(
|
||||||
|
MembersUrl(circleId),
|
||||||
|
new { userId = "bob" });
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteMember_returns_200_then_Get_does_not_include_member()
|
||||||
|
{
|
||||||
|
ResetDatabaseWithUsers();
|
||||||
|
var circleId = SeedCircle("alice", "Famille");
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" });
|
||||||
|
|
||||||
|
var deleteResponse = await http.DeleteAsync(
|
||||||
|
$"{MembersUrl(circleId)}/bob");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
|
||||||
|
|
||||||
|
var getResponse = await http.GetAsync(MembersUrl(circleId));
|
||||||
|
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMembers_returns_404_when_circle_not_owned_by_caller()
|
||||||
|
{
|
||||||
|
ResetDatabaseWithUsers();
|
||||||
|
// Alice's circle, Bob tries to read its members.
|
||||||
|
var circleId = SeedCircle("alice", "Famille");
|
||||||
|
using var http = NewClient("bob");
|
||||||
|
|
||||||
|
var response = await http.GetAsync(MembersUrl(circleId));
|
||||||
|
|
||||||
|
// 404, not 403 — the controller deliberately avoids leaking
|
||||||
|
// the existence of someone else's circle.
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
|
@ -27,7 +26,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IEnumerable<Circle> GetCircle()
|
public IEnumerable<Circle> GetCircle()
|
||||||
{
|
{
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
return _context.Circle.Where(c => c.OwnerId == uid);
|
return _context.Circle.Where(c => c.OwnerId == uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +42,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return BadRequest(ModelState);
|
return BadRequest(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
Circle circle = await _context.Circle.SingleOrDefaultAsync(
|
Circle circle = await _context.Circle.SingleOrDefaultAsync(
|
||||||
m => m.Id == id && m.OwnerId == uid);
|
m => m.Id == id && m.OwnerId == uid);
|
||||||
|
|
||||||
|
|
@ -74,7 +73,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
var existing = await _context.Circle.SingleOrDefaultAsync(
|
var existing = await _context.Circle.SingleOrDefaultAsync(
|
||||||
c => c.Id == id && c.OwnerId == uid);
|
c => c.Id == id && c.OwnerId == uid);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
|
|
@ -118,7 +117,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return BadRequest(ModelState);
|
return BadRequest(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
circle.OwnerId = uid;
|
circle.OwnerId = uid;
|
||||||
|
|
||||||
_context.Circle.Add(circle);
|
_context.Circle.Add(circle);
|
||||||
|
|
@ -156,7 +155,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return BadRequest(ModelState);
|
return BadRequest(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
Circle circle = await _context.Circle.SingleOrDefaultAsync(
|
Circle circle = await _context.Circle.SingleOrDefaultAsync(
|
||||||
m => m.Id == id && m.OwnerId == uid);
|
m => m.Id == id && m.OwnerId == uid);
|
||||||
if (circle == null)
|
if (circle == null)
|
||||||
|
|
@ -170,6 +169,143 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return Ok(circle);
|
return Ok(circle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the members of one of the caller's circles.
|
||||||
|
/// Returns 404 (not 403) when the circle does not exist
|
||||||
|
/// or is not owned by the caller, mirroring the scoping
|
||||||
|
/// of the rest of this controller.
|
||||||
|
/// </summary>
|
||||||
|
// GET: api/circle/5/members
|
||||||
|
[HttpGet("{id}/members")]
|
||||||
|
public async Task<IActionResult> GetMembers([FromRoute] long id)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
|
||||||
|
if (!ownsIt)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var members = await _context.CircleMembers
|
||||||
|
.Where(m => m.CircleId == id)
|
||||||
|
.Select(m => new CircleMemberDto
|
||||||
|
{
|
||||||
|
Id = m.MemberId,
|
||||||
|
UserName = m.Member.UserName ?? string.Empty,
|
||||||
|
FullName = m.Member.FullName,
|
||||||
|
Avatar = m.Member.Avatar,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(members);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a Yavsc user to one of the caller's circles. The
|
||||||
|
/// body carries the user id (resolved client-side via the
|
||||||
|
/// central <c>/api/user-search</c> endpoint). Returns
|
||||||
|
/// 404 (not 403) when the circle does not exist or is not
|
||||||
|
/// owned by the caller, and 404 when the target user does
|
||||||
|
/// not exist, so the caller can't probe whether an email
|
||||||
|
/// belongs to a real account.
|
||||||
|
///
|
||||||
|
/// <para>Returns 409 Conflict if the user is already a
|
||||||
|
/// member of the circle; the client treats this as a
|
||||||
|
/// no-op success.</para>
|
||||||
|
/// </summary>
|
||||||
|
// POST: api/circle/5/members
|
||||||
|
// body: { "userId": "..." }
|
||||||
|
[HttpPost("{id}/members")]
|
||||||
|
public async Task<IActionResult> AddMember(
|
||||||
|
[FromRoute] long id,
|
||||||
|
[FromBody] AddCircleMemberDto body)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
|
||||||
|
if (!ownsIt)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject unknown user ids the same way as an unknown
|
||||||
|
// circle: 404. Probing the user table by id should not
|
||||||
|
// be possible through this endpoint.
|
||||||
|
var userExists = await _context.Users.AnyAsync(u => u.Id == body.UserId);
|
||||||
|
if (!userExists)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: re-adding an existing member is a
|
||||||
|
// 409, not a silent success. Clients that don't
|
||||||
|
// dedupe beforehand will at least get an actionable
|
||||||
|
// status code rather than a misleading "created".
|
||||||
|
var alreadyMember = await _context.CircleMembers.AnyAsync(
|
||||||
|
m => m.CircleId == id && m.MemberId == body.UserId);
|
||||||
|
if (alreadyMember)
|
||||||
|
{
|
||||||
|
return new StatusCodeResult(StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.CircleMembers.Add(new CircleMember
|
||||||
|
{
|
||||||
|
CircleId = id,
|
||||||
|
MemberId = body.UserId,
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync(User.GetUserId());
|
||||||
|
|
||||||
|
return CreatedAtRoute("GetCircle", new { id }, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a user from one of the caller's circles.
|
||||||
|
/// Returns 404 when the circle does not exist or is not
|
||||||
|
/// owned by the caller, mirroring the rest of this
|
||||||
|
/// controller's scoping. Returns 404 when the user is
|
||||||
|
/// not a member of the circle (idempotent: removing a
|
||||||
|
/// non-member is the same as having nothing to remove).
|
||||||
|
/// </summary>
|
||||||
|
// DELETE: api/circle/5/members/tester
|
||||||
|
[HttpDelete("{id}/members/{userId}")]
|
||||||
|
public async Task<IActionResult> RemoveMember(
|
||||||
|
[FromRoute] long id,
|
||||||
|
[FromRoute] string userId)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
|
||||||
|
if (!ownsIt)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var membership = await _context.CircleMembers.SingleOrDefaultAsync(
|
||||||
|
m => m.CircleId == id && m.MemberId == userId);
|
||||||
|
if (membership is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.CircleMembers.Remove(membership);
|
||||||
|
await _context.SaveChangesAsync(User.GetUserId());
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
|
|
@ -184,4 +320,30 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return _context.Circle.Count(e => e.Id == id) > 0;
|
return _context.Circle.Count(e => e.Id == id) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire shape for <c>GET /api/circle/{id}/members</c>.
|
||||||
|
/// Mirrors <see cref="UserSearchResultDto"/> but stops
|
||||||
|
/// short of the Email field — circle membership UI only
|
||||||
|
/// needs to render a name and an avatar, not contact
|
||||||
|
/// details.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CircleMemberDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string? FullName { get; set; }
|
||||||
|
public string? Avatar { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire shape for <c>POST /api/circle/{id}/members</c>.
|
||||||
|
/// The body is intentionally tiny: the client resolves
|
||||||
|
/// the user id via <c>/api/user-search</c> before
|
||||||
|
/// posting, so all we need is the resolved id.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddCircleMemberDto
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue