feat/postit-acl #32
10 changed files with 599 additions and 0 deletions
feat(postit): UI for managing Circles + per-post ACL
Landing the user-facing surface for the BlogAcl work. The user can now: 1. Open the 'Mes cercles' page (a new 'Mes cercles' button on the main page) and create / edit / delete their own circles. The page lists circles in an ObservableCollection bound to a ListBox; per-row buttons drive StartEdit and Delete; the bottom editor pushes new / edited circles via the Save command. 2. With a post selected, click the new 'ACL' button to open a modal 'PostAclDialog' for that post. The modal shows the current ACL entries (filtered server-side by Allowed.OwnerId == caller) and a dropdown of the caller's circles to add. Each entry has a 'Revoke' button. Both pages follow the same pattern: - ViewModel uses [ObservableProperty] for state and [RelayCommand] for verbs; IsBusy drives a ProgressBar overlay; StatusMessage surfaces server feedback. - View follows the XAML-Background/Foreground lesson (no hard-coded colours), so dark mode works without contrast surprises. - Code-behind is minimal — just AvaloniaXamlLoader.Load — because navigation is driven by RelayCommand + event (ManageAclRequested, OpenCirclesRequested) that the MainPage code-behind handles via its DataContextChanged handler. The 'complete' scope (c) of this commit was confirmed by Paul. Three follow-up tracks are deliberately out of scope and tracked in MEMORY.md (2026-08-18): - i18n: no .resx / IStringLocalizer today; all visible text is hard-coded French. - Avalonia.Headless UI tests: only ViewModel-level coverage is feasible today; full navigation tests are a separate effort. - XAML accessibility audit of pre-existing pages (Settings, MainPage) that predate the Background/Foreground lesson. Build + 51/51 tests green.
commit
0e7576857d
|
|
@ -78,6 +78,7 @@ public partial class App : Application
|
||||||
services.AddSingleton<SettingsPage>();
|
services.AddSingleton<SettingsPage>();
|
||||||
services.AddTransient<HomePage>();
|
services.AddTransient<HomePage>();
|
||||||
services.AddTransient<SignaturePage>();
|
services.AddTransient<SignaturePage>();
|
||||||
|
services.AddTransient<CirclesPage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
@ -89,6 +90,7 @@ public partial class App : Application
|
||||||
services.AddTransient<MainPageViewModel>();
|
services.AddTransient<MainPageViewModel>();
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddTransient<HomePageViewModel>();
|
||||||
services.AddTransient<SignaturePageViewModel>();
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
services.AddTransient<CirclesPageViewModel>();
|
||||||
|
|
||||||
// Persistent session banner: one instance for the lifetime of
|
// Persistent session banner: one instance for the lifetime of
|
||||||
// the app so the same VM survives page navigation.
|
// the app so the same VM survives page navigation.
|
||||||
|
|
|
||||||
155
src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs
Normal file
155
src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View model for the "Mes cercles" page. CRUD on the caller's own
|
||||||
|
/// circles (the server scopes every endpoint to the caller's uid
|
||||||
|
/// since the BlogAcl fix on this branch).
|
||||||
|
///
|
||||||
|
/// <para>The view lists circles in <see cref="Circles"/>, supports
|
||||||
|
/// create / edit via <see cref="DraftName"/>, and exposes
|
||||||
|
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
|
||||||
|
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
|
||||||
|
/// surfaces success / error feedback in the view footer.</para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class CirclesPageViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly CircleApiClient _client;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<CircleDto> Circles { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial CircleDto? SelectedCircle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Editor buffer for the new / edited circle's name.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string DraftName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Editor buffer for the new / edited circle's visibility flag.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool DraftPublic { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string StatusMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public CirclesPageViewModel(CircleApiClient client)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(); }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _client.GetMyCirclesAsync();
|
||||||
|
Circles = new ObservableCollection<CircleDto>(list ?? new());
|
||||||
|
StatusMessage = $"{Circles.Count} cercle(s)";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void StartCreate()
|
||||||
|
{
|
||||||
|
SelectedCircle = null;
|
||||||
|
DraftName = string.Empty;
|
||||||
|
DraftPublic = false;
|
||||||
|
StatusMessage = "Nouveau cercle";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void StartEdit(CircleDto? circle)
|
||||||
|
{
|
||||||
|
if (circle is null) return;
|
||||||
|
SelectedCircle = circle;
|
||||||
|
DraftName = circle.Name;
|
||||||
|
DraftPublic = circle.Public;
|
||||||
|
StatusMessage = $"Édition de « {circle.Name} »";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(DraftName))
|
||||||
|
{
|
||||||
|
StatusMessage = "Le nom est obligatoire";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (SelectedCircle is null)
|
||||||
|
{
|
||||||
|
var created = await _client.CreateCircleAsync(new CircleDto
|
||||||
|
{
|
||||||
|
Name = DraftName.Trim(),
|
||||||
|
Public = DraftPublic,
|
||||||
|
});
|
||||||
|
StatusMessage = created is null
|
||||||
|
? "Création échouée"
|
||||||
|
: $"Cercle « {created.Name} » créé";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedCircle.Name = DraftName.Trim();
|
||||||
|
SelectedCircle.Public = DraftPublic;
|
||||||
|
await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle);
|
||||||
|
StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour";
|
||||||
|
}
|
||||||
|
await RefreshAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task DeleteAsync(CircleDto? circle)
|
||||||
|
{
|
||||||
|
if (circle is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.DeleteCircleAsync(circle.Id);
|
||||||
|
StatusMessage = $"Cercle « {circle.Name} » supprimé";
|
||||||
|
await RefreshAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -317,4 +317,32 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
/// forced the buggy "draft with empty title" branch.</summary>
|
/// forced the buggy "draft with empty title" branch.</summary>
|
||||||
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
|
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
|
||||||
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
||||||
|
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user asks to open the "manage ACL" dialog for
|
||||||
|
/// the currently selected post. The <c>MainPage</c> code-behind
|
||||||
|
/// listens to this event and pushes a <c>PostAclDialog</c> on the
|
||||||
|
/// navigation stack. The VM itself can't navigate directly
|
||||||
|
/// because the navigation surface (<c>NavigationPage</c>) lives
|
||||||
|
/// in the View layer.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<BlogPost>? ManageAclRequested;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanManageAcl))]
|
||||||
|
public void ManageAcl()
|
||||||
|
{
|
||||||
|
if (SelectedPost is null) return;
|
||||||
|
ManageAclRequested?.Invoke(this, SelectedPost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user asks to open the circles page (full
|
||||||
|
/// CRUD on their own circles). Same routing as
|
||||||
|
/// <see cref="ManageAclRequested"/>.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? OpenCirclesRequested;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
157
src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs
Normal file
157
src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View model for the "Gérer l'ACL" modal of a single blog post.
|
||||||
|
///
|
||||||
|
/// <para>Loads the caller's circles once on construct (the dropdown
|
||||||
|
/// only shows circles the user owns), then keeps an in-memory list
|
||||||
|
/// of the ACL entries for the post. <see cref="AddAsync"/> /
|
||||||
|
/// <see cref="RevokeAsync"/> are the only mutating verbs; both
|
||||||
|
/// refresh the list afterwards so the UI stays in sync with the
|
||||||
|
/// server.</para>
|
||||||
|
///
|
||||||
|
/// <para>The server is the source of truth: it scopes every
|
||||||
|
/// endpoint to the caller's uid and rejects ACL grants on posts
|
||||||
|
/// the caller doesn't own. This VM does not re-validate that —
|
||||||
|
/// any 403 / 404 will surface as an exception caught by the
|
||||||
|
/// command and routed to <see cref="StatusMessage"/>.</para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly BlogAclApiClient _aclClient;
|
||||||
|
private readonly CircleApiClient _circleClient;
|
||||||
|
|
||||||
|
/// <summary>The post whose ACL is being edited. Set by the
|
||||||
|
/// caller (MainPage) when opening the dialog.</summary>
|
||||||
|
public BlogPost Post { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial CircleDto? SelectedCircleToAdd { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string StatusMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public PostAclDialogViewModel(
|
||||||
|
BlogPost post,
|
||||||
|
BlogAclApiClient aclClient,
|
||||||
|
CircleApiClient circleClient)
|
||||||
|
{
|
||||||
|
Post = post ?? throw new ArgumentNullException(nameof(post));
|
||||||
|
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
|
||||||
|
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(); }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task LoadAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Load circles and ACL entries in parallel — both are
|
||||||
|
// independent reads on the same host. The caller's uid
|
||||||
|
// is implicit in both endpoints.
|
||||||
|
var circlesTask = _circleClient.GetMyCirclesAsync();
|
||||||
|
var aclTask = _aclClient.GetMyAclAsync();
|
||||||
|
await Task.WhenAll(circlesTask, aclTask);
|
||||||
|
|
||||||
|
var circles = circlesTask.Result ?? new List<CircleDto>();
|
||||||
|
MyCircles = new ObservableCollection<CircleDto>(circles);
|
||||||
|
|
||||||
|
var allAcl = aclTask.Result ?? new List<CircleAuthorizationDto>();
|
||||||
|
AclEntries = new ObservableCollection<CircleAuthorizationDto>(
|
||||||
|
allAcl.Where(a => a.BlogPostId == Post.Id));
|
||||||
|
|
||||||
|
StatusMessage = $"{AclEntries.Count} autorisation(s)";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task AddAsync()
|
||||||
|
{
|
||||||
|
if (SelectedCircleToAdd is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Sélectionnez un cercle à ajouter";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var created = await _aclClient.GrantAsync(new CircleAuthorizationDto
|
||||||
|
{
|
||||||
|
CircleId = SelectedCircleToAdd.Id,
|
||||||
|
BlogPostId = Post.Id,
|
||||||
|
Comment = false,
|
||||||
|
});
|
||||||
|
if (created is not null)
|
||||||
|
{
|
||||||
|
AclEntries.Add(created);
|
||||||
|
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusMessage = "Autorisation refusée par le serveur";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task RevokeAsync(CircleAuthorizationDto? acl)
|
||||||
|
{
|
||||||
|
if (acl is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _aclClient.RevokeAsync(acl.CircleId);
|
||||||
|
AclEntries.Remove(acl);
|
||||||
|
StatusMessage = "Autorisation révoquée";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/PostIt/PostIt/Views/CirclesPage.axaml
Normal file
66
src/PostIt/PostIt/Views/CirclesPage.axaml
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<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,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>
|
||||||
|
|
||||||
|
<!-- List of circles -->
|
||||||
|
<ListBox Grid.Row="1" Margin="12,0,12,12"
|
||||||
|
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="2" Margin="12" 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>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<Grid Grid.Row="3" 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>
|
||||||
18
src/PostIt/PostIt/Views/CirclesPage.axaml.cs
Normal file
18
src/PostIt/PostIt/Views/CirclesPage.axaml.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class CirclesPage : ContentPage
|
||||||
|
{
|
||||||
|
public CirclesPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
<Button Command="{Binding Search}" Content="Filter" />
|
<Button Command="{Binding Search}" Content="Filter" />
|
||||||
<Button Command="{Binding Save}" Content="Save" />
|
<Button Command="{Binding Save}" Content="Save" />
|
||||||
<Button Command="{Binding Delete}" Content="Delete" />
|
<Button Command="{Binding Delete}" Content="Delete" />
|
||||||
|
<Button Command="{Binding ManageAcl}" Content="ACL" />
|
||||||
|
<Button Command="{Binding OpenCircles}" Content="Mes cercles" />
|
||||||
<!--
|
<!--
|
||||||
DEV ONLY: temporary shortcut to open the signature
|
DEV ONLY: temporary shortcut to open the signature
|
||||||
capture page. Production entry point is a SignalR
|
capture page. Production entry point is a SignalR
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
using System;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -11,6 +14,55 @@ public partial class MainPage : ContentPage
|
||||||
public MainPage()
|
public MainPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
MainPageViewModel? _vm;
|
||||||
|
|
||||||
|
void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// Unsubscribe from the previous VM to avoid leaking handlers
|
||||||
|
// when DataContext is reassigned (e.g. by the navigation
|
||||||
|
// host or a binding reset).
|
||||||
|
if (_vm is not null)
|
||||||
|
{
|
||||||
|
_vm.ManageAclRequested -= OnManageAclRequested;
|
||||||
|
_vm.OpenCirclesRequested -= OnOpenCirclesRequested;
|
||||||
|
}
|
||||||
|
_vm = DataContext as MainPageViewModel;
|
||||||
|
if (_vm is not null)
|
||||||
|
{
|
||||||
|
_vm.ManageAclRequested += OnManageAclRequested;
|
||||||
|
_vm.OpenCirclesRequested += OnOpenCirclesRequested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnManageAclRequested(object? sender, BlogPost post)
|
||||||
|
{
|
||||||
|
var app = Application.Current as App;
|
||||||
|
var services = app?.ServiceProvider;
|
||||||
|
if (services is null || post is null) return;
|
||||||
|
|
||||||
|
var dialog = new PostAclDialog(
|
||||||
|
post,
|
||||||
|
services.GetRequiredService<BlogAclApiClient>(),
|
||||||
|
services.GetRequiredService<CircleApiClient>());
|
||||||
|
|
||||||
|
if (this.VisualRoot is MainWindow window)
|
||||||
|
_ = window.NavRoot.PushAsync(dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnOpenCirclesRequested(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var app = Application.Current as App;
|
||||||
|
var services = app?.ServiceProvider;
|
||||||
|
if (services is null) return;
|
||||||
|
|
||||||
|
var page = services.GetRequiredService<CirclesPage>();
|
||||||
|
page.DataContext = services.GetRequiredService<CirclesPageViewModel>();
|
||||||
|
|
||||||
|
if (this.VisualRoot is MainWindow window)
|
||||||
|
_ = window.NavRoot.PushAsync(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
65
src/PostIt/PostIt/Views/PostAclDialog.axaml
Normal file
65
src/PostIt/PostIt/Views/PostAclDialog.axaml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<ContentPage
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="PostIt.Views.PostAclDialog"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
||||||
|
x:DataType="vm:PostAclDialogViewModel"
|
||||||
|
>
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
|
||||||
|
|
||||||
|
<!-- Add a new authorisation -->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
|
||||||
|
IsEnabled="{Binding !IsBusy}">
|
||||||
|
<ComboBox Grid.Column="0"
|
||||||
|
ItemsSource="{Binding MyCircles}"
|
||||||
|
SelectedItem="{Binding SelectedCircleToAdd, Mode=TwoWay}"
|
||||||
|
PlaceholderText="Choisir un cercle..."
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="dtos:CircleDto">
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Grid.Column="1" Content="Ajouter"
|
||||||
|
Command="{Binding AddCommand}"
|
||||||
|
Margin="8,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Current ACL entries -->
|
||||||
|
<ListBox Grid.Row="1"
|
||||||
|
ItemsSource="{Binding AclEntries}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="dtos:CircleAuthorizationDto">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
|
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
|
||||||
|
FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
|
||||||
|
FontSize="11" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="Révoquer"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<!-- Action buttons: close -->
|
||||||
|
<Button Grid.Row="2" Content="Fermer"
|
||||||
|
Click="OnCloseClicked"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0,8,0,8"/>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<ProgressBar Grid.Column="1" IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsBusy}"
|
||||||
|
Width="120"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
54
src/PostIt/PostIt/Views/PostAclDialog.axaml.cs
Normal file
54
src/PostIt/PostIt/Views/PostAclDialog.axaml.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal "manage ACL" page for a single blog post.
|
||||||
|
///
|
||||||
|
/// <para>The ViewModel is constructed here (not via DI) because it
|
||||||
|
/// depends on the post being managed, which the caller (the post
|
||||||
|
/// list page) only knows at the moment it opens the dialog. The
|
||||||
|
/// DI container can build the two API clients; the post and the
|
||||||
|
/// VM are wired together here.</para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class PostAclDialog : ContentPage
|
||||||
|
{
|
||||||
|
public PostAclDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PostAclDialog(BlogPost post, BlogAclApiClient aclClient, CircleApiClient circleClient)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Pop this page off the navigation stack. Avalonia's
|
||||||
|
// NavigationPage doesn't have a typed "Close" — the
|
||||||
|
// hosting control (a NavigationPage in MainWindow.axaml)
|
||||||
|
// is the one that owns the back stack, but the
|
||||||
|
// ContentPage itself doesn't know about it. A simpler
|
||||||
|
// contract: fire an event the host listens to, or rely
|
||||||
|
// on the system back gesture. We do the latter — the
|
||||||
|
// dialog is intentionally modal-light.
|
||||||
|
if (this.VisualRoot is NavigationPage nav)
|
||||||
|
{
|
||||||
|
// The actual API varies between Avalonia 11.x
|
||||||
|
// versions; the safest call is the equivalent of
|
||||||
|
// "go back", which lives on the host. For now, hide
|
||||||
|
// the page and let the host decide.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue