Skip to content

Keep the entry form's base version and the values a person didn't touch - #2638

Merged
pournasserian merged 9 commits into
v2from
2630-entry-form-fixes
Oct 19, 2026
Merged

pournasserian merged 9 commits into
v2from
2630-entry-form-fixes

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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 (Add search-backed pickers for reference and media fields #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 Keep the entry form's base version, stop rewriting untouched values, and say which draft the form shows #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:

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

  • 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

# Conflicts:
#	tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs
#	tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs
@pournasserian
pournasserian merged commit f35ea66 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2630-entry-form-fixes branch October 19, 2026 22:36
pournasserian added a commit that referenced this pull request Oct 20, 2026
…vive a lost circuit (#2655)

Closes #2639

## What

Follow-ups from the independent review of #2622 (F-FRM, #2426).

- **The search box owns its text.** The server never writes `value`
back, so a key typed while a search runs is never lost. `admin.js`
empties the box after a choice.
- **The island takes no option list.** Its parameters are the field, the
chosen id and text, so the circuit's start message stays small however
many entries or assets there are.
- Before the circuit attaches, the prerendered select loads its own
choices: the 200 newest, and the chosen one named.
  - `EntryEditor` no longer loads every entry and asset for its pickers.
- **Every read goes through a scope of its own.** The pickers of a form
load at once, and searches overlap as the person types, while a
`DbContext` takes one query at a time. This is the rule from #2642.
- **A search waits for a 250 ms pause in typing,** and only the newest
search shows its results.
- **The media picker asks the library for its text.** `MediaQuery`
gains:
  - `Text`, which matches a file name or folder whatever its case;
- `Newest`, which returns that many of the newest assets, newest first.

The text is matched in .NET over the names alone, because SQLite's
`lower()` folds ASCII letters only. Only the matching assets are read
after that, so the picker no longer reads every asset on every
keystroke.
- **Enter in the box never submits the form.** It chooses the only
match, or enters the list when there are more (`admin.js`).
- The list says which text it answers (`data-for`). Enter acts only when
the box still holds that text, so a fast typist never chooses a match
left from an earlier word.
- Text, not a count of keys, so keys typed while the connection was down
can't put the box and the server out of step.
- **Focus stays in the box** after a choice, Clear or Escape.
- **The choice is kept with the circuit** (`[PersistentState]`, keyed by
field). A circuit paused and resumed, as after a laptop sleeps, keeps
what the person chose rather than going back to the stored value. A new
stored value replaces it, as when enhanced navigation shows another
version of the entry.
- **A choice is named by the type's first text field,** in definition
order, with its state and short id. An entity's fact value counts. The
chosen option in the prerendered select is named the same way.
- **A lost circuit doesn't reload a page whose form holds a picker or
any typed input,** such as a rejection reason on the set view.
- The reconnect display says the connection was lost and the page keeps
what was typed. It offers to reload, and says that reloading loses it.
  - The picker boxes are disabled. The hidden choice still posts.
- Enhanced navigation is turned off for the rest of the page's life, so
every link loads its page in full. So do Back and Forward: Blazor
handles them without looking at that attribute, so a capture-phase
`popstate` listener reloads the page instead. Without a circuit nothing
can render the next page's islands, and an enhanced navigation would
keep this page's: another version's picker choice, or another set's
items.

A choice made in the select before the circuit attaches is still
replaced by the stored one. That is #2654. The select still reads every
entry of its type to show the newest 200. That is #2656.

## Why

- **No `value` on the box rather than `@bind`.** Every server render
writes a bound value back, and that write is what dropped keys under
latency. The box needs no server state between keys: the search reads
the text from the event.
- **Full page loads after a lost circuit belong to this PR.** Before it,
a lost circuit always reloaded the page. #2639's acceptance keeps the
page, so the form's input survives. A page kept without a circuit can't
render the next page's islands, and an enhanced navigation would carry
this page's dead ones. Turning enhanced navigation off once the page is
kept completes that change.
- **The select loads its choices in prerender.** Anything passed as an
island parameter travels in the circuit's start message, and SignalR
closes the connection when that message passes 32 KB.

## Tests

- **Browser, on PostgreSQL when it is configured (as in CI), otherwise
SQLite:**
- A keyboard-only path from the top of the page, with no `FocusAsync`,
on a target type of 500 entries:
    - type at full speed and keep every character;
- Enter chooses the only match without submitting, and focus stays in
the box;
    - Tab to the rationale and save.
- Searches with no pause overlap, the newest one shows, and the circuit
stays up.
  - Enter never chooses a match left from an earlier search.
- A paused and resumed circuit keeps the choice, and Enter still chooses
after keys typed while it was paused. Without the fix, the test fails.
- Starting from the published version gives the picker that version's
value, and the save proposes it.
- A rejected circuit keeps the form, the typed title and the choice, and
disables the search. The form then saves with both.
- A rejected circuit keeps a set view's typed rejection reason, and N
then loads the next set in full with its own items.
- With the circuit lost, "Start from the published version" loads the
published value into the picker.
- Back after an enhanced move, with the circuit lost, loads the earlier
page in full: the draft with its own choice, or the first set with its
own items.
  - Without the fix, both of these fail.
- The axe check on a form with pickers is the kitchen-sink form from
#2638.
- **Integration, on SQLite and PostgreSQL:** media text search finds
"Ärzte.png" by "ärzte" and "ÄRZTE", and `Newest` keeps the newest first.
- **bUnit:**
  - The island's parameters are only strings and flags.
  - Only the newest of two overlapping searches shows.
  - A media search asks the library for its text and the 10 newest.
- An entry is named by its type's first text field, and the select names
the chosen entry.

`dotnet build -warnaserror`, the unit and bUnit suite, the browser tests
on SQLite and PostgreSQL, and the form and media tests 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

type:bug Defect v2 FluentCMS v2 (v2 branch)

Projects

None yet

1 participant