diff --git a/InterlinedList/Models/ListFieldDefaults.cs b/InterlinedList/Models/ListFieldDefaults.cs
new file mode 100644
index 0000000..5696f0f
--- /dev/null
+++ b/InterlinedList/Models/ListFieldDefaults.cs
@@ -0,0 +1,139 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace InterlinedList.Models;
+
+///
+/// The defaultValue encoding rules for a column, which differ between the two
+/// schema write shapes and are NOT symmetric with how a default reads back.
+/// All three behaviours were live-probed 2026-09-16 against throwaway lists
+/// (since deleted) and are load-bearing — a naive "just send the value" here
+/// either loses the default or 500s the whole request:
+///
+///
+/// DSL (POST /api/lists, the destructive rebuild) takes the
+/// RAW typed value: 3, false, "open". The server stores it
+/// JSON-encoded ("3", "false", "\"open\"").
+/// properties (the non-destructive PUT) takes only a JSON STRING.
+/// A raw number or boolean answers 500 Internal server error and the
+/// WHOLE request is discarded — so a default is written as the unquoted
+/// literal text ("7", "true", "hello").
+/// Reads come back two ways: GET /api/lists/{id} →
+/// properties[].defaultValue is the stored, still-encoded string, while
+/// GET /api/lists/{id}/schema → fields[].defaultValue is
+/// JSON.parse'd ("3"→3, "\"open\""→"open"), falling
+/// back to the raw string when it isn't valid JSON.
+///
+///
+/// One documented quirk of the properties path: a text default that reads
+/// like JSON (7, true) decodes to that scalar in the schema view.
+/// It round-trips to the same editor text, so the editor is consistent either way.
+///
+public static class ListFieldDefaults
+{
+ ///
+ /// Stored (still-encoded) properties[].defaultValue → text for an editor
+ /// box. Unwraps the one level of JSON encoding the server applies, so
+ /// "\"open\"" shows as open and "3" as 3.
+ ///
+ public static string FromStored(JsonElement? stored)
+ {
+ if (stored is not { } element || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ return string.Empty;
+
+ if (element.ValueKind != JsonValueKind.String)
+ return element.GetRawText();
+
+ var text = element.GetString() ?? string.Empty;
+ try
+ {
+ using var doc = JsonDocument.Parse(text);
+ return doc.RootElement.ValueKind == JsonValueKind.String
+ ? doc.RootElement.GetString() ?? string.Empty
+ : text;
+ }
+ catch (JsonException)
+ {
+ // Not JSON — it's already the plain value (how the properties path stores it).
+ return text;
+ }
+ }
+
+ ///
+ /// Decoded fields[].defaultValue from GET …/schema → editor text.
+ /// The value arrives as a because
+ /// is typed object?.
+ ///
+ public static string FromDecoded(object? decoded) => decoded switch
+ {
+ null => string.Empty,
+ JsonElement e => e.ValueKind switch
+ {
+ JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
+ JsonValueKind.String => e.GetString() ?? string.Empty,
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ _ => e.GetRawText()
+ },
+ string s => s,
+ bool b => b ? "true" : "false",
+ IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
+ var other => other.ToString() ?? string.Empty
+ };
+
+ ///
+ /// Editor text → the properties body's defaultValue: always a
+ /// JSON string, never a raw number/boolean (those 500 the request), or null
+ /// to clear it. Note that OMITTING it clears the stored default, so the
+ /// caller must send this on every item it means to keep.
+ ///
+ public static JsonElement? ToPropertyDefault(string? text) =>
+ string.IsNullOrWhiteSpace(text)
+ ? null
+ : JsonSerializer.SerializeToElement(text.Trim());
+
+ ///
+ /// Editor text → the DSL's defaultValue: a raw typed CLR value, so a
+ /// number column pre-fills with a number rather than the string "3".
+ /// Returns null for blank text or text that doesn't parse for the type —
+ /// callers validate first (see the column editor) so unparseable text is
+ /// reported rather than silently dropped.
+ ///
+ public static object? ToDslDefault(string? type, string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ var trimmed = text.Trim();
+
+ return type switch
+ {
+ ListFieldType.Number => TryParseNumber(trimmed, out var number) ? number : null,
+ ListFieldType.Boolean => TryParseBoolean(trimmed, out var flag) ? flag : null,
+ _ => trimmed
+ };
+ }
+
+ public static bool TryParseNumber(string? text, out double value) =>
+ double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
+
+ /// Accepts the wire spelling (true/false) case-insensitively.
+ public static bool TryParseBoolean(string? text, out bool value)
+ {
+ value = false;
+ if (text is null) return false;
+ var trimmed = text.Trim();
+ if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)) { value = true; return true; }
+ if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase)) return true;
+ return false;
+ }
+
+ /// Hint text for the default box, so the per-type encoding isn't a guessing game.
+ public static string EditorHint(string? type) => type switch
+ {
+ ListFieldType.Number => "e.g. 0",
+ ListFieldType.Boolean => "true or false",
+ ListFieldType.Date => "YYYY-MM-DD",
+ ListFieldType.DateTime => "YYYY-MM-DDTHH:MM:SSZ",
+ ListFieldType.MultiSelect => "comma-separated options",
+ _ => "optional"
+ };
+}
diff --git a/InterlinedList/ViewModels/ListColumnDraftViewModel.cs b/InterlinedList/ViewModels/ListColumnDraftViewModel.cs
new file mode 100644
index 0000000..fdf722a
--- /dev/null
+++ b/InterlinedList/ViewModels/ListColumnDraftViewModel.cs
@@ -0,0 +1,300 @@
+using System.Text;
+using System.Text.Json;
+using CommunityToolkit.Mvvm.ComponentModel;
+using InterlinedList.Models;
+
+namespace InterlinedList.ViewModels;
+
+/// One entry in the column editor's type picker.
+/// The wire value ().
+/// Human label.
+///
+/// False for the six richer types the non-destructive properties PUT
+/// rejects — picking one of those costs safe editing on that list for good.
+///
+public sealed record ListColumnTypeOption(string Value, string DisplayName, bool PropertiesEditable)
+{
+ /// Picker caption; the six rebuild-only types are flagged in the list itself.
+ public string Caption => PropertiesEditable ? DisplayName : DisplayName + " (rebuild only)";
+}
+
+///
+/// One column being edited in the builder. Deliberately a mutable draft rather
+/// than the immutable /
+/// wire types: the editor needs half-finished state (an options box mid-typing,
+/// a default that doesn't parse yet) that those types can't legally hold.
+///
+/// It carries two things it never edits — and
+/// (owned by #19/#20) — so that emitting a DSL rebuild
+/// from this draft doesn't silently drop rules the user never saw. The
+/// non-destructive properties shape can't express either, and leaves both
+/// untouched (live-verified 2026-09-16).
+///
+public partial class ListColumnDraftViewModel : ObservableObject
+{
+ /// All twelve types, richer ones flagged, for the picker.
+ public static IReadOnlyList TypeOptions { get; } =
+ ListFieldType.All
+ .Select(t => new ListColumnTypeOption(t, ListFieldType.DisplayName(t),
+ ListFieldType.SupportsPropertiesEdit(t)))
+ .ToList();
+
+ private bool _keyEditedByHand;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(KeyNote))]
+ private string key = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(RequiresOptions))]
+ [NotifyPropertyChangedFor(nameof(SupportsPropertiesEdit))]
+ [NotifyPropertyChangedFor(nameof(TypeWarning))]
+ [NotifyPropertyChangedFor(nameof(HasTypeWarning))]
+ [NotifyPropertyChangedFor(nameof(DefaultHint))]
+ private string type = ListFieldType.Text;
+
+ [ObservableProperty]
+ private string label = "";
+
+ [ObservableProperty]
+ private bool isRequired;
+
+ [ObservableProperty]
+ private bool isVisible = true;
+
+ [ObservableProperty]
+ private string defaultValueText = "";
+
+ [ObservableProperty]
+ private string placeholder = "";
+
+ [ObservableProperty]
+ private string helpText = "";
+
+ /// Comma-separated; mandatory for select/multiselect.
+ [ObservableProperty]
+ private string optionsText = "";
+
+ /// Inline, per-column error — set by the editor before/after a save attempt.
+ [ObservableProperty]
+ private string? issueMessage;
+
+ /// Stored column id, when this draft came from a saved list. Null = a new column.
+ public string? PropertyId { get; private init; }
+
+ /// The key this column was saved under; null for a new column.
+ public string? StoredKey { get; private init; }
+
+ /// The type this column was saved with; null for a new column.
+ public string? StoredType { get; private init; }
+
+ /// True once at least one row holds a value for .
+ public bool HasRowData { get; set; }
+
+ /// Rules the editor carries but doesn't edit (#19).
+ public ListFieldValidation? Validation { get; private init; }
+
+ /// Conditional visibility the editor carries but doesn't edit (#20).
+ public ListFieldVisibility? Visibility { get; private init; }
+
+ public bool IsExistingColumn => StoredKey is not null;
+
+ ///
+ /// The server refuses a key change on a saved column
+ /// (400 propertyKey cannot change for an existing property; rename
+ /// propertyName instead, live-verified), so the box is locked instead of
+ /// letting the user type something that can only fail.
+ ///
+ public bool IsKeyEditable => !IsExistingColumn;
+
+ public bool RequiresOptions => ListFieldType.RequiresOptions(Type);
+
+ public bool SupportsPropertiesEdit => ListFieldType.SupportsPropertiesEdit(Type);
+
+ public string DefaultHint => ListFieldDefaults.EditorHint(Type);
+
+ public string? KeyNote => IsExistingColumn
+ ? $"Key '{StoredKey}' is fixed — rename the label instead."
+ : null;
+
+ ///
+ /// The warning #18 has to show at the MOMENT a richer type is picked: those
+ /// six types can never go through the non-destructive edit, so every later
+ /// change to this list has to be a full column rebuild.
+ ///
+ public string? TypeWarning
+ {
+ get
+ {
+ if (!SupportsPropertiesEdit)
+ return $"{ListFieldType.DisplayName(Type)} can only be saved by rebuilding every column. "
+ + $"Safe single-column edits ({string.Join(", ", ListFieldType.PropertiesEditable)}) "
+ + "stop being possible on this list.";
+
+ if (IsExistingColumn && StoredType is { } stored && stored != Type)
+ return $"Changing {StoredKey} from {ListFieldType.DisplayName(stored)} to "
+ + $"{ListFieldType.DisplayName(Type)} does not convert the values already in rows "
+ + "— they stay as they are and will fail validation the next time that row is saved.";
+
+ return null;
+ }
+ }
+
+ public bool HasTypeWarning => TypeWarning is not null;
+
+ /// Options as the wire wants them; also fills in priority's implicit four.
+ public List OptionList =>
+ OptionsText
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ /// A blank draft, ready for the user to fill in.
+ public static ListColumnDraftViewModel ForNewColumn() => new();
+
+ ///
+ /// A draft of a saved column. supplies the id the
+ /// non-destructive edit needs to update in place (it only exists on
+ /// GET /api/lists/{id} → properties[], not in the DSL view).
+ ///
+ public static ListColumnDraftViewModel FromField(ListField field, ListProperty? stored)
+ {
+ var draft = new ListColumnDraftViewModel
+ {
+ PropertyId = stored?.Id,
+ StoredKey = field.Key,
+ StoredType = field.Type,
+ Validation = field.Validation,
+ Visibility = field.Visibility,
+ Key = field.Key,
+ Type = field.Type,
+ Label = field.Label,
+ IsRequired = field.IsRequired,
+ IsVisible = field.IsVisible,
+ Placeholder = field.Placeholder ?? "",
+ HelpText = field.HelpText ?? "",
+ OptionsText = field.Options is { Count: > 0 } ? string.Join(", ", field.Options) : "",
+ DefaultValueText = ListFieldDefaults.FromDecoded(field.DefaultValue)
+ };
+ draft._keyEditedByHand = true;
+ return draft;
+ }
+
+ /// DSL form, for list creation and the destructive rebuild.
+ public ListField ToField(int displayOrder)
+ {
+ var options = OptionList;
+ return new ListField
+ {
+ Key = Key.Trim(),
+ Type = Type,
+ Label = string.IsNullOrWhiteSpace(Label) ? Key.Trim() : Label.Trim(),
+ Required = IsRequired ? true : null,
+ Visible = IsVisible ? null : false,
+ DefaultValue = ListFieldDefaults.ToDslDefault(Type, DefaultValueText),
+ Placeholder = string.IsNullOrWhiteSpace(Placeholder) ? null : Placeholder.Trim(),
+ HelpText = string.IsNullOrWhiteSpace(HelpText) ? null : HelpText.Trim(),
+ // priority takes its implicit low/medium/high/urgent set when options are
+ // omitted, so only send its options when the user actually narrowed them.
+ Options = options.Count > 0 && !IsImplicitPrioritySet(options) ? options : null,
+ Validation = Validation is { IsEmpty: false } ? Validation : null,
+ Visibility = Visibility?.Condition is not null ? Visibility : null,
+ DisplayOrder = displayOrder
+ };
+ }
+
+ ///
+ /// Non-destructive form. Every field the shape can express is sent on every
+ /// item, because an omitted one is CLEARED server-side — live-verified
+ /// 2026-09-16: a PUT that left out isVisible/isRequired/defaultValue flipped
+ /// a hidden column visible and wiped two defaults.
+ ///
+ public ListPropertyUpdate ToPropertyUpdate(int displayOrder) => new()
+ {
+ Id = PropertyId,
+ PropertyKey = (StoredKey ?? Key).Trim(),
+ PropertyName = string.IsNullOrWhiteSpace(Label) ? Key.Trim() : Label.Trim(),
+ PropertyType = Type,
+ DisplayOrder = displayOrder,
+ IsVisible = IsVisible,
+ IsRequired = IsRequired,
+ DefaultValue = ListFieldDefaults.ToPropertyDefault(DefaultValueText),
+ Placeholder = string.IsNullOrWhiteSpace(Placeholder) ? null : Placeholder.Trim(),
+ HelpText = string.IsNullOrWhiteSpace(HelpText) ? null : HelpText.Trim()
+ };
+
+ ///
+ /// Everything wrong with this one column that can be known without a
+ /// request. Duplicate keys and the "at least one column" rule are checked by
+ /// the editor across the whole set, not here.
+ ///
+ public List LocalIssues()
+ {
+ var issues = new List();
+ var key = Key.Trim();
+
+ if (key.Length == 0)
+ issues.Add("Needs a key.");
+ else if (key.Any(char.IsWhiteSpace))
+ issues.Add("Key can't contain spaces.");
+
+ if (string.IsNullOrWhiteSpace(Label))
+ issues.Add("Needs a label.");
+
+ if (!ListFieldType.IsValid(Type))
+ issues.Add($"'{Type}' isn't one of the twelve column types.");
+
+ if (RequiresOptions && OptionList.Count == 0)
+ issues.Add($"{ListFieldType.DisplayName(Type)} needs at least one option.");
+
+ if (!string.IsNullOrWhiteSpace(DefaultValueText))
+ {
+ if (Type == ListFieldType.Number && !ListFieldDefaults.TryParseNumber(DefaultValueText, out _))
+ issues.Add("Default has to be a number.");
+ if (Type == ListFieldType.Boolean && !ListFieldDefaults.TryParseBoolean(DefaultValueText, out _))
+ issues.Add("Default has to be true or false.");
+ if (RequiresOptions && Type == ListFieldType.Select
+ && !OptionList.Contains(DefaultValueText.Trim(), StringComparer.Ordinal))
+ issues.Add("Default isn't one of the options.");
+ }
+
+ return issues;
+ }
+
+ private bool IsImplicitPrioritySet(List options) =>
+ Type == ListFieldType.Priority
+ && options.Count == ListFieldType.PriorityOptions.Count
+ && options.SequenceEqual(ListFieldType.PriorityOptions, StringComparer.Ordinal);
+
+ /// Slug a label into a usable machine key (only while the user hasn't typed one).
+ private static string Slug(string label)
+ {
+ var builder = new StringBuilder(label.Length);
+ foreach (var c in label.Trim().ToLowerInvariant())
+ {
+ if (char.IsLetterOrDigit(c)) builder.Append(c);
+ else if (builder.Length > 0 && builder[^1] != '_') builder.Append('_');
+ }
+ return builder.ToString().Trim('_');
+ }
+
+ partial void OnKeyChanged(string value) => _keyEditedByHand = true;
+
+ partial void OnLabelChanged(string value)
+ {
+ if (_keyEditedByHand || IsExistingColumn) return;
+
+ var slug = Slug(value);
+ Key = slug;
+ // Key's setter flags the key as hand-edited; undo that so the slug keeps following.
+ _keyEditedByHand = false;
+ }
+
+ partial void OnTypeChanged(string value)
+ {
+ // priority carries an implicit option set; showing it makes the picker honest
+ // and lets the user narrow or reword it.
+ if (value == ListFieldType.Priority && OptionList.Count == 0)
+ OptionsText = string.Join(", ", ListFieldType.PriorityOptions);
+ }
+}
diff --git a/InterlinedList/ViewModels/ListColumnEditorViewModel.cs b/InterlinedList/ViewModels/ListColumnEditorViewModel.cs
new file mode 100644
index 0000000..17c4562
--- /dev/null
+++ b/InterlinedList/ViewModels/ListColumnEditorViewModel.cs
@@ -0,0 +1,476 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The column form builder — add / edit / remove / reorder a list's columns
+/// across all twelve field types, either for a list that doesn't exist yet
+/// (, the schema rides along on
+/// POST /api/lists) or for a saved one.
+///
+/// Saving a SAVED list goes through the non-destructive properties PUT
+/// only. That is the whole reason this editor exists in two pieces: the
+/// destructive DSL rebuild is a separate, explicitly-labelled action (#22), and
+/// until it lands a draft that can only be expressed as a rebuild is refused
+/// here with an explanation rather than quietly wiping every column.
+///
+/// Everything the editor does about the API's sharp edges is live-verified
+/// (2026-09-16, throwaway lists since deleted):
+///
+/// the properties body accepts only six of the twelve types, so
+/// picking a richer one is warned about at the moment of the click;
+/// an omitted field on a properties item is CLEARED, so every
+/// draft always sends its full projection;
+/// dropping a column that still holds data answers 409 with
+/// propertiesWithData, which becomes an in-place confirmation naming
+/// those columns instead of a bare error;
+/// a saved column's key can't change (400), so the key box locks.
+///
+///
+public partial class ListColumnEditorViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ /// Raised after a successful save so the host can re-read the list.
+ public event EventHandler? Saved;
+
+ public ObservableCollection Columns { get; } = new();
+
+ /// Keys the list had when the editor opened — used to spell out removals.
+ private readonly List _storedKeys = new();
+
+ private List? _pendingForcedSave;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HeaderText))]
+ private bool isOpen;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HeaderText))]
+ [NotifyPropertyChangedFor(nameof(SaveButtonText))]
+ private bool isNewListMode;
+
+ [ObservableProperty]
+ private ListSummary? list;
+
+ [ObservableProperty]
+ private bool isBusy;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ [ObservableProperty]
+ private string? statusMessage;
+
+ /// Set when a save was refused with 409; names the data-bearing columns.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NeedsForceConfirmation))]
+ private string? forceConfirmationMessage;
+
+ public bool NeedsForceConfirmation => ForceConfirmationMessage is not null;
+
+ public string HeaderText => IsNewListMode
+ ? "Columns for the new list"
+ : List is { } list ? $"Columns — {list.Title}" : "Columns";
+
+ public string SaveButtonText => IsNewListMode ? "Use these columns" : "Save columns";
+
+ ///
+ /// True while every column in the draft is one of the six the safe path
+ /// accepts. False means the only way to save is the destructive rebuild.
+ ///
+ public bool CanUsePropertiesPath => Columns.Count > 0 && Columns.All(c => c.SupportsPropertiesEdit);
+
+ /// Columns that were saved on the list but are no longer in the draft.
+ public IReadOnlyList RemovedKeys =>
+ _storedKeys.Where(k => !Columns.Any(c => string.Equals(c.StoredKey, k, StringComparison.Ordinal)))
+ .ToList();
+
+ public string? RemovalWarning => RemovedKeys.Count == 0
+ ? null
+ : $"Saving deletes {string.Join(", ", RemovedKeys)} and strips those keys from every row.";
+
+ public string? RebuildOnlyWarning => IsNewListMode || CanUsePropertiesPath
+ ? null
+ : "This draft uses "
+ + string.Join(", ", Columns.Where(c => !c.SupportsPropertiesEdit)
+ .Select(c => ListFieldType.DisplayName(c.Type))
+ .Distinct(StringComparer.Ordinal))
+ + " — types the safe single-column save can't express (it only accepts "
+ + string.Join(", ", ListFieldType.PropertiesEditable)
+ + "). Saving those requires the destructive column rebuild.";
+
+ public ListColumnEditorViewModel(SessionService session)
+ {
+ _session = session;
+ Columns.CollectionChanged += OnColumnsChanged;
+ }
+
+ // The "can this be saved safely" answer depends on each column's TYPE, not
+ // just on how many columns there are, so the editor listens to the drafts too.
+ private void OnColumnsChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ foreach (var removed in e.OldItems?.OfType() ?? [])
+ removed.PropertyChanged -= OnDraftPropertyChanged;
+ foreach (var added in e.NewItems?.OfType() ?? [])
+ added.PropertyChanged += OnDraftPropertyChanged;
+
+ NotifyDraftSetChanged();
+ }
+
+ private void OnDraftPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ // IssueMessage is set BY this class; reacting to it would recurse.
+ if (e.PropertyName == nameof(ListColumnDraftViewModel.IssueMessage)) return;
+ NotifyDraftSetChanged();
+ }
+
+ /// Clear with the per-draft subscriptions detached (a Reset event carries no OldItems).
+ private void ClearColumns()
+ {
+ foreach (var column in Columns)
+ column.PropertyChanged -= OnDraftPropertyChanged;
+ Columns.Clear();
+ }
+
+ /// Open the builder for a list that hasn't been created yet.
+ [RelayCommand]
+ private void OpenForNewList()
+ {
+ Reset();
+ IsNewListMode = true;
+ List = null;
+ Columns.Add(ListColumnDraftViewModel.ForNewColumn());
+ IsOpen = true;
+ }
+
+ /// Open the builder on a saved list, reading its current columns.
+ public async Task OpenForListAsync(ListSummary list)
+ {
+ Reset();
+ IsNewListMode = false;
+ List = list;
+ IsOpen = true;
+ await ReloadAsync();
+ }
+
+ [RelayCommand]
+ private void Close()
+ {
+ IsOpen = false;
+ Reset();
+ }
+
+ private void Reset()
+ {
+ ClearColumns();
+ _storedKeys.Clear();
+ _pendingForcedSave = null;
+ ForceConfirmationMessage = null;
+ ErrorMessage = null;
+ StatusMessage = null;
+ }
+
+ ///
+ /// Read the columns twice, on purpose: the DSL view carries label/default/
+ /// options/validation/visibility, while GET /api/lists/{id} is the only
+ /// place each column's id lives — and the non-destructive PUT needs that id
+ /// to update a column in place instead of recreating it.
+ ///
+ private async Task ReloadAsync()
+ {
+ if (List is not { } list) return;
+
+ IsBusy = true;
+ try
+ {
+ var schema = await _session.Api.GetListSchemaAsync(list.Id);
+ var stored = await _session.Api.GetListPropertiesAsync(list.Id);
+
+ ClearColumns();
+ _storedKeys.Clear();
+ foreach (var field in schema.FieldsInDisplayOrder)
+ {
+ var match = stored.FirstOrDefault(p => string.Equals(p.PropertyKey, field.Key, StringComparison.Ordinal));
+ Columns.Add(ListColumnDraftViewModel.FromField(field, match));
+ _storedKeys.Add(field.Key);
+ }
+
+ StatusMessage = Columns.Count == 0
+ ? "This list has no columns yet — its rows are freeform JSON. Add one to give it a shape."
+ : null;
+ ErrorMessage = null;
+ }
+ catch (ListSchemaException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ NotifyDraftSetChanged();
+ }
+ }
+
+ [RelayCommand]
+ private void AddColumn()
+ {
+ Columns.Add(ListColumnDraftViewModel.ForNewColumn());
+ StatusMessage = null;
+ }
+
+ [RelayCommand]
+ private void RemoveColumn(ListColumnDraftViewModel column)
+ {
+ Columns.Remove(column);
+ ForceConfirmationMessage = null;
+ _pendingForcedSave = null;
+ }
+
+ [RelayCommand]
+ private void MoveColumnUp(ListColumnDraftViewModel column)
+ {
+ var index = Columns.IndexOf(column);
+ if (index > 0) Columns.Move(index, index - 1);
+ }
+
+ [RelayCommand]
+ private void MoveColumnDown(ListColumnDraftViewModel column)
+ {
+ var index = Columns.IndexOf(column);
+ if (index >= 0 && index < Columns.Count - 1) Columns.Move(index, index + 1);
+ }
+
+ ///
+ /// The DSL the draft describes. is the LIST's title
+ /// on both write paths (the rebuild PUT overwrites it), so the caller passes
+ /// the title it means to end up with.
+ ///
+ public ListSchema BuildSchema(string name, string? description) => new()
+ {
+ Name = name,
+ Description = description,
+ Fields = Columns.Select((c, i) => c.ToField(i)).ToList()
+ };
+
+ ///
+ /// Why the draft can't be saved yet, recomputed on every keystroke so the
+ /// save button is disabled (not merely refused) while a select column has no
+ /// options or a column has no key — #18's "before save is enabled" rule.
+ ///
+ public bool IsDraftSavable => DraftBlockers().Count == 0;
+
+ /// The first blocker, shown beside the disabled save button.
+ public string? SaveBlockedReason => DraftBlockers().FirstOrDefault();
+
+ private List DraftBlockers()
+ {
+ var blockers = new List();
+
+ if (Columns.Count == 0)
+ {
+ blockers.Add("Add at least one column — a schema with none is rejected.");
+ return blockers;
+ }
+
+ foreach (var column in Columns)
+ blockers.AddRange(column.LocalIssues().Select(issue => $"{Describe(column)}: {issue}"));
+
+ foreach (var group in Columns
+ .Where(c => !string.IsNullOrWhiteSpace(c.Key))
+ .GroupBy(c => c.Key.Trim(), StringComparer.Ordinal)
+ .Where(g => g.Count() > 1))
+ {
+ blockers.Add($"Two columns share the key '{group.Key}'.");
+ }
+
+ return blockers;
+ }
+
+ private static string Describe(ListColumnDraftViewModel column) =>
+ !string.IsNullOrWhiteSpace(column.Label) ? column.Label.Trim()
+ : !string.IsNullOrWhiteSpace(column.Key) ? column.Key.Trim()
+ : "New column";
+
+ ///
+ /// Client-side gate, so a bad draft never becomes a request: at least one
+ /// column, no duplicate keys, every column individually legal, and the same
+ /// checks the server runs on a DSL body. Issues are attached to the column
+ /// they belong to.
+ ///
+ public bool ValidateDraft()
+ {
+ foreach (var column in Columns)
+ column.IssueMessage = null;
+
+ var blocking = new List();
+
+ if (Columns.Count == 0)
+ {
+ ErrorMessage = "A schema needs at least one column (the server rejects an empty one).";
+ return false;
+ }
+
+ foreach (var column in Columns)
+ {
+ if (column.LocalIssues() is { Count: > 0 } issues)
+ {
+ column.IssueMessage = string.Join(" ", issues);
+ blocking.AddRange(issues);
+ }
+ }
+
+ foreach (var group in Columns
+ .Where(c => !string.IsNullOrWhiteSpace(c.Key))
+ .GroupBy(c => c.Key.Trim(), StringComparer.Ordinal)
+ .Where(g => g.Count() > 1))
+ {
+ foreach (var column in group)
+ column.IssueMessage = $"Duplicate key '{group.Key}'.";
+ blocking.Add($"Duplicate key '{group.Key}'.");
+ }
+
+ if (blocking.Count > 0)
+ {
+ ErrorMessage = "Fix the columns marked below.";
+ return false;
+ }
+
+ // The server's own DSL validator, mirrored client-side — catches the
+ // conditional-visibility mistakes it would otherwise accept and ignore.
+ var schemaIssues = BuildSchema(List?.Title ?? "Untitled", List?.Description).Validate();
+ if (schemaIssues.Count > 0)
+ {
+ foreach (var issue in schemaIssues)
+ {
+ var column = Columns.FirstOrDefault(c => string.Equals(c.Key.Trim(), issue.FieldKey, StringComparison.Ordinal));
+ if (column is not null) column.IssueMessage = issue.ShortMessage;
+ }
+ ErrorMessage = schemaIssues[0].ShortMessage;
+ return false;
+ }
+
+ ErrorMessage = null;
+ return true;
+ }
+
+ private bool CanSave() => !IsBusy && IsDraftSavable;
+
+ [RelayCommand(CanExecute = nameof(CanSave))]
+ private async Task SaveAsync()
+ {
+ if (!ValidateDraft()) return;
+
+ // New-list mode never writes: the host posts the schema with the list.
+ if (IsNewListMode)
+ {
+ StatusMessage = $"{Columns.Count} column(s) ready — they're created with the list.";
+ IsOpen = false;
+ Saved?.Invoke(this, EventArgs.Empty);
+ return;
+ }
+
+ if (!CanUsePropertiesPath)
+ {
+ ErrorMessage = RebuildOnlyWarning;
+ return;
+ }
+
+ await SendPropertiesAsync(Columns.Select((c, i) => c.ToPropertyUpdate(i)).ToList(), force: false);
+ }
+
+ /// Re-send the same edit with ?force=true after the 409 was confirmed.
+ [RelayCommand]
+ private async Task ConfirmForcedSaveAsync()
+ {
+ if (_pendingForcedSave is not { } items) return;
+ ForceConfirmationMessage = null;
+ await SendPropertiesAsync(items, force: true);
+ }
+
+ [RelayCommand]
+ private void CancelForcedSave()
+ {
+ ForceConfirmationMessage = null;
+ _pendingForcedSave = null;
+ StatusMessage = "Nothing was changed.";
+ }
+
+ private async Task SendPropertiesAsync(List items, bool force)
+ {
+ if (List is not { } list) return;
+
+ IsBusy = true;
+ SaveCommand.NotifyCanExecuteChanged();
+ try
+ {
+ var saved = await _session.Api.UpdateListPropertiesAsync(list.Id, items, force);
+ _pendingForcedSave = null;
+ ErrorMessage = null;
+ StatusMessage = $"Saved {saved.Count} column(s). Row data was preserved.";
+ await ReloadAsync();
+ Saved?.Invoke(this, EventArgs.Empty);
+ }
+ catch (ListSchemaException ex) when (ex.RequiresForce)
+ {
+ // Name the columns rather than showing a bare 409.
+ _pendingForcedSave = items;
+ var affected = ex.PropertiesWithData
+ .Select(k => Columns.FirstOrDefault(c => string.Equals(c.StoredKey, k, StringComparison.Ordinal))?.Label is { Length: > 0 } label
+ ? $"{label} ({k})" : k);
+ ForceConfirmationMessage =
+ $"{string.Join(", ", affected)} still hold row data. Deleting them removes that value "
+ + "from every row in this list. Everything else about the rows is kept.";
+ StatusMessage = null;
+ ErrorMessage = null;
+ }
+ catch (ListSchemaException ex)
+ {
+ AttachIssues(ex);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ SaveCommand.NotifyCanExecuteChanged();
+ }
+ }
+
+ private void AttachIssues(ListSchemaException ex)
+ {
+ foreach (var issue in ex.Issues)
+ {
+ var column = Columns.FirstOrDefault(c =>
+ string.Equals(c.StoredKey, issue.FieldKey, StringComparison.Ordinal)
+ || string.Equals(c.Key.Trim(), issue.FieldKey, StringComparison.Ordinal));
+ if (column is null && issue.FieldIndex is { } index && index >= 0 && index < Columns.Count)
+ column = Columns[index];
+ if (column is not null) column.IssueMessage = issue.ShortMessage;
+ }
+ ErrorMessage = ex.Message;
+ }
+
+ private void NotifyDraftSetChanged()
+ {
+ OnPropertyChanged(nameof(CanUsePropertiesPath));
+ OnPropertyChanged(nameof(RemovedKeys));
+ OnPropertyChanged(nameof(RemovalWarning));
+ OnPropertyChanged(nameof(RebuildOnlyWarning));
+ OnPropertyChanged(nameof(IsDraftSavable));
+ OnPropertyChanged(nameof(SaveBlockedReason));
+ SaveCommand.NotifyCanExecuteChanged();
+ }
+}
diff --git a/InterlinedList/ViewModels/ListsViewModel.cs b/InterlinedList/ViewModels/ListsViewModel.cs
index fc283fd..cea0385 100644
--- a/InterlinedList/ViewModels/ListsViewModel.cs
+++ b/InterlinedList/ViewModels/ListsViewModel.cs
@@ -64,9 +64,35 @@ public partial class ListsViewModel : ObservableObject
private static readonly JsonSerializerOptions RowEditJsonOptions = new() { WriteIndented = true };
+ ///
+ /// The column form builder (#18). Hosted here rather than owned by the view
+ /// so the create-list form can hand its draft straight to
+ /// POST /api/lists as a schema, which is the only way to give a
+ /// brand-new list columns without an immediately-destructive second call.
+ ///
+ public ListColumnEditorViewModel ColumnEditor { get; }
+
public ListsViewModel(SessionService session)
{
_session = session;
+ ColumnEditor = new ListColumnEditorViewModel(session);
+ ColumnEditor.Saved += OnColumnsSaved;
+ }
+
+ ///
+ /// A column save can rename the list (only on the destructive rebuild, but
+ /// the editor owns that decision), so re-read the browser afterwards.
+ ///
+ private void OnColumnsSaved(object? sender, EventArgs e)
+ {
+ if (ColumnEditor.IsNewListMode) return;
+ _ = LoadListsAsync();
+ }
+
+ [RelayCommand]
+ private async Task OpenColumnEditorAsync(ListSummary list)
+ {
+ await ColumnEditor.OpenForListAsync(list);
}
[RelayCommand]
@@ -98,14 +124,35 @@ private async Task LoadListsAsync()
[RelayCommand(CanExecute = nameof(CanCreateList))]
private async Task CreateListAsync()
{
+ var title = NewListTitle.Trim();
+ var description = string.IsNullOrWhiteSpace(NewListDescription) ? null : NewListDescription.Trim();
+
+ // A draft in the column builder rides along as POST /api/lists' `schema`
+ // (live-verified: the same DSL validation as the rebuild PUT, and the
+ // list comes back with its columns already created).
+ ListSchema? schema = null;
+ if (ColumnEditor is { IsNewListMode: true, Columns.Count: > 0 })
+ {
+ if (!ColumnEditor.ValidateDraft())
+ {
+ ErrorMessage = "Fix the new list's columns before creating it.";
+ return;
+ }
+ schema = ColumnEditor.BuildSchema(title, description);
+ }
+
try
{
- var description = string.IsNullOrWhiteSpace(NewListDescription) ? null : NewListDescription.Trim();
- await _session.Api.CreateListAsync(NewListTitle.Trim(), description);
+ await _session.Api.CreateListAsync(title, description, schema);
NewListTitle = "";
NewListDescription = "";
+ if (schema is not null) ColumnEditor.CloseCommand.Execute(null);
await LoadListsAsync();
}
+ catch (ListSchemaException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 2042e68..ef32d4a 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -107,6 +107,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -143,10 +252,16 @@
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="52"/>
-
+
+
+
+
+