diff --git a/InterlinedList/Models/ListFieldConditionEvaluator.cs b/InterlinedList/Models/ListFieldConditionEvaluator.cs
new file mode 100644
index 0000000..38e133b
--- /dev/null
+++ b/InterlinedList/Models/ListFieldConditionEvaluator.cs
@@ -0,0 +1,138 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace InterlinedList.Models;
+
+///
+/// Client-side evaluation of a column's conditional visibility. This is not a
+/// convenience — it is the ONLY evaluation there is: the server stores a
+/// visibilityCondition verbatim and never applies it (an unknown
+/// operator, a missing watched key and a forward reference all save happily and
+/// then never match — live-verified 2026-09-16), so a form that doesn't do this
+/// simply shows every column always.
+///
+/// Values arrive as whatever the row editor is holding — a string, a
+/// , a number, a list of strings for a multiselect, or the
+/// raw straight off a stored row — hence the
+/// text/number coercion rather than a typed comparison.
+///
+public static class ListFieldConditionEvaluator
+{
+ ///
+ /// True when should be shown, given a lookup for
+ /// the current value of any other column by key. A field with no condition
+ /// is always shown; a condition naming a column the lookup doesn't know
+ /// resolves against null (which is what the server would effectively do —
+ /// never match — for anything but isEmpty).
+ ///
+ public static bool IsShown(ListField field, Func valueOf)
+ {
+ if (field.Visibility?.Condition is not { } condition) return true;
+ return Matches(condition, valueOf(condition.Field));
+ }
+
+ public static bool Matches(ListFieldCondition condition, object? value)
+ {
+ var text = AsText(value);
+ var targetText = AsText(condition.Value);
+
+ return condition.Operator switch
+ {
+ ListFieldOperator.Equal => Equal(value, condition.Value),
+ ListFieldOperator.NotEqual => !Equal(value, condition.Value),
+ ListFieldOperator.Contains => Contains(value, targetText),
+ ListFieldOperator.NotContains => !Contains(value, targetText),
+ ListFieldOperator.GreaterThan => Compare(text, targetText) > 0,
+ ListFieldOperator.LessThan => Compare(text, targetText) < 0,
+ ListFieldOperator.GreaterThanOrEqual => Compare(text, targetText) >= 0,
+ ListFieldOperator.LessThanOrEqual => Compare(text, targetText) <= 0,
+ ListFieldOperator.IsEmpty => IsEmpty(value),
+ ListFieldOperator.IsNotEmpty => !IsEmpty(value),
+ // An operator neither side understands can only ever be false —
+ // matching the server's "saves fine, never matches" behaviour.
+ _ => false
+ };
+ }
+
+ public static bool IsEmpty(object? value) => value switch
+ {
+ null => true,
+ string s => s.Length == 0,
+ JsonElement e => e.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
+ || (e.ValueKind == JsonValueKind.String && (e.GetString() ?? "").Length == 0)
+ || (e.ValueKind == JsonValueKind.Array && e.GetArrayLength() == 0),
+ System.Collections.IEnumerable list and not string => !list.Cast().Any(),
+ _ => false
+ };
+
+ private static bool Equal(object? value, object? target)
+ {
+ if (TryNumber(value, out var left) && TryNumber(target, out var right))
+ return Math.Abs(left - right) < double.Epsilon;
+ return string.Equals(AsText(value), AsText(target), StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool Contains(object? value, string target)
+ {
+ if (target.Length == 0) return false;
+
+ // A multiselect "contains" means membership, not substring.
+ if (value is JsonElement { ValueKind: JsonValueKind.Array } array)
+ return array.EnumerateArray().Any(e => string.Equals(AsText(e), target, StringComparison.OrdinalIgnoreCase));
+
+ if (value is System.Collections.IEnumerable list and not string)
+ return list.Cast().Any(item => string.Equals(AsText(item), target, StringComparison.OrdinalIgnoreCase));
+
+ return AsText(value).Contains(target, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int Compare(string left, string right)
+ {
+ if (TryNumber(left, out var l) && TryNumber(right, out var r))
+ return l.CompareTo(r);
+ return string.Compare(left, right, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool TryNumber(object? value, out double number)
+ {
+ switch (value)
+ {
+ case null:
+ number = 0;
+ return false;
+ case double d:
+ number = d;
+ return true;
+ case int i:
+ number = i;
+ return true;
+ case JsonElement { ValueKind: JsonValueKind.Number } element:
+ return element.TryGetDouble(out number);
+ }
+
+ return double.TryParse(AsText(value), NumberStyles.Float, CultureInfo.InvariantCulture, out number);
+ }
+
+ /// Flatten any held value to the text form comparisons use.
+ public static string AsText(object? value) => value switch
+ {
+ null => string.Empty,
+ string s => s,
+ bool b => b ? "true" : "false",
+ JsonElement e => e.ValueKind switch
+ {
+ JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
+ JsonValueKind.String => e.GetString() ?? string.Empty,
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ JsonValueKind.Array => string.Join(", ", e.EnumerateArray().Select(AsTextElement)),
+ _ => e.GetRawText()
+ },
+ System.Collections.IEnumerable list and not string =>
+ string.Join(", ", list.Cast().Select(AsText)),
+ IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
+ var other => other.ToString() ?? string.Empty
+ };
+
+ private static string AsTextElement(JsonElement element) => AsText(element);
+}
diff --git a/InterlinedList/Models/ListRowFieldError.cs b/InterlinedList/Models/ListRowFieldError.cs
new file mode 100644
index 0000000..0bfc638
--- /dev/null
+++ b/InterlinedList/Models/ListRowFieldError.cs
@@ -0,0 +1,14 @@
+namespace InterlinedList.Models;
+
+///
+/// One entry of a row write's 422 body:
+/// { "error": "Validation failed", "code": "validation_failed",
+/// "details": [ { "field": "mail", "message": "Email must be a valid email address" } ] }
+///
+/// is the column's key (so it maps straight onto a
+/// form control) while is phrased with the column's
+/// label — both live-verified 2026-09-16, including that a single write
+/// can come back with several details at once (a five-problem row returned all
+/// five).
+///
+public sealed record ListRowFieldError(string Field, string Message);
diff --git a/InterlinedList/Services/InterlinedApiClient.Lists.cs b/InterlinedList/Services/InterlinedApiClient.Lists.cs
index addd044..2521f0b 100644
--- a/InterlinedList/Services/InterlinedApiClient.Lists.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Lists.cs
@@ -54,6 +54,14 @@ public async Task DeleteListAsync(string listId, CancellationToken ct = default)
await EnsureSuccessAsync(resp, ct);
}
+ ///
+ /// Strict read of a list's rows. Note that the endpoint does NOT send a row's
+ /// listId , which currently declares
+ /// required — so this throws a until that
+ /// model is relaxed (#144/PR #146). Use , which
+ /// maps the rows tolerantly, unless you specifically want the pagination
+ /// envelope.
+ ///
public async Task GetListDataAsync(string listId, int limit = 50, int offset = 0, CancellationToken ct = default)
{
using var resp = await SendAsync(HttpMethod.Get, $"api/lists/{listId}/data?limit={limit}&offset={offset}", body: null, ct);
@@ -62,13 +70,73 @@ public async Task GetListDataAsync(string listId, int limit = 50,
?? throw new InterlinedApiException((int)resp.StatusCode, "GET /api/lists/{id}/data returned no body.");
}
+ ///
+ /// Create a row. The body is { data: { … } } keyed by each column's
+ /// KEY (verified live 2026-09-16; rowData as the wrapper is not
+ /// accepted, and unknown — even nested — keys go through untouched).
+ ///
+ /// Throws — not
+ /// — on the 422 a schema'd list
+ /// answers, so a typed editor can attach each message to the control that
+ /// caused it. Everything else fails the usual way. The success body isn't
+ /// parsed; callers re-read the rows (read-after-write, as elsewhere here).
+ ///
public async Task AddListRowAsync(string listId, Dictionary rowData, CancellationToken ct = default)
{
- // Write-path response shape wasn't fully verified live — re-fetch rows afterward instead of parsing this.
using var resp = await SendAsync(HttpMethod.Post, $"api/lists/{listId}/data", new { data = rowData }, ct);
- await EnsureSuccessAsync(resp, ct);
+ await EnsureRowWriteSucceededAsync(resp, ct);
}
+ ///
+ /// Read a list's rows tolerantly. GET /api/lists/{id}/data omits
+ /// listId (and rowNumber ) from every row — verified live
+ /// 2026-09-16 — which makes 's required
+ /// throw a
+ /// straight through . That model is being
+ /// relaxed in #144/PR #146; until then this maps the rows by hand and fills
+ /// listId in from the request, so the typed row editor has something
+ /// to edit either way. It also means version is not surfaced yet —
+ /// no loss today, since the server ignores a version sent on a row
+ /// write (a deliberately stale one still answered 200).
+ ///
+ public async Task> GetListRowsAsync(
+ string listId, int limit = 50, int offset = 0, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/lists/{listId}/data?limit={limit}&offset={offset}", ct);
+
+ var rows = new List();
+ if (!json.TryGetProperty("rows", out var arr) || arr.ValueKind != JsonValueKind.Array)
+ return rows;
+
+ foreach (var item in arr.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.Object) continue;
+
+ var data = new Dictionary(StringComparer.Ordinal);
+ if (item.TryGetProperty("rowData", out var rowData) && rowData.ValueKind == JsonValueKind.Object)
+ foreach (var property in rowData.EnumerateObject())
+ data[property.Name] = property.Value.Clone();
+
+ rows.Add(new ListDataRow
+ {
+ Id = item.TryGetProperty("id", out var id) ? id.GetString() ?? string.Empty : string.Empty,
+ ListId = listId,
+ RowData = data,
+ CreatedAt = ReadDate(item, "createdAt"),
+ UpdatedAt = ReadDate(item, "updatedAt")
+ });
+ }
+
+ return rows;
+ }
+
+ private static DateTimeOffset ReadDate(JsonElement element, string name) =>
+ element.TryGetProperty(name, out var value)
+ && value.ValueKind == JsonValueKind.String
+ && DateTimeOffset.TryParse(value.GetString(), out var parsed)
+ ? parsed
+ : default;
+
// GET /api/lists/{id} returns the list metadata under a "data" envelope
// (verified live 2026-07-31).
public async Task GetListAsync(string listId, CancellationToken ct = default)
@@ -81,10 +149,32 @@ public async Task GetListAsync(string listId, CancellationToken ct
public Task UpdateListAsync(string listId, string title, string? description, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Put, $"api/lists/{listId}", new { title, description }, ct);
- // Edit/delete of an individual row — the pieces that made rows write-once
- // before. Same read-after-write discipline as AddListRowAsync.
- public Task UpdateListRowAsync(string listId, string rowId, Dictionary rowData, CancellationToken ct = default)
- => SendVoidAsync(HttpMethod.Put, $"api/lists/{listId}/data/{rowId}", new { data = rowData }, ct);
+ ///
+ /// Replace a row. REPLACE, not merge — live-verified 2026-09-16: a PUT
+ /// carrying one key left the row holding only that key, dropping five others
+ /// (orphaned values included). Callers must send every key they mean to keep.
+ /// Required columns are enforced here too, so a partial write of a schema'd
+ /// list answers 422 even when the value was already stored.
+ ///
+ /// Throws on that 422, same as
+ /// .
+ ///
+ public async Task UpdateListRowAsync(
+ string listId, string rowId, Dictionary rowData, CancellationToken ct = default)
+ {
+ using var resp = await SendAsync(HttpMethod.Put, $"api/lists/{listId}/data/{rowId}", new { data = rowData }, ct);
+ await EnsureRowWriteSucceededAsync(resp, ct);
+ }
+
+ ///
+ /// Row writes bypass the shared EnsureSuccessAsync because it keeps only
+ /// status + the top-level message and would drop a 422's details array.
+ ///
+ private static async Task EnsureRowWriteSucceededAsync(HttpResponseMessage resp, CancellationToken ct)
+ {
+ if (resp.IsSuccessStatusCode) return;
+ throw await ListRowValidationException.FromResponseAsync(resp, ct);
+ }
public Task DeleteListRowAsync(string listId, string rowId, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/data/{rowId}", null, ct);
diff --git a/InterlinedList/Services/ListRowValidationException.cs b/InterlinedList/Services/ListRowValidationException.cs
new file mode 100644
index 0000000..d5ceca6
--- /dev/null
+++ b/InterlinedList/Services/ListRowValidationException.cs
@@ -0,0 +1,90 @@
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// A refused row write, carrying the per-field details the shared
+/// EnsureSuccessAsync throws away (it keeps only status + the top-level
+/// error string, which for these is always the useless "Validation
+/// failed"). The typed row editor needs the details to put a message on the
+/// control that caused it.
+///
+/// Live-verified 2026-09-16 on a throwaway list: POST and PUT
+/// /api/lists/{id}/data both answer 422 with
+/// details:[{field,message}] — field is the column key, the
+/// message is phrased with its label — for a missing required value, a
+/// malformed email/url/date, a number outside validation.min /max ,
+/// a non-boolean in a boolean column, a select value outside its options, and a
+/// multiselect that isn't an array. Several details can arrive at once.
+///
+/// Not an because that type is sealed; only
+/// the two row-write methods in InterlinedApiClient.Lists.cs throw this,
+/// and their XML docs say so.
+///
+public sealed class ListRowValidationException : Exception
+{
+ public int StatusCode { get; }
+
+ /// The server's code ("validation_failed"), when present.
+ public string? Code { get; }
+
+ /// One entry per offending column; empty when the body named none.
+ public IReadOnlyList Details { get; }
+
+ public ListRowValidationException(
+ int statusCode, string message, string? code = null,
+ IReadOnlyList? details = null)
+ : base(message)
+ {
+ StatusCode = statusCode;
+ Code = code;
+ Details = details ?? [];
+ }
+
+ ///
+ /// Build from a non-success row-write response. Mirrors
+ /// EnsureSuccessAsync 's "prefer the JSON error property" behaviour and
+ /// then adds the details array.
+ ///
+ public static async Task FromResponseAsync(
+ HttpResponseMessage resp, CancellationToken ct)
+ {
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ var message = body;
+ string? code = null;
+ var details = new List();
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ if (doc.RootElement.TryGetProperty("error", out var error) && error.GetString() is { Length: > 0 } text)
+ message = text;
+ if (doc.RootElement.TryGetProperty("code", out var codeProp))
+ code = codeProp.GetString();
+ if (doc.RootElement.TryGetProperty("details", out var arr) && arr.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in arr.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.Object) continue;
+ var field = item.TryGetProperty("field", out var f) ? f.GetString() : null;
+ var detail = item.TryGetProperty("message", out var m) ? m.GetString() : null;
+ if (field is { Length: > 0 } && detail is { Length: > 0 })
+ details.Add(new ListRowFieldError(field, detail));
+ }
+ }
+ }
+ catch (JsonException)
+ {
+ // Body wasn't JSON — surface the raw text, same as EnsureSuccessAsync.
+ }
+
+ // With details present the top-level "Validation failed" says nothing the
+ // per-field messages don't say better, so lead with those.
+ if (details.Count > 0)
+ message = string.Join(" ", details.Select(d => d.Message));
+
+ return new ListRowValidationException((int)resp.StatusCode, message, code, details);
+ }
+}
diff --git a/InterlinedList/ViewModels/ListRowEditorViewModel.cs b/InterlinedList/ViewModels/ListRowEditorViewModel.cs
new file mode 100644
index 0000000..0a90ad3
--- /dev/null
+++ b/InterlinedList/ViewModels/ListRowEditorViewModel.cs
@@ -0,0 +1,264 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The typed row form — one control per column, generated from the list's live
+/// schema, replacing the raw-JSON box for schema'd lists (the JSON escape hatch
+/// stays for schema-less ones, which the API still fully supports).
+///
+/// Three things it has to get right, all live-verified 2026-09-16:
+///
+/// - Row data is keyed by each column's
key , never its label.
+/// - A row write REPLACES
rowData , so the form re-sends every key
+/// it knows about — including keys with no column at all, which are kept
+/// verbatim (and are exactly what a destructive rebuild leaves behind).
+/// - A refused write is
422 with details:[{field,message}] ,
+/// which unpacks so each message
+/// lands on the control that caused it.
+///
+///
+public partial class ListRowEditorViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ /// Raised after a successful write so the host can re-read the rows.
+ public event EventHandler? Saved;
+
+ public ObservableCollection Fields { get; } = new();
+
+ /// Stored keys with no column — carried through a save untouched.
+ private Dictionary _keysWithoutColumns = new(StringComparer.Ordinal);
+
+ private ListDataRow? _row;
+
+ [ObservableProperty]
+ private bool isOpen;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HeaderText))]
+ [NotifyPropertyChangedFor(nameof(SaveButtonText))]
+ private bool isEditing;
+
+ [ObservableProperty]
+ private bool isBusy;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ [ObservableProperty]
+ private string? statusMessage;
+
+ /// Columns saved with visible: false are folded away until asked for.
+ [ObservableProperty]
+ private bool showHiddenColumns;
+
+ /// Set while editing a row that holds values no column covers.
+ [ObservableProperty]
+ private string? orphanNote;
+
+ public string? ListId { get; private set; }
+
+ public string HeaderText => IsEditing ? "Edit row" : "New row";
+
+ public string SaveButtonText => IsEditing ? "Save row" : "Add row";
+
+ public bool HasHiddenColumns => Fields.Any(f => f.IsHiddenColumn);
+
+ public ListRowEditorViewModel(SessionService session)
+ {
+ _session = session;
+ }
+
+ /// Open a blank form, pre-filled from each column's defaultValue .
+ public void StartAdd(string listId, ListSchema schema)
+ {
+ Build(listId, schema);
+ _row = null;
+ IsEditing = false;
+
+ foreach (var field in Fields)
+ field.LoadDefault();
+
+ RefreshVisibility();
+ IsOpen = true;
+ }
+
+ /// Open the form on an existing row.
+ public void StartEdit(string listId, ListSchema schema, ListDataRow row)
+ {
+ Build(listId, schema);
+ _row = row;
+ IsEditing = true;
+
+ foreach (var field in Fields)
+ field.LoadStored(row.RowData.TryGetValue(field.Key, out var value) ? value : null);
+
+ _keysWithoutColumns = row.RowData
+ .Where(kv => schema.FindField(kv.Key) is null)
+ .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal);
+
+ OrphanNote = _keysWithoutColumns.Count == 0
+ ? null
+ : $"This row also holds {string.Join(", ", _keysWithoutColumns.Keys)} — no column covers "
+ + "those any more, so they aren't shown. Saving keeps them as they are.";
+
+ RefreshVisibility();
+ IsOpen = true;
+ }
+
+ private void Build(string listId, ListSchema schema)
+ {
+ foreach (var field in Fields)
+ field.ValueChanged -= OnFieldValueChanged;
+ Fields.Clear();
+
+ ListId = listId;
+ ErrorMessage = null;
+ StatusMessage = null;
+ OrphanNote = null;
+ _keysWithoutColumns = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var field in schema.FieldsInDisplayOrder)
+ {
+ var vm = new ListRowFieldViewModel(field) { ShowHiddenColumns = ShowHiddenColumns };
+ vm.ValueChanged += OnFieldValueChanged;
+ Fields.Add(vm);
+ }
+
+ OnPropertyChanged(nameof(HasHiddenColumns));
+ }
+
+ private void OnFieldValueChanged(object? sender, EventArgs e) => RefreshVisibility();
+
+ ///
+ /// Re-run every conditional-visibility rule against the values held right
+ /// now. The server never evaluates these, so without this pass a conditional
+ /// column would simply always show.
+ ///
+ private void RefreshVisibility()
+ {
+ foreach (var field in Fields)
+ field.MatchesCondition = ListFieldConditionEvaluator.IsShown(field.Field, ValueOf);
+ }
+
+ private object? ValueOf(string key)
+ {
+ var field = Fields.FirstOrDefault(f => string.Equals(f.Key, key, StringComparison.Ordinal));
+ if (field is not null) return field.CurrentValue();
+ return _keysWithoutColumns.TryGetValue(key, out var stored) ? stored : null;
+ }
+
+ /// Close the form without writing (also used by the host when the selection changes).
+ [RelayCommand]
+ public void Cancel()
+ {
+ IsOpen = false;
+ ErrorMessage = null;
+ StatusMessage = null;
+ }
+
+ private bool CanSave() => !IsBusy && ListId is { Length: > 0 };
+
+ [RelayCommand(CanExecute = nameof(CanSave))]
+ private async Task SaveAsync()
+ {
+ if (ListId is not { Length: > 0 } listId) return;
+
+ // Start from the keys no column covers so a save never drops them (the
+ // write replaces rowData wholesale).
+ var data = new Dictionary(StringComparer.Ordinal);
+ foreach (var (key, value) in _keysWithoutColumns)
+ data[key] = value;
+
+ var blocked = false;
+ foreach (var field in Fields)
+ {
+ if (field.TryBuildValue(out var value, out var error))
+ {
+ field.ErrorMessage = null;
+
+ // Don't invent keys: a blank optional column the row never had
+ // stays out of rowData. Clearing a stored value still sends null.
+ if (value is not null || field.HasStoredValue)
+ data[field.Key] = value;
+ }
+ else
+ {
+ field.ErrorMessage = error;
+ blocked = true;
+ }
+ }
+
+ if (blocked)
+ {
+ ErrorMessage = "Fix the fields marked below.";
+ return;
+ }
+
+ IsBusy = true;
+ SaveCommand.NotifyCanExecuteChanged();
+ try
+ {
+ if (IsEditing && _row is { } row)
+ await _session.Api.UpdateListRowAsync(listId, row.Id, data);
+ else
+ await _session.Api.AddListRowAsync(listId, data);
+
+ ErrorMessage = null;
+ StatusMessage = null;
+ IsOpen = false;
+ Saved?.Invoke(this, EventArgs.Empty);
+ }
+ catch (ListRowValidationException ex)
+ {
+ AttachDetails(ex);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ SaveCommand.NotifyCanExecuteChanged();
+ }
+ }
+
+ /// Put each 422 detail on the control whose key it names.
+ private void AttachDetails(ListRowValidationException ex)
+ {
+ var unattributed = new List();
+
+ foreach (var detail in ex.Details)
+ {
+ var field = Fields.FirstOrDefault(f => string.Equals(f.Key, detail.Field, StringComparison.Ordinal));
+ if (field is not null)
+ {
+ field.ErrorMessage = detail.Message;
+ // A rule can fire on a column the user can't see; show it rather
+ // than leaving a message nobody can reach.
+ if (!field.IsShown) unattributed.Add($"{field.Label}: {detail.Message}");
+ }
+ else
+ {
+ unattributed.Add($"{detail.Field}: {detail.Message}");
+ }
+ }
+
+ ErrorMessage = unattributed.Count > 0
+ ? string.Join(" ", unattributed)
+ : ex.Details.Count > 0 ? "Fix the fields marked below." : ex.Message;
+ }
+
+ partial void OnShowHiddenColumnsChanged(bool value)
+ {
+ foreach (var field in Fields)
+ field.ShowHiddenColumns = value;
+ }
+}
diff --git a/InterlinedList/ViewModels/ListRowFieldViewModel.cs b/InterlinedList/ViewModels/ListRowFieldViewModel.cs
new file mode 100644
index 0000000..9f183fa
--- /dev/null
+++ b/InterlinedList/ViewModels/ListRowFieldViewModel.cs
@@ -0,0 +1,455 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using CommunityToolkit.Mvvm.ComponentModel;
+using InterlinedList.Models;
+
+namespace InterlinedList.ViewModels;
+
+/// The control shape a column's editor uses; drives template selection in XAML.
+public static class ListRowFieldKind
+{
+ public const string Text = "Text";
+ public const string LongText = "LongText";
+ public const string Number = "Number";
+ public const string Boolean = "Boolean";
+ public const string Date = "Date";
+ public const string DateTime = "DateTime";
+ public const string Select = "Select";
+ public const string MultiSelect = "MultiSelect";
+
+ public static string For(string? type) => type switch
+ {
+ ListFieldType.TextArea => LongText,
+ ListFieldType.Number => Number,
+ ListFieldType.Boolean => Boolean,
+ ListFieldType.Date => Date,
+ ListFieldType.DateTime => DateTime,
+ ListFieldType.Select or ListFieldType.Priority => Select,
+ ListFieldType.MultiSelect => MultiSelect,
+ // text / email / url / tel all edit as one line; their validation differs.
+ _ => Text
+ };
+}
+
+/// One checkbox in a multiselect.
+public partial class ListRowOptionViewModel : ObservableObject
+{
+ public required string Value { get; init; }
+
+ [ObservableProperty]
+ private bool isSelected;
+}
+
+///
+/// One column's control in the typed row form. Holds the half-typed editor state
+/// a wire type can't (a number mid-keystroke, a date with no time yet) and turns
+/// it back into the exact JSON the row endpoints accept.
+///
+/// Two behaviours here are load-bearing, both live-verified 2026-09-16:
+///
+/// Untouched values are echoed verbatim. A row write REPLACES
+/// rowData wholesale, so every key has to be re-sent; re-sending the
+/// stored rather than a re-rendered one keeps values
+/// the editor can't fully model (a naive datetime, a number stored as a string,
+/// a nested object) exactly as they were.
+/// Types are enforced on write. "true" in a boolean column
+/// is 422 Done must be true or false , a multiselect must be a real JSON
+/// array, and a date must be YYYY-MM-DD — so this emits real booleans,
+/// real arrays and ISO strings, never their text renderings. (A number is the
+/// exception the server is loose about — it accepts "7" and then skips
+/// its own min/max check — which is exactly why this sends a number.)
+///
+///
+public partial class ListRowFieldViewModel : ObservableObject
+{
+ public const string BoolTrue = "True";
+ public const string BoolFalse = "False";
+ public const string BoolNeither = "Neither";
+
+ private static readonly Regex EmailPattern =
+ new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled);
+
+ private bool _loading;
+ private JsonElement? _original;
+
+ public ListField Field { get; }
+
+ public string Key => Field.Key;
+ public string Label => Field.Label;
+ public string Kind { get; }
+ public bool IsRequired => Field.IsRequired;
+ public string? HelpText => Field.HelpText;
+ public string Placeholder => Field.Placeholder ?? string.Empty;
+ public bool IsHiddenColumn => !Field.IsVisible;
+ public string TypeLabel => Field.TypeDisplayName;
+
+ /// Options for a single-select, with a blank first entry when the column is optional.
+ public IReadOnlyList SelectOptions { get; }
+
+ /// Checkboxes for a multiselect.
+ public ObservableCollection MultiOptions { get; } = new();
+
+ ///
+ /// True/False, plus Neither (a stored null ) when the column is
+ /// optional — the tri-state #21 calls for.
+ ///
+ public IReadOnlyList BoolOptions { get; }
+
+ /// The validation.step hint; the server never enforces it.
+ public string? StepHint => Field.Validation?.Step is { } step
+ ? $"steps of {step.ToString(CultureInfo.InvariantCulture)}"
+ : null;
+
+ [ObservableProperty]
+ private string textValue = "";
+
+ [ObservableProperty]
+ private string boolSelection = BoolNeither;
+
+ [ObservableProperty]
+ private string? selectedOption;
+
+ [ObservableProperty]
+ private DateTime? dateValue;
+
+ /// HH:mm (or HH:mm:ss) half of a datetime column.
+ [ObservableProperty]
+ private string timeValue = "";
+
+ /// Message from a 422 detail, or from the local pre-flight check.
+ [ObservableProperty]
+ private string? errorMessage;
+
+ /// False while a conditional-visibility rule says to hide this column.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsShown))]
+ private bool matchesCondition = true;
+
+ /// Set by the editor's "show hidden columns" toggle.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsShown))]
+ private bool showHiddenColumns;
+
+ public bool IsShown => MatchesCondition && (!IsHiddenColumn || ShowHiddenColumns);
+
+ /// True once the user has changed this control (see the verbatim-echo note).
+ public bool IsDirty { get; private set; }
+
+ ///
+ /// True when the row being edited actually had a value under this key — so a
+ /// blank optional column on a NEW row can be left out of the write entirely
+ /// instead of padding rowData with nulls, while clearing a value that WAS
+ /// there still sends an explicit null to erase it.
+ ///
+ public bool HasStoredValue => _original is not null;
+
+ public ListRowFieldViewModel(ListField field)
+ {
+ Field = field;
+ Kind = ListRowFieldKind.For(field.Type);
+
+ BoolOptions = field.IsRequired
+ ? [BoolTrue, BoolFalse]
+ : [BoolTrue, BoolFalse, BoolNeither];
+
+ var options = field.EffectiveOptions;
+ SelectOptions = field.IsRequired ? options : ["", .. options];
+
+ foreach (var option in options)
+ {
+ var item = new ListRowOptionViewModel { Value = option };
+ item.PropertyChanged += OnOptionChanged;
+ MultiOptions.Add(item);
+ }
+ }
+
+ private void OnOptionChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (_loading) return;
+ IsDirty = true;
+ ValueChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// Raised whenever the held value changes, so the editor can re-run visibility.
+ public event EventHandler? ValueChanged;
+
+ ///
+ /// Fill the controls for a NEW row: from defaultValue , which is why
+ /// the DSL's decoded default matters (a number column pre-fills with 3, not "3").
+ ///
+ public void LoadDefault()
+ {
+ _original = null;
+ Load(DefaultAsText(), Field.DefaultValue);
+ IsDirty = false;
+ }
+
+ /// Fill the controls from a stored row value (or blank when the row has no such key).
+ public void LoadStored(JsonElement? stored)
+ {
+ _original = stored;
+ Load(stored is { } element ? ListFieldConditionEvaluator.AsText(element) : string.Empty, stored);
+ IsDirty = false;
+ }
+
+ private void Load(string text, object? raw)
+ {
+ _loading = true;
+ try
+ {
+ ErrorMessage = null;
+ TextValue = text;
+
+ switch (Kind)
+ {
+ case ListRowFieldKind.Boolean:
+ BoolSelection = text switch
+ {
+ "true" => BoolTrue,
+ "false" => BoolFalse,
+ _ => BoolOptions.Contains(BoolNeither) ? BoolNeither : BoolFalse
+ };
+ break;
+
+ case ListRowFieldKind.Select:
+ SelectedOption = SelectOptions.Contains(text, StringComparer.Ordinal)
+ ? text
+ : text.Length > 0 ? text : SelectOptions.FirstOrDefault();
+ break;
+
+ case ListRowFieldKind.MultiSelect:
+ var selected = SelectedFrom(raw, text);
+ foreach (var option in MultiOptions)
+ option.IsSelected = selected.Contains(option.Value, StringComparer.Ordinal);
+ break;
+
+ case ListRowFieldKind.Date:
+ DateValue = System.DateTime.TryParse(text, CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var date) ? date.Date : null;
+ break;
+
+ case ListRowFieldKind.DateTime:
+ if (System.DateTime.TryParse(text, CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind, out var stamp))
+ {
+ DateValue = stamp.Date;
+ TimeValue = stamp.ToString("HH:mm", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ DateValue = null;
+ TimeValue = "";
+ }
+ break;
+ }
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
+
+ private static List SelectedFrom(object? raw, string text)
+ {
+ if (raw is JsonElement { ValueKind: JsonValueKind.Array } array)
+ return array.EnumerateArray().Select(e => ListFieldConditionEvaluator.AsText(e)).ToList();
+
+ return text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
+ }
+
+ private string DefaultAsText() => ListFieldDefaults.FromDecoded(Field.DefaultValue);
+
+ /// The value as the condition evaluator should see it right now.
+ public object? CurrentValue()
+ {
+ if (!TryBuildValue(out var value, out _)) return null;
+ return value;
+ }
+
+ ///
+ /// Turn the controls back into the JSON the row endpoints want. Returns false
+ /// with a message for anything catchable client-side (a required blank, an
+ /// unparseable number, a malformed email/url, a time that isn't a time,
+ /// a validation rule the column declares) so the round trip is skipped.
+ ///
+ public bool TryBuildValue(out object? value, out string? error)
+ {
+ error = null;
+
+ // Untouched: echo exactly what the server gave us.
+ if (!IsDirty && _original is { } original)
+ {
+ value = original;
+ return true;
+ }
+
+ var text = TextValue?.Trim() ?? string.Empty;
+
+ switch (Kind)
+ {
+ case ListRowFieldKind.Boolean:
+ value = BoolSelection switch
+ {
+ BoolTrue => true,
+ BoolFalse => false,
+ _ => null
+ };
+ break;
+
+ case ListRowFieldKind.Number:
+ if (text.Length == 0) { value = null; break; }
+ if (!ListFieldDefaults.TryParseNumber(text, out var number))
+ {
+ error = $"{Label} must be a valid number.";
+ value = null;
+ return false;
+ }
+ value = number;
+ break;
+
+ case ListRowFieldKind.Date:
+ value = DateValue?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ break;
+
+ case ListRowFieldKind.DateTime:
+ if (DateValue is not { } day) { value = null; break; }
+ if (!TryParseTime(TimeValue, out var time))
+ {
+ error = $"{Label} needs a time as HH:MM.";
+ value = null;
+ return false;
+ }
+ value = day.Date.Add(time).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
+ break;
+
+ case ListRowFieldKind.Select:
+ value = string.IsNullOrWhiteSpace(SelectedOption) ? null : SelectedOption;
+ break;
+
+ case ListRowFieldKind.MultiSelect:
+ // Must be a real array — a comma string answers 422 "must be an array".
+ value = MultiOptions.Where(o => o.IsSelected).Select(o => o.Value).ToList();
+ break;
+
+ default:
+ value = text.Length == 0 ? null : TextValue;
+ break;
+ }
+
+ if (!ValidateValue(value, ref error))
+ return false;
+
+ return true;
+ }
+
+ private bool ValidateValue(object? value, ref string? error)
+ {
+ var isEmpty = ListFieldConditionEvaluator.IsEmpty(value);
+
+ if (IsRequired && isEmpty)
+ {
+ error = $"{Label} is required.";
+ return false;
+ }
+
+ if (isEmpty) return true;
+
+ var text = ListFieldConditionEvaluator.AsText(value);
+
+ switch (Field.Type)
+ {
+ case ListFieldType.Email when !EmailPattern.IsMatch(text):
+ error = $"{Label} must be a valid email address.";
+ return false;
+
+ case ListFieldType.Url when !Uri.TryCreate(text, UriKind.Absolute, out var uri)
+ || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps):
+ error = $"{Label} must be a valid URL (http or https).";
+ return false;
+ }
+
+ // The column's own rules. The server enforces these too (422), but
+ // catching them here keeps the message next to the control.
+ if (Field.Validation is { } rules)
+ {
+ if (value is double number)
+ {
+ if (rules.Min is { } min && ListFieldDefaults.TryParseNumber(
+ ListFieldConditionEvaluator.AsText(min), out var minimum) && number < minimum)
+ {
+ error = $"{Label} must be at least {ListFieldConditionEvaluator.AsText(min)}.";
+ return false;
+ }
+ if (rules.Max is { } max && ListFieldDefaults.TryParseNumber(
+ ListFieldConditionEvaluator.AsText(max), out var maximum) && number > maximum)
+ {
+ error = $"{Label} must be at most {ListFieldConditionEvaluator.AsText(max)}.";
+ return false;
+ }
+ }
+
+ if (value is string s)
+ {
+ if (rules.MinLength is { } minLength && s.Length < minLength)
+ {
+ error = $"{Label} must be at least {minLength} characters.";
+ return false;
+ }
+ if (rules.MaxLength is { } maxLength && s.Length > maxLength)
+ {
+ error = $"{Label} must be at most {maxLength} characters.";
+ return false;
+ }
+ if (rules.Pattern is { Length: > 0 } pattern && !MatchesPattern(s, pattern))
+ {
+ error = $"{Label} format is invalid.";
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static bool MatchesPattern(string value, string pattern)
+ {
+ try
+ {
+ return Regex.IsMatch(value, pattern);
+ }
+ catch (ArgumentException)
+ {
+ // A pattern this client can't compile isn't the user's problem —
+ // let the server have the last word on it.
+ return true;
+ }
+ }
+
+ private static bool TryParseTime(string? text, out TimeSpan time)
+ {
+ time = TimeSpan.Zero;
+ var trimmed = (text ?? string.Empty).Trim();
+ if (trimmed.Length == 0) return true; // midnight
+
+ return TimeSpan.TryParseExact(trimmed, ["hh\\:mm", "h\\:mm", "hh\\:mm\\:ss"],
+ CultureInfo.InvariantCulture, out time)
+ || TimeSpan.TryParse(trimmed, CultureInfo.InvariantCulture, out time);
+ }
+
+ partial void OnTextValueChanged(string value) => MarkDirty();
+ partial void OnBoolSelectionChanged(string value) => MarkDirty();
+ partial void OnSelectedOptionChanged(string? value) => MarkDirty();
+ partial void OnDateValueChanged(DateTime? value) => MarkDirty();
+ partial void OnTimeValueChanged(string value) => MarkDirty();
+
+ private void MarkDirty()
+ {
+ if (_loading) return;
+ IsDirty = true;
+ ErrorMessage = null;
+ ValueChanged?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/InterlinedList/ViewModels/ListRowViewModel.cs b/InterlinedList/ViewModels/ListRowViewModel.cs
new file mode 100644
index 0000000..9d98825
--- /dev/null
+++ b/InterlinedList/ViewModels/ListRowViewModel.cs
@@ -0,0 +1,136 @@
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.ViewModels;
+
+/// How a cell renders in the row grid.
+public static class ListRowCellKind
+{
+ public const string Text = "Text";
+ public const string Boolean = "Boolean";
+ public const string Chips = "Chips";
+}
+
+/// One column's value on one row, already shaped for display.
+public sealed class ListRowCellViewModel
+{
+ public required string Label { get; init; }
+ public required string Kind { get; init; }
+
+ /// Rendered value, or an em dash when the row has no value for the column.
+ public required string Text { get; init; }
+
+ public bool Flag { get; init; }
+
+ /// False when the row simply has no value here (a null or a missing key).
+ public bool HasValue { get; init; }
+
+ public IReadOnlyList Chips { get; init; } = [];
+}
+
+///
+/// A row as the grid shows it: one typed cell per visible column instead of the
+/// JSON blob the view used to print. Keys the schema doesn't cover are summarised
+/// separately rather than hidden, because a destructive column rebuild leaves
+/// exactly those behind (values stay in rowData , no longer shown or
+/// validated — live-verified 2026-09-16) and pretending they're gone is how data
+/// gets lost quietly.
+///
+public sealed class ListRowViewModel
+{
+ private const string Empty = "—";
+
+ public ListDataRow Row { get; }
+ public string Id => Row.Id;
+ public bool HasSchema { get; }
+ public IReadOnlyList Cells { get; }
+
+ /// The old freeform "key: value, …" line — still the right thing for a schema-less list.
+ public string RawSummary { get; }
+
+ public string? OrphanSummary { get; }
+
+ private ListRowViewModel(ListDataRow row, bool hasSchema,
+ IReadOnlyList cells,
+ string rawSummary, string? orphanSummary)
+ {
+ Row = row;
+ HasSchema = hasSchema;
+ Cells = cells;
+ RawSummary = rawSummary;
+ OrphanSummary = orphanSummary;
+ }
+
+ public static ListRowViewModel Create(ListDataRow row, ListSchema? schema)
+ {
+ var rawSummary = string.Join(", ", row.RowData.Select(kv =>
+ $"{kv.Key}: {ListFieldConditionEvaluator.AsText(kv.Value)}"));
+
+ if (schema is null || !schema.HasFields)
+ return new ListRowViewModel(row, false, [], rawSummary, null);
+
+ var cells = new List();
+ foreach (var field in schema.FieldsInDisplayOrder)
+ {
+ if (!field.IsVisible) continue;
+
+ // Conditional columns are evaluated per row — the server stores the
+ // rule but never applies it.
+ if (!ListFieldConditionEvaluator.IsShown(field,
+ key => row.RowData.TryGetValue(key, out var watched) ? watched : null))
+ continue;
+
+ cells.Add(BuildCell(field, row.RowData.TryGetValue(field.Key, out var value) ? value : null));
+ }
+
+ var orphans = row.RowData.Keys.Where(k => schema.FindField(k) is null).ToList();
+ var orphanSummary = orphans.Count == 0
+ ? null
+ : $"No column for: {string.Join(", ", orphans)}";
+
+ return new ListRowViewModel(row, true, cells, rawSummary, orphanSummary);
+ }
+
+ private static ListRowCellViewModel BuildCell(ListField field, JsonElement? value)
+ {
+ var hasValue = value is { } element
+ && element.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined);
+
+ if (field.Type == ListFieldType.Boolean)
+ {
+ var flag = hasValue && value!.Value.ValueKind == JsonValueKind.True;
+ return new ListRowCellViewModel
+ {
+ Label = field.Label,
+ Kind = ListRowCellKind.Boolean,
+ Text = hasValue ? (flag ? "true" : "false") : Empty,
+ Flag = flag,
+ HasValue = hasValue
+ };
+ }
+
+ if (field.Type == ListFieldType.MultiSelect)
+ {
+ var chips = hasValue && value!.Value.ValueKind == JsonValueKind.Array
+ ? value.Value.EnumerateArray().Select(e => ListFieldConditionEvaluator.AsText(e)).ToList()
+ : [];
+ return new ListRowCellViewModel
+ {
+ Label = field.Label,
+ Kind = ListRowCellKind.Chips,
+ Text = chips.Count > 0 ? string.Join(", ", chips) : Empty,
+ HasValue = chips.Count > 0,
+ Chips = chips
+ };
+ }
+
+ var text = hasValue ? ListFieldConditionEvaluator.AsText(value!.Value) : Empty;
+ return new ListRowCellViewModel
+ {
+ Label = field.Label,
+ Kind = ListRowCellKind.Text,
+ Text = text.Length == 0 ? Empty : text,
+ HasValue = hasValue && text.Length > 0
+ };
+ }
+}
diff --git a/InterlinedList/ViewModels/ListsViewModel.cs b/InterlinedList/ViewModels/ListsViewModel.cs
index cea0385..42cc6dd 100644
--- a/InterlinedList/ViewModels/ListsViewModel.cs
+++ b/InterlinedList/ViewModels/ListsViewModel.cs
@@ -14,7 +14,7 @@ public partial class ListsViewModel : ObservableObject
private readonly SessionService _session;
public ObservableCollection Lists { get; } = new();
- public ObservableCollection Rows { get; } = new();
+ public ObservableCollection Rows { get; } = new();
public ObservableCollection SharedWithMe { get; } = new();
public ObservableCollection ShareLinks { get; } = new();
public ObservableCollection Watchers { get; } = new();
@@ -52,7 +52,7 @@ public partial class ListsViewModel : ObservableObject
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsEditingRow))]
- private ListDataRow? editingRow;
+ private ListRowViewModel? editingRow;
[ObservableProperty]
private string editRowJson = "";
@@ -60,8 +60,39 @@ public partial class ListsViewModel : ObservableObject
[ObservableProperty]
private string watcherSearchQuery = "";
+ /// The selected list's columns, or null while it has none.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasSchema))]
+ [NotifyPropertyChangedFor(nameof(ShowTypedRowEditor))]
+ [NotifyPropertyChangedFor(nameof(ShowRawJsonRowEditor))]
+ [NotifyPropertyChangedFor(nameof(RowEditorModeNote))]
+ private ListSchema? selectedSchema;
+
+ ///
+ /// The raw-JSON escape hatch. It is the ONLY editor for a schema-less list
+ /// (still fully supported: such a list answers GET …/schema with
+ /// fields: [] and accepts arbitrary, even nested, keys — verified live
+ /// 2026-09-16) and stays available on a schema'd one for orphaned keys and
+ /// anything the typed form can't express.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowTypedRowEditor))]
+ [NotifyPropertyChangedFor(nameof(ShowRawJsonRowEditor))]
+ [NotifyPropertyChangedFor(nameof(RowEditorModeNote))]
+ private bool useRawJsonRowEditor;
+
public bool IsEditingRow => EditingRow is not null;
+ public bool HasSchema => SelectedSchema is { HasFields: true };
+
+ public bool ShowTypedRowEditor => HasSchema && !UseRawJsonRowEditor;
+
+ public bool ShowRawJsonRowEditor => !ShowTypedRowEditor;
+
+ public string RowEditorModeNote => HasSchema
+ ? "This list has columns, so rows are edited through typed fields."
+ : "This list has no columns — rows are freeform JSON. Add columns to get a typed form.";
+
private static readonly JsonSerializerOptions RowEditJsonOptions = new() { WriteIndented = true };
///
@@ -72,21 +103,43 @@ public partial class ListsViewModel : ObservableObject
///
public ListColumnEditorViewModel ColumnEditor { get; }
+ /// The typed row form (#21), generated from .
+ public ListRowEditorViewModel RowEditor { get; }
+
public ListsViewModel(SessionService session)
{
_session = session;
ColumnEditor = new ListColumnEditorViewModel(session);
ColumnEditor.Saved += OnColumnsSaved;
+ RowEditor = new ListRowEditorViewModel(session);
+ RowEditor.Saved += OnRowSaved;
}
///
/// A column save can rename the list (only on the destructive rebuild, but
- /// the editor owns that decision), so re-read the browser afterwards.
+ /// the editor owns that decision) and always changes the shape of the row
+ /// form, so re-read the browser and the schema afterwards.
///
private void OnColumnsSaved(object? sender, EventArgs e)
{
if (ColumnEditor.IsNewListMode) return;
- _ = LoadListsAsync();
+ _ = ReloadAfterColumnsSavedAsync();
+ }
+
+ private async Task ReloadAfterColumnsSavedAsync()
+ {
+ await LoadListsAsync();
+ if (SelectedList is { } list && !IsViewingShared)
+ {
+ await LoadSchemaAsync(list.Id);
+ await LoadRowsAsync(list.Id);
+ }
+ }
+
+ private void OnRowSaved(object? sender, EventArgs e)
+ {
+ var listId = IsViewingShared ? SelectedSharedList?.Id : SelectedList?.Id;
+ if (listId is { Length: > 0 } id) _ = LoadRowsAsync(id);
}
[RelayCommand]
@@ -209,6 +262,10 @@ private async Task SelectListAsync(ListSummary list)
SelectedList = list;
WatcherSearchQuery = "";
WatcherSearchResults.Clear();
+ RowEditor.Cancel();
+ EditingRow = null;
+ EditRowJson = "";
+ await LoadSchemaAsync(list.Id);
await LoadRowsAsync(list.Id);
await LoadShareLinksAsync(list.Id);
await LoadWatchersAsync(list.Id);
@@ -226,6 +283,8 @@ private async Task SelectSharedListAsync(WatchedList watched)
Watchers.Clear();
WatcherSearchResults.Clear();
WatcherSearchQuery = "";
+ RowEditor.Cancel();
+ await LoadSchemaAsync(watched.Id);
await LoadRowsAsync(watched.Id);
}
@@ -359,16 +418,42 @@ private async Task RemoveWatcherAsync(Collaborator watcher)
}
}
+ ///
+ /// Read the list's columns, which is what the typed row form is generated
+ /// from. A list with none answers fields: [] — a normal state, not an
+ /// error — and the view falls back to the raw-JSON editor.
+ ///
+ private async Task LoadSchemaAsync(string listId)
+ {
+ try
+ {
+ var schema = await _session.Api.GetListSchemaAsync(listId);
+ SelectedSchema = schema.HasFields ? schema : null;
+ }
+ catch (ListSchemaException)
+ {
+ SelectedSchema = null;
+ }
+ catch (InterlinedApiException)
+ {
+ // A shared list may not expose its schema to a watcher; freeform is
+ // the honest fallback rather than an error banner over the rows.
+ SelectedSchema = null;
+ }
+ }
+
private async Task LoadRowsAsync(string listId)
{
IsLoadingRows = true;
try
{
- var page = await _session.Api.GetListDataAsync(listId, limit: PageSize, offset: 0);
+ // GetListRowsAsync, not GetListDataAsync: the endpoint omits each
+ // row's listId, which the strict model still requires (#144/PR #146).
+ var rows = await _session.Api.GetListRowsAsync(listId, limit: PageSize, offset: 0);
Rows.Clear();
- foreach (var row in page.Rows)
- Rows.Add(row);
+ foreach (var row in rows)
+ Rows.Add(ListRowViewModel.Create(row, SelectedSchema));
ErrorMessage = null;
}
@@ -384,6 +469,7 @@ private async Task LoadRowsAsync(string listId)
private bool CanAddRow() => SelectedList is not null && !string.IsNullOrWhiteSpace(NewRowJson);
+ /// Raw-JSON add — the schema-less path, and the escape hatch.
[RelayCommand(CanExecute = nameof(CanAddRow))]
private async Task AddRowAsync()
{
@@ -408,18 +494,49 @@ private async Task AddRowAsync()
RowErrorMessage = null;
await LoadRowsAsync(list.Id);
}
+ catch (ListRowValidationException ex)
+ {
+ RowErrorMessage = ex.Message;
+ }
catch (InterlinedApiException ex)
{
RowErrorMessage = ex.Message;
}
}
+ private bool CanOpenTypedRowForm() => SelectedList is not null && HasSchema;
+
+ /// Open the typed form for a new row (defaults pre-filled).
+ [RelayCommand(CanExecute = nameof(CanOpenTypedRowForm))]
+ private void StartAddRow()
+ {
+ if (SelectedList is not { } list || SelectedSchema is not { } schema) return;
+ EditingRow = null;
+ EditRowJson = "";
+ RowErrorMessage = null;
+ RowEditor.StartAdd(list.Id, schema);
+ }
+
+ ///
+ /// Edit a row: through the typed form when the list has columns, through the
+ /// JSON box otherwise (or when the hatch is switched on).
+ ///
[RelayCommand]
- private void StartEditRow(ListDataRow row)
+ private void StartEditRow(ListRowViewModel row)
{
- EditingRow = row;
- EditRowJson = JsonSerializer.Serialize(row.RowData, RowEditJsonOptions);
RowErrorMessage = null;
+
+ if (ShowTypedRowEditor && SelectedList is { } list && SelectedSchema is { } schema)
+ {
+ EditingRow = null;
+ EditRowJson = "";
+ RowEditor.StartEdit(list.Id, schema, row.Row);
+ return;
+ }
+
+ RowEditor.Cancel();
+ EditingRow = row;
+ EditRowJson = JsonSerializer.Serialize(row.Row.RowData, RowEditJsonOptions);
}
[RelayCommand]
@@ -454,6 +571,10 @@ private async Task SaveRowEditAsync()
RowErrorMessage = null;
await LoadRowsAsync(list.Id);
}
+ catch (ListRowValidationException ex)
+ {
+ RowErrorMessage = ex.Message;
+ }
catch (InterlinedApiException ex)
{
RowErrorMessage = ex.Message;
@@ -461,12 +582,17 @@ private async Task SaveRowEditAsync()
}
[RelayCommand]
- private async Task DeleteRowAsync(ListDataRow row)
+ private async Task DeleteRowAsync(ListRowViewModel row)
{
if (SelectedList is not { } list) return;
try
{
await _session.Api.DeleteListRowAsync(list.Id, row.Id);
+ if (EditingRow == row)
+ {
+ EditingRow = null;
+ EditRowJson = "";
+ }
await LoadRowsAsync(list.Id);
}
catch (InterlinedApiException ex)
@@ -477,9 +603,12 @@ private async Task DeleteRowAsync(ListDataRow row)
partial void OnNewListTitleChanged(string value) => CreateListCommand.NotifyCanExecuteChanged();
+ partial void OnSelectedSchemaChanged(ListSchema? value) => StartAddRowCommand.NotifyCanExecuteChanged();
+
partial void OnSelectedListChanged(ListSummary? value)
{
AddRowCommand.NotifyCanExecuteChanged();
+ StartAddRowCommand.NotifyCanExecuteChanged();
CreateShareLinkCommand.NotifyCanExecuteChanged();
SearchWatcherUsersCommand.NotifyCanExecuteChanged();
AddWatcherCommand.NotifyCanExecuteChanged();
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index ef32d4a..4979c9b 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -14,6 +14,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -930,7 +1146,7 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/InterlinedList/Views/ListsViewConverters.cs b/InterlinedList/Views/ListsViewConverters.cs
index b5970fc..180c411 100644
--- a/InterlinedList/Views/ListsViewConverters.cs
+++ b/InterlinedList/Views/ListsViewConverters.cs
@@ -23,3 +23,16 @@ public object Convert(object? value, Type targetType, object? parameter, Culture
public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
+
+///
+/// Visible when the bound bool is FALSE. Used for the "—" placeholder a row grid
+/// shows where a column has no value, which is a different thing from false.
+///
+public sealed class InverseBoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is true ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}