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 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + +