Profile editing
The Profile editing content element first renders all
profiles assigned to the authenticated frontend user. Its Edit
action opens the selected profile in Profile editing; View opens
the public academic_persons Detail plugin on the page configured through
plugin.tx_academicpersons.detailPid. This is the same target setting used
by the Academic Persons list views. The shipped
Resources/ editor template
contains four independently persisted areas:
- profile fields using the generic JSON update endpoint,
- the synchronization checkbox using its own JSON endpoint,
- the expanding profile-image editor using dedicated upload and delete endpoints, and
- the structured document sections — contracts, their contacts and the profile-information collections — each with their own form, create, update, delete and sort endpoints.
Fluid renders the markup of that view, and five custom elements own its
behavior. The entry point is maintained as TypeScript in
Resources/, from which the
frontend build generates
Resources/; it defines those
elements and does nothing else. Every editor on the page is an
<academic-persons-edit-profile-editing> element that starts itself when the
browser upgrades it, so a second plugin on the page — or one that is loaded
into the page later — needs no start-up scan and cannot be started twice.
Typed feature modules below
Resources/ separately own common
requests and status output, field editing, documents, rich text,
synchronization, image editing and sticky positioning; the five elements below
…/ drive them. All of them are plain custom elements
that depend on no user interface library, and none of them renders markup: each
controls what Fluid rendered. No framework is bundled with the extension, and
none is loaded from anywhere else either.
All changes are saved through AJAX without reloading the page. Editable fields
are discovered across the complete component root, even when the responsive
page layout places them in separate data-pe-fields-form elements. Every
element renders into the light DOM — none of them has a shadow root — so a
project's stylesheet reaches every control they render, exactly as it reaches
the Fluid-rendered ones. The inline collapse targets, the two status regions
and the client-side templates live in the same component scope.
Assigned profile overview
ProfileController::listAction() is the default action. It uses the
authenticated frontend-user relation and passes {profileListItems} to
Resources/. Every item contains
the Profile object and the title of its sys_language_uid from the current
site configuration. The profile column contains a square thumbnail or the
shipped placeholder followed by the complete name. Edit passes the
explicit profileUid to indexAction(); the action resolves that UID again
through the authenticated user's assigned profiles before rendering anything.
An unassigned or manipulated UID receives the 403 access denied response of
the site, the same one an unauthenticated visitor gets — raised as a propagated
response rather than returned by the action, because on TYPO3 v13 the status
code of an Extbase plugin response never reaches the frontend response.
The View URI uses extension academicpersons, controller
Profile, action detail and plugin Detail. Its target page comes
from plugin.tx_academicpersons.detailPid and must contain the
academicpersons_detail content element. The ProfileEditing TypoScript copies
the Academic Persons constant into plugin.tx_academicpersonsedit.settings;
there is no separate editor-specific detail PID. Importing
EXT: is optional and
turns the query-string link into a speaking URI.
View data
The controller assigns the following variables to the Fluid template:
| Variable | Description |
|---|---|
{profile} | Explicitly selected profile after its assignment to the authenticated frontend user has been verified. |
{profileListItems} | Assigned Profiles and their readable site-language labels. This variable belongs to the default list action and list template. |
{profileSections} | Ordered Fluid view models generated from
profile
. Every
section contains regular fields and inserted composite special items. |
{specialFields} | Typed
special
components, including composed title, image and
synchronization metadata. |
{profileFieldOptions} | Options for every configured
render field. The
option source remains the matching Profile TCA field. |
{documentSections} | Ordered structured-section view models derived from
document, including mapping, read-only and validation
metadata plus the typed records. |
{imageAllowedMimeTypes} | Comma-separated MIME types accepted by the image input. The server validates the configured values independently. |
{editorLanguage} | The language code of the current site language, lower-cased and without its region. CKEditor is initialized with it; an empty string when the request carries no site language. |
{data} and {record} | Current content element data and its record object. |
View structure
The template is intentionally a composition root. The main partial groups are:
| Partial group | Responsibility |
|---|---|
Image/Card.html and Image/Editor.html | Profile image heading above the sticky page preview, animated full-width editor, file selection, crop preview and image actions. |
Profile/Personal.html, Profile/About.html and
Profile/Fields.html | Personal-data and about-section boundaries plus ordered iteration of settings-driven profile fields. |
Field/Editable.html, Field/Control.html,
Field/Select.html, Field/Checkbox.html and
Field/Group.html | renderType-driven controls, previews, grouped values and
persistence actions. |
Documents/Sections.html, Documents/Contract.html,
Documents/ProfileInformation.html and their *Row.html | Structured document sections, their rows, the row actions and the hidden row prototype that is cloned after a create. |
Documents/Editor.html | The panel of the document editor, as a prototype the element clones; see How the document editor is rendered. |
Header.html and StatusToast.html | Complete profile-name heading with synchronization/edit-all controls,
and scoped status output. The status output is two live regions, one
assertive and one polite, because a region's politeness cannot be
changed reliably once it is in the accessibility tree. The personal
form renders its own Personal data heading.
ButtonTemplates.html remains a compatibility fallback for
existing template overrides; the shipped read view does not use its
button-shaped value controls. |
Layout and responsive behavior
The view uses Bootstrap 5 grid, spacing, typography, background, positioning
and form utilities; the Fluid templates contain no inline style declarations.
Resources/ is generated from
Resources/ by the repository
build and holds only what Bootstrap cannot express: the display: block the
custom elements need, a [hidden] rule that outranks Bootstrap's display
utilities, three corrections to a surrounding theme (a .section overflow,
one frame spacing variable, the stacking of the sticky card), the focus ring of
the controls and buttons, the drag states of a sortable list and the enter/leave
classes of the two editor transitions. All Bootstrap button controls of the
shipped editor carry rounded-0 so their corners remain square.
The focus ring is the one appearance the view takes away from the surrounding
theme rather than correcting. Bootstrap draws it with box-shadow, several
themes layer further opaque rings behind that one, and a translucent accent ring
over an opaque black one reads as a hard dark rectangle around the focused
control. A shadow is also the wrong mechanism twice over: it is painted outside
the border box, so the overflow: hidden of the two collapse panels cut the
ring off the fields and buttons inside them, and forced-colours mode drops
shadows altogether while the rules that draw them also set outline: 0, which
leaves the appearance in that mode to the browser rather than to the site.
The view therefore draws its own ring as a real outline on
:focus-visible — the pseudo-class the theme's own rules use, so the rule
sits exactly where the defect is — sunk inside the border box with a negative outline-offset so that no
ancestor can clip it, coloured with currentcolor so that it contrasts with
whatever the control it sits on is filled with, and it rewrites box-shadow
in the same rule so no layered ring and no inset shadow survives underneath. It
applies to every input, select, textarea and button the view
renders — the five controls of Partials/, the
image upload of Partials/, the synchronisation
switch of Partials/ and the buttons of every action
group — because two focus appearances in one form would be worse than the one
being replaced.
:focus-visible is not a synonym for keyboard focus. Measured in Chrome, a
pointer click on a checkbox or on the synchronisation switch does not match it
and those two keep Bootstrap's soft glow, while a pointer click on a text
input or a select does match it and those take the inset ring. Most of what
the view renders is in the second group, so the change is visible to a visitor
using a mouse and not only to one using the keyboard. The colour is
currentcolor, which is the colour a control draws its own text in — but
not the colour of a checkbox tick or a switch knob, which Bootstrap paints as
a background image with a hardcoded #ffffff. On those two the ring takes
the inherited body colour, #212121 on the #577760 of a checked control
with the shipped theme, and measures 3.23:1: enough for WCAG 2.1 SC 1.4.11 and
no more, so a site with a darker $primary has to check that pair. Plain links are not covered, no theme rule takes their focus
ring away. CKEditor 5 is covered by halves: its editable region is a div,
so it is outside the rule and keeps the library's own focus styling, while the
buttons of its toolbar are button elements below the plugin root and take
this ring rather than the library's. That is the same answer as everywhere
else - one focus appearance for everything the visitor can focus.
Above the grid, the complete profile name and the synchronization/edit-all
controls share one responsive header row. The controls wrap below the name on
narrow viewports. On lg and larger viewports the first content row uses a
4 / 8 column split.
The profile image block has sticky-top so the image and its edit action
stay visible while the profile data scrolls. Below lg both columns stack in
document order. The about section follows the complete first row and therefore
never overlaps the sticky column.
At runtime initializeStickyImageOffset() reads the visible outer height of
#page-header.navbar-fixed-top through getBoundingClientRect().height,
adds a 10-pixel visual gap and assigns the result to the top property of
data-pe-sticky-image. The measurement itself is
observePageHeaderOffset() of academic_persons, which the public detail
view uses for its own sticky navigation — one implementation, two callers. A
ResizeObserver watches the header's border-box and keeps the offset
synchronized whenever the navbar changes height, including height or padding
changes caused by a scroll-dependent header state. Environments without it use
the window resize event as a fallback. If the matching fixed page header
is absent, Bootstrap's regular sticky-top value remains in control.
The two columns live in their own align-items-stretch row. The image column
inherits the stretched cross-axis size, giving the sticky image a containing
block as tall as the adjacent profile data. The full-width about section keeps
its own col-12 in a separate sibling row below it.
The complete profile name is the page's h1 above both columns. Both Fluid and
JavaScript use the ordered fields list from
special.. Fluid
renders the initial name; data-pe-profile-name-field-ids lets JavaScript
recompose the same name after a successful update without reloading the page.
Profile values are rendered as readable text rows with alternating
bg-body-tertiary surfaces. The only read-mode action is a borderless pencil
button with an accessible label. Name components and the URL/title pair of
each link share one preview row and open as one editing group. Only one field
or group is ever open: a second pencil, and Edit all, discard the
one that is open before they open theirs
(Two editing modes).
The special name editor retains the established responsive grid (academic
title / first name at 4 / 8 and middle / last name at 6 / 6) without
putting layout metadata into YAML.
Settings-driven controls
ProfileSectionProvider converts the typed
profile
and
special
settings into an ordered Fluid view model. Section placement
itself remains explicit in Templates/: the
template decides where profileSections.information and
profileSections.aboutme appear. It does not enumerate their individual
fields.
Profile/Fields.html chooses the field control solely from renderType.
Option values are not duplicated in YAML: for a select field,
ProfileFieldOptionsService reads the corresponding Profile TCA items.
Preview behavior likewise follows the rendered control type.
Removing a special component removes its controls; marking the image or
synchronization special readonly or disabled also blocks the matching
write endpoint, not just its Fluid control.
A translated profile initially uses TYPO3's parent localization state for
the image and follows changes to the default-language image. The first upload
or deletion in that frontend language changes the image field to custom;
the translated profile then owns its FAL reference and can select, replace or
delete its image independently. The image controls therefore remain available
while editing a translated profile; the endpoint still verifies that the
requested profile is assigned to the authenticated frontend user.
The shared
Resources/ partial
composes three focused, reusable partials:
Field/renders the text preview and pencil trigger — the trigger that opens this field and closes whichever other one is open,Preview. html Field/renders eitherControl. html f:form.textfieldorf:form.textarea, including the CKEditor hook, andField/renders delete, cancel and save.Actions. html
Field/ composes related textfields below one preview. Its
data-pe-field-ids value defines which fields open, cancel and save together;
data-pe-display-field-ids and data-pe-display-mode control whether the
preview joins values (the name) or uses the first non-empty value (link title
falling back to its URL).
Helptext buttons are edit controls: field and group previews do not render them. They appear after the corresponding field editor is opened. Document helptexts follow the same rule and are present in add/edit forms, but not in a document editor opened in view mode.
validation.inputType supplies the concrete HTML input type. Checkbox
controls save immediately on change. Select controls use the same clear, undo
and save actions as text fields. The synchronization switch in
Header. is persisted through its own endpoint.
| Requirement | Implementation |
|---|---|
| Telephone number | Textfield with inputType: 'tel' or a validation input type of
tel. |
| Website address | Textfield with inputType: 'url' or a validation input type of
url. |
| Free text input | Default textfield. |
| Select | Field/; options come from the configured
field's TCA items and changes save immediately. |
| Checkbox | Field/ for direct Profile flags. The
synchronization special uses its own form and endpoint. |
| Multiline text | Field partial with textarea: true. Passing richText: true
additionally turns the textarea into the TYPO3 CKEditor 5 when the
field is opened. |
Two editing modes
The profile fields have two mutually exclusive editing modes, and the controls say which one is active.
Single field. The pencil beside a value opens that field — or, for a group,
that group — and the three buttons of Field/ act on it:
clear empties the control and keeps it open, undo restores the saved value and
closes it, save posts what changed. This is the mode described everywhere else
on this page, and the editors of the document and contract panels behave the
same way.
Only one editor is open at a time — one field or group, the whole form, one document row or one contact of a contract. Opening another one, and entering full form editing, closes the one that is open first. An editor that still holds the values it was opened with closes silently, exactly as its own Undo or Cancel would close it. An editor with changes does not: the view asks, in a dialog of its own rather than a browser prompt, whether to Save and continue, to Discard changes or to Keep editing. Saving stores exactly what the open editor's own save would store and then opens the other editor; a save the server refuses keeps the visitor in the refused editor with its messages, and opens nothing. Discarding puts the editor back to the stored values and announces it in the polite live region: Unsaved changes were discarded. Keeping — and Escape in the dialog — opens nothing at all. Pressing the pencil of the field or group that is already open changes nothing and keeps what has been typed.
The dialog is the unsaved-changes template of
Partials/, a <dialog> element the view
clones and shows modally; its labels are the extension's own and are
overridable with the partial. A site that drops the template leaves the visitor
in the editor that is open: nothing is thrown away because a template is
missing.
Full form. Edit all opens every editable field of the profile
at once. While it is open, every per-field and per-group button group is hidden
— including the undo beside an autosaving checkbox — and one bar governs the
whole form. Field/ renders it at the end of every
data-pe-fields-form, and the shipped template has two of those (personal
data and about me); each bar acts on all of them.
| Control | Behavior |
|---|---|
| Apply | Posts every changed field in one request to the generic field update endpoint below. On success the stored values are written back into the controls, the previews and the name heading, the form closes and the focus returns to Edit all. |
| Undo | Restores every field to the value that is stored and keeps the form open. Announced in the polite live region. |
| Discard | Restores every field and closes the form. Pressing Edit all again does the same. |
updateAction() validates the complete field map before it persists anything,
so an apply either stores all of it or none of it. A refusal reverts nothing:
the entered values stay where they were entered, each refused field is marked
and described by its own message, the caret goes to the first of them, and the
refusal is announced once rather than once per field.
While an apply is on its way to the server, Undo, Discard, Edit all and Escape do nothing. The request cannot be taken back, and reverting under it would leave the stored profile and the editor's own baseline disagreeing without anything on screen saying so.
The same holds for a save beside a single field: because opening an editor now discards one, a pencil, Edit all and the Delete content and Undo beside a field or group do nothing at all while such a save is on its way — not even open the editor that was asked for. All of them work again as soon as the answer has been written back, and while they do not, they say so in the polite live region: Please wait until the change has been saved.
A request that the server accepts and never answers therefore leaves the editors of that profile refusing until the page is reloaded. The frontend deliberately sets no timeout on its requests, so nothing here decides on the visitor's behalf that a slow save has failed.
A pencil pressed while Edit all is open does nothing at all, and says nothing: every field is already open, and the form's own bar is the way out of the mode.
A checkbox that saves on change does not save while the form is open — it is
applied with everything else. Without that it would reach the database while the
visitor is still deciding and Discard could not take it back. The
synchronization switch of Header. is outside the field forms and
keeps saving immediately.
Keyboard and assistive technology
- Opening the form puts the caret in the first editable field.
- The bar stands after the fields in document order, so tabbing on from the last field reaches Apply, Undo, Discard.
- Escape discards the form, unless the caret is inside a CKEditor instance, where the key closes the editor's own balloon first.
- Ctrl + Enter applies it, and so does Enter in a text field: the form is submitted and the submission is turned into an apply rather than into a page load.
- Both keys are handled by the field form itself, so a document, contract or image editor that is open at the same time keeps its own handling of them.
- The bar is a
role="group"with its own accessible name. Edit all carriesaria-pressedand names the forms it controls inaria-controls. Results are announced in the two live regions the editor already has. The restored notice and a success are polite; a failure and a refusal of the form are assertive, because the caret has just been moved to the first refused field and a polite message queued behind that is routinely dropped. A refusal beside a single field stays polite — it stands next to the control the visitor is already in.
What an override has to keep
Profile/ renders Profile/ at its
end; an override of it that drops the line leaves the profile with no way to
apply the form. An override of the bar itself keeps the
data-pe-form-actions element, its data-pe-form-reverted-message and the
three buttons marked data-pe-form-apply, data-pe-form-undo and
data-pe-form-discard. Everything else about it — the tags, the classes, the
labels, the order — is Fluid, and none of it is spelled in JavaScript. The
optional sectionLabel argument names the button group after the section it
stands in, so a page with two field forms does not offer two groups of the same
name.
Generic field update
The URL is generated by f:uri.action for updateAction() with page type
1733735
(see The page type of the JSON endpoints). Requests must use
POST, Content-Type: application/json and the request header
X-Requested-With: XMLHttpRequest (see The request header the endpoints require).
Only values changed since the last successful save are sent. An empty string
clears a property when its section-local validation permits an empty value;
omitted properties remain unchanged.
{
"profile": 123,
"data": {
"gender": "female",
"website": ""
}
}
Unknown profile properties, configured read-only/disabled fields and select
values not configured in the matching TCA field are rejected. The direct academic/honorific
profile.title field is an ordinary configured Profile property; the
composed display name is
special.. skipSync is a direct special
property. A configured combinedLink additionally enables its matching
*Title companion. All other writable Profile properties come from
profile
.
Extbase validation errors are returned in an errors object keyed by
property path and are mapped back to the corresponding form controls by the
frontend module.
ProfileFieldOptionsService supplies both the presentation options and the
strict allow-list validation for every configured select. Thus another select
does not require a new Fluid partial, validator service or JavaScript branch.
Rich-text content fields
The shipped configuration marks five profile properties with
render. Four are displayed with the information
section, while miscellaneous belongs to
aboutme
:
coreCompetences,teachingArea,supervisedDoctoralThesis,supervisedThesis, andmiscellaneous.
The editor is TYPO3's shipped CKEditor 5 from the
typo3/cms-rte-ckeditor package. The profile/rich-text.js module imports
its CKEditor modules through TYPO3's JavaScript import map; it does not load an
editor from a CDN. An editor instance is created lazily when a rich-text field is opened.
If initialization fails, the original textarea remains available and the
component reports an error.
The toolbar intentionally exposes only undo, redo, bold, italic, bulleted lists, numbered lists and links. Editor changes are mirrored into the underlying textarea, so required-field validation, changed-value detection and the existing JSON request remain the single persistence path. CKEditor's initial HTML normalization becomes the local comparison baseline; merely opening an editor therefore does not submit or rewrite legacy content.
Profile fields may configure a positive characterLimit next to
renderType: ckeditor. Fluid then adds the limit metadata and an accessible
live counter to that field. The shared rich-text module counts normalized
visible text, keeps the last accepted value when an addition would exceed the
limit and still allows older over-limit content to be shortened. The Extbase
validator checks the submitted sanitized value again before the partial update
is persisted. The shipped miscellaneous field uses a limit of 1000.
Outside edit mode, each rich-text field renders its formatted content directly
and provides a borderless pencil action on the right. Empty values show a
localized placeholder. The preview is initially rendered through TYPO3's
HTML formatting pipeline and is replaced after a successful save with the
sanitized markup returned by the server. The frontend applies the same strict
tag, attribute and URI-scheme allowlist without assigning markup through
innerHTML.
Each open text or rich-text field has three explicit actions. For
renderType: ckeditor only, the action group sits in the label row with
Bootstrap's ms-auto utility, leaving the editor itself full width. Other
field types retain the action group beside their control. Selects and checkboxes
save immediately when their value changes.
- Delete (
data-pe-dismiss) clears the current browser-side draft. The editor stays open and no request is sent. - Cancel (
data-pe-cancel) restores the last successfully persisted value and closes only that field. No request is sent. - Save (
data-pe-save) sends that field through the JSON AJAX endpoint. It closes the field only after a successful response or when there is no changed value to persist.
The action group uses Bootstrap utility classes to remain content-sized and
align itself to the end of the editor row instead of stretching to the
CKEditor height. No additional stylesheet or inline style is required. The
Edit all toggle beside the page heading opens both regular
fields and grouped rows, receives Bootstrap's active state and changes its
label to Close all. Activating it again collapses every editor
without saving or discarding browser-side drafts. There is no global footer
action area; save and undo remain explicit per-field actions.
The pencil is rendered through TYPO3's core:icon ViewHelper. Template
overrides may replace the icon but must retain the button's edit hook,
data-pe-for target and accessible label. The profile value itself must not
be placed back inside the button.
Server-side sanitization
Client-side editor configuration is not treated as a security boundary.
ProfileUpdateValidationService derives writable properties from the
configured profile sections. It passes every configured
render property through ProfileRichTextSanitizer
before validation and persistence. The sanitizer uses TYPO3's allow-list based
HTML sanitizer and permits only:
- the tags
p,br,strong,em,ul,ol,lianda; - the
hrefattribute ona; and - local links and the URI schemes
http,https,mailtoandtel.
Scripts, event-handler attributes, style attributes, images, unknown tags and unsafe URI schemes are removed. The successful JSON response contains the normalized, sanitized values. The frontend replaces its local editor and preview state with exactly those returned values rather than trusting the submitted markup.
Each AJAX request is validated as a partial profile submission. The validator
selects only submitted or explicitly overridden DTO properties from their
configured profile sections. A required rule on an omitted sibling field
therefore does not block a field update or the dedicated skipSync request.
Submitted inline overrides are validated after normalization and sanitization.
The extension requires TYPO3 13.4 or TYPO3 14.3. These constraints include the HTML-sanitizer fixes published with TYPO3-CORE-SA-2026-006. Projects must still keep TYPO3 security updates current.
Editable structured profile sections
The profile editing view renders the structured records directly below the
About me field. ProfileDocumentSectionProvider supplies one
ordered view model instead of duplicating relation mapping in Fluid. It reads
all sections, including contracts, from the shared settings graph. That
graph is built from the active packages'
EXT: files,
so configured order
is also presentation order. The same graph supplies the public profile,
frontend validation and backend TCA metadata.
For every document section the provider reuses the configured identifier,
fieldName, record type, LLL label, readonly state,
rowFields, actions and the section-local validations. The heading is
translated directly from that label.
A newly configured type consequently does not require another section registry
in academic_persons_edit. The generic localized empty state is used until
an identifier-specific message is added.
contracts contains FGTCLB\AcademicPersons\Domain\Model\Contract
objects. Every other collection contains
FGTCLB\AcademicPersons\Domain\Model\ProfileInformation objects. Contract
rows show the configured values, which are start date and position in the
shipped settings. Profile-information rows render only the configured values in
their declared order. The aliases from, to and description map to
yearStart, yearEnd and bodytext. All sections remain visible when
empty and display a localized empty state.
The three year properties year, yearStart and yearEnd are four
digit integers stored in nullable
int
columns. Their add/edit control is
an <input type="number"> carrying min="0", max="9999" and
step="1" - the bounds of the TCA range of the same columns, which the
endpoint enforces again on every submission, so a client that ignores them is
refused rather than clamped. Nothing about a year is formatted and nothing
follows a locale.
The three controls each use col-12 col-md-3 and share one responsive row on
medium and larger viewports. Their HTML and server-side required states come
from the same validation set: only a field with the additional required
flag must be filled. In the shipped settings this applies to year but not
to from or to.
The two Contract dates validFrom and validTo are the only date fields
of the editor, and their control is a plain <input type="text"> showing
d.m.Y behind the hint dd.mm.yyyy - the control the previous editor
had. It is not an <input type="date">: the native control follows the
locale of the browser rather than the one of the site, it cannot be styled
with the rest of the editor, and its calendar is not the date picker this
editor is meant to get. A date picker is deliberately not shipped yet. It
is a feature of its own, it will not be the browser's, and until it exists the
date is typed.
The endpoint accepts two formats for such a field. d.m.Y is what the
control shows and submits. Y-m-d is what the endpoint answered and
accepted while the control was a native one and stays accepted, so a client
written against that shape keeps working. Both are read strictly - the parsed
date is formatted back and has to match what came in, so 32.01.2026 is
refused rather than read as the first of February - and anything that is
neither format is refused with The value must be a valid date.
The read view is unaffected: a stored date is rendered as the MEDIUMDATE
of the site language, the way the public profile renders it.
How the document editor is rendered
The editor of a document row is rendered in the browser, and cannot be
rendered anywhere else: its fields, their labels, their select options and
their display values all come from the documentForm response, which the
permission-checked endpoint decides per section, per record and per mode.
Partials/ therefore renders the shape of
that editor, as a <template data-pe-proto="document-panel"> block, and the
custom element <academic-persons-edit-document-editor> clones it and fills
its slots. The element is created by
Type inside the collapse target
below the selected section heading for add actions, and inside the selected
record row for view, edit and delete actions. The element is handed the
response as properties and is removed again when its close transition reports
back. This avoids pre-rendering one hidden form per section and record while
keeping the static structure in Fluid and mapping validation errors directly
back to the returned field names.
Exactly one document collapse is open at a time, while the complete profile
view remains visible — and it counts as the open editor of the profile, so a
pencil of another row, of another section, of a profile field or
Edit all closes it first and asks about unsaved changes exactly
as described for the profile fields above. Activating the same add or view
trigger a second time closes its collapse with the same cleanup as
Cancel. Saving an edit keeps the collapse open with what it
stored, so the next change starts from the stored record; creating and
deleting close it. The contact editor inside a contract behaves the same way:
switching to another contact of the contract asks about a changed one, and a
saved edit stays open. The element is
created where it is shown and is never moved: moving it would disconnect it,
and a disconnect destroys the CKEditor instances below it. The collapse target
keeps the unique ID the controller assigns it when it is first opened, now
for aria-controls alone.
A hidden ordinary DOM container supplies the Fluid-rendered row prototype used
after creation (data-pe-document-item-template). It is a plain container
rather than an HTML template element, and the successful create response is
rendered by cloning the row inside it.
The icons of a browser-rendered editor cannot be resolved in the browser:
core:icon asks the icon registry, which knows the set this extension
registers and whatever a site overrode. Under the prototype design that needs no
mechanism of its own: an icon is rendered by Fluid inside the prototype that
draws it — the help button of a field, the five row controls of a contact and
the add control of a section — so it is part of the markup an override reaches
and no module ever looks one up.
contract is retained as a separate document kind. It uses the same editor
for its configured fields and appends three contract-specific contact sections
in the read view: physical addresses, email addresses and phone numbers. Their
field schemas and validation flags come from
contracts.contactSections.<section>.fields in the shared settings graph.
The Contract form itself follows the order and metadata in
contracts.fields.
Those sections, their rows and the editor of one contact are rendered by a
second custom element, <academic-persons-edit-contract-contacts>, which the
document editor creates in its own template. The contact editor stands below
the contact-section heading for add actions and directly inside the selected
contact row for view, edit and delete actions — the two placements
Documents/ renders one prototype for.
Every contact row of a contract also carries a visibility toggle, the first
control of its action group: it flips the record's own hidden column - the
TCA enablecolumns.disabled field of the address, email and phone number
tables, exactly the switch the previous editor offered for these three record
kinds - through the toggleContractContactVisibility endpoint, which is sent
the target state rather than a "flip" so that a double press stores what the
visitor saw. The button's label names the press - Hide in frontend
or Show in frontend - and its glyph (academic-persons-edit-visible
respectively academic-persons-edit-hidden) shows the state; it carries no
aria-pressed, because a label that changes with the state would announce
the opposite of what a pressed button does. A hidden row is drawn in the
secondary text colour, its controls at full contrast, and carries a
Hidden tag in front of its action group. The toggle follows the
edit entry of the contracts allow-list.
Contracts and profile information rows have the same toggle as the hide
entry of their section's actions list - configurable like every other
action, listed first in the shipped settings, and withdrawn by readonly
with the others. It flips the record's own hidden column through the
toggleDocumentVisibility endpoint, which is sent the target state and
answers the serialised record; the row is written from that answer
(data-item-hidden, the Hidden tag, the label and the glyph of
the toggle) without being rebuilt, so the caret stays on the button. A hidden
record stays in the editor - it is the one place it can be shown again - while
the public views keep reading the relations, which respect the enable fields.
Every writable section heading has an Add action. Record controls
are rendered in the exact order of the configured actions list. The first
row's move-up action and the final row's move-down action are disabled. A list
with both directions also has a drag handle; dropping a row submits the complete
UID order and the server accepts it only when it contains every current section
record exactly once. After a successful mutation JavaScript updates the row
collection, alternating background, sort controls and empty placeholder without
reloading the page. The drag handle is hidden below Bootstrap's md
breakpoint; the explicit up/down controls remain available on mobile.
The action group is drawn in one line and is never pushed below the values of
its row. From the md breakpoint up it stands at the right edge of the row
(col-md-auto flex-shrink-0 ms-md-auto) and the value columns give way to
it: the date columns are col-md-2, the title or position column takes what
is left, and a long word breaks (text-break) instead of claiming the width
of the buttons. Below md the group drops onto a full-width line of its own
below the values and is centred there. The list header carries the same column
widths, so it stays above the values it names.
The add, view, edit and delete workflows share one inline collapse. Its field
schema and current values are loaded through documentFormAction(). Contract
fields include the current organisational-unit, function-type and location
options. Profile-information fields use the section's validation metadata. In
every mode the view heading uses the non-empty title field of the current
record. New records, contracts and records without a title fall back to the
translated section heading; the mode label remains as its prefix.
A field carrying the html flag is rendered as a full-width CKEditor 5
control; ordinary textareas are full width as well. Such a field is an
<academic-persons-edit-rich-text> element of its own, which creates the
editor when it is connected and destroys it when it is disconnected, so a
re-render of the editor around it — which every validation error causes — never
reaches into the subtree CKEditor owns. Rich-text values are sanitized before
persistence and parsed through the frontend sanitizer before the row or the
read-only view receives the HTML.
When that field's editor.limit is a positive integer, the textarea carries
the normalized limit and the view renders an accessible live character
counter below CKEditor. The count uses normalized visible text rather than the
stored HTML. CKEditor rejects additions past the limit while still allowing an
older over-limit value to be shortened. The JSON endpoint and the Extbase
form-data validator independently reject over-limit submissions, so client-side
code is not the security boundary. Character limits do not alter backend TCA or
the database schema; the shared required, readonly and field-type metadata does.
The document pending state is released before CKEditor is initialized, because
CKEditor deliberately skips disabled controls.
Every JSON request increments one shared busy counter. While at least one
request is active the document shows the wait cursor, and the final request
restores the previous cursor in a finally path. aria-busy is set on the
region that is actually waiting — the plugin root while a profile field is
saved, the section while its rows are sorted, the open document or contact
editor, the image editor, the synchronization form — and never on <body>,
which would make a screen reader stop reporting the rest of the page as well. This
keeps failures and concurrent requests from leaving a stale loading state.
Opening a different document replaces only the in-memory editor schema and values after the new form has loaded. It never calls the submit action: document forms have no blur, change, teardown or focus-loss save hook. Only their explicit save/delete submit control persists a mutation. Focus moves into the first writable control (or the read/delete heading), returns to the action that opened a closed editor and remains on native buttons throughout keyboard sorting. Drag sorting is an optional pointer enhancement; the up/down actions remain the complete keyboard path.
All generated controls keep an explicit label. Validation errors have stable
IDs referenced through aria-describedby and update aria-invalid;
dynamic editor headings and expanded controls expose their relationships via
aria-controls and aria-expanded. The field editors are not modal
dialogs and deliberately do not trap focus.
When the view enters delete mode, its submit control is rendered with
btn-danger. Every other mode renders it with btn-primary.
An open editor carries its controls in one action bar below the form:
Cancel, and the submit control in every mode but the read view. The
panel header carries the heading alone. A read view has no form and therefore
offers no control of its own - the row action that opened it closes it again
and reports which state it is in through aria-expanded.
That row action also looks like the way out while the panel stands: the eye
is swapped for a crossed-out eye and its label changes from View to
Close details. Both icons and both strings are rendered by Fluid
into the button - the second icon carries the hidden attribute - so an
override reaches them the way it reaches every other label of the editor. The
browser only flips hidden and rewrites aria-label and title.
With a keyboard that action is reached by tabbing backwards out of the panel. The panel is rendered directly after the action group of the row it belongs to, so Shift + Tab passes the actions of that row and arrives at the control that opened it. No key closes a panel from the inside: the editor of a single profile field is the only place Escape is bound, and a document or contact panel is not a modal dialog, does not trap focus and leaves the page below it reachable.
An override of Documents/ or
Documents/ that still renders the removed
header control keeps working. Its condition data-pe-when="showClose" is no
longer filled, an unfilled condition counts as not met, and the control is
removed with it - such an override renders the panel without the second
Cancel rather than failing to render it at all.
createDocumentAction(), updateDocumentAction(),
deleteDocumentAction() and sortDocumentAction() complete the document
JSON API.
Up, down and drag-and-drop intentionally share the sort endpoint. All endpoints
resolve the profile from the authenticated frontend user and then resolve a
record only inside the requested section. A UID from another profile, model kind
or profile-information type is therefore rejected. They additionally enforce
readonly and the configured action list. The shipped contracts section is
writable and exposes the complete configured Contract field set.
The dedicated contractContactForm, createContractContact,
updateContractContact, deleteContractContact and
sortContractContact actions operate below a Contract resolved through the
authenticated Profile. A contact UID from another Contract is rejected. New
contacts are appended with a normalized sorting value; the up/down controls
persist their order independently in each contact section. The first and last
controls are disabled at their respective boundaries. Read-only Contract
configuration blocks every contact mutation at the endpoint as well.
Contract and contact helptexts use the same edit-only Bootstrap popovers as
other inline fields. Physical-address countries are selected from localized
TYPO3 CountryProvider options and persisted as ISO alpha-2 codes; submitted
values outside that list are rejected by the endpoint.
The ProfileEditing plugin registers only ProfileController. All profile,
contract, profile-information and contact mutations are handled by its normal
or non-cacheable action map.
Section order is centralized and every section emits data-section-key and
data-section-position together with the configured
data-section-field-name, data-section-record-type,
data-section-kind and data-section-readonly. Records additionally emit data-item-uid,
data-item-sorting and data-item-position. data-section-sortable
exposes whether both sorting directions are available. The explicit up/down
controls and drag handle persist the same record order.
The presentation uses Bootstrap rows with one shared desktop column heading,
compact flat records, separating borders and alternating tertiary backgrounds
within each document section, which are a :nth-child(odd) rule of the
extension's stylesheet rather than a class on the row. The year columns
remain narrow while title and position columns consume the available width.
On small viewports every record repeats its field labels instead of rendering
the desktop heading. An empty section keeps its heading and add action,
followed by one unobtrusive translated status line. During drag sorting the
browser uses the complete record row as the drag image.
Extension-specific state classes outline both the source row and active list,
while a prominent line above or below the hovered row marks the exact insertion
edge. Dropping into free list space shows the same line at the end of the list.
Profile-editing development boundary
academicpersonsedit_profileediting is the only profile-editing content
element offered in the backend CType selector and new-content-element wizard.
All profile-editing behavior is implemented through the Profile template
and partial tree, ProfileController AJAX actions and the inline JavaScript
component. The former dedicated controllers and their Fluid trees are removed;
they are not compatibility entry points.
The ProfileEditing TypoScript, AJAX page type, site set and page TSconfig live in
their own Configuration/*/ProfileEditing components. ProfileEditing is the
only component enabled through either its component configuration or the
aggregate.
The ProfileEditing functional test setup reflects the same boundary. It uses a
dedicated academicpersonsedit_profileediting fixture and the neutral
AbstractFrontendProfilePluginTestCase base.
Synchronization checkbox
The synchronization checkbox appears as the compact
Private switch
immediately left of the Edit all/Close all toggle in
the page header and is persisted immediately through
updateSkipSyncAction(). Its form is a sibling of the profile form, not a
nested form. Its presence and writable metadata follow the
special.skipSync configuration; the underlying data and endpoint semantics
remain skipSync. It does not submit or mutate any other field. The endpoint
accepts exactly one boolean property:
{
"profile": 123,
"data": {
"skipSync": true
}
}
Any additional property or a non-boolean value returns invalid_payload.
On failure the JavaScript restores the last successfully persisted checkbox
state.
Expanding profile image editor
Clicking the compact edit button below the current profile image or its
placeholder keeps the profile view active.
Partials/ is rendered by Fluid into a
dedicated full-width target above the profile header,
data-pe-image-editor-target, which is where it is shown: it is hidden until
the editor is opened rather than inserted then, so the container contributes
its final width and height from the first paint and the cropper can initialize
with the complete available width.
The header, profile fields and structured sections remain in the same profile
flow below the cropper; no separate view or overlay is involved. While the
editor is open, the complete image-preview column is hidden and the
profile-fields column changes from col-lg-8 to col-lg-12. The editor
itself uses a bordered, padded surface. Closing it scrolls the restored
image-preview column into view and focuses its edit action without causing a
second browser scroll. The focus is restored only after two animation frames
have applied the collapsed layout. A 1.5rem scroll margin keeps the
restored preview clear of the viewport edge.
<academic-persons-edit-image-editor> is the element that drives that
partial. It renders nothing of its own — the <f:form> carries the
__trustedProperties signature the property mapper validates the upload
against, and only the server can produce that — so it is a controller over
Fluid's markup: it binds the events and writes everything the shipped view
derives from the editing state, including the two column widths of
Templates/.
Opening and closing is animated with a short vertical move and fade, driven by
the …-image-editor-enter-active / -enter-from / -leave-active /
-leave-to classes of the extension's stylesheet. An explicit CSS grid row
expands and collapses the editor height, padding, margin and border instead of
removing the complete block in one layout step. The open scroll leaves 2rem
above the editor. The return scroll starts together with the collapse and uses
the preview's calculated final position; native scroll anchoring is disabled
only during that phase, so it cannot introduce a competing correction.
Environments requesting reduced motion skip the transition, and the close then
completes in the same frame rather than waiting for an animation that never
runs.
The full-width btn-sm edit action sits directly below the preview. Its
visible label and image upload icon are complemented by localized title and
aria-label attributes.
The image editor deliberately has no state-dependent Add or
Replace action. Selecting a file immediately replaces only the
inline crop preview with a local object URL. The page preview and persisted profile
remain unchanged until Save succeeds. A successful upload replaces
both previews, collapses the editor and shows the saved image directly. During
the leave transition, the preview remains hidden and the profile fields retain
their full width. The regular 4 / 8 grid is restored only after the editor
has finished collapsing, preventing competing layout changes while the page
returns to the image preview.
Cancel discards the selected preview and restores the persisted
image. Delete is shown only while an image is persisted.
If special.image.renderType is cropper and
special.image.settings.ratio contains a positive ratio such as 1x1 or
16:9, the editor instantiates the CropperJS module the TYPO3 core maps as
cropperjs - version 1.6.1 on both supported core versions. The selection
remains constrained to that ratio and only the generated cropped file is added
to the multipart request. No CDN or other runtime request is used. The ratio
remains fixed while the selection can be dragged and resized to choose the
visible image area, and the cropped file is written at the resolution of the
selected file rather than at the size the editor displays it, up to 2400 pixels
wide. The cropper is instantiated only after a new local file is selected. A
persisted profile image and the placeholder remain normal previews and cannot
themselves become crop input. The original upload behavior remains
active for every other render type.
The save button remains disabled until a new local file is selected. This also prevents an already persisted image from being fetched, cropped and uploaded again. While an upload or deletion is pending, all image controls are disabled and the active action displays a Bootstrap spinner. This prevents duplicate requests and leaving the image editor during a running operation.
Upload
The image form is intercepted by the frontend module and sends
multipart/form-data to uploadImageAction() through fetch().
The FormData object is built before the file control is disabled for the
pending state. Disabled controls are omitted by the browser and would otherwise
produce an apparently valid request without an uploaded image.
Extbase's file handling service validates the configured maximum file size and
allowed MIME types, stores the file in the configured target folder and updates
the FAL relation. Authorization is checked before Extbase maps or stores the
uploaded file. A replaced physical file is removed only when it has no other
references.
After persistence, the ordered non-empty values title, firstName,
middleName and lastName are joined with spaces. That composed profile
name is written to alternative and title of the profile's file-reference
overlay, which is the language-correct place for it: a translation carries its
own name there, and the file may be shared between the languages of a profile.
The metadata record of the uploaded file itself is written once, by the upload,
and only where it is empty — the record
sys_ carries is the
backend editor's from then on, and no later change of the profile name touches
it. It is written at all because nothing else fills it: alternative and
title are the fields an installation running
typo3/cms-filemetadata
or
fgtclb/file-required-attributes
reports as missing required
attributes for a file uploaded in the frontend.
The JSON response returns the composed name so the in-page preview immediately uses the persisted metadata.
The image editor does not render f:form.validationResults. Upload validation
failures are returned as JSON and displayed in an alert inside the active view.
The controller additionally compares the submitted image with the
persisted FAL reference. It returns image_upload_missing with status
422 instead of reporting success when no new file arrived.
The cropper preserves JPEG, PNG and WebP as output formats and falls back to
PNG for unsupported source types. Every upload remains a separate FAL file.
When a profile already has exactly one independent image relation, replacing
the image keeps the sys_file_reference uid but assigns the newly uploaded
file to it through DataHandler and persists image=custom in the profile's
l10n_state. The old physical file is removed only after no active reference
uses it anymore. Localized or duplicate legacy references are rebuilt as one
independent relation, so changing one language cannot overwrite another
language's custom image.
The upgrade wizard
academicPersonsEdit_repairLocalizedProfileImages repairs legacy shared,
localized or duplicate image relations and inconsistent relation counters. It
rebuilds every relation through the DataHandler and deletes no file: a file may
be reached through a soft reference that
sys_ knows nothing
about, and an unattended bulk run is the wrong place to act on that question.
All JSON actions propagate non-successful responses out of TYPO3's
Extbase USER content rendering with PropagateResponseException. A
JsonResponse returned by the action alone would contribute its body to the
surrounding PAGE object while the outer frontend response retained status
200. Propagation therefore preserves the documented non-200 status
codes for the AJAX client.
The relevant TypoScript settings are:
plugin.tx_academicpersonsedit.settings.editForm.profileImage {
targetFolder = 1:/profile-images
validation {
maxFileSize = 2M
allowedMimeTypes = image/jpeg,image/png,image/webp
}
}
Delete
The delete button calls deleteImageAction() exclusively through AJAX. The
endpoint accepts a POST JSON request without profile field changes:
{
"profile": 123,
"data": {}
}
The profile relation is cleared first. The physical file is deleted only if no
other record references it. The response includes deleted and
hasImage so clients can synchronize their local state.
Authentication and responses
Every endpoint above requires an authenticated frontend user and accepts only the profile assigned to that user. The generic update, synchronization and delete endpoints propagate machine-readable JSON errors. Image upload validation is also converted to and propagated as JSON by the controller's error action.
| Status | Error identifier | Meaning |
|---|---|---|
200 | — | The request was persisted successfully. |
400 | invalid_request, invalid_json or invalid_payload | The X-Requested-With header is missing, or the JSON or the request
structure is invalid. |
401 | authentication_required | No frontend user is authenticated. |
403 | profile_not_editable | The profile is not assigned to the frontend user. |
405 | method_not_allowed | A JSON endpoint was called with a method other than POST. |
415 | unsupported_media_type | A JSON endpoint was called without `Content-`. |
422 | invalid_profile_data, validation_failed or
image_upload_missing | A field value or uploaded file is invalid. |
500 | internal_server_error | An unexpected error occurred. Details are logged but not exposed in the JSON response. |
Customizing the view
Override Resources/ and the partials below
Resources/ through the regular template
and partial root paths. The index keeps URL/data setup, the responsive main
grid, the prototypes and the composition. The Profile, Documents,
Image and Field directories group the corresponding UI
responsibilities; the status regions and client-side button templates remain
shared at the root.
Every file in that tree is an override point, including the two regions a
browser builds at runtime. The editor of one document or contract and the
contacts of one contract cannot be rendered as finished markup - their fields,
labels, options and display values come from the documentForm and
contractContactForm responses - so Fluid renders their shapes instead, as
<template data-pe-proto="…"> blocks, and the elements clone one and fill it.
| File | What it renders |
|---|---|
Partials/ | Every shape the browser-rendered editors draw, once per page: the five controls, the three field rows, an option, a help button and a display row. It renders the three partials below. |
Partials/ | The panel of one document or contract, as the document-panel
prototype. |
Partials/ | The section, the row and one summary cell of a contact list. |
Partials/ | The editor of one contact, for both places it is shown. |
Partials/ | The only place a form control is spelled - inline for the permanent
profile fields and, with its prototype flag, into the prototypes. |
Partials/ | The image editor. Its <f:form> and hidden fields must stay - only
the server can sign __trustedProperties - and so must its
data-pe-* hooks; what the template used to derive is written by
the element. |
A prototype is filled through exactly four attributes, and an override keeps them and their keys:
| Attribute | Meaning |
|---|---|
data-pe-slot="key" | The text of the node becomes the value. |
data-pe-attr="attribute:key …" | Those attributes take the value; an absent or false value removes them. |
data-pe-when="key" | The node is removed when the value is falsy. |
data-pe-list="key" | Where repeated clones go. |
What an override may change is every tag, every class and every label. What it
may not change is the vocabulary - the data-pe-* hooks and the slot,
condition and list keys - and what it cannot change is the order the elements
insert things in and which slot carries which value; both are TypeScript. See
Important: The profile editor is Fluid, driven by five custom elements in the
changelog.
The custom elements and their events
Five element names are part of the public contract of this extension from version 3.0 on. The prefix is the extension key with its underscores replaced, because a custom element name is global and has no scoping mechanism.
| Element | What it owns |
|---|---|
<academic-persons-edit-profile-editing> | One editor. It wraps the plugin root, reads its data-* contract
once and starts everything below it. It renders nothing. |
<academic-persons-edit-image-editor> | The profile image editor, as a controller over the server-rendered upload form. It renders nothing. |
<academic-persons-edit-document-editor> | One open document or contract editor, in add, view, edit
or delete mode. It clones the document-panel prototype and
fills it from the documentForm response. It renders nothing. |
<academic-persons-edit-contract-contacts> | The contacts of one contract and the editor of one contact, from the section, row and editor prototypes. It renders nothing. |
<academic-persons-edit-rich-text> | One rich text field of a document editor and the CKEditor 5 instance on it. It wraps the textarea its prototype carries and renders nothing. The rich text fields of the profile itself stay Fluid-rendered textareas and are not wrapped in it. |
Four events report what an open document editor did; the root element listens for a fifth, so that a descendant which does not hold the editing context can still have a status shown. All of them bubble, and none of them crosses a shadow boundary because there is none:
| Event | Meaning |
|---|---|
pe:status | Asks the root element to write one of the two live regions; detail
{ type, message? }. Listened for on the root, dispatched by
nothing the extension ships. |
pe:document-close | The cancel button of an open editor was pressed. |
pe:document-submit | Its form was submitted; the browser's own submission is prevented. |
pe:document-input | A control changed; detail { name, value }. |
pe:document-closed | The close transition is over and the element may be removed. |
Two vocabularies carry the rest, and both are unchanged in meaning by the move
to custom elements. The plugin root of Templates/
carries the configuration of this profile — fifteen endpoint URLs, the
profile uid and the editor language, five image settings, twenty-six messages
and six labels. It is read once, when the element above it starts the editor,
and an attribute changed afterwards is not seen. Every control below the root
carries a data-pe-* hook, including the controls an element clones out of a
prototype: those carry the same hooks the removed partials did.
Keep the following contracts when reusing the shipped JavaScript. A hook that sits in a prototype is authored in Fluid like any other, so an override may retag, restyle and relabel it; what an override may not do is rename the hook, and what it cannot do is change the order the element inserts things in or which slot carries which value.
| Selector or attribute | Purpose |
|---|---|
data-academic-persons-profile-editing | Root component and scope for all queries. Everything below is read
from it, and the <academic-persons-edit-profile-editing> element
that wraps it is what starts the editor. |
data-pe-missing-ajax-page-type | The role="alert" message Templates/
renders above the plugin root when the site delivers no
PAGE
object with
type, which
is the one configuration error the editor cannot survive: every save
would be answered with the site's error page instead of JSON. The
same request logs the cause and names the site set to include. It is
absent on a correctly configured site. |
data-profile-uid and data-editor-language | Positive profile identifier, and the language code CKEditor is initialized with. |
data-update-url | Generic field update endpoint. |
data-skip-sync-url | Synchronization endpoint. |
data-delete-image-url | Image deletion endpoint. |
data-document-form-url, data-create-document-url,
data-update-document-url, data-delete-document-url,
data-sort-document-url and data-toggle-document-visibility-url | The six document endpoints. |
data-contract-contact-form-url,
data-create-contract-contact-url,
data-update-contract-contact-url,
data-delete-contract-contact-url,
data-sort-contract-contact-url and
data-toggle-contract-contact-visibility-url | The six contract-contact endpoints. |
data-message-* and data-label-* | The localized texts a browser-rendered control needs. They are on the root because a template cannot be reached from the browser. |
data-pe-proto | One <template> per shape a browser-rendered editor draws. The
elements clone one and fill its data-pe-slot, data-pe-attr,
data-pe-when and data-pe-list nodes. |
data-pe-fields-form and
academic-persons-profile-editing__field | Generic field forms and controls. Separate forms preserve valid markup across the personal-data and about-section grid areas. |
data-pe-rich-text and data-pe-editor-container | Marks a textarea for lazy CKEditor initialization and its wrapper for show/hide handling. |
data-pe-rich-text-preview and
data-pe-rich-text-preview-content | Direct formatted read preview and its safely replaceable content container. |
data-pe-field-preview and data-pe-field-editor | Plain read row and the inline control region for one field. |
data-pe-profile-name and
data-pe-profile-name-field-ids | Main heading and the name controls used to refresh it after saving. |
data-pe-sticky-image | Sticky image container receiving the measured
#page-header.navbar-fixed-top height plus a 10-pixel visual gap
as its runtime top offset. |
data-pe-document-sections and data-pe-document-section | Editable structured-section list and stable boundary for its AJAX controls. |
data-section-key and data-section-position | Stable section identity and current zero-based presentation position. |
data-section-field-name, data-section-record-type,
data-section-kind and data-section-readonly | Field, relation type, record kind and write state, taken from the shared settings graph for section-specific persistence. |
data-pe-document-items and data-pe-document-item | Mutable item collection and record boundaries inside a section. |
data-pe-document-item-template | Hidden Fluid-rendered row prototype retained in the mounted DOM and cloned after a successful create response. |
data-pe-document-add-collapse-target and
data-pe-document-item-collapse-target | Where the <academic-persons-edit-document-editor> element is
created: below a section heading for an addition, inside an individual
record row for everything else. The document controller assigns a
unique ID when a target is first opened, for aria-controls. |
data-item-uid, data-item-sorting, data-item-position and
data-item-hidden | Persisted record identity, domain sorting value, current zero-based presentation position, and - present only on a hidden record - the styling anchor of the dimmed row. |
data-pe-document-empty-state | Localized placeholder rendered when a structured collection is empty. |
data-pe-document-add, data-pe-document-view,
data-pe-document-edit and data-pe-document-delete | Section creation and in-place row actions. |
data-pe-document-sort | Up/down row action persisted through the shared sort endpoint. |
data-pe-document-hide and data-pe-document-hidden-badge | The visibility toggle of a row (its label names the press, its glyph shows the state) and the Hidden tag in front of its action group, both written from the answer of the visibility endpoint. |
data-pe-document-view-container, data-pe-document-form,
data-pe-document-heading, data-pe-document-fields and
data-pe-document-field | The collapse, the form, the heading, the field region used for add,
view, edit and delete, and one control inside it. Fluid renders all
of them: the first four in the document-panel prototype of
Partials/, the last through
Partials/, which carries the field
name as its value. <academic-persons-edit-document-editor> clones
the prototype and fills its slots. |
data-pe-contract-contact-section, -item, -hidden,
-heading, -form, -fields, -field, -editor,
-actions, -add, -view, -edit, -delete, -sort,
-hide, -cancel and -save | The contact sections of a contract, their rows, the editor and its
controls. Fluid renders them too: the section, the row and one
summary cell in the contact-section, contact-row and
contact-summary-cell prototypes of
Partials/, the editor
in the contact-editor-panel prototype of
Partials/, and
-field again through Partials/.
<academic-persons-edit-contract-contacts> clones and fills them;
the presses are delegated on the plugin root like the document ones.
-hidden marks a hidden contact row: it is the styling anchor of
the dimmed row, and the hidden key that carries it also renders
the Hidden badge. -hide is the visibility toggle of
the row, see below. |
data-pe-field-group, data-pe-field-ids and
data-pe-display-field-ids | Grouped preview/editor and the controls participating in it. |
data-pe-group-edit, data-pe-group-dismiss,
data-pe-group-cancel and data-pe-group-save | Open, clear the draft, restore and persist a grouped field row. |
data-pe-field-actions | Content-sized Bootstrap group for the three per-field actions. |
data-pe-autosave-on-change | Saves configured checkbox controls immediately after a change. |
data-pe-autosave-undo and data-pe-cancel | Marks the undo action beside an editable checkbox. It restores the last successfully persisted value and closes the editor without sending another request. Select fields use the regular clear, undo and save action group. |
data-academic-persons-profile-editing-edit-all-btn | Enters and leaves full form editing: it discards the single field or
group that is open, opens every editable field and grouped row at
once, hides the per-field action groups and shows the form bars.
Pressed again it discards the form, exactly as Discard
does. It carries aria-pressed and names the field forms it
controls in aria-controls. |
data-pe-edit-all-label, data-pe-close-all-label and
data-pe-edit-all-button-label | Localized labels and replaceable label container for the edit-all toggle. |
data-pe-dismiss | Deletes the current draft value without closing or saving it. |
data-pe-cancel | Restores the last persisted value and closes one field without a request. |
data-pe-save | Persists one field through the generic JSON endpoint. |
data-pe-sync-form and
academic-persons-profile-editing__sync-checkbox | Synchronization control. |
academic-persons-profile-editing__image-form and
data-pe-image-view-container | AJAX-only multipart upload form and the editor panel around it. The
form is server rendered and stays that way: it carries the
__trustedProperties signature the property mapper validates the
upload against, which only the server can produce. |
data-pe-image-editor-target | Profile-specific full-width container Fluid renders the image editor into, above the profile header. |
data-pe-image-preview and
data-pe-image-view-preview | Image locations updated after upload or deletion. |
data-image-render-type and data-image-cropper-ratio | Render type and ratio consumed by the cropper of the image editor. |
data-pe-status-toast="status" and
data-pe-status-toast="alert" | The two scoped status regions of the component: the polite one for saving, success and information, the assertive one for a failure. Both must exist — a region's politeness cannot be changed reliably once it is in the accessibility tree. |
Not every hook in the table has a reader in the shipped JavaScript. Fifteen of them are override and styling anchors: they name a region so a stylesheet or a project's own script can address it, and no module of this extension looks them up. They are part of the contract for that reason and not by accident - an override may move them, and should keep them:
data-pe-autosave-undo data-pe-document-kind
data-pe-compact data-pe-document-section-header
data-pe-contract-contact-actions data-pe-document-sections
data-pe-contract-contact-fields data-pe-group-actions
data-pe-contract-contact-form data-pe-helptext
data-pe-document-actions data-pe-image-editor-heading
data-pe-document-fields data-pe-profile-header
data-pe-rich-text-heading
Of these, data-pe-compact is the only one a stylesheet of this extension
uses; the rest exist for overrides.
One consequence of the prototype mechanism is worth stating: a clone keeps the
data-pe-slot, data-pe-attr, data-pe-when and data-pe-list
attributes of the block it came from. They are inert in the live DOM - the
filler has already written the values they name - and they are not state. Do not
read them, and do not style on them.
Every editable field needs one invalid-feedback element in its closest
data-pe-field-wrapper, data-pe-group-control or .form-check wrapper.
Inline collapse targets, views, status regions, icon templates and
compatibility-template elements must remain inside the component root. All DOM
lookups are scoped to that root, so multiple components remain independent.
State classes and selectors JavaScript uses
No element and no prototype filler writes markup: every tag, every class
attribute and every label of the editor is authored in Fluid. What the modules
below them do is toggle state classes on markup that already exists, and
select a few nodes by class. Both are part of the override contract - renaming
one of them breaks the editor silently, because nothing throws when a
classList.toggle() writes a class no stylesheet defines.
| Class or selector | Written or read for |
|---|---|
d-none | Everything that is shown and hidden by the field editing: a preview, an editor, a per-field action group, an empty section, the toast. |
d-md-flex | The header row of a structured document list, which is shown only while the list has rows. |
is-invalid | A control the server refused, on a profile field and on the synchronisation switch. |
text-danger | A rich text character counter that is over its limit. |
text-body-secondary | A preview that shows the empty label instead of a value. |
active | The Edit all toggle while it is pressed. |
bg-danger, bg-success, bg-info, bg-warning | The severity of the status toast; the four are exchanged, never combined. |
.status-title and .status-message | The two nodes of Partials/ a status is
written into. |
is-drag-active, is-dragging, is-drop-before,
is-drop-after, is-drop-at-end | The drag sorting of a structured document list and its insertion indicator. |
is-image-closing | The plugin root while the image editor collapses. |
col-lg-4, col-lg-8, col-lg-12 and
.academic-persons-profile-editing__profile-fields-column | The two column widths the image editor exchanges while it is open. |
.alert[role="alert"] and .spinner-border | The message and the busy indicator of an open document or contact panel, written onto the panel that is already there rather than by rebuilding it. |
.invalid-feedback, .form-check and .mb-3 | The message element of a refused field, and the two wrappers it is
looked up from when the field carries no data-pe-field-wrapper
or data-pe-group-control. |
.academic-persons-profile-editing__field | Every editable control of the profile fields. This is the class the field editing enumerates by, so an override that drops it leaves the field unreachable. |
.academic-persons-profile-editing__sync-checkbox | The synchronisation switch of Partials/. |
.ck | CKEditor's own root, read to decide whether Escape belongs to the editor or to the form. It is the library's class, not this extension's, and is the one entry here an override cannot change. |
…-enter-from, …-enter-active, …-leave-active,
…-leave-to | The collapse transitions, derived from the prefix of the editor that
runs them. The declarations are in
Resources/. |
Frontend build
The package has no separate development toolchain below
Resources/. TypeScript sources, SCSS sources and the committed
JavaScript and CSS output use the repository-wide frontend suites from the
repository root:
Build/Scripts/runTests.sh -s buildJs
Build/Scripts/runTests.sh -s checkJsBuildClean
Build/Scripts/runTests.sh -s lintTypescript -n
Build/Scripts/runTests.sh -s typecheckJs
Build/Scripts/runTests.sh -s testJs
Nothing is bundled: the build emits one module per source file and leaves every
import as it was written. No JavaScript library is committed with the
extension - both libraries the editor uses, CKEditor 5 and CropperJS, are
resolved through the import map from rte_ckeditor and core.
Tests
Controller unit tests cover method, payload and authentication errors for the JSON actions. Sanitizer unit tests cover the supported profile properties, allowed editor markup and rejection of scripts, event attributes, styles, unknown tags and unsafe link schemes. Validation-service unit tests verify that sanitization happens before a value is registered for persistence.
A JavaScript suite runs the shipped modules and elements against a DOM
(Build/Scripts/runTests.sh -s testJs). It drives the rendered markup of the
Fluid partials through the modules that read it, which is the only place the
event handling, the optimistic list updates, the transitions and the focus
management are executed at all.
Functional plugin tests render the inline-collapse targets, the prototypes and
the image view, verify the decomposed Fluid contracts, AJAX-only controls,
direct rich-text previews and the separate delete, cancel and save actions.
They also assert the prototype inventory, every slot key of every prototype,
and that a prototype control and the live control of the same type carry the
same tag, the same classes and the same attributes. The section-provider unit
test
verifies that order, identifiers, field names, relation types and labels come
from the shared settings graph while row fields, action capabilities,
presentation modes and typed records are preserved. Functional fixtures cover
contracts and every configured profile-information relation; the rendered page
test derives the expected
order and metadata from the same settings service, then checks placement below
About me, alternating records, empty states, writable add controls
and exactly the configured row actions. AJAX lifecycle tests cover schema
loading, CKEditor inline fields, creation, partial updates, arrow and drag
sorting, cross-section rejection, deletion and read-only endpoint denial.
Contract tests additionally cover every configured field plus address,
email-address and phone-number creation, display, update, independent sorting,
deletion and cross-Contract rejection. They also guard the ProfileEditing
plugin against accidentally exposing removed mutation controllers.
Registration tests ensure that ProfileEditing is the only editing content
element offered to editors. The architecture unit test scans the
ProfileController, Fluid tree and JavaScript for forbidden legacy
controller and template references.
The AJAX tests persist malicious rich-text input through the real update
endpoint and assert that only the sanitized response is stored. The inline
image tests verify that a missing file can never return success and that a real
multipart upload returns hasImage: true and creates the FAL relation. They
also exercise the dedicated image deletion endpoint through the generated
action URL. Form submissions reuse the complete rendered action URL, including
the JSON page type, so the tests exercise the same routing contract as the
browser. Upload tests are assigned to the not-core-13 PHPUnit group because
TYPO3 v13's CLI upload permission check requires a real HTTP upload.
The page type of the JSON endpoints
Every writing endpoint of the editor is an Extbase action of the same plugin,
reached through a dedicated
PAGE
object with
type. The object is delivered by the site set and by
the static template of this extension, it is not cached, and it sets
Content-Type: application/json on the page - TYPO3 v13 drops the headers a
plugin response carries, so the page has to declare it.
Three things in a project's infrastructure have to know about that number:
-
The TypoScript of the site itself. The object exists where the TypoScript of this extension is included - through the site set
fgtclb/academic-persons-edit-profile-editingor the static template Academic Persons Edit: Profile editing. There was no such page type before version 3.0, because the previous editor was a server-rendered form flow, so a site package that copied the extension's TypoScript into its own instead of including it has noacademicobject. The editor then renders, and every save is answered with the HTML of the page instead of JSON - a failure that happens in the browser, with nothing in the TYPO3 log to look at. The editor therefore checks the TypoScript setup of the request for aPersons Profile Editing Ajax PAGEobject with thattype, and where there is none it renders aNum role="alert"message (data-pe-missing-ajax-page-type) instead of the silent failure, and logs the cause at error level. Include the delivered TypoScript, or copy the object into the site package:EXT:my_sitepackage/Configuration/TypoScript/setup.typoscriptacademicPersonsProfileEditingAjax = PAGE academicPersonsProfileEditingAjax { typeNum = 1733735 10 < tt_content.academicpersonsedit_profileediting.20 config { disableAllHeaderCode = 1 admPanel = 0 debug = 0 disablePrefixComment = 1 no_cache = 1 additionalHeaders.10 { header = Content-Type: application/json replace = 1 } } }Copied! -
Route enhancers. A
PageTypeDecoratorthat maps page types onto URL suffixes has to list1733735, or the editor's request URLs are not resolvable. Without a decorator the type travels as&type=1733735and nothing has to be configured.config/sites/<identifier>/config.yamlrouteEnhancers: PageTypeSuffix: type: PageType default: '' index: '' map: profile-editing.json: 1733735Copied! - Web application firewalls and reverse proxies. The endpoints are
POSTrequests carrying JSON, and one of them carries amultipart/form-dataimage upload. A rule set that blocks unknown JSON bodies, strips theX-Requested-Withheader or limits the upload size below the configuredmaxFileSizemakes the editor fail with a generic error message and no server side log entry.
The request header the endpoints require
Every writing endpoint - the fifteen JSON actions and the image upload -
requires the request header X-Requested-With: XMLHttpRequest. A request
without it is answered with 400 and the error code invalid_request.
The shipped JavaScript sends the header on every request, so this is invisible
in normal use. It exists for the image upload, which is a
multipart/form-data request and therefore one that any foreign page could
submit with a plain form, carrying the visitor's session along. A custom header
cannot be set on a cross-origin request without a CORS preflight, which the
browser refuses for a cross-site page - so requiring one closes that door
without relying on the SameSite attribute of the session cookie, which an
installation may change.
A project that talks to the endpoints from its own code has to send the header.
Icon identifiers
The action icons of the editor are registered in
Configuration/ and rendered through core:icon with
alternativeMarkupIdentifier="inline", so the SVG is inlined and follows the
text colour of the button it sits in. The files are Bootstrap Icons (MIT, see
Resources/) drawn in
currentColor.
Identifier and file name name the action, not the glyph: a project that replaces the icon set changes the drawing, not the identifiers its template overrides address.
| Identifier | File | Used for |
|---|---|---|
academic-persons-edit-add | add. | Add a document, contract or contact row |
academic-persons-edit-back | back. | Back to the profile overview |
academic-persons-edit-clear | clear. | Clear the value of a field |
academic-persons-edit-delete | delete. | Delete a row or the profile image |
academic-persons-edit-edit | edit. | Open a field or a row for editing |
academic-persons-edit-help | help. | Help text popover |
academic-persons-edit-move-down | move- | Move a row down |
academic-persons-edit-move-up | move- | Move a row up |
academic-persons-edit-save | save. | Save a field or a row |
academic-persons-edit-sort-handle | sort- | Drag handle of a sortable list |
academic-persons-edit-undo | undo. | Restore the last saved value |
academic-persons-edit-upload-image | upload- | Open the profile image editor |
academic-persons-edit-view | view. | Open a row read-only, or the public profile |
academic-persons-edit-view-close | view- | Close the read view a row action opened |