From 633d83387896b1d2f1c0dae14e6d6484050273a4 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:19:36 -0700 Subject: [PATCH] Lists: split the destructive column rebuild from the safe properties edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /api/lists/{id}/schema is one route with two bodies whose consequences are nothing alike, so the column editor now offers two clearly different actions instead of one "Save schema" that would silently pick the destructive one: * **Save columns** → `{ properties: [ … ] }`. Renames, adds, single removals and reordering, applied in place: column ids kept, row data kept, and the list's own title and description untouched. It also prints what it is about to do ("Save columns adds 1, renames 1, deletes link, reorders the columns — in place, with every row's data kept") before it runs. * **Rebuild columns…** → `{ schema: { … } }`. A separate, explicitly-labelled action behind an in-place confirmation (no native dialog, nothing blocking the dispatcher) that names exactly what is lost: - every column dropped and recreated with a new id; - rows are **NOT deleted** — values whose key no longer has a column stay in each row, unshown and unvalidated, and the affected keys are named; - validation rules and visibility conditions survive only because the drafts re-send what they read, which the copy says plainly; - the list's **title** is overwritten, from an editable box pre-filled with the current one; - the list's **description** is overwritten, and CLEARED if the box is blank. Verified live today on throwaway lists (created, exercised, deleted — the account is back to its one real list), driving the bodies the real draft view-model serializes: * Safe path with a rename + an added column + a reorder + dropping a data-bearing column: 409 `propertiesWithData: ["link"]`, then `?force=true` → 200. Both rows came back with identical ids, the dropped key stripped from each, and the list's title and description untouched. * Destructive path on the same list: 200, title became the name in the DSL, the description survived when sent and came back null when omitted, and both rows were still there with their ids. That last asymmetry is why the two confirmations are worded differently, and it is easy to get backwards: removing a column through `properties ?force=true` DELETES that value from every row, while a rebuild KEEPS it as an orphan. Closes #22 Co-Authored-By: Claude Opus 5 --- .../ViewModels/ListColumnDraftViewModel.cs | 15 ++ .../ViewModels/ListColumnEditorViewModel.cs | 196 +++++++++++++++++- InterlinedList/Views/ListsView.xaml | 67 ++++++ 3 files changed, 277 insertions(+), 1 deletion(-) diff --git a/InterlinedList/ViewModels/ListColumnDraftViewModel.cs b/InterlinedList/ViewModels/ListColumnDraftViewModel.cs index fdf722a..f2e2eb5 100644 --- a/InterlinedList/ViewModels/ListColumnDraftViewModel.cs +++ b/InterlinedList/ViewModels/ListColumnDraftViewModel.cs @@ -88,6 +88,19 @@ public partial class ListColumnDraftViewModel : ObservableObject /// The type this column was saved with; null for a new column. public string? StoredType { get; private init; } + /// The label it was saved with — a rename is a safe, non-destructive edit. + public string? StoredLabel { get; private init; } + + /// Its saved position, so a reorder can be reported as one. + public int? StoredOrder { get; private init; } + + /// True when the label differs from the one on the server. + public bool IsRenamed => + IsExistingColumn && !string.Equals((Label ?? "").Trim(), StoredLabel ?? "", StringComparison.Ordinal); + + /// True when the type differs from the one on the server. + public bool IsRetyped => IsExistingColumn && StoredType is { } stored && stored != Type; + /// True once at least one row holds a value for . public bool HasRowData { get; set; } @@ -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, diff --git a/InterlinedList/ViewModels/ListColumnEditorViewModel.cs b/InterlinedList/ViewModels/ListColumnEditorViewModel.cs index 17c4562..82a139b 100644 --- a/InterlinedList/ViewModels/ListColumnEditorViewModel.cs +++ b/InterlinedList/ViewModels/ListColumnEditorViewModel.cs @@ -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. + + /// True while the destructive rebuild is waiting for confirmation. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RebuildImpactSummary))] + private bool isRebuildConfirmationOpen; + + /// + /// The title the rebuild will write. Editable because the rebuild writes it + /// whether the user meant to or not: schema.name overwrote a list's + /// title in testing, so the box is pre-filled with the current one. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RebuildImpactSummary))] + private string rebuildTitle = ""; + + /// + /// The description the rebuild will write. Blank CLEARS it — omitting + /// schema.description wiped a list's description in testing — so the + /// box is pre-filled and the confirmation says so. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RebuildImpactSummary))] + private string rebuildDescription = ""; + + /// What the SAFE save is about to do, in the user's terms. + public string SavePlanSummary + { + get + { + if (IsNewListMode) + return $"{Columns.Count} column(s) will be created together with the list."; + + var parts = new List(); + + 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."; + } + } + + /// True when the saved columns are no longer in their saved order. + 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); + } + } + + /// + /// 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. + /// + public string RebuildImpactSummary + { + get + { + var lines = new List + { + $"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; + + /// + /// Open the rebuild confirmation. Separate from on + /// purpose — a single "Save schema" button that always sent the DSL is the + /// data-loss footgun this whole split exists to prevent. + /// + [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; @@ -173,6 +363,7 @@ private void Reset() _storedKeys.Clear(); _pendingForcedSave = null; ForceConfirmationMessage = null; + IsRebuildConfirmationOpen = false; ErrorMessage = null; StatusMessage = null; } @@ -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; } @@ -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(); } } diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml index 0e51688..b116936 100644 --- a/InterlinedList/Views/ListsView.xaml +++ b/InterlinedList/Views/ListsView.xaml @@ -868,6 +868,59 @@ Margin="0,4,0,0" Visibility="{Binding ColumnEditor.StatusMessage, Converter={StaticResource NullOrEmptyToVisibility}}"/> + + + + + + + + + + + + + + + + + + + + + + + + +