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/Private/Templates/Profile/Index.html 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/Private/TypeScript/frontend/profile.ts, from which the frontend build generates Resources/Public/JavaScript/frontend/profile.js; 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/Private/TypeScript/frontend/profile/ separately own common requests and status output, field editing, documents, rich text, synchronization, image editing and sticky positioning; the five elements below …/profile/elements/ 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/Private/Templates/Profile/List.html. 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:academic_persons/Configuration/Routes/Detail.yaml 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 renderType: select field. The option source remains the matching Profile TCA field.
{documentSections} Ordered structured-section view models derived from documentSections , 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/Public/Css/frontend/profile-editing.css is generated from Resources/Private/Scss/frontend/profile-editing.scss 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/Profile/Field/Control.html, the image upload of Partials/Profile/Image/Editor.html, the synchronisation switch of Partials/Profile/Header.html 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.title . 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/Profile/Index.html: 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/Private/Partials/Profile/Field/Editable.html partial composes three focused, reusable partials:

  • Field/Preview.html renders the text preview and pencil trigger — the trigger that opens this field and closes whichever other one is open,
  • Field/Control.html renders either f:form.textfield or f:form.textarea, including the CKEditor hook, and
  • Field/Actions.html renders delete, cancel and save.

Field/Group.html 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.html 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/Select.html; options come from the configured field's TCA items and changes save immediately.
Checkbox Field/Checkbox.html 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/Actions.html 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/Profile/UnsavedChanges.html, 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/FormActions.html 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.html 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 carries aria-pressed and names the forms it controls in aria-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/Fields.html renders Profile/Field/FormActions 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.

Partial profile update
{
  "profile": 123,
  "data": {
    "gender": "female",
    "website": ""
  }
}
Copied!

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.title . 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 renderType: ckeditor . Four are displayed with the information section, while miscellaneous belongs to aboutme :

  • coreCompetences,
  • teachingArea,
  • supervisedDoctoralThesis,
  • supervisedThesis, and
  • miscellaneous.

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 renderType: ckeditor 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, li and a;
  • the href attribute on a; and
  • local links and the URI schemes http, https, mailto and tel.

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:academic_persons/Configuration/AcademicPersons/Settings.yaml 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/Profile/Documents/Editor.html 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 TypeScript/frontend/profile/documents.ts 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/ContractContactEditor.html 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/Editor.html or Documents/ContractContactEditor.html 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:

Synchronization update
{
  "profile": 123,
  "data": {
    "skipSync": true
  }
}
Copied!

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/Profile/Image/Editor.html 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/Profile/Index.html.

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_file_metadata 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_file_reference 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
    }
}
Copied!

Delete 

The delete button calls deleteImageAction() exclusively through AJAX. The endpoint accepts a POST JSON request without profile field changes:

Image deletion
{
  "profile": 123,
  "data": {}
}
Copied!

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-Type: application/json`.
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/Private/Templates/Profile/Index.html and the partials below Resources/Private/Partials/Profile/ 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/Profile/Prototypes.html 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/Profile/Documents/Editor.html The panel of one document or contract, as the document-panel prototype.
Partials/Profile/Documents/ContractContacts.html The section, the row and one summary cell of a contact list.
Partials/Profile/Documents/ContractContactEditor.html The editor of one contact, for both places it is shown.
Partials/Profile/Field/Control.html The only place a form control is spelled - inline for the permanent profile fields and, with its prototype flag, into the prototypes.
Partials/Profile/Image/Editor.html 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/Profile/Index.html 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/Profile/Index.html renders above the plugin root when the site delivers no PAGE object with typeNum = 1733735 , 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/Profile/Documents/Editor.html, the last through Partials/Profile/Field/Control.html, 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/Profile/Documents/ContractContacts.html, the editor in the contact-editor-panel prototype of Partials/Profile/Documents/ContractContactEditor.html, and -field again through Partials/Profile/Field/Control.html. <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
Copied!

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/Profile/StatusToast.html 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/Profile/Header.html.
.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/Private/Scss/frontend/profile-editing.scss.

Frontend build 

The package has no separate development toolchain below Resources/Public/. 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
Copied!

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 typeNum = 1733735 . 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-editing or 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 no academicPersonsProfileEditingAjax object. 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 a PAGE object with that typeNum , and where there is none it renders a 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.typoscript
    academicPersonsProfileEditingAjax = 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 PageTypeDecorator that maps page types onto URL suffixes has to list 1733735, or the editor's request URLs are not resolvable. Without a decorator the type travels as &type=1733735 and nothing has to be configured.

    config/sites/<identifier>/config.yaml
    routeEnhancers:
      PageTypeSuffix:
        type: PageType
        default: ''
        index: ''
        map:
          profile-editing.json: 1733735
    Copied!
  • Web application firewalls and reverse proxies. The endpoints are POST requests carrying JSON, and one of them carries a multipart/form-data image upload. A rule set that blocks unknown JSON bodies, strips the X-Requested-With header or limits the upload size below the configured maxFileSize makes 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/Icons.php 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/Public/Icons/LICENSE-bootstrap-icons.txt) 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.svg Add a document, contract or contact row
academic-persons-edit-back back.svg Back to the profile overview
academic-persons-edit-clear clear.svg Clear the value of a field
academic-persons-edit-delete delete.svg Delete a row or the profile image
academic-persons-edit-edit edit.svg Open a field or a row for editing
academic-persons-edit-help help.svg Help text popover
academic-persons-edit-move-down move-down.svg Move a row down
academic-persons-edit-move-up move-up.svg Move a row up
academic-persons-edit-save save.svg Save a field or a row
academic-persons-edit-sort-handle sort-handle.svg Drag handle of a sortable list
academic-persons-edit-undo undo.svg Restore the last saved value
academic-persons-edit-upload-image upload-image.svg Open the profile image editor
academic-persons-edit-view view.svg Open a row read-only, or the public profile
academic-persons-edit-view-close view-close.svg Close the read view a row action opened