From 68a1210da954c72c5fdc09ab0e5f92cab3af0db0 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:59:47 -0700 Subject: [PATCH] Lists: add the schema column form builder (12 field types) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A column editor in ListsView for creating and editing a list's columns — add / edit / remove / reorder across all twelve DSL types, with `key`, `type`, `label`, `required`, `defaultValue`, `placeholder`, `helpText`, `options`, `visible` and `displayOrder` per column. It is used at list-create time (the draft rides along as POST /api/lists' `schema`) and on a saved list, where it writes through the NON-DESTRUCTIVE `properties` PUT only. Client-side gates, so a bad draft never becomes a request: at least one column, no duplicate keys, a label and key on every column, at least one option for select/multiselect, and a default that parses for its type. The save button is disabled while any of those hold, with the first blocker spelled out next to it. `priority` shows (and falls back to) low/medium/high/urgent. Three API behaviours drive the design, all live-probed today against throwaway lists (created, exercised, deleted — the account is back to its one real list): * The `properties` body accepts only text/number/boolean/date/url/email, so the editor warns the moment a richer type is picked: choosing one costs safe editing on that list permanently. A draft that needs the rebuild is refused here with that explanation rather than silently wiping every column — the destructive path is #22's separate, labelled action. * An omitted field on a `properties` item is CLEARED, not preserved. Verified: a PUT that left out isVisible/isRequired/defaultValue flipped a hidden column visible and wiped two defaults. Every draft therefore sends its full projection on every save. * `defaultValue` in a `properties` body must be a JSON *string*: a raw number or boolean answers 500 and discards the whole request. `ListFieldDefaults` owns that asymmetry (the DSL wants the raw typed value; the stored form is encoded text; GET …/schema hands it back parsed). Plus: deleting a column that still holds data answers 409 with `propertiesWithData`, which becomes an in-place confirmation naming those columns (no native dialog); a saved column's key is locked because the server rejects a key change; and changing a saved column's type is allowed but warns, since existing row values are neither converted nor re-validated. Verified live end-to-end by feeding the bodies the real draft view-model serializes to the API: the twelve-type DSL created all thirteen columns with labels, defaults, options, required/visible and placeholder intact, and the six-type `properties` body round-tripped its defaults decoded (2.5, false). Closes #18 Co-Authored-By: Claude Opus 5 --- InterlinedList/Models/ListFieldDefaults.cs | 139 +++++ .../ViewModels/ListColumnDraftViewModel.cs | 300 +++++++++++ .../ViewModels/ListColumnEditorViewModel.cs | 476 ++++++++++++++++++ InterlinedList/ViewModels/ListsViewModel.cs | 51 +- InterlinedList/Views/ListsView.xaml | 360 ++++++++++++- 5 files changed, 1320 insertions(+), 6 deletions(-) create mode 100644 InterlinedList/Models/ListFieldDefaults.cs create mode 100644 InterlinedList/ViewModels/ListColumnDraftViewModel.cs create mode 100644 InterlinedList/ViewModels/ListColumnEditorViewModel.cs 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 eaf3e0f..12a62d0 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"/> -