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
75 changes: 75 additions & 0 deletions src/FluentCMS.Admin/Components/Forms/FactFields.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
@using FluentCMS.Admin.Forms
@using FluentCMS.Core.Schema
@inject IStringLocalizer<FactFields> L

@* The fact around an entity field's value (SCH-03): its source, owner and validity, each posted under the field's name. *@
<fieldset class="grid gap-4 rounded-md border p-4 sm:col-span-2" data-fact="@Field">
<legend class="px-1 text-sm font-medium">@L["Fact details for {0}", Field]</legend>
@if (Unsourced)
{
<p class="text-sm text-destructive" data-testid="fact-unsourced">@L["This fact has no source, so it is flagged unsourced. Say where the value comes from."]</p>
}
<div class="grid gap-4 sm:grid-cols-[12rem_1fr]">
<FormField Label="@L["Source kind"]" Error="@Error(FactValues.Kind)">
<Select name="@Name(FactValues.Kind)">
<option value="">@L["None"]</option>
@foreach (var kind in FactMetadata.SourceKinds)
{
<option value="@kind" selected="@(Value(FactValues.Kind) == kind)">@KindLabel(kind)</option>
}
</Select>
</FormField>
<FormField Label="@L["Source"]" Description="@L["The URL, file, person or system the value comes from."]" Error="@Error(FactValues.Reference)">
<Input name="@Name(FactValues.Reference)" value="@Value(FactValues.Reference)" dir="ltr" />
</FormField>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<FormField Label="@L["Owner"]" Error="@Error(FactValues.Owner)">
<Select name="@Name(FactValues.Owner)">
<option value="">@L["None"]</option>
@foreach (var owner in Owners)
{
<option value="@owner.Value" selected="@(Value(FactValues.Owner) == owner.Value)">@owner.Label</option>
}
</Select>
</FormField>
<FormField Label="@L["Valid from (UTC)"]" Error="@Error(FactMetadata.ValidFrom)">
<Input Type="datetime-local" name="@Name(FactMetadata.ValidFrom)" value="@Value(FactMetadata.ValidFrom)" dir="ltr" />
</FormField>
<FormField Label="@L["Valid until (UTC)"]" Error="@Error(FactMetadata.ValidUntil)">
<Input Type="datetime-local" name="@Name(FactMetadata.ValidUntil)" value="@Value(FactMetadata.ValidUntil)" dir="ltr" />
</FormField>
<FormField Label="@L["Last verified (UTC)"]" Error="@Error(FactMetadata.LastVerified)">
<Input Type="datetime-local" name="@Name(FactMetadata.LastVerified)" value="@Value(FactMetadata.LastVerified)" dir="ltr" />
</FormField>
</div>
</fieldset>

@code {
[Parameter, EditorRequired] public string Field { get; set; } = string.Empty;
[Parameter] public IReadOnlyDictionary<string, string?> Values { get; set; } = new Dictionary<string, string?>();
[Parameter] public IReadOnlyDictionary<string, string> Problems { get; set; } = new Dictionary<string, string>();
[Parameter] public IReadOnlyList<FieldOption> 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"],
};
}
18 changes: 2 additions & 16 deletions src/FluentCMS.Admin/Components/Pages/EntriesView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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."])
</p>
</div>
@if (!type.Definition.IsEntity)
{
<a href="@($"content/{type.Slug}/new")" aria-keyshortcuts="N" class="@ButtonVariants.Classes()" data-testid="new-entry">@L["New entry"]</a>
}
<a href="@($"content/{type.Slug}/new")" aria-keyshortcuts="N" class="@ButtonVariants.Classes()" data-testid="new-entry">@L["New entry"]</a>
</div>
@if (type.Definition.IsEntity)
{
<p class="mt-4 rounded-md border px-3 py-2 text-sm text-muted-foreground" data-testid="entity-form-note">@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."]</p>
}

@if (_entries.Count == 0)
{
Expand All @@ -51,14 +44,7 @@
{
<TableRow data-entry="@entry.Id.ToString("N")">
<TableCell>
@if (type.Definition.IsEntity)
{
<bdi dir="ltr" class="font-mono text-xs">@entry.Id</bdi>
}
else
{
<a href="@($"content/{type.Slug}/{entry.Id}")" data-ui="rove-item" tabindex="@(index == 0 ? null : "-1")" class="font-mono text-xs underline-offset-4 hover:underline" dir="ltr">@entry.Id</a>
}
<a href="@($"content/{type.Slug}/{entry.Id}")" data-ui="rove-item" tabindex="@(index == 0 ? null : "-1")" class="font-mono text-xs underline-offset-4 hover:underline" dir="ltr">@entry.Id</a>
</TableCell>
<TableCell><Badge Variant="outline">@StateLabel(entry.State)</Badge></TableCell>
<TableCell>@entry.LatestVersion</TableCell>
Expand Down
117 changes: 76 additions & 41 deletions src/FluentCMS.Admin/Components/Pages/EntryEditor.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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<EntryEditor> L
Expand All @@ -31,54 +32,58 @@

@if (type.Definition.IsEntity)
{
<p class="mt-6 rounded-md border px-3 py-2 text-sm text-muted-foreground" data-testid="entity-form-note">@L["Entity forms, with a source, owner and validity on every fact, come with the next forms slice."]</p>
<p class="mt-2 text-sm text-muted-foreground">@L["An entity type: every field is a fact with its source, owner and validity."]</p>
}
else
{
<form method="post" @onsubmit="SaveAsync" @formname="entry" class="mt-6 grid gap-6" data-testid="entry-form">
<AntiforgeryToken />
<input type="hidden" name="Form.BaseVersion" value="@(_entry?.LatestVersion ?? 0)" />
@if (_formError is not null)
{
<p role="alert" class="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive" data-testid="entry-form-error">@_formError</p>
}
<Card>
<CardHeader>
<CardTitle><h2>@L["Fields"]</h2></CardTitle>
</CardHeader>
<CardContent Class="grid gap-6 sm:grid-cols-2">
@foreach (var field in type.Definition.Fields)
<form method="post" @onsubmit="SaveAsync" @formname="entry" class="mt-6 grid gap-6" data-testid="entry-form">
<AntiforgeryToken />
<input type="hidden" name="Form.BaseVersion" value="@(_entry?.LatestVersion ?? 0)" />
@if (_formError is not null)
{
<p role="alert" class="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive" data-testid="entry-form-error">@_formError</p>
}
<Card>
<CardHeader>
<CardTitle><h2>@L["Fields"]</h2></CardTitle>
</CardHeader>
<CardContent Class="grid gap-6 sm:grid-cols-2">
@foreach (var field in type.Definition.Fields)
{
<FieldWidget Field="field" Value="@Shown.Values.GetValueOrDefault(field.Name)" Error="@_fieldErrors.GetValueOrDefault(field.Name)" Options="@_options.GetValueOrDefault(field.Name, [])" />
@if (type.Definition.IsEntity)
{
<FieldWidget Field="field" Value="@Shown.Values.GetValueOrDefault(field.Name)" Error="@_fieldErrors.GetValueOrDefault(field.Name)" Options="@_options.GetValueOrDefault(field.Name, [])" />
<FactFields Field="@field.Name" Values="Shown.Facts" Problems="@_factProblems.GetValueOrDefault(field.Name, new Dictionary<string, string>())" Owners="_owners" Unsourced="@_unsourced.Contains(field.Name)" />
}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle><h2>@L["Change set"]</h2></CardTitle>
<CardDescription>@L["The reviewer reads why you made the change and where it comes from."]</CardDescription>
</CardHeader>
<CardContent Class="grid gap-6">
<FormField Label="@L["Why (required)"]" Error="@_rationaleError">
<Textarea name="Form.Rationale" rows="2" value="@Shown.Rationale" required />
</FormField>
<FormField Label="@L["Sources"]" Description="@L["Optional, one per line, such as a URL or a file name."]">
<Textarea name="Form.Sources" rows="2" value="@Shown.Sources" dir="ltr" />
</FormField>
</CardContent>
</Card>
<div>
<Button Type="submit" data-action="save">@L["Save to a change set"]</Button>
</div>
</form>
}
}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle><h2>@L["Change set"]</h2></CardTitle>
<CardDescription>@L["The reviewer reads why you made the change and where it comes from."]</CardDescription>
</CardHeader>
<CardContent Class="grid gap-6">
<FormField Label="@L["Why (required)"]" Error="@_rationaleError">
<Textarea name="Form.Rationale" rows="2" value="@Shown.Rationale" required />
</FormField>
<FormField Label="@L["Sources"]" Description="@L["Optional, one per line, such as a URL or a file name."]">
<Textarea name="Form.Sources" rows="2" value="@Shown.Sources" dir="ltr" />
</FormField>
</CardContent>
</Card>
<div>
<Button Type="submit" data-action="save">@L["Save to a change set"]</Button>
</div>
</form>
}

@code {
private ContentType? _type;
private Entry? _entry;
private Dictionary<string, IReadOnlyList<FieldOption>> _options = new(StringComparer.Ordinal);
private Dictionary<string, string> _fieldErrors = new(StringComparer.Ordinal);
private Dictionary<string, Dictionary<string, string>> _factProblems = new(StringComparer.Ordinal);
private HashSet<string> _unsourced = new(StringComparer.Ordinal);
private IReadOnlyList<FieldOption> _owners = [];
private string? _rationaleError;
private string? _formError;

Expand Down Expand Up @@ -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);
}

Expand All @@ -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)
Expand All @@ -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;
}
Expand Down Expand Up @@ -190,15 +211,29 @@
continue;
}

fields[field.Name] = value;
if (!_type.Definition.IsEntity)
{
fields[field.Name] = value;
continue;
}

var problems = new Dictionary<string, string>();
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))
{
_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;
Expand Down
3 changes: 3 additions & 0 deletions src/FluentCMS.Admin/Forms/EntryFormModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ public sealed class EntryFormModel
{
public Dictionary<string, string?> Values { get; set; } = new(StringComparer.Ordinal);

// For an entity type, each field's source, owner and validity by "field.part" (FactValues).
public Dictionary<string, string?> Facts { get; set; } = new(StringComparer.Ordinal);

public string? Rationale { get; set; }

// One per line, such as a URL or a file name.
Expand Down
Loading
Loading