Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions InterlinedList/ViewModels/ListColumnDraftViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ public partial class ListColumnDraftViewModel : ObservableObject
/// <summary>The type this column was saved with; null for a new column.</summary>
public string? StoredType { get; private init; }

/// <summary>The label it was saved with — a rename is a safe, non-destructive edit.</summary>
public string? StoredLabel { get; private init; }

/// <summary>Its saved position, so a reorder can be reported as one.</summary>
public int? StoredOrder { get; private init; }

/// <summary>True when the label differs from the one on the server.</summary>
public bool IsRenamed =>
IsExistingColumn && !string.Equals((Label ?? "").Trim(), StoredLabel ?? "", StringComparison.Ordinal);

/// <summary>True when the type differs from the one on the server.</summary>
public bool IsRetyped => IsExistingColumn && StoredType is { } stored && stored != Type;

/// <summary>True once at least one row holds a value for <see cref="StoredKey"/>.</summary>
public bool HasRowData { get; set; }

Expand Down Expand Up @@ -164,6 +177,8 @@ public static ListColumnDraftViewModel FromField(ListField field, ListProperty?
PropertyId = stored?.Id,
StoredKey = field.Key,
StoredType = field.Type,
StoredLabel = field.Label,
StoredOrder = field.DisplayOrder,
Validation = field.Validation,
Visibility = field.Visibility,
Key = field.Key,
Expand Down
196 changes: 195 additions & 1 deletion InterlinedList/ViewModels/ListColumnEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,196 @@ public partial class ListColumnEditorViewModel : ObservableObject
+ string.Join(", ", ListFieldType.PropertiesEditable)
+ "). Saving those requires the destructive column rebuild.";

// ── The two write paths, deliberately two actions ───────────────────────
// PUT /api/lists/{id}/schema is one route with two bodies whose consequences
// are nothing alike, so the UI never chooses for the user:
//
// "Save columns" → { properties: [ … ] } updates columns in place,
// row data untouched, column ids kept.
// "Rebuild columns…" → { schema: { … } } drops and recreates EVERY
// column, overwrites the list's title AND description,
// and leaves values whose key no longer has a column
// orphaned in rowData.
//
// Both measured live 2026-09-16. Note the asymmetry in what "removing a
// column" costs, which is why the two confirmations read differently:
// * properties + ?force=true STRIPS the key from every row (data deleted);
// * a rebuild LEAVES the value in rowData, unshown and unvalidated.

/// <summary>True while the destructive rebuild is waiting for confirmation.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RebuildImpactSummary))]
private bool isRebuildConfirmationOpen;

/// <summary>
/// The title the rebuild will write. Editable because the rebuild writes it
/// whether the user meant to or not: <c>schema.name</c> overwrote a list's
/// title in testing, so the box is pre-filled with the current one.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RebuildImpactSummary))]
private string rebuildTitle = "";

/// <summary>
/// The description the rebuild will write. Blank CLEARS it — omitting
/// <c>schema.description</c> wiped a list's description in testing — so the
/// box is pre-filled and the confirmation says so.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RebuildImpactSummary))]
private string rebuildDescription = "";

/// <summary>What the SAFE save is about to do, in the user's terms.</summary>
public string SavePlanSummary
{
get
{
if (IsNewListMode)
return $"{Columns.Count} column(s) will be created together with the list.";

var parts = new List<string>();

if (Columns.Count(c => !c.IsExistingColumn) is > 0 and var added)
parts.Add($"adds {added}");
if (Columns.Count(c => c.IsRenamed) is > 0 and var renamed)
parts.Add($"renames {renamed}");
if (Columns.Count(c => c.IsRetyped) is > 0 and var retyped)
parts.Add($"retypes {retyped}");
if (RemovedKeys.Count > 0)
parts.Add($"deletes {string.Join(", ", RemovedKeys)}");
if (IsReordered)
parts.Add("reorders the columns");

return parts.Count == 0
? "No column changes yet."
: $"Save columns {string.Join(", ", parts)} — in place, with every row's data kept.";
}
}

/// <summary>True when the saved columns are no longer in their saved order.</summary>
private bool IsReordered
{
get
{
var orders = Columns.Where(c => c.IsExistingColumn)
.Select(c => c.StoredOrder ?? 0)
.ToList();
return orders.Zip(orders.Skip(1)).Any(pair => pair.Second < pair.First);
}
}

/// <summary>
/// Exactly what the rebuild costs, with the affected columns named. Says
/// nothing about rows being deleted, because they are not: the measured
/// cost is orphaned values, new column ids, and an overwritten
/// title/description.
/// </summary>
public string RebuildImpactSummary
{
get
{
var lines = new List<string>
{
$"All {Columns.Count} column(s) are dropped and recreated with new ids."
};

lines.Add(RemovedKeys.Count > 0
? "Your rows are NOT deleted — but the values under "
+ string.Join(", ", RemovedKeys)
+ " stay in each row with no column to show or validate them."
: "Your rows are NOT deleted, and every column here keeps its key, so their values stay reachable.");

var carried = Columns.Count(c => c.Validation is { IsEmpty: false } || c.Visibility?.Condition is not null);
lines.Add(carried > 0
? $"Validation rules and visibility conditions on {carried} column(s) are re-sent as they were read, so they survive — anything added on the web since this editor opened does not."
: "Any validation rule or visibility condition a column has that this editor didn't read is lost.");

lines.Add(string.IsNullOrWhiteSpace(RebuildTitle)
? "The list needs a title — the rebuild writes it from this box."
: $"The list's title becomes “{RebuildTitle.Trim()}”.");

lines.Add(string.IsNullOrWhiteSpace(RebuildDescription)
? "The list's description is CLEARED (a blank box clears it)."
: $"The list's description becomes “{RebuildDescription.Trim()}”.");

return string.Join("\n", lines.Select(line => "• " + line));
}
}

private bool CanStartRebuild() => !IsBusy && !IsNewListMode && List is not null && IsDraftSavable;

/// <summary>
/// Open the rebuild confirmation. Separate from <see cref="SaveCommand"/> on
/// purpose — a single "Save schema" button that always sent the DSL is the
/// data-loss footgun this whole split exists to prevent.
/// </summary>
[RelayCommand(CanExecute = nameof(CanStartRebuild))]
private void StartRebuild()
{
if (List is not { } list) return;
if (!ValidateDraft()) return;

RebuildTitle = list.Title;
RebuildDescription = list.Description ?? "";
ForceConfirmationMessage = null;
_pendingForcedSave = null;
ErrorMessage = null;
StatusMessage = null;
IsRebuildConfirmationOpen = true;
}

[RelayCommand]
private void CancelRebuild()
{
IsRebuildConfirmationOpen = false;
StatusMessage = "Nothing was changed.";
}

[RelayCommand]
private async Task ConfirmRebuildAsync()
{
if (List is not { } list) return;
if (!ValidateDraft()) return;

if (string.IsNullOrWhiteSpace(RebuildTitle))
{
ErrorMessage = "The rebuild writes the list's title — give it one.";
return;
}

IsBusy = true;
SaveCommand.NotifyCanExecuteChanged();
StartRebuildCommand.NotifyCanExecuteChanged();
try
{
var description = string.IsNullOrWhiteSpace(RebuildDescription) ? null : RebuildDescription.Trim();
var updated = await _session.Api.RebuildListSchemaDestructiveAsync(
list.Id, BuildSchema(RebuildTitle.Trim(), description));

IsRebuildConfirmationOpen = false;
ErrorMessage = null;
List = updated;
StatusMessage = $"Rebuilt {Columns.Count} column(s). The list is now titled “{updated.Title}”. "
+ "Rows were kept; any value whose column is gone is still stored but no longer shown.";
await ReloadAsync();
Saved?.Invoke(this, EventArgs.Empty);
}
catch (ListSchemaException ex)
{
AttachIssues(ex);
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsBusy = false;
SaveCommand.NotifyCanExecuteChanged();
StartRebuildCommand.NotifyCanExecuteChanged();
}
}

public ListColumnEditorViewModel(SessionService session)
{
_session = session;
Expand Down Expand Up @@ -173,6 +363,7 @@ private void Reset()
_storedKeys.Clear();
_pendingForcedSave = null;
ForceConfirmationMessage = null;
IsRebuildConfirmationOpen = false;
ErrorMessage = null;
StatusMessage = null;
}
Expand Down Expand Up @@ -382,7 +573,7 @@ private async Task SaveAsync()

if (!CanUsePropertiesPath)
{
ErrorMessage = RebuildOnlyWarning;
ErrorMessage = RebuildOnlyWarning + " Use “Rebuild columns” for that — it says what the rebuild costs before it runs.";
return;
}

Expand Down Expand Up @@ -471,6 +662,9 @@ private void NotifyDraftSetChanged()
OnPropertyChanged(nameof(RebuildOnlyWarning));
OnPropertyChanged(nameof(IsDraftSavable));
OnPropertyChanged(nameof(SaveBlockedReason));
OnPropertyChanged(nameof(SavePlanSummary));
OnPropertyChanged(nameof(RebuildImpactSummary));
SaveCommand.NotifyCanExecuteChanged();
StartRebuildCommand.NotifyCanExecuteChanged();
}
}
67 changes: 67 additions & 0 deletions InterlinedList/Views/ListsView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,59 @@
Margin="0,4,0,0"
Visibility="{Binding ColumnEditor.StatusMessage, Converter={StaticResource NullOrEmptyToVisibility}}"/>

<!-- ── Destructive rebuild confirmation (#22) ─────────────
In place, not a native message box: nothing here
blocks the dispatcher. The copy names exactly what
the DSL rebuild costs — and deliberately does NOT
say rows are deleted, because they are not. -->
<Border Margin="0,8,0,0"
Background="{DynamicResource Surface3Brush}"
BorderBrush="{DynamicResource AmberBrush}"
BorderThickness="1"
CornerRadius="3"
Padding="10,8"
Visibility="{Binding ColumnEditor.IsRebuildConfirmationOpen, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Rebuild every column?"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
Margin="0,0,0,4"/>

<TextBlock Text="{Binding ColumnEditor.RebuildImpactSummary}"
FontSize="11"
Foreground="{DynamicResource TextBodyBrush}"
TextWrapping="Wrap"/>

<!-- The rebuild writes both of these onto the list,
so they are shown and editable rather than implied. -->
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="List title after the rebuild" Style="{StaticResource CaptionStyle}"/>
<TextBox Text="{Binding ColumnEditor.RebuildTitle, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource MicroFieldStyle}"/>
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="List description (blank clears it)" Style="{StaticResource CaptionStyle}"/>
<TextBox Text="{Binding ColumnEditor.RebuildDescription, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource MicroFieldStyle}"/>
</StackPanel>
</Grid>

<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="Cancel"
Command="{Binding ColumnEditor.CancelRebuildCommand}"
Style="{StaticResource RowActionBtnStyle}"/>
<Button Content="Rebuild the columns"
Command="{Binding ColumnEditor.ConfirmRebuildCommand}"
Style="{StaticResource SmallBtnStyle}"/>
</StackPanel>
</StackPanel>
</Border>

<!-- 409: the server named the columns that still hold data.
Confirmed in place — no native message box (dispatcher rule). -->
<Border Margin="0,8,0,0"
Expand All @@ -893,6 +946,13 @@
</StackPanel>
</Border>

<!-- What the SAFE save will do, before it runs -->
<TextBlock Text="{Binding ColumnEditor.SavePlanSummary}"
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"
Margin="0,6,0,0"/>

<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="Add column"
Command="{Binding ColumnEditor.AddColumnCommand}"
Expand All @@ -901,6 +961,13 @@
<Button Content="{Binding ColumnEditor.SaveButtonText}"
Command="{Binding ColumnEditor.SaveCommand}"
Style="{StaticResource PrimaryBtnStyle}"/>
<!-- The destructive path: its own button, its own words -->
<Button Content="Rebuild columns…"
Command="{Binding ColumnEditor.StartRebuildCommand}"
Style="{StaticResource SmallBtnStyle}"
VerticalAlignment="Center"
Margin="6,0,0,0"
ToolTip="Drops and recreates every column, and writes the list's title and description"/>
<TextBlock Text="{Binding ColumnEditor.SaveBlockedReason}"
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
Expand Down
Loading