Compare commits

..

No commits in common. "076162f6ee01010869f57f7594660ece0f5151f4" and "7322af703baee75080356cdb5edbdf5e67ea07f3" have entirely different histories.

5 changed files with 34 additions and 162 deletions

View File

@ -26,7 +26,7 @@ public interface IMgGridBase : IGrid
bool IsSyncing { get; } bool IsSyncing { get; }
string Caption { get; set; } string Caption { get; set; }
/// <summary> /// <summary>
/// Current edit state of the grid (None, New, Edit) /// Current edit state of the grid (None, New, Edit)
/// </summary> /// </summary>
@ -51,7 +51,7 @@ public interface IMgGridBase : IGrid
/// Navigates to the next row in the grid /// Navigates to the next row in the grid
/// </summary> /// </summary>
void StepNextRow(); void StepNextRow();
/// <summary> /// <summary>
/// InfoPanel instance for displaying row details (from wrapper) /// InfoPanel instance for displaying row details (from wrapper)
/// </summary> /// </summary>
@ -73,24 +73,22 @@ public interface IMgGridBase : IGrid
void ToggleFullscreen(); void ToggleFullscreen();
/// <summary> /// <summary>
/// Saves the current layout to user storage (manual save); roams it to the server when server layouts are enabled /// Saves the current layout to user storage (manual save)
/// </summary> /// </summary>
Task SaveUserLayoutAsync(); Task SaveUserLayoutAsync();
/// <summary> /// <summary>
/// Loads layout from user storage (manual load); prefers the server copy when server layouts are enabled /// Loads layout from user storage (manual load)
/// </summary> /// </summary>
Task LoadUserLayoutAsync(); Task LoadUserLayoutAsync();
/// <summary> /// <summary>
/// Resets the layout by clearing the auto-saved layout and restoring the default layout. /// Resets the layout by clearing auto-saved layout and reloading the page
/// Saved copies (local UserSave and server) are not touched — Load brings them back.
/// </summary> /// </summary>
Task ResetLayoutAsync(); Task ResetLayoutAsync();
/// <summary> /// <summary>
/// Checks if a user-saved layout exists locally without loading it; /// Checks if a user-saved layout exists without loading it
/// returns true optimistically when server layouts are enabled (no server round-trip)
/// </summary> /// </summary>
Task<bool> HasUserLayoutAsync(); Task<bool> HasUserLayoutAsync();
} }
@ -155,7 +153,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
public MgGridWithInfoPanel? GridWrapper { get; set; } public MgGridWithInfoPanel? GridWrapper { get; set; }
private object _focusedDataItem; private object _focusedDataItem;
/// <summary> /// <summary>
/// InfoPanel instance for displaying row details. /// InfoPanel instance for displaying row details.
/// First checks own wrapper, then gets InfoPanel from root grid. /// First checks own wrapper, then gets InfoPanel from root grid.
@ -167,16 +165,16 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
// First check if we have a direct wrapper with InfoPanel // First check if we have a direct wrapper with InfoPanel
if (GridWrapper?.InfoPanelInstance != null) if (GridWrapper?.InfoPanelInstance != null)
return GridWrapper.InfoPanelInstance; return GridWrapper.InfoPanelInstance;
// Get InfoPanel from root grid (handles nested grids) // Get InfoPanel from root grid (handles nested grids)
var rootGrid = GetRootGrid(); var rootGrid = GetRootGrid();
if (rootGrid != this) if (rootGrid != this)
return rootGrid.InfoPanelInstance; return rootGrid.InfoPanelInstance;
return null; return null;
} }
} }
/// <inheritdoc /> /// <inheritdoc />
public bool IsFullscreen => GridWrapper?.IsFullscreen ?? _isStandaloneFullscreen; public bool IsFullscreen => GridWrapper?.IsFullscreen ?? _isStandaloneFullscreen;
@ -197,7 +195,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
InvokeAsync(StateHasChanged); InvokeAsync(StateHasChanged);
} }
} }
public MgGridBase() : base() public MgGridBase() : base()
{ {
} }
@ -267,14 +265,6 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
[Inject] protected IJSRuntime JSRuntime { get; set; } = null!; [Inject] protected IJSRuntime JSRuntime { get; set; } = null!;
/// <summary>
/// When true, the UserSave layout tier also roams via the SignalR server endpoint
/// (AcSignalRTags.Get/Save/DeleteGridLayoutTag): SaveUserLayoutAsync pushes to the server after the
/// local save, LoadUserLayoutAsync prefers the server copy. The server must implement the tags.
/// Server communication happens exclusively on user clicks (Save/Load).
/// </summary>
[Parameter] public bool EnableServerLayouts { get; set; } = true;
[Parameter] public TLoggerClient Logger { get; set; } [Parameter] public TLoggerClient Logger { get; set; }
[Parameter] public string GridName { get; set; } [Parameter] public string GridName { get; set; }
[Parameter] public IId<TId>? ParentDataItem { get; set; } [Parameter] public IId<TId>? ParentDataItem { get; set; }
@ -282,7 +272,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
[Parameter] public object[]? ContextIds { get; set; } [Parameter] public object[]? ContextIds { get; set; }
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name; [Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
/// <summary> /// <summary>
/// Name for auto-saving/loading grid layout. If not set, defaults to "Grid{TDataItem.Name}" /// Name for auto-saving/loading grid layout. If not set, defaults to "Grid{TDataItem.Name}"
/// </summary> /// </summary>
@ -370,7 +360,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
private void SetWorkingReferenceList(AcObservableCollection<TDataItem>? referenceList) private void SetWorkingReferenceList(AcObservableCollection<TDataItem>? referenceList)
{ {
_dataSource?.SetWorkingReferenceList(referenceList); _dataSource?.SetWorkingReferenceList(referenceList);
SetGridData(referenceList); SetGridData(referenceList);
} }
@ -401,21 +391,21 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
_dataSource.FilterText = FilterText; _dataSource.FilterText = FilterText;
SetGridData(_dataSource.GetReferenceInnerList()); SetGridData(_dataSource.GetReferenceInnerList());
_dataSource.OnDataSourceLoaded += OnDataSourceLoaded; _dataSource.OnDataSourceLoaded += OnDataSourceLoaded;
_dataSource.OnDataSourceItemChanged += OnDataSourceItemChanged; _dataSource.OnDataSourceItemChanged += OnDataSourceItemChanged;
_dataSource.OnSyncingStateChanged += OnDataSourceSyncingStateChanged; _dataSource.OnSyncingStateChanged += OnDataSourceSyncingStateChanged;
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private void OnDataSourceSyncingStateChanged(bool isSyncing) private void OnDataSourceSyncingStateChanged(bool isSyncing)
{ {
if (_isDisposed) return; if (_isDisposed) return;
// Forward the event to external subscribers // Forward the event to external subscribers
//OnSyncingStateChanged?.Invoke(isSyncing); //OnSyncingStateChanged?.Invoke(isSyncing);
// Trigger UI update // Trigger UI update
InvokeAsync(StateHasChanged); InvokeAsync(StateHasChanged);
} }
@ -439,7 +429,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
private async Task OnDataSourceLoaded() private async Task OnDataSourceLoaded()
{ {
if (_isDisposed) return; if (_isDisposed) return;
Logger.Debug($"{_gridLogName} OnDataSourceLoaded; Count: {_dataSource?.Count}"); Logger.Debug($"{_gridLogName} OnDataSourceLoaded; Count: {_dataSource?.Count}");
await InvokeAsync(() => SetGridData(_dataSource!.GetReferenceInnerList())); await InvokeAsync(() => SetGridData(_dataSource!.GetReferenceInnerList()));
@ -508,7 +498,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
protected PropertyInfo? GetDataItemPropertyInfo(string propertyName) protected PropertyInfo? GetDataItemPropertyInfo(string propertyName)
=> typeof(TDataItem).GetProperty(propertyName); => typeof(TDataItem).GetProperty(propertyName);
protected virtual async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) protected virtual async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{ {
var editModel = (e.EditModel as TDataItem)!; var editModel = (e.EditModel as TDataItem)!;
@ -556,11 +546,11 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{ {
infoPanelInstance.ClearEditMode(); infoPanelInstance.ClearEditMode();
} }
// Frissítjük az InfoPanel-t az új sor adataival // Frissítjük az InfoPanel-t az új sor adataival
infoPanelInstance.RefreshData(this, e.DataItem, e.VisibleIndex); infoPanelInstance.RefreshData(this, e.DataItem, e.VisibleIndex);
} }
await OnGridFocusedRowChanged.InvokeAsync(e); await OnGridFocusedRowChanged.InvokeAsync(e);
} }
@ -730,11 +720,11 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{ {
if (GridName.IsNullOrWhiteSpace()) GridName = $"{typeof(TDataItem).Name}Grid"; if (GridName.IsNullOrWhiteSpace()) GridName = $"{typeof(TDataItem).Name}Grid";
_gridLogName = $"[{GridName}]"; _gridLogName = $"[{GridName}]";
// Set default AutoSaveLayoutName if not provided // Set default AutoSaveLayoutName if not provided
if (AutoSaveLayoutName.IsNullOrWhiteSpace()) if (AutoSaveLayoutName.IsNullOrWhiteSpace())
AutoSaveLayoutName = $"Grid{typeof(TDataItem).Name}"; AutoSaveLayoutName = $"Grid{typeof(TDataItem).Name}";
// Set up layout auto-loading/saving // Set up layout auto-loading/saving
LayoutAutoLoading = Grid_LayoutAutoLoading; LayoutAutoLoading = Grid_LayoutAutoLoading;
LayoutAutoSaving = Grid_LayoutAutoSaving; LayoutAutoSaving = Grid_LayoutAutoSaving;
@ -777,25 +767,6 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
return null; return null;
} }
/// <summary>
/// Reads the UserSave layout JSON from the server endpoint (roaming). Best-effort: returns null when
/// the server has no copy or is unreachable — callers fall back to localStorage.
/// </summary>
private async Task<string?> GetServerLayoutJsonAsync()
{
try
{
return await SignalRClient.GetByIdAsync<string>(AcSignalRTags.GetGridLayoutTag, [UserLayoutStorageKey, GetLayoutUserId()]);
}
catch (Exception ex)
{
// Server roaming is best-effort — fall back to the local copy
Logger.Warning($"[{GetType().Name}] Server layout GET failed — falling back to the local copy; key: {UserLayoutStorageKey}; error: {ex.Message}");
}
return null;
}
private async Task Grid_LayoutAutoLoading(GridPersistentLayoutEventArgs e) private async Task Grid_LayoutAutoLoading(GridPersistentLayoutEventArgs e)
{ {
// Save the default layout before loading any saved layout // Save the default layout before loading any saved layout
@ -815,7 +786,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{ {
var json = await GetStorageItem(localStorageKey); var json = await GetStorageItem(localStorageKey);
if (!string.IsNullOrWhiteSpace(json)) if (!string.IsNullOrWhiteSpace(json))
return JsonSerializer.Deserialize<GridPersistentLayout>(json); return JsonSerializer.Deserialize<GridPersistentLayout>(json);
} }
catch catch
@ -858,54 +829,13 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
var layout = SaveLayout(); var layout = SaveLayout();
await SaveLayoutToLocalStorageAsync(layout, UserLayoutStorageKey); await SaveLayoutToLocalStorageAsync(layout, UserLayoutStorageKey);
await SaveLayoutToLocalStorageAsync(layout, AutomaticLayoutStorageKey);
if (EnableServerLayouts)
{
try
{
var json = JsonSerializer.Serialize(layout);
await SignalRClient.PostAsync<bool>(AcSignalRTags.SaveGridLayoutTag, [UserLayoutStorageKey, json, GetLayoutUserId()]);
}
catch (Exception ex)
{
// Server roaming is best-effort — the local copy is already persisted
Logger.Warning($"[{GetType().Name}] Server layout SAVE failed — the local copy is persisted; key: {UserLayoutStorageKey}; error: {ex.Message}");
}
}
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task LoadUserLayoutAsync() public async Task LoadUserLayoutAsync()
{ {
GridPersistentLayout? layout = null; var layout = await LoadLayoutFromLocalStorageAsync(UserLayoutStorageKey);
if (EnableServerLayouts)
{
var json = await GetServerLayoutJsonAsync();
if (!json.IsNullOrWhiteSpace())
{
try
{
layout = JsonSerializer.Deserialize<GridPersistentLayout>(json!);
if (layout != null)
{
await SaveLayoutToLocalStorageAsync(layout, UserLayoutStorageKey);
await SaveLayoutToLocalStorageAsync(layout, AutomaticLayoutStorageKey);
}
}
catch (Exception ex)
{
// Corrupt server copy — fall back to the local one
Logger.Warning($"[{GetType().Name}] Server layout copy could not be applied — falling back to the local copy; key: {UserLayoutStorageKey}; error: {ex.Message}");
}
}
}
// Fallback: local UserSave copy (offline, or no server copy yet)
layout ??= await LoadLayoutFromLocalStorageAsync(UserLayoutStorageKey);
if (layout != null) if (layout != null)
{ {
LoadLayout(layout); LoadLayout(layout);
@ -930,13 +860,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> HasUserLayoutAsync() public async Task<bool> HasUserLayoutAsync()
{ {
// Local check only — server communication happens exclusively on user clicks (Save/Load) return !(await GetStorageItem(UserLayoutStorageKey)).IsNullOrWhiteSpace();
if (!(await GetStorageItem(UserLayoutStorageKey)).IsNullOrWhiteSpace())
return true;
// With server layouts enabled we can't know without a round-trip — report true optimistically;
// LoadUserLayoutAsync no-ops when neither the server nor localStorage has a copy
return EnableServerLayouts;
} }
#endregion #endregion
@ -952,7 +876,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{ {
// Force grid re-initialization by changing the render key // Force grid re-initialization by changing the render key
_gridRenderKey = Guid.NewGuid(); _gridRenderKey = Guid.NewGuid();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }

View File

@ -32,30 +32,10 @@ Splitter_GridOrder_Master_AutoSave_42 ← splitter pane size
| Method | Behavior | | Method | Behavior |
|---|---| |---|---|
| `SaveUserLayoutAsync()` | Saves to the local UserSave key first (the AutoSave key is already current — `LayoutAutoSaving` fires on every user-made layout change), then (when `EnableServerLayouts`) pushes the layout JSON to the server | | `SaveUserLayoutAsync()` | Saves current layout to both UserSave AND AutoSave keys |
| `LoadUserLayoutAsync()` | Prefers the server copy (when `EnableServerLayouts`) — writing it back to the local UserSave + AutoSave keys so it survives a page refresh — and falls back to the local UserSave copy; then applies the layout | | `LoadUserLayoutAsync()` | Loads from UserSave key (if exists) |
| `ResetLayoutAsync()` | Removes AutoSave key, restores in-memory `_defaultLayoutJson` (never touches the server copy) | | `ResetLayoutAsync()` | Removes AutoSave key, restores in-memory `_defaultLayoutJson` |
| `HasUserLayoutAsync()` | Local check; when `EnableServerLayouts` and no local copy exists, returns `true` optimistically — no server round-trip | | `HasUserLayoutAsync()` | Checks if UserSave key exists in localStorage |
## Server Roaming (UserSave tier)
`EnableServerLayouts` (default `true`) roams the **UserSave** tier through the grid's own `SignalRClient`,
using the framework tags defined in `AcSignalRTags` (AyCode.Services):
| Tag | Value | Params (in order) | Returns |
|---|---|---|---|
| `GetGridLayoutTag` | 90010 | `key` (string), `userId` (int) | layout JSON, or null |
| `SaveGridLayoutTag` | 90011 | `key`, `layoutJson`, `userId` | bool |
| `DeleteGridLayoutTag` | 90012 | `key`, `userId` | bool |
- The `key` is the full composite UserSave storage key (it already contains the user id); the server treats
it as an opaque string. `userId` comes from `GetLayoutUserId()`.
- **Server communication happens exclusively on user clicks** (Save/Load layout toolbar buttons) — never on
grid init, toolbar init, detail-row expand, or prerender.
- All server calls are **best-effort** (`try/catch`): offline, or when the server does not implement the
tags, behavior degrades to the local-only flow without errors.
- The server must implement the three tags; the storage schema is the consumer's choice.
- The `AutoSave` and `Splitter` tiers never touch the server.
## Persisted State ## Persisted State

View File

@ -39,7 +39,6 @@ These are bundled into a `SignalRCrudTags` during `OnInitializedAsync`. See `AyC
| `Caption` | `string` | `typeof(TDataItem).Name` | Grid title (shown in fullscreen header) | | `Caption` | `string` | `typeof(TDataItem).Name` | Grid title (shown in fullscreen header) |
| `GridName` | `string` | `"{TDataItem.Name}Grid"` | Name used in log messages | | `GridName` | `string` | `"{TDataItem.Name}Grid"` | Name used in log messages |
| `AutoSaveLayoutName` | `string?` | `"Grid{TDataItem.Name}"` | Base name for layout storage keys | | `AutoSaveLayoutName` | `string?` | `"Grid{TDataItem.Name}"` | Base name for layout storage keys |
| `EnableServerLayouts` | `bool` | `true` | Roams the UserSave layout tier via the SignalR server endpoint (see `MGGRID_LAYOUT.md` → Server Roaming). Server calls happen only on the Save/Load layout buttons. |
## Event Callbacks ## Event Callbacks

View File

@ -108,7 +108,7 @@ The public contract exposed to companion components (toolbar, InfoPanel, wrapper
| `SaveUserLayoutAsync()` | `Task` | Save layout manually | | `SaveUserLayoutAsync()` | `Task` | Save layout manually |
| `LoadUserLayoutAsync()` | `Task` | Load manually saved layout | | `LoadUserLayoutAsync()` | `Task` | Load manually saved layout |
| `ResetLayoutAsync()` | `Task` | Reset to default layout | | `ResetLayoutAsync()` | `Task` | Reset to default layout |
| `HasUserLayoutAsync()` | `Task<bool>` | Check if manual save exists (optimistic `true` when server layouts are enabled) | | `HasUserLayoutAsync()` | `Task<bool>` | Check if manual save exists |
## Event Args Classes ## Event Args Classes

View File

@ -1,31 +0,0 @@
# AyCode.Blazor — Consumer Workspace Map
> **Bounded consumer-awareness exception** to the Framework-First Design Principle (LLMP-DEC-62): this single file lists which higher-layer repos consume `AyCode.Blazor` (Layer 1 UI framework). For change-impact assessment when modifying ACBLAZOR — NOT a coupling vector.
>
> **Strict rule**: AyCode.Blazor code, types, namespaces, topic IDs, and any other `.md` files MUST NOT name any specific consumer listed here. **This is the SINGLE place** in ACBLAZOR that may name consumer repos.
## Workspace consumer map (as of 2026-07-08)
| Prefix | Repo | Layer | Type | Path (workspace-relative) | Brief usage |
|------------|--------------------|------:|-----------------|-------------------------------|---------------------------------------------------------------------------|
| `FBANKAPP` | FruitBankHybridApp | 2 | product (hybrid)| `Mango/Source/FruitBankHybridApp` | MAUI/Blazor hybrid client — MgGrid + SignalR datasource stack |
| `GIAAPP` | GiaApp | 2 | product (web) | `Mango/Source/GiaApp` | Blazor Server app — MgGrid with server-side data (no SignalR datasource) |
(See `AyCode.Core/.github/REPO_PREFIXES.md` for the prefix scheme; each consumer's own `.github/copilot-instructions.md` `@repo` block declares its `prefix` value.)
## When modifying AyCode.Blazor — change-impact lens
- A breaking change to `MgGridBase` / `MgGridWithInfoPanel` affects both consumers above.
- A change to `MgGridSignalRDataSource` affects only SignalR-datasource consumers (FBANKAPP; GIAAPP binds server-side data directly).
- DevExpress version bumps cascade to every consumer (license + API compatibility check per consumer).
**Do NOT** use this list to write framework code that conditionalizes on specific consumers. Generic abstractions only. The list is a **HEURISTIC for impact**, not an **INPUT to logic**.
## Maintenance
When a repo joins or leaves the workspace consuming AyCode.Blazor, update the table above. The list ITSELF is workspace-meta-tooling — not framework code domain.
## Cross-references
- **Decision Log entries**: LLMP-DEC-62 (CONSUMERS.md pattern introduction); LLMP-DEC-69 (GiaApp join).
- **AyCode.Core's consumer map**: `AyCode.Core/CONSUMERS.md`.