diff --git a/src/FluentCMS.Admin/Components/Forms/FactFields.razor b/src/FluentCMS.Admin/Components/Forms/FactFields.razor
new file mode 100644
index 000000000..81ff72ec2
--- /dev/null
+++ b/src/FluentCMS.Admin/Components/Forms/FactFields.razor
@@ -0,0 +1,75 @@
+@using FluentCMS.Admin.Forms
+@using FluentCMS.Core.Schema
+@inject IStringLocalizer L
+
+@* The fact around an entity field's value (SCH-03): its source, owner and validity, each posted under the field's name. *@
+
+ @L["Fact details for {0}", Field]
+ @if (Unsourced)
+ {
+ @L["This fact has no source, so it is flagged unsourced. Say where the value comes from."]
+ }
+
+
+
+ @L["None"]
+ @foreach (var kind in FactMetadata.SourceKinds)
+ {
+ @KindLabel(kind)
+ }
+
+
+
+
+
+
+
+
+
+ @L["None"]
+ @foreach (var owner in Owners)
+ {
+ @owner.Label
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ [Parameter, EditorRequired] public string Field { get; set; } = string.Empty;
+ [Parameter] public IReadOnlyDictionary Values { get; set; } = new Dictionary();
+ [Parameter] public IReadOnlyDictionary Problems { get; set; } = new Dictionary();
+ [Parameter] public IReadOnlyList Owners { get; set; } = [];
+ [Parameter] public bool Unsourced { get; set; }
+
+ private string Name(string part) => $"Form.Facts[{FactValues.Key(Field, part)}]";
+
+ private string? Value(string part) => Values.GetValueOrDefault(FactValues.Key(Field, part));
+
+ private string? Error(string part) => Problems.GetValueOrDefault(part) switch
+ {
+ null => null,
+ "kind" => L["Choose a kind of source."].Value,
+ "source" => L["Give both the kind of source and the source, or neither."].Value,
+ "owner" => L["Choose an owner from the list."].Value,
+ _ => L["Enter a date and a time."].Value,
+ };
+
+ private string KindLabel(string kind) => kind switch
+ {
+ "url" => L["URL"],
+ "file" => L["File"],
+ "person" => L["Person"],
+ _ => L["System"],
+ };
+}
diff --git a/src/FluentCMS.Admin/Components/Pages/EntriesView.razor b/src/FluentCMS.Admin/Components/Pages/EntriesView.razor
index 7eb4a9864..1c28e823c 100644
--- a/src/FluentCMS.Admin/Components/Pages/EntriesView.razor
+++ b/src/FluentCMS.Admin/Components/Pages/EntriesView.razor
@@ -20,15 +20,8 @@
@(type.Definition.IsEntity ? L["An entity type: each field is a fact with its source, owner and validity."] : L["Entries of this content type."])
- @if (!type.Definition.IsEntity)
- {
- @L["New entry"]
- }
+ @L["New entry"]
- @if (type.Definition.IsEntity)
- {
- @L["Entity forms, with a source, owner and validity on every fact, come with the next forms slice. Agents write entities with set_entity_fact until then."]
- }
@if (_entries.Count == 0)
{
@@ -51,14 +44,7 @@
{
- @if (type.Definition.IsEntity)
- {
- @entry.Id
- }
- else
- {
- @entry.Id
- }
+ @entry.Id
@StateLabel(entry.State)
@entry.LatestVersion
diff --git a/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor b/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor
index bd3934527..80c089855 100644
--- a/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor
+++ b/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor
@@ -13,6 +13,7 @@
@inject IEntryDirectory Entries
@inject IEntryEdits Edits
@inject IMediaLibrary Media
+@inject FluentCMS.Core.Identity.IUserDirectory Users
@inject FieldTypeRegistry FieldTypes
@inject NavigationManager Navigation
@inject IStringLocalizer L
@@ -31,47 +32,48 @@
@if (type.Definition.IsEntity)
{
- @L["Entity forms, with a source, owner and validity on every fact, come with the next forms slice."]
+ @L["An entity type: every field is a fact with its source, owner and validity."]
}
- else
- {
-
}
@code {
@@ -79,6 +81,9 @@
private Entry? _entry;
private Dictionary> _options = new(StringComparer.Ordinal);
private Dictionary _fieldErrors = new(StringComparer.Ordinal);
+ private Dictionary> _factProblems = new(StringComparer.Ordinal);
+ private HashSet _unsourced = new(StringComparer.Ordinal);
+ private IReadOnlyList _owners = [];
private string? _rationaleError;
private string? _formError;
@@ -127,9 +132,20 @@
}
Shown = new EntryFormModel { BaseVersion = _entry?.LatestVersion ?? 0 };
+ _unsourced.Clear();
foreach (var field in _type.Definition.Fields)
{
JsonElement? stored = _entry is not null && _entry.Latest.Fields.TryGetProperty(field.Name, out var value) ? value : null;
+ if (_type.Definition.IsEntity)
+ {
+ if (FactValues.IsUnsourced(stored))
+ {
+ _unsourced.Add(field.Name);
+ }
+
+ stored = FactValues.Show(field.Name, stored, Shown.Facts);
+ }
+
Shown.Values[field.Name] = FieldValues.Show(field, stored);
}
@@ -139,6 +155,11 @@
// Until the pickers search (F-SRC), a reference offers every entry of its target type and a media field every asset.
private async Task LoadOptionsAsync()
{
+ if (_type!.Definition.IsEntity)
+ {
+ _owners = [.. (await Users.ListAsync()).Where(user => !user.IsDisabled).Select(user => new FieldOption(user.Id.ToString(), user.DisplayName))];
+ }
+
foreach (var field in _type!.Definition.Fields)
{
if (field.Type == "reference" && FieldValues.Setting(field, "target") is { } target)
@@ -157,7 +178,7 @@
private async Task SaveAsync()
{
- if (_type is null || Form is null || _type.Definition.IsEntity)
+ if (_type is null || Form is null)
{
return;
}
@@ -190,7 +211,21 @@
continue;
}
- fields[field.Name] = value;
+ if (!_type.Definition.IsEntity)
+ {
+ fields[field.Name] = value;
+ continue;
+ }
+
+ var problems = new Dictionary();
+ var fact = FactValues.Read(field.Name, value, Form.Facts, problems);
+ if (fact is null)
+ {
+ _factProblems[field.Name] = problems;
+ continue;
+ }
+
+ fields[field.Name] = fact;
}
if (string.IsNullOrWhiteSpace(Form.Rationale))
@@ -198,7 +233,7 @@
_rationaleError = L["Say why you are making this change."];
}
- if (_fieldErrors.Count > 0 || _rationaleError is not null)
+ if (_fieldErrors.Count > 0 || _factProblems.Count > 0 || _rationaleError is not null)
{
Shown = Form;
return;
diff --git a/src/FluentCMS.Admin/Forms/EntryFormModel.cs b/src/FluentCMS.Admin/Forms/EntryFormModel.cs
index f61dc6568..87b163a85 100644
--- a/src/FluentCMS.Admin/Forms/EntryFormModel.cs
+++ b/src/FluentCMS.Admin/Forms/EntryFormModel.cs
@@ -5,6 +5,9 @@ public sealed class EntryFormModel
{
public Dictionary Values { get; set; } = new(StringComparer.Ordinal);
+ // For an entity type, each field's source, owner and validity by "field.part" (FactValues).
+ public Dictionary Facts { get; set; } = new(StringComparer.Ordinal);
+
public string? Rationale { get; set; }
// One per line, such as a URL or a file name.
diff --git a/src/FluentCMS.Admin/Forms/FactValues.cs b/src/FluentCMS.Admin/Forms/FactValues.cs
new file mode 100644
index 000000000..2799cf595
--- /dev/null
+++ b/src/FluentCMS.Admin/Forms/FactValues.cs
@@ -0,0 +1,120 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using FluentCMS.Core.Schema;
+
+namespace FluentCMS.Admin.Forms;
+
+// The fact around an entity field's value (SCH-03): where it comes from, who owns it and when it holds. A generated form posts each
+// part as text under the field's name, such as "price.kind"; this turns them into the fact object entities store, and back.
+public static class FactValues
+{
+ public const string Kind = "kind";
+ public const string Reference = "reference";
+ public const string Owner = "owner";
+
+ public static IReadOnlyList Times { get; } = [FactMetadata.ValidFrom, FactMetadata.ValidUntil, FactMetadata.LastVerified];
+
+ public static string Key(string field, string part) => $"{field}.{part}";
+
+ // The fact for a value, or null with the problems of each part by part name.
+ public static JsonObject? Read(string field, JsonNode value, IReadOnlyDictionary posted, Dictionary problems)
+ {
+ string? Part(string part) => posted.GetValueOrDefault(Key(field, part)) is { } text && text.Trim().Length > 0 ? text.Trim() : null;
+
+ var kind = Part(Kind);
+ var reference = Part(Reference);
+ if (kind is not null && !FactMetadata.SourceKinds.Contains(kind))
+ {
+ problems[Kind] = "kind";
+ }
+ else if ((kind is null) != (reference is null))
+ {
+ problems[kind is null ? Kind : Reference] = "source";
+ }
+
+ Guid? owner = null;
+ if (Part(Owner) is { } ownerText)
+ {
+ if (Guid.TryParse(ownerText, out var ownerId))
+ {
+ owner = ownerId;
+ }
+ else
+ {
+ problems[Owner] = "owner";
+ }
+ }
+
+ var times = new Dictionary();
+ foreach (var name in Times)
+ {
+ times[name] = null;
+ if (Part(name) is not { } text)
+ {
+ continue;
+ }
+
+ if (DateTime.TryParseExact(text, [FieldValues.DateTimeFormat, FieldValues.DateTimeFormat + ":ss"], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var time))
+ {
+ times[name] = time.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ problems[name] = "dateTime";
+ }
+ }
+
+ if (problems.Count > 0)
+ {
+ return null;
+ }
+
+ return new JsonObject
+ {
+ [FactMetadata.Value] = value.DeepClone(),
+ [FactMetadata.Source] = kind is null ? null : new JsonObject { [FactMetadata.SourceKind] = kind, [FactMetadata.SourceReference] = reference },
+ [FactMetadata.Owner] = owner?.ToString(),
+ [FactMetadata.ValidFrom] = times[FactMetadata.ValidFrom],
+ [FactMetadata.ValidUntil] = times[FactMetadata.ValidUntil],
+ [FactMetadata.LastVerified] = times[FactMetadata.LastVerified],
+ };
+ }
+
+ // The stored fact's value for the value widget, and its parts for the metadata widgets.
+ public static JsonElement? Show(string field, JsonElement? stored, IDictionary facts)
+ {
+ if (stored is not { ValueKind: JsonValueKind.Object } fact)
+ {
+ return null;
+ }
+
+ if (fact.TryGetProperty(FactMetadata.Source, out var source) && source.ValueKind == JsonValueKind.Object)
+ {
+ facts[Key(field, Kind)] = source.GetProperty(FactMetadata.SourceKind).GetString();
+ facts[Key(field, Reference)] = source.GetProperty(FactMetadata.SourceReference).GetString();
+ }
+
+ if (fact.TryGetProperty(FactMetadata.Owner, out var owner) && owner.ValueKind == JsonValueKind.String)
+ {
+ facts[Key(field, Owner)] = owner.GetString();
+ }
+
+ foreach (var name in Times)
+ {
+ if (fact.TryGetProperty(name, out var time) && time.ValueKind == JsonValueKind.String
+ && DateTimeOffset.TryParse(time.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
+ {
+ facts[Key(field, name)] = parsed.UtcDateTime.ToString(FieldValues.DateTimeFormat, CultureInfo.InvariantCulture);
+ }
+ }
+
+ return fact.TryGetProperty(FactMetadata.Value, out var value) ? value : null;
+ }
+
+ public static bool IsUnsourced(JsonElement? stored)
+ {
+ return stored is { ValueKind: JsonValueKind.Object } fact
+ && (!fact.TryGetProperty(FactMetadata.Source, out var source) || source.ValueKind != JsonValueKind.Object);
+ }
+}
diff --git a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs
index ce53a53b7..0d3f11b9c 100644
--- a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs
+++ b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs
@@ -27,7 +27,7 @@ public async Task The_showcase_desk_and_set_view_have_no_wcag_21_aa_violations()
AssetMetadata.Create("drill.jpg", "products", ["hero"], null, null).Value))).Value.Id);
await session.SignInWithKeyboardAsync();
- string[] paths = ["/ui", "/", "/?queue=stale-facts", $"/sets/{set.Id}?queue=awaiting-me", "/security", "/media", $"/media/{asset}", "/content", "/content/product", "/content/product/new"];
+ string[] paths = ["/ui", "/", "/?queue=stale-facts", $"/sets/{set.Id}?queue=awaiting-me", "/security", "/media", $"/media/{asset}", "/content", "/content/product", "/content/product/new", "/content/supplier/new"];
var violations = new List();
foreach (var theme in new[] { "light", "dark" })
{
@@ -54,6 +54,8 @@ private static async Task AwaitingReviewAsync(BrowserSession session)
{
var type = await session.Host.AsUserAsync(session.OwnerId, async directory =>
(await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value);
+ await session.Host.AsUserAsync(session.OwnerId, async directory =>
+ (await directory.CreateAsync(new NewContentType("Supplier", "supplier", new ContentTypeDefinition([new FieldDefinition("name", "text")], isEntity: true)))).Value);
return await session.Host.AsUserAsync(session.OwnerId, async directory =>
{
diff --git a/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs b/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs
index e4546dce6..4857bf7c5 100644
--- a/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs
+++ b/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs
@@ -35,7 +35,7 @@ public static async Task StartAsync()
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
var page = await browser.NewPageAsync(new BrowserNewPageOptions { ViewportSize = new ViewportSize { Width = 1280, Height = 900 } });
- page.SetDefaultTimeout(15_000);
+ page.SetDefaultTimeout(30_000);
return new BrowserSession(install, host, ownerId, playwright, browser, page);
}
diff --git a/tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs b/tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs
new file mode 100644
index 000000000..0edeb35d6
--- /dev/null
+++ b/tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs
@@ -0,0 +1,95 @@
+using System.Net;
+using System.Text.Json;
+using FluentCMS.Core.ChangeSets;
+using FluentCMS.Core.Content;
+using FluentCMS.Core.Entities;
+using FluentCMS.Core.Schema;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.IntegrationTests.Forms;
+
+public sealed class EntityFormScreenTests
+{
+ [Fact]
+ public async Task A_person_creates_an_entity_whose_facts_carry_their_source_owner_and_validity()
+ {
+ using var install = new TemporaryInstall();
+ await using var host = await AdminHost.StartAsync(install);
+ var ownerId = await host.CreateFirstAdministratorAsync();
+ await ProductTypeAsync(host, ownerId);
+ await host.SignInAsync(AdminHost.Owner);
+
+ var form = await host.Client.GetStringAsync("/content/product/new");
+ Assert.Contains("name=\"Form.Facts[price.kind]\"", form);
+ Assert.Contains($"", form);
+
+ var saved = await host.SubmitAsync("/content/product/new", "entry", new Dictionary
+ {
+ ["Form.Values[name]"] = "Drill",
+ ["Form.Values[price]"] = "129.5",
+ ["Form.Facts[price.kind]"] = "url",
+ ["Form.Facts[price.reference]"] = "https://example.com/catalog",
+ ["Form.Facts[price.owner]"] = ownerId.ToString(),
+ ["Form.Facts[price.validUntil]"] = "2026-12-31T23:59",
+ ["Form.Facts[price.lastVerified]"] = "2026-10-18T09:00",
+ ["Form.Rationale"] = "Add the drill.",
+ ["Form.BaseVersion"] = "0",
+ });
+
+ var set = await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(Guid.Parse(host.LocationOf(saved)![6..42]))).Value);
+ var operation = Assert.Single(set.Operations);
+ Assert.Equal((OperationKind.Create, ItemKind.Entity), (operation.Kind, operation.ItemKind));
+ var entity = await host.AsUserAsync(ownerId, async entries => (await entries.GetAsync(operation.ItemId)).Value);
+ var price = entity.Latest.Fields.GetProperty("price");
+ Assert.Equal(129.5m, price.GetProperty("value").GetDecimal());
+ Assert.Equal(("url", "https://example.com/catalog"), (price.GetProperty("source").GetProperty("kind").GetString(), price.GetProperty("source").GetProperty("reference").GetString()));
+ Assert.Equal(ownerId.ToString(), price.GetProperty("owner").GetString());
+ Assert.Equal("2026-12-31T23:59:00Z", price.GetProperty("validUntil").GetString());
+ Assert.Equal("2026-10-18T09:00:00Z", price.GetProperty("lastVerified").GetString());
+ Assert.Equal(JsonValueKind.Null, price.GetProperty("validFrom").ValueKind);
+
+ // The name was saved without a source: it is stored, flagged unsourced, and the form says so.
+ var unsourced = await host.AsUserAsync>(ownerId, async facts => (await facts.UnsourcedAsync("product")).Value);
+ Assert.Equal(["name"], unsourced.Select(flag => flag.Field));
+ var edit = await host.Client.GetStringAsync($"/content/product/{entity.Id}");
+ Assert.Contains("value=\"https://example.com/catalog\"", edit);
+ Assert.Contains("value=\"2026-12-31T23:59\"", edit);
+ Assert.Contains("
+ {
+ ["Form.Values[name]"] = "Drill",
+ ["Form.Values[price]"] = "10",
+ ["Form.Facts[name.kind]"] = "url",
+ ["Form.Facts[price.validUntil]"] = "next year",
+ ["Form.Rationale"] = "Add the drill.",
+ ["Form.BaseVersion"] = "0",
+ });
+ var page = await refused.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, refused.StatusCode);
+ Assert.Contains("Give both the kind of source and the source, or neither.", page);
+ Assert.Contains("Enter a date and a time.", page);
+ Assert.Contains("value=\"next year\"", page);
+ Assert.Empty(await host.AsUserAsync>(ownerId, async entries => (await entries.ListAsync("product")).Value));
+ }
+
+ private static Task ProductTypeAsync(AdminHost host, Guid ownerId)
+ {
+ return host.AsUserAsync(ownerId, async types => (await types.CreateAsync(new NewContentType(
+ "Product",
+ "product",
+ new ContentTypeDefinition([new FieldDefinition("name", "text", required: true), new FieldDefinition("price", "number")], isEntity: true)))).Value);
+ }
+}
diff --git a/tests/FluentCMS.Tests/Admin/FactValuesTests.cs b/tests/FluentCMS.Tests/Admin/FactValuesTests.cs
new file mode 100644
index 000000000..98e25955f
--- /dev/null
+++ b/tests/FluentCMS.Tests/Admin/FactValuesTests.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using FluentCMS.Admin.Forms;
+
+namespace FluentCMS.Tests.Admin;
+
+public sealed class FactValuesTests
+{
+ private static readonly Guid Owner = Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e");
+
+ [Fact]
+ public void Posted_parts_become_the_fact_object_with_every_property()
+ {
+ var problems = new Dictionary();
+
+ var fact = FactValues.Read("price", JsonValue.Create(10m), Posted(
+ ("price.kind", "url"),
+ ("price.reference", " https://example.com "),
+ ("price.owner", Owner.ToString()),
+ ("price.validFrom", "2026-01-01T00:00")), problems);
+
+ Assert.Empty(problems);
+ Assert.Equal(
+ $$"""{"value":10,"source":{"kind":"url","reference":"https://example.com"},"owner":"{{Owner}}","validFrom":"2026-01-01T00:00:00Z","validUntil":null,"lastVerified":null}""",
+ fact!.ToJsonString());
+ }
+
+ [Fact]
+ public void A_fact_without_any_details_is_unsourced_and_keeps_null_properties()
+ {
+ var fact = FactValues.Read("name", JsonValue.Create("Drill"), Posted(), []);
+
+ Assert.Equal("""{"value":"Drill","source":null,"owner":null,"validFrom":null,"validUntil":null,"lastVerified":null}""", fact!.ToJsonString());
+ Assert.True(FactValues.IsUnsourced(JsonDocument.Parse(fact.ToJsonString()).RootElement));
+ }
+
+ [Theory]
+ [InlineData("price.kind", "url", "reference", "source")]
+ [InlineData("price.reference", "x", "kind", "source")]
+ [InlineData("price.kind", "rumour", "kind", "kind")]
+ [InlineData("price.owner", "someone", "owner", "owner")]
+ [InlineData("price.lastVerified", "yesterday", "lastVerified", "dateTime")]
+ public void A_part_that_cannot_stand_is_a_problem_of_that_part(string key, string posted, string part, string problem)
+ {
+ var problems = new Dictionary();
+
+ Assert.Null(FactValues.Read("price", JsonValue.Create(10m), Posted((key, posted)), problems));
+ Assert.Equal(problem, problems[part]);
+ }
+
+ [Fact]
+ public void A_stored_fact_shows_its_value_and_parts()
+ {
+ var facts = new Dictionary();
+ var stored = JsonDocument.Parse($$"""{"value": 10, "source": {"kind": "file", "reference": "prices.csv"}, "owner": "{{Owner}}", "validFrom": null, "validUntil": "2026-12-31T23:59:00+01:00", "lastVerified": null}""").RootElement;
+
+ var value = FactValues.Show("price", stored, facts);
+
+ Assert.Equal(10, value!.Value.GetInt32());
+ Assert.Equal(("file", "prices.csv", Owner.ToString(), "2026-12-31T22:59"), (facts["price.kind"], facts["price.reference"], facts["price.owner"], facts["price.validUntil"]));
+ Assert.False(FactValues.IsUnsourced(stored));
+ }
+
+ private static Dictionary Posted(params (string Key, string Value)[] parts)
+ {
+ return parts.ToDictionary(part => part.Key, part => (string?)part.Value);
+ }
+}