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 4979c9b..8888574 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}}"/> + + + + + + + + + + + + + + + + + + + + + + + + +