Round-trip out a long-standing hole in the ACL feature: until
this commit a circle on Yavsc was an empty named bucket. You
could create 'Famille' and grant it on a post, but the circle
carried no members, so 'Famille' authorised no one. The MVC
admin controller (Yavsc.Org.Controllers.CircleMembersController)
existed but had no REST counterpart, so PostIt — which only
talks to the Yavsc.Blogs API — had no way to manage membership
at all.
This commit closes that gap end-to-end:
Server (Yavsc.Blogs)
- GET /api/circle/{id}/members list members
- POST /api/circle/{id}/members add a user (body { userId })
- DELETE /api/circle/{id}/members/{userId} remove a user
All three are scoped to caller == circle.OwnerId; non-owned
circles return 404 (not 403) to avoid leaking existence, in
line with the rest of the controller.
- Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the
wire shapes. CircleMemberDto mirrors UserSearchResultDto
minus Email — membership UI doesn't need contact details.
Tests (Yavsc.Blogs.Tests)
- CircleMembersApiTests: 5 [Fact] covering empty list,
add+get, duplicate add → 409, remove, and cross-owner 404.
Test users (alice, bob) are seeded directly through the
in-memory DbContext — the Blogs fixture doesn't stand up
UserManager<ApplicationUser>.
Client (Yavsc.Api.Client)
- CircleMemberDto + 3 methods on CircleApiClient:
GetMembersAsync, AddMemberAsync, RemoveMemberAsync.
All match the server's contract: 404 flattens to null,
409 surfaces as an exception (callers can dedupe beforehand
if they want idempotent behaviour).
UI (PostIt)
- CirclesPageViewModel gains a Members ObservableCollection
that auto-loads on SelectedCircle change (via the partial
setter generated by [ObservableProperty]). Commands:
LoadMembersAsync, OpenAddMember (raises an event the view
subscribes to), OnAddMemberConfirmedAsync (called by the
view when the dialog confirms a selection), RemoveMemberAsync.
409 (already a member) is detected from the exception
message and surfaced as a friendly status rather than an
error — a likely race when the same user gets added twice
through two UI paths.
- CirclesPage layout is now two-pane (circles + editor on the
left, members of the selected circle on the right). The
member pane has an 'Ajouter un membre' button that opens
AddCircleMemberDialog. Code-behind wires the dialog's
Confirmed event back into the VM via an async lambda
wrapper (EventHandler<T> wants void, the VM method is
async Task).
- AddCircleMemberDialog is a ContentPage (light modal, same
pattern as PostAclDialog). Its ViewModel consumes
IUserDirectory — the abstraction introduced by af5e71aa
to fix the 'user search should not be IContactService'
confusion. The dialog raises Confirmed with the picked
UserSummary; the host (CirclesPage) is responsible for
calling CircleApiClient.AddMemberAsync.
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: all visible text still hard-coded French.
- XAML accessibility audit of pre-existing pages.
- Avalonia.Headless UI tests of the new navigation flow.
Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests
green (was 15; +5 for CircleMembersApiTests), 44/44
Yavsc.Org.Tests green (no regression).
111 lines
5.8 KiB
XML
111 lines
5.8 KiB
XML
<ContentPage
|
|
xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
x:Class="PostIt.Views.CirclesPage"
|
|
xmlns:vm="using:PostIt.ViewModels"
|
|
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
|
x:DataType="vm:CirclesPageViewModel"
|
|
>
|
|
<Grid RowDefinitions="Auto,*,Auto">
|
|
|
|
<!-- Toolbar: refresh + new -->
|
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
|
|
<Button Content="Rafraîchir"
|
|
Command="{Binding RefreshCommand}"/>
|
|
<Button Content="Nouveau"
|
|
Command="{Binding StartCreateCommand}"/>
|
|
</StackPanel>
|
|
|
|
<!-- Two-pane body: circles (left) + members (right) -->
|
|
<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}"
|
|
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
|
|
<ListBox.ItemTemplate>
|
|
<DataTemplate x:DataType="dtos:CircleDto">
|
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
|
<StackPanel Grid.Column="0" Spacing="2">
|
|
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
|
|
<TextBlock Text="{Binding Public, StringFormat='Public : {0}'}"
|
|
FontSize="11" Opacity="0.6"/>
|
|
</StackPanel>
|
|
<Button Grid.Column="1" Content="Éditer"
|
|
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).StartEditCommand}"
|
|
CommandParameter="{Binding}"/>
|
|
<Button Grid.Column="2" Content="Supprimer"
|
|
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).DeleteCommand}"
|
|
CommandParameter="{Binding}"/>
|
|
</Grid>
|
|
</DataTemplate>
|
|
</ListBox.ItemTemplate>
|
|
</ListBox>
|
|
|
|
<!-- Editor -->
|
|
<Grid Grid.Row="1" Margin="0,12,0,0" RowDefinitions="Auto,Auto,Auto"
|
|
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
|
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
|
|
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
|
<TextBox Grid.Row="0" Grid.Column="1"
|
|
Text="{Binding DraftName, Mode=TwoWay}"/>
|
|
<CheckBox Grid.Row="1" Grid.Column="1"
|
|
Content="Public"
|
|
IsChecked="{Binding DraftPublic, Mode=TwoWay}"/>
|
|
<Button Grid.Row="2" Grid.Column="1" Content="Enregistrer"
|
|
Command="{Binding SaveCommand}"
|
|
HorizontalAlignment="Right" Margin="0,8,0,0"/>
|
|
</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 -->
|
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
|
|
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
|
|
VerticalAlignment="Center"/>
|
|
<ProgressBar Grid.Column="1" IsIndeterminate="True"
|
|
IsVisible="{Binding IsBusy}"
|
|
Width="120"/>
|
|
</Grid>
|
|
</Grid>
|
|
</ContentPage>
|