Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions InterlinedList/Models/ListFieldConditionEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Globalization;
using System.Text.Json;

namespace InterlinedList.Models;

/// <summary>
/// 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
/// <c>visibilityCondition</c> 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
/// <see cref="bool"/>, a number, a list of strings for a multiselect, or the
/// raw <see cref="JsonElement"/> straight off a stored row — hence the
/// text/number coercion rather than a typed comparison.
/// </summary>
public static class ListFieldConditionEvaluator
{
/// <summary>
/// True when <paramref name="field"/> 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).
/// </summary>
public static bool IsShown(ListField field, Func<string, object?> 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<object?>().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<object?>().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);
}

/// <summary>Flatten any held value to the text form comparisons use.</summary>
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<object?>().Select(AsText)),
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
var other => other.ToString() ?? string.Empty
};

private static string AsTextElement(JsonElement element) => AsText(element);
}
14 changes: 14 additions & 0 deletions InterlinedList/Models/ListRowFieldError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace InterlinedList.Models;

/// <summary>
/// One entry of a row write's <c>422</c> body:
/// <c>{ "error": "Validation failed", "code": "validation_failed",
/// "details": [ { "field": "mail", "message": "Email must be a valid email address" } ] }</c>
///
/// <see cref="Field"/> is the column's <b>key</b> (so it maps straight onto a
/// form control) while <see cref="Message"/> is phrased with the column's
/// <b>label</b> — 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).
/// </summary>
public sealed record ListRowFieldError(string Field, string Message);
102 changes: 96 additions & 6 deletions InterlinedList/Services/InterlinedApiClient.Lists.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ public async Task DeleteListAsync(string listId, CancellationToken ct = default)
await EnsureSuccessAsync(resp, ct);
}

/// <summary>
/// Strict read of a list's rows. Note that the endpoint does NOT send a row's
/// <c>listId</c>, which <see cref="ListDataRow"/> currently declares
/// <c>required</c> — so this throws a <see cref="JsonException"/> until that
/// model is relaxed (#144/PR #146). Use <see cref="GetListRowsAsync"/>, which
/// maps the rows tolerantly, unless you specifically want the pagination
/// envelope.
/// </summary>
public async Task<ListDataPage> 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);
Expand All @@ -62,13 +70,73 @@ public async Task<ListDataPage> GetListDataAsync(string listId, int limit = 50,
?? throw new InterlinedApiException((int)resp.StatusCode, "GET /api/lists/{id}/data returned no body.");
}

/// <summary>
/// Create a row. The body is <c>{ data: { … } }</c> keyed by each column's
/// KEY (verified live 2026-09-16; <c>rowData</c> as the wrapper is not
/// accepted, and unknown — even nested — keys go through untouched).
///
/// Throws <see cref="ListRowValidationException"/> — not
/// <see cref="InterlinedApiException"/> — on the <c>422</c> 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).
/// </summary>
public async Task AddListRowAsync(string listId, Dictionary<string, object?> 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);
}

/// <summary>
/// Read a list's rows tolerantly. <c>GET /api/lists/{id}/data</c> omits
/// <c>listId</c> (and <c>rowNumber</c>) from every row — verified live
/// 2026-09-16 — which makes <see cref="ListDataRow"/>'s <c>required</c>
/// <see cref="ListDataRow.ListId"/> throw a <see cref="JsonException"/>
/// straight through <see cref="GetListDataAsync"/>. That model is being
/// relaxed in #144/PR #146; until then this maps the rows by hand and fills
/// <c>listId</c> in from the request, so the typed row editor has something
/// to edit either way. It also means <c>version</c> is not surfaced yet —
/// no loss today, since the server ignores a <c>version</c> sent on a row
/// write (a deliberately stale one still answered 200).
/// </summary>
public async Task<List<ListDataRow>> 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<ListDataRow>();
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<string, JsonElement>(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<ListSummary> GetListAsync(string listId, CancellationToken ct = default)
Expand All @@ -81,10 +149,32 @@ public async Task<ListSummary> 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<string, object?> rowData, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Put, $"api/lists/{listId}/data/{rowId}", new { data = rowData }, ct);
/// <summary>
/// Replace a row. <b>REPLACE, not merge</b> — 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 <c>422</c> even when the value was already stored.
///
/// Throws <see cref="ListRowValidationException"/> on that 422, same as
/// <see cref="AddListRowAsync"/>.
/// </summary>
public async Task UpdateListRowAsync(
string listId, string rowId, Dictionary<string, object?> rowData, CancellationToken ct = default)
{
using var resp = await SendAsync(HttpMethod.Put, $"api/lists/{listId}/data/{rowId}", new { data = rowData }, ct);
await EnsureRowWriteSucceededAsync(resp, ct);
}

/// <summary>
/// Row writes bypass the shared EnsureSuccessAsync because it keeps only
/// status + the top-level message and would drop a 422's <c>details</c> array.
/// </summary>
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);
Expand Down
90 changes: 90 additions & 0 deletions InterlinedList/Services/ListRowValidationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Net.Http;
using System.Text.Json;
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// A refused row write, carrying the per-field <c>details</c> the shared
/// <c>EnsureSuccessAsync</c> throws away (it keeps only status + the top-level
/// <c>error</c> 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: <c>POST</c> and <c>PUT</c>
/// <c>/api/lists/{id}/data</c> both answer <c>422</c> with
/// <c>details:[{field,message}]</c> — <c>field</c> is the column key, the
/// message is phrased with its label — for a missing required value, a
/// malformed email/url/date, a number outside <c>validation.min</c>/<c>max</c>,
/// 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 <see cref="InterlinedApiException"/> because that type is sealed; only
/// the two row-write methods in <c>InterlinedApiClient.Lists.cs</c> throw this,
/// and their XML docs say so.
/// </summary>
public sealed class ListRowValidationException : Exception
{
public int StatusCode { get; }

/// <summary>The server's <c>code</c> ("validation_failed"), when present.</summary>
public string? Code { get; }

/// <summary>One entry per offending column; empty when the body named none.</summary>
public IReadOnlyList<ListRowFieldError> Details { get; }

public ListRowValidationException(
int statusCode, string message, string? code = null,
IReadOnlyList<ListRowFieldError>? details = null)
: base(message)
{
StatusCode = statusCode;
Code = code;
Details = details ?? [];
}

/// <summary>
/// Build from a non-success row-write response. Mirrors
/// <c>EnsureSuccessAsync</c>'s "prefer the JSON error property" behaviour and
/// then adds the details array.
/// </summary>
public static async Task<ListRowValidationException> FromResponseAsync(
HttpResponseMessage resp, CancellationToken ct)
{
var body = await resp.Content.ReadAsStringAsync(ct);
var message = body;
string? code = null;
var details = new List<ListRowFieldError>();

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);
}
}
Loading
Loading