Skip to content

Editor round-trip - semantic-id-aware mention contract#23204

Open
akabiru wants to merge 10 commits into
feature/text-macro-preload-cachefrom
feature/editor-mention-semantic-id
Open

Editor round-trip - semantic-id-aware mention contract#23204
akabiru wants to merge 10 commits into
feature/text-macro-preload-cachefrom
feature/editor-mention-semantic-id

Conversation

@akabiru
Copy link
Copy Markdown
Member

@akabiru akabiru commented May 13, 2026

Ticket

https://community.openproject.org/wp/74949

Note

Stacked on #23203. Final slice carved out of #22976. Paired with commonmark-ckeditor-build PR 113; both ship together per the build repo's README.

What are you trying to accomplish?

Close the editor round-trip for work-package mentions: an author picks #PROJ-7 from autocomplete and the markdown source persists as <mention data-id="10244" data-display-id="PROJ-7" …>. The reverse direction — stored <mention data-id="10244" data-display-id="PROJ-7"> renders as a link whose label and href both output the user-facing identifier. With this slice plus CKEditor PR 113 merged, the contract is consistent end-to-end across both modes.

Contract

data-id = work-package id (stable across renames). data-display-id = user-facing identifier (PROJ-7 in semantic mode, the numeric id as a string in classic). Same convention on <mention> envelopes and <opce-macro-wp-quickinfo>. Matches data-type="user"/"group" mentions, which already used numeric data-id.

Storage rule (CKEditor side)

One invariant across both modes:

  • Autocomplete pick → <mention> envelope.
  • Source-typed shorthand → bare markdown.

The presence of a record-id signal (wpId on the widget model, data-display-id on the wire) is the discriminator. Autocomplete picks set it; source-typed parses don't.

Backend render matrix

Stored markdown Rendered HTML
<mention data-id="<id>" data-display-id="<displayId>" data-type="work_package">…</mention> mention link / quickinfo widget, label and href use data-display-id
Legacy <mention data-id="42"> (no data-display-id) mention link, collapses to #42
Bare #N / #PROJ-N (source-typed) plain text → resolved link via macro pipeline (slice S4)
Bare ##N / ###N (source-typed) <opce-macro-wp-quickinfo> widget (slice S4)

auto_complete returns displayId as a string (APIv3 parity); the frontend treats it as the user-facing label and the value the macro pipeline can resolve. MentionFilter reads data-id to fetch the WP and renders the link with display_id so the rendered output never disagrees with the envelope. The Angular fallback in work-package-quickinfo-macro.component.ts prefers dataset.displayId and falls back to dataset.id so legacy stored markdown (single-attribute envelopes emitted before the split) keeps loading.

Screenshots

Autocomplete dropdown labels show Type SEMANTIC-ID: subject (driven by mode-aware WorkPackage#to_s):

pr22976-autocomplete-dropdown-semantic-annotated

## quickinfo trigger — autocomplete then the inserted widget displays the semantic identifier:

pr22976-autocomplete-double-hash-trigger-annotated pr22976-double-hash-widget-inserted-annotated

What approach did you choose and why?

The split between data-id (record id) and data-display-id (user-facing identifier) means the envelope survives a rename: a stored mention keeps pointing at the same work package even after its project moves or the work package's identifier changes. The rendered label and href always resolve via the current display_id, so users see consistent text everywhere the macro pipeline runs.

On the autocomplete API, displayId ships as a string so it's interchangeable with the value the user typed and with what APIv3 already returns elsewhere. formattedId was briefly considered as a parallel field but folded back out — displayId plus the front-end's existing label composition is sufficient. The mention-trigger feature spec runs in both classic and semantic mode through a shared example, which catches drift on either branch.

The CKEditor bundle is rebuilt against PR 113's source so the OpenProject worktree carries the matching wire format. The rebuild commit is separated from the logic changes to keep the diff scannable.

Merge checklist

  • RSpec — 85 examples, 0 failures across auto_completes_controller_spec, mention_filter_spec, in_tool_links_spec, link_handlers/work_packages_spec
  • Rubocop clean (no new offences on changed files)
  • Vendored CKEditor bundle rebuilt from commonmark-ckeditor-build PR 113
  • Manual editor round-trip: type #PROJ-1 / ##PROJ-1 / ###PROJ-1, pick from autocomplete, stored markdown carries <mention data-id data-display-id>, reload and resave clean
  • Paired ship: do not merge until commonmark-ckeditor-build PR 113 is ready

@akabiru akabiru force-pushed the feature/text-macro-semantic-id-rendering branch from f83491a to c365ceb Compare May 13, 2026 15:27
@akabiru akabiru force-pushed the feature/editor-mention-semantic-id branch from c775cff to 7112d77 Compare May 13, 2026 15:31
@akabiru akabiru force-pushed the feature/text-macro-semantic-id-rendering branch from 76dc2e2 to 5feb9d3 Compare May 13, 2026 18:17
@akabiru akabiru force-pushed the feature/editor-mention-semantic-id branch from 810b665 to f2d3302 Compare May 13, 2026 18:37
@akabiru akabiru force-pushed the feature/text-macro-semantic-id-rendering branch from 66da22d to cbeaeca Compare May 15, 2026 05:24
akabiru added 10 commits May 15, 2026 10:59
`MentionFilter#work_package_mention` and
`LinkHandlers::WorkPackages#render_work_package_macro` render
`<opce-macro-wp-quickinfo data-id="<wp.id>" data-display-id="<wp.display_id>" data-detailed="…">`.
`data-id` is the work-package id (stable across renames);
`data-display-id` is the user-facing identifier (semantic in
semantic mode, numeric string in classic). The convention matches
the wire form on `<mention>` envelopes and the
`data-type="user"`/`"group"` mention convention, where `data-id`
has always been the record id.

The link-handler fetches the work package on the semantic-mode
quickinfo branch too — the preload is already populated in semantic
mode, so this is a `RequestStore` hit, not a SELECT.
`MentionFilter` reads `data-id` and resolves via `find_by(id:)`.
Non-numeric `data-id` (parser-emitted source-typed mentions) falls
back to literal text.

`WorkPackageQuickinfoMacroComponent` reads
`dataset.displayId ?? dataset.id` so stored markdown produced before
the attribute split keeps loading: legacy
`<opce-macro-wp-quickinfo data-id="DISPLAY">` resolves via the
fallback; new shape resolves via the preferred attribute.

Backend specs lock the new shape end-to-end: the link handler test
fixture, the in-tool-links pipeline test, and the MentionFilter
spec all assert distinct `data-id` (id) and `data-display-id`
(display_id) values where they diverge, and identical values where
they don't.
The CKEditor mention plugin (in commonmark-ckeditor-build) reads
`displayId` from each search result so the markdown source it inserts
on autocomplete-pick speaks the user-facing identifier — `#PROJ-7` in
semantic mode, `#1234` in classic. The endpoint previously serialised
`work_package.attributes`, which gave the raw `identifier` column but
not the mode-aware `display_id` / `formatted_id` accessors.

Fold both into the JSON response. `displayId` collapses to the numeric
id in classic mode, so the frontend never needs a per-mode branch.
The only consumer of `/work_packages/auto_complete.json` is the CKEditor
mention feed in commonmark-ckeditor-build, which reads `displayId`
(needed to build the markdown source `#PROJ-7` and the link URL) and
`to_s` (used as the dropdown label). `formattedId` was added alongside
`displayId` for symmetry but nothing reads it.
The auto_complete endpoint exposed `displayId` as the bare
`work_package.display_id`, which is an Integer in classic mode and a
String in semantic mode. APIv3 already coerces this field on the work
package representer (and on its `:children` and `:ancestors` link
collections), so the auto_complete payload was the lone outlier.

The single CKEditor consumer interpolates the value into a string, so
this change is purely a consistency fix at the JSON boundary; nothing
behavioural depends on the wire type.
The CKEditor mention plugin reads `displayId` (camelCase, stringified)
off each entry to insert `#PROJ-7` or `#1234` into the editor source and
to build the mention's link URL. The contract must hold in both classic
and semantic mode so the frontend doesn't have to branch on identifier
shape, and the comment in the controller cites APIv3 parity as the reason
the value is forced to a String.

Render-views spec asserts the camelCase key, the stringified value, and
the mode-conditional shape (numeric id in classic, semantic identifier
in semantic).
The "can autocomplete work packages with different triggers" example
exercised \`#\` / \`##\` / \`###\` markers but only in classic mode with
hardcoded numeric ids — the whole point of the PR is mode-aware
behaviour, so semantic mode getting no integration coverage was a real
gap.

Wrap the body as a \`shared_examples\` and pull in via two contexts.
Parameterised over \`wp.display_id\` (what the user types) and
\`wp.formatted_id\` (the visible link label in rendered journal notes).
In classic mode both collapse to the existing strings, so the original
assertions still hold; in semantic mode they expand to \`PROJ-7\`.
The previous version reached for `wp.display_id` and `wp.formatted_id`
inside the example body, so the spec was implicitly re-testing the
dispatch logic of those helpers (covered elsewhere) rather than
asserting on the column values it actually cares about.

Each consuming context now defines `wp_id` and `wp_label` lets so the
shared example asserts directly on the typed value and the rendered
link label. Classic mode passes `wp.id` and `"##{wp.id}"`. Semantic
mode passes `wp.identifier` for both — the rendered link drops the `#`
for semantic identifiers.
The `## / ###` autocomplete assertions pin both `data-id` (the
record id, `mentioned_work_package.id` in either mode) and
`data-display-id` (what the user typed — `wp_display_id` resolving
to the numeric id in classic and the identifier in semantic) on the
widget DOM that appears in the editor right after a pick. The
classic / semantic asymmetry now lives in the assertion itself
rather than in a per-context CSS selector.
- mention_filter.rb: rename local `href_id` to `display_id` to mirror the
  method name and the `data-display-id` wire attribute (the value is the
  user-facing identifier, used for both the href and the data-attribute).
- auto_completes_controller.rb: trim the `displayId` doc comment that
  incorrectly claimed the editor builds the mention's link URL from this
  field. The URL is composed server-side at render time from `data-id` /
  `data-display-id`; the editor only inserts the markdown source.
@akabiru akabiru force-pushed the feature/editor-mention-semantic-id branch from f2d3302 to 27c6f72 Compare May 15, 2026 07:59
@akabiru akabiru changed the base branch from feature/text-macro-semantic-id-rendering to feature/text-macro-preload-cache May 15, 2026 07:59
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 15, 2026

Deploying openproject with PullPreview

Field Value
Latest commit 27c6f72
Job deploy
Status ✅ Deploy successful
Preview URL https://pr-23204-editor-mention-ip-178-104-180-200.my.opf.run:443

View logs

@akabiru akabiru requested review from a team, oliverguenther and thykel May 15, 2026 23:34
@akabiru akabiru marked this pull request as ready for review May 15, 2026 23:34
@akabiru akabiru changed the title Editor round-trip — semantic-id-aware mention contract Editor round-trip - semantic-id-aware mention contract May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

1 participant