Skip to content

Add generated forms for content types, saved as a person's change set - #2613

Merged
pournasserian merged 2 commits into
v2from
2605-generated-forms
Oct 19, 2026
Merged

pournasserian merged 2 commits into
v2from
2605-generated-forms

Conversation

@pournasserian

Copy link
Copy Markdown
Member

Closes #2605

What

The first slice of F-FRM (#2426): generated create and edit forms for content types, with a widget per built-in field type through FormField (FRM-01). A save is a person's operation in a change set (FRM-02), which goes through checks and review like an agent's.

  • Core (FluentCMS.Core.Content): IEntryEdits.SaveAsync(EntryEdit).
    • It writes the version, then opens a change set with the person's rationale and sources and adds the operation, all in one unit of work, as ProposedEntryWrite does for agents. A refused set or field rolls the version back, so no version exists outside a set (AUD-N1).
    • An update is based on the published version, and a draft never published is a create of its next version.
    • The set stays open, and the person submits it from the set view (Show the check run on the set view and submit an open set #2603).
  • Admin:
    • /content lists the content types, and /content/{slug} lists a type's entries, newest first, with state and versions. Both lists rove.
    • /content/{slug}/new and /content/{slug}/{id} render the type's latest definition, one FieldWidget per field. Each widget is in a FormField with the field's description, a required mark and its error:
      • text: input with maxlength
      • richText: textarea
      • number: number input
      • boolean: checkbox
      • dateTime: datetime-local in UTC, or a date input for dateOnly
      • enum: select of its values
      • reference: select of the target type's entries
      • media: select of the library's assets
      • list and json: JSON textarea
    • FieldValues turns each posted text into the field's JSON value, and a stored value back into what the widget shows. Each value is checked with FieldTypeRegistry.ValidateValue before the save, and a required field left empty is reported. Each problem shows next to its field, and the typed values stay.
    • The directory checks the document again on save, and its "Field 'x': ..." errors go to their fields.
    • An edit posts the version it read. A form from an older version is refused, and the entry is shown as it is now.
    • A save lands on the new set with a note saying to submit it. The header gets a Content link.
    • Entity types are listed, with a note that their form, with fact metadata, is the next slice.
  • Accessibility: the axe check covers /content, a type's entries and the new-entry form.

Why

  • A Core service for the save. The form needs the same version-plus-operation unit of work an agent's write uses. P1 keeps that logic out of the screen, and a later tool for people's edits can call the same service.
  • One set per save, left open. A person may want several edits reviewed together, but joining an existing open set needs a picker and the CHG-07 rules. That comes with edit-in-place. Leaving the set open lets the person look at the diff before submitting.
  • Selects for references and media for now. The epic's pickers need search (Add the search index on PostgreSQL and SQLite and the search tool #2566 just landed). A select of every candidate works at M1 scale until the next slice.
  • UTC for date and time. The browser's date-time input has no offset, and the stored value needs one. The form says the time is UTC rather than guessing the person's zone.
  • Not in this PR: entity forms (next slice), search-backed pickers, rule hints per field (FRM-03, needs F-RUL F-RUL: Rule book as versioned content #2419), and editing inside an existing set (CHG-07).

Tests

Unit and bUnit (GeneratedFormTests):

  • Every built-in type turns its posted text into the right JSON value. A bad number, time, date or JSON is a problem, and an empty field has no value.
  • Stored values show as their widgets take them: a UTC date-time from any offset, booleans, numbers as stored, and indented JSON with non-Latin text unescaped.
  • Each type renders its widget with the field's name, label link, required mark and error, and an enum marks the stored value.

Integration:

  • EntryEditsTests on SQLite and PostgreSQL:
    • A save opens an open set with the person as initiator, the rationale, the sources and the create.
    • An edit of a draft is a create of version 2 in a new set, and a stale base version is refused.
    • A blank rationale or an invalid field stores neither the version nor a set.
  • EntryFormScreenTests against the host:
    • A person creates an entry with a field of every type and lands on the open set. The set holds the create with the sources split by line, and every value is stored as typed.
    • The edit form shows the stored values, and its save opens a second set with version 2.
    • Bad values and missing fields are reported next to their fields, and nothing is stored.
    • A stale form shows the entry as it is now.
    • The content pages list types and entries, need sign-in, and answer 404 for an unknown type or entry.

dotnet build -warnaserror and the full suite with the browser tests pass locally against a postgres:17 container as well as SQLite. One run on a loaded machine had seven PostgreSQL failures in untouched classes and one in the keyboard browser test; all 65 tests of those classes passed on a rerun. No new package and no new project reference.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:forms Generated forms (FRM) labels Oct 19, 2026
@pournasserian
pournasserian merged commit 81fcfdd into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2605-generated-forms branch October 19, 2026 08:36
pournasserian added a commit that referenced this pull request Oct 19, 2026
…2619)

Closes #2614

## What

The second slice of F-FRM (#2426): generated forms for entity types,
where every field is a fact with its source, owner and validity (FRM-01,
SCH-03). Saving works as for content types (#2613): the version and its
operation go into a new open change set, here as an entity operation.

- **`FactFields`** (`Components/Forms`): a fieldset under each field's
value widget. It has the source kind (URL, file, person or system) and
the source, the owner from the users list, and valid from, valid until
and last verified as UTC date and times. A stored fact without a source
is marked unsourced (ENT-02) next to its field.
- **`FactValues`** (`Admin/Forms`) turns the posted parts
(`Form.Facts[price.kind]` and so on) into the fact object SCH-03
defines, always with all six properties. It also shows a stored fact
back.
- A kind without a source or a source without a kind, an unknown kind or
owner, and a time that is not a date and time are each reported next to
the field.
  - A field whose value is left empty is left out, as for content types.
- **The editor** renders the fact fields for entity types, and **the
entries list** links entities to their form. The "comes later" notes
from #2613 are gone.
- **Browser tests**: the default timeout is 30 seconds, since the axe
check now walks ten pages twice per theme, and a loaded machine once
timed out at 15. An entity form is one of those pages.

## Why

- **One editor for both kinds of type.** An entity field is a fact
around a value. The value takes the same widget it would take on a
content type, and the fact fields sit next to it. That keeps one save
path and one set of field widgets.
- **Owners from the users list.** SCH-03's owner is a user, so a select
is both simpler and harder to get wrong than a typed id.
- **Not in this PR**: search-backed pickers, confirming a value without
changing it (ENT-05, M2), and rule hints (FRM-03, needs F-RUL #2419).

## Tests

- Unit (`FactValuesTests`):
  - Posted parts become the full fact object, trimmed, with UTC times.
- A fact without details is unsourced and keeps every property as null.
- Each half-given source, unknown kind, bad owner or bad time is a
problem of its own part.
  - A stored fact shows its value and parts, with the times in UTC.
- Integration, host (`EntityFormScreenTests`):
- A person creates a product entity. The operation is an entity create,
and the price fact stores its value, source, owner, valid until and last
verified exactly, with valid from null.
- The name, saved without a source, is flagged unsourced by
`IEntityFacts` and marked once in the edit form, which shows the stored
source and times.
- A half-given source and a bad time are reported next to their fields,
the typed text stays, and nothing is stored.
- Browser: the axe check covers an entity type's form.

`dotnet build -warnaserror` and the full suite with the browser tests
pass locally against a `postgres:17` container as well as SQLite. A
loaded full run had nine PostgreSQL failures in untouched classes, and
all 103 tests of those classes passed on a rerun. No new package and no
new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2621

## What

The third slice of F-FRM (#2426): pickers for reference and media fields
(FRM-01), backed by search (`ISearch`, #2566, #2606) and the media
library (#2543).

- **`PickerField`** (`Components/Forms`) is an interactive island inside
the static generated form. `FieldWidget` renders it for reference and
media fields.
- Over the circuit it shows the current choice, a Clear button and a
search box. The choice is a hidden input named `Form.Values[field]`, so
the form posts and saves exactly as before.
- **Entries**: as the person types, it searches the reference's target
type (at most 10 direct hits). Each hit shows its first text value, or
an entity's first text fact, with its state and a short id.
- **Media**: it filters the library by file name or folder, newest
first, with a preview for images.
- The matches are a `listbox` of buttons that rove with the module from
#2585. ArrowDown moves from the search box into the list, the arrows
move through it, Enter chooses and Escape clears the matches.
- Before the circuit attaches, or when it is lost, the prerendered
island is the plain select from #2613, so a form posted then still
saves.
- **`admin.js`**: a `data-ui="rove-input"` box moves focus to the first
item of the list its `aria-controls` names on ArrowDown.

## Why

- **An island, not a page in interactive mode.** Only the search needs
the circuit. The form, its other fields and the save stay static, so the
save path from #2613 does not change, and a lost circuit costs only the
search.
- **A hidden input in the island.** The static form collects it with the
other fields, and no server state has to survive between the choice and
the save.
- **`FieldOption[]` rather than an interface.** An island's parameters
cross into the circuit as JSON. With `IReadOnlyList<FieldOption>`, the
browser refused the root components with "The list of component
operations is not valid". The parameter says why in a comment.
- **Not in this PR**: field filters in the picker (`SearchQuery.Fields`
exists for it once a field's settings name a filter), list-of-reference
widgets, and rule hints (FRM-03, needs F-RUL #2419).

## Tests

- bUnit (`PickerFieldTests`):
  - Before the circuit, a picker is the select with the stored choice.
- An entry picker searches its target type and names hits by their first
text fact. It wires the listbox to the search box and the rove module,
and choosing posts the id and shows the name. Clear empties the choice.
  - A search with no match says so, and Escape clears the matches.
- A media picker finds an asset by file name, case-insensitively, with
its preview, and posts its id.
- `GeneratedFormTests` renders the reference and media widgets before
the circuit, as selects.
- Browser (`FormPickerTests`), keyboard alone: type the title, Tab to
the picker, type part of a product's name, press ArrowDown and Enter,
then write the rationale and save. The stored entry references the
product.

`dotnet build -warnaserror`, the unit and bUnit suite, the browser tests
and the SQLite integration suite pass locally. The local PostgreSQL run
on a loaded machine had nine failures in untouched classes, the same
pattern as the last three PRs. CI runs the full PostgreSQL suite. No new
package and no new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 19, 2026
…ch (#2638)

Closes #2630
Closes #2633

## What

Follow-ups from the independent reviews of #2613 and #2619 (F-FRM,
#2426). The generated form now keeps the version it was made from and
the values the person didn't touch.

- **Base version.** The hidden `BaseVersion` comes from the form being
shown. A form refused for its own problems keeps the version it started
from, so its next save is refused as a conflict instead of overwriting a
change saved meanwhile.
- **Untouched values.**
  - `FieldValues.Read` and `FactValues.Read` take the stored value.
- A posted text that shows the same as the stored value keeps the stored
JSON. Seconds, offsets, `4.50` and LF line breaks survive a save.
- Textarea line breaks are compared as LF. Only CR and CRLF are
converted; other line separators stay as typed.
- A stored value the field's current type no longer takes, as after a
number field became text, is read from what was posted.
- A `text` value with line breaks is shown in a textarea, since a
single-line input drops them.
- A textarea value that starts with a line break gets one more, since
HTML drops the first.
- **Which draft the form shows.**
- When the latest version isn't the published one and no set is
reviewing it, a note names the version shown. It offers
`?from=published`. The update is still made from the latest version, so
the version check holds.
  - A reverted create, which leaves nothing published, says so too.
- **Entity facts.**
- The owner list holds every user, and disabled users are labelled
"(disabled)", so a stored owner stays selected.
  - Owner ids are compared as GUIDs, and the stored text is kept.
- A posted owner that is neither a user nor the fact's stored owner is
refused.
- **Untouched facts are kept exactly.** A fact whose value and parts all
show as stored is saved as stored, with its property order, spaces and
any other properties.
- **A stored plain value** of a type made an entity type later shows as
the value of a fact with no details yet, flagged unsourced. It no longer
fails the save or is dropped. A fact is an object with a `value`, so a
plain JSON object is a plain value too.
- **An owner who is no user**, such as one an agent set, shows as
"Unknown user" on its own field and stays selected.
- **The published version the values came from** is posted under
`?from=published`, and whenever the latest version is the published one.
- `EntryEdit.PublishedBase` carries it to `EntryEdits`. That checks it
before the unit of work, and again against the version the operation is
based on.
- If publishing or a revert has moved on, the save is refused as a
conflict, so it can't undo what was published.
- **Booleans.** An unchecked box saves `false` only when a value was
stored, the field is required, or its fact details were given. Otherwise
the field is left out, as an empty field is. A name-only edit no longer
adds an unsourced `{"value": false}` fact.
- **Fact details for an empty value** are reported next to the field
instead of being dropped.
- **`EntryEdits`** checks the rationale and sources before it writes.
- **`PickerField`** names `aria-controls` only while its list exists.
The new axe check found it pointing at a missing id (#2622).

## Why

- **Keep the stored JSON when the text matches.** No widget can show
every stored value exactly, such as a date-time's seconds or a number's
trailing zero. Comparing what the widget would show tells an untouched
field from an edited one without client script.
- **The base version stays the latest version under `?from=published`.**
Another base would weaken the version check. The person sees which
values they start from, and the set's diff shows the full change.

## Tests

- Unit:
- `FieldValues.Read` keeps stored date-times, numbers, CRLF text and
JSON whose text matches, and replaces a changed one.
  - Unchecked booleans.
- `FactValues.Read` keeps a stored owner's case and a stored time's
seconds.
  - An unknown owner is refused.
  - `HasDetails`.
  - A JSON object without a `value` is a plain value.
  - A U+2028 in an edited text is kept.
  - A picker without matches has no `aria-controls`.
- Integration (`EntryFormScreenTests`, `EntityFormScreenTests`):
  - The #2630 overwrite scenario ends in the conflict message.
- After a published revert, the form names the reverted draft and offers
the published version. A form from a published version since replaced is
refused.
  - A reverted create says so.
- Editing an entry written before its type held facts shows its plain
values, a JSON object included, and saves its facts.
  - A title-only edit keeps several values exactly as stored:
    - a CRLF body that starts with a line break;
    - a multi-line `text` value, which gets a textarea;
    - the date-time text `09:30:45.5+02:00`;
    - `4.50`;
    - an absent boolean.
- `EntryEdits` refuses a blank source, and an update whose published
base isn't the published version. Neither writes a version or an event.
- A form open on the published version when a revert publishes an older
one is refused on save.
- An untouched value after its field changed from number to text is
saved as text.
- Editing a published entry proposes an update from version 1, and a
stray draft is named. The saved set is submitted from the set view.
  - The unknown enum value is reported.
- A name-only entity edit keeps the other fact byte for byte, including
a disabled upper-case owner, a source in another order with a trailing
space, and times with seconds and offsets. It also keeps an owner who is
no user, and an absent boolean.
- Problems and the unsourced marker sit inside their field's fact
fieldset.
  - A forged owner and fact details without a value are refused.
- Browser:
- A title-only edit in Chromium keeps a multi-line text and a rich text
that starts with a line break.
- The axe check covers a form with every widget, and a refused submit.
The keyboard path through a form and its save is `FormPickerTests` from
#2622.

`dotnet build -warnaserror`, the unit and bUnit suite, the browser tests
and the integration suite pass locally. No new package and no new
project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:forms Generated forms (FRM) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generated forms for content types, saved as a person's change set

1 participant