Skip to content

Commit 23391ca

Browse files
committed
refactor question field
1 parent cc65b26 commit 23391ca

31 files changed

Lines changed: 700 additions & 178 deletions

DECISIONS.md

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,18 +200,54 @@ einzuführen.
200200

201201
## D7 – Formularantwort (entschieden)
202202

203-
Die vier Schreibaktionen verwenden einen Frame-lokalen
204-
Post/Redirect/Get-Flow: POST mutiert ausschließlich über den jeweiligen
205-
Service und antwortet mit `303 See Other` auf den passenden HTML-Frame-
206-
Endpunkt. Turbo folgt dem Redirect innerhalb des absendenden Frames; der
207-
Server rendert anschließend den vollständigen aktuellen Zustand. Das vermeidet
208-
eine zweite Turbo-Stream-Variantenmatrix für Reader und Bühne und bleibt bei
209-
deaktiviertem Turbo Drive robust. Fachliche Ablehnungen liefern denselben
210-
semantischen Frame mit Fehlermeldung und Status `422 Unprocessable Entity`,
211-
damit Turbo ihn ersetzt. Fehlende Authentifizierung, fehlende
212-
Steuerberechtigung und CSRF-Abweisungen behalten ihre harten HTTP-Statuscodes.
213-
214-
Alle sechs Routen liegen durch `config/routes.yaml` im Frontend-Scope. Die
203+
Die vier Schreibaktionen behalten den Post/Redirect/Get-Flow: POST mutiert
204+
ausschließlich über den jeweiligen Service und antwortet mit `303 See Other`
205+
auf den passenden GET-Endpunkt. Turbo fordert bei unsicheren Formularrequests
206+
automatisch `text/vnd.turbo-stream.html` an und behält den Accept-Header beim
207+
Redirect bei (`public/turbo.es2017-esm.js`, `FormSubmission.prepareRequest()`).
208+
Die GET-Endpunkte antworten deshalb bei dieser Content-Negotiation mit
209+
Turbo-Streams, bei normalen Frame-Polls weiterhin mit vollständigem
210+
HTML-Frame-Markup.
211+
212+
Diese Erweiterung von D7 ist für die Reader-Aufteilung notwendig. Status und
213+
Frageformular liegen im nicht gepollten Controls-Frame
214+
`qna-session-<id>-reader`; Fragen und Votes liegen im gepollten Questions-Frame
215+
`qna-session-<id>-questions`. Das Frageformular zielt per
216+
`data-turbo-frame` auf den Questions-Frame. Nach erfolgreicher Erstellung
217+
aktualisiert die Redirect-GET-Antwort die Liste und ersetzt den Controls-Inhalt
218+
einmalig, wodurch das Feld geleert wird. Nach Vote, Start und Stopp
219+
aktualisieren Streams nur ihren jeweiligen dynamischen Bereich. Die
220+
Sortierlinks verwenden dagegen eine normale Frame-Navigation, damit Turbo die
221+
gewählte URL als neue Frame-`src` übernimmt; das Bundle setzt für diese
222+
Navigation den vorhandenen Turbo-Morph-Renderer ein, damit der Fokus an der
223+
stabilen Link-ID erhalten bleibt.
224+
Da Turbo Stream-Antworten vor einer Frame-Navigation abfängt
225+
(`StreamObserver.inspectFetchResponse()`), wird der einmalige
226+
`resetQuestionForm`-Redirect nicht zur dauerhaften `src` des Questions-Frames.
227+
228+
Normale Listen-Polls führen ein statusselektives Stream-Update mit: Nur ein
229+
Controls-Knoten, dessen `data-qna-state` nicht dem aktuellen Serverstatus
230+
entspricht, ist Ziel. Bei unverändertem `open` bleibt der bestehende
231+
Textarea-DOM-Knoten samt Wert, Cursor und Auswahl unangetastet; bei
232+
`waiting`/`closed` wird der Formularbereich spätestens durch den nächsten
233+
Listen-Poll ersetzt. Es wird kein Formularzustand in JavaScript gespeichert.
234+
235+
Fachliche Ablehnungen bleiben HTML-Antworten mit `422 Unprocessable Entity`.
236+
Für eine fehlgeschlagene Frame-Form-Submission lädt Turbo ausdrücklich die
237+
Antwort in den ursprünglichen Frame statt in das mit `data-turbo-frame`
238+
angegebene Erfolgsziel (`FrameController.formSubmissionFailedWithResponse()`
239+
im ausgelieferten Turbo-Modul). Deshalb erscheint eine Fragenvalidierung im
240+
Controls-Frame; der eingereichte Wert wird serverseitig erneut gerendert.
241+
Fehlende Authentifizierung, fehlende Steuerberechtigung und CSRF-Abweisungen
242+
behalten ihre harten HTTP-Statuscodes.
243+
244+
Polling-Reloads der Reader-Liste und der Bühne verwenden Morphing mit stabilen
245+
IDs. Das Polling-Modul verschiebt außerdem Reloads, während der betreffende
246+
Frame Fokus enthält oder beschäftigt ist. Dadurch bleiben Vote-Klicks,
247+
Tastaturfokus und die Bühnen-Sortierumschaltung vor einem zeitgleichen Tausch
248+
geschützt, ohne das Polling der außerhalb liegenden Frageneingabe anzuhalten.
249+
250+
Alle sieben Routen liegen durch `config/routes.yaml` im Frontend-Scope. Die
215251
vier Aktionen akzeptieren nur POST und setzen `_token_check = true`; der in
216252
`vendor/contao/core-bundle/src/EventListener/RequestTokenListener.php`
217253
verifizierte Listener prüft dadurch bei zustandsbehafteten bzw.

README.md

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ The bundle contains two content elements and one page controller:
8484

8585
- `qna_session_list` renders published sessions and links to the selected
8686
reader page through Contao's `ContentUrlGenerator`.
87-
- `qna_session_reader` renders a cache-neutral lazy Turbo Frame shell for the
88-
URL's session alias.
87+
- `qna_session_reader` renders cache-neutral lazy Turbo Frame shells for the
88+
URL's session alias: one stable controls frame and one polling questions
89+
frame.
8990
- page type `qna_stage` renders either the published-session overview or one
9091
session's operator view. Modern layouts use Contao's Twig-slot
9192
`ContentComposition`; classic layouts use the documented legacy fallback.
@@ -110,19 +111,30 @@ them.
110111

111112
## Turbo actions and polling
112113

113-
The reader and stage detail start with a lazy Turbo Frame. Separate GET frame
114-
routes render current status, questions, vote counts, member vote state,
115-
forms and CSRF tokens. Question, vote, start and stop forms use POST and a
116-
Contao `REQUEST_TOKEN`. Successful writes return a frame-local `303 See
117-
Other`; a business rejection that must remain visible in the frame returns
118-
`422 Unprocessable Entity`. Missing authentication, failed CSRF validation
119-
and denied authorization retain their own hard error status.
114+
The reader starts with two lazy Turbo Frames. Its non-polling controls frame
115+
contains status, form errors and the question form. Its polling questions
116+
frame contains questions, vote counts and vote buttons. A status-selective
117+
Turbo Stream updates the controls only when the server-side session state has
118+
changed, so normal question polling never replaces text being edited. The
119+
stage detail uses one polling frame.
120+
121+
Question, vote, start and stop forms use POST and a Contao `REQUEST_TOKEN`.
122+
Successful writes return a frame-local `303 See Other`; the redirected GET
123+
uses Turbo Streams to update the affected regions. A created question updates
124+
the list and resets the form once. A business rejection that must remain
125+
visible returns `422 Unprocessable Entity` as HTML for the originating frame;
126+
rejected question text is rendered back into the form. Missing authentication,
127+
failed CSRF validation and denied authorization retain their own hard error
128+
status.
120129

121130
Only frames are refreshed; Turbo Drive is not enabled by this bundle. Polling
122131
pauses while the tab is hidden, removes timers for detached/cached frames,
123132
avoids duplicate timers and backs off exponentially after transport or frame
124-
errors, capped at sixteen times the base interval. Sorting by votes or time is
125-
performed in the database, and the selected sort remains in the frame URL.
133+
errors, capped at sixteen times the base interval. A polling reload is delayed
134+
while its frame contains keyboard focus or is processing an action. Polling
135+
reloads morph elements with stable IDs, protecting vote and sort interactions
136+
that overlap an already running request. Sorting by votes or time is performed
137+
in the database, and the selected sort remains in the frame URL.
126138

127139
Every frame and action response uses `Cache-Control: private, no-store`. The
128140
reader shell and stage detail shell are cache-neutral and contain no member

SPEC.md

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,25 @@ Vote-Button, Kennzeichnung bereits abgegebener Votes.
354354
**`closed`** — Fragen und Vote-Zahlen bleiben sichtbar, Formular und
355355
Vote-Buttons entfallen, Hinweis auf das Ende der Fragerunde.
356356

357+
Der Reader trennt den member-spezifischen dynamischen Inhalt strukturell in
358+
zwei benachbarte Turbo-Frames:
359+
360+
* `qna-session-<id>-reader` enthält ausschließlich Status, Fehlermeldungen und
361+
— nur bei `open` und für authentifizierte Mitglieder — das Frageformular.
362+
Dieser Controls-Frame wird lazy geladen, aber nicht periodisch gepollt.
363+
* `qna-session-<id>-questions` enthält ausschließlich die Fragenliste samt
364+
Vote-Zahlen und statusabhängigen Vote-Buttons. Nur dieser Frame wird im
365+
konfigurierten Intervall gepollt.
366+
367+
Jede Listenantwort enthält zusätzlich ein deklaratives Turbo-Stream-Update für
368+
den Controls-Inhalt, dessen CSS-Ziel nur bei einem abweichenden Session-Status
369+
existiert. Normales Polling im unveränderten Zustand `open` ersetzt das
370+
Formular daher nie; ein Wechsel nach `waiting` oder `closed` entfernt es aber
371+
spätestens mit dem nächsten Listen-Poll. Beim erfolgreichen Absenden wird der
372+
Controls-Inhalt einmalig gezielt ersetzt, damit das Formular geleert wird.
373+
Eine 422-Antwort rendert dagegen den Controls-Frame mit Fehlermeldung und dem
374+
serverseitig erneut ausgegebenen Fragetext.
375+
357376
### 5.4 Sortierung Teilnehmeransicht
358377

359378
```sql
@@ -579,6 +598,19 @@ neu laden — das ist der häufigste Implementierungsfehler in diesem Aufbau.
579598
Empfohlen: `loading="lazy"` mit neutralem, nicht member-spezifischem
580599
Initial-Markup, damit die umgebende Seite cachebar bleibt (Abschnitt 8).
581600

601+
Im Reader besteht dieses neutrale Initial-Markup aus zwei lazy Frames. Der
602+
nicht gepollte Controls-Frame hält Status und Frageformular; der gepollte
603+
Questions-Frame hält Fragen und Votes. Das Formular zielt mit
604+
`data-turbo-frame` auf den Questions-Frame. Nach dem POST folgt Turbo dem
605+
303-Redirect dorthin; bei angefordertem Turbo-Stream-Format aktualisiert die
606+
GET-Antwort Liste und Controls getrennt. Damit bleiben PRG und eine sofortige
607+
Listenaktualisierung erhalten, obwohl das Formular außerhalb des Listen-Frames
608+
liegt.
609+
610+
Reader-Liste und Bühnen-Frame verwenden für Polling-Reloads `refresh="morph"`
611+
und stabile IDs an interaktiven Elementen. Der Formular-Controls-Frame hat
612+
weder `data-qna-poll` noch einen Polling-Timer.
613+
582614
### 7.3 Polling
583615

584616
Turbo pollt nicht von selbst. Die Erweiterung liefert ein **sehr kleines
@@ -599,6 +631,14 @@ Anforderungen:
599631
* keine doppelten Timer nach Turbo-Visits
600632
* nur Frames pollen, die im DOM vorhanden sind
601633
* keine Geschäftslogik im JavaScript
634+
* kein Polling-Reload, solange der Frame Fokus enthält oder durch eine
635+
laufende Frame-/Formaktion als beschäftigt markiert ist
636+
637+
Die letzte Regel betrifft die im gepollten Bereich verbleibenden Vote-Buttons
638+
und die Sortierumschaltung der Bühne. Das ausgelagerte Fragefeld liegt nicht im
639+
Polling-Frame: Während dort geschrieben wird, aktualisiert sich die Liste
640+
weiterhin. Morphing und stabile IDs decken zusätzlich den engen Fall ab, dass
641+
ein bereits laufender Poll erst während einer beginnenden Interaktion rendert.
602642

603643
**Lastabschätzung gehört ins README:** 2500 ms Intervall × Zuschauerzahl ergibt
604644
die Requests pro Sekunde auf nicht cachebare Endpunkte. Prüfe, ob die
@@ -630,6 +670,7 @@ Bundle selbst, im eigenen Namensraum:
630670

631671
```
632672
contao_qna_reader_frame GET
673+
contao_qna_reader_controls GET
633674
contao_qna_stage_questions GET
634675
contao_qna_question_create POST
635676
contao_qna_vote_create POST
@@ -760,12 +801,19 @@ contao/templates/
760801
│ ├── qna_session_list.html.twig
761802
│ └── qna_session_reader.html.twig
762803
└── qna/
763-
├── reader_frame.html.twig
804+
├── reader_controls.html.twig
805+
├── reader_controls_frame.html.twig
806+
├── reader_controls_update.html.twig
807+
├── reader_questions.html.twig
808+
├── reader_questions_frame.html.twig
809+
├── reader_update.stream.html.twig
764810
├── question_list.html.twig
765811
├── question.html.twig
766812
├── stage_overview.html.twig
767813
├── stage_detail.html.twig
768-
└── stage_questions.html.twig
814+
├── stage_questions.html.twig
815+
├── stage_content.html.twig
816+
└── stage_update.stream.html.twig
769817
```
770818

771819
Referenziert wird über die Contao-Hierarchie, nicht über den

contao/templates/content_element/qna_session_reader.html.twig

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,19 @@
1111
<turbo-frame
1212
id="{{ view.frameId }}"
1313
class="qna-reader__frame"
14-
src="{{ frame_src }}"
14+
src="{{ controls_frame_src }}"
1515
loading="lazy"
16-
data-qna-frame="reader"
16+
data-qna-frame="reader-controls"
17+
>
18+
<p class="qna-reader__loading">{{ 'qna.reader.loading'|trans({}, 'contao_default') }}</p>
19+
</turbo-frame>
20+
<turbo-frame
21+
id="{{ view.questionsFrameId }}"
22+
class="qna-reader__questions-frame"
23+
src="{{ questions_frame_src }}"
24+
loading="lazy"
25+
refresh="morph"
26+
data-qna-frame="reader-questions"
1727
data-qna-poll
1828
data-qna-poll-interval="{{ polling_interval }}"
1929
data-qna-poll-max-interval="{{ polling_max_interval }}"

contao/templates/qna/question.html.twig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<li class="qna-question-list__item">
1+
<li id="qna-question-{{ question.id }}" class="qna-question-list__item">
22
<article class="qna-question" aria-labelledby="qna-question-{{ question.id }}-text">
33
<p id="qna-question-{{ question.id }}-text" class="qna-question__text">{{ question.question }}</p>
44
<p class="qna-question__votes">
@@ -9,6 +9,7 @@
99
<form class="qna-vote-form" action="{{ vote_url }}" method="post">
1010
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
1111
<button
12+
id="qna-question-{{ question.id }}-vote"
1213
class="qna-button qna-vote-button{{ question.hasVoted ? ' qna-vote-button--selected' : '' }}"
1314
type="submit"
1415
aria-label="{{ (question.hasVoted ? 'qna.vote.selected_label' : 'qna.vote.label')|trans([question.question], 'contao_default') }}"
Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
<turbo-frame
2-
id="{{ view.frameId }}"
3-
class="qna-reader__frame"
4-
data-qna-frame="reader"
1+
<div
2+
id="{{ view.controlsContentId }}"
3+
class="qna-reader__controls qna-reader__controls--{{ view.state }}"
54
data-qna-state="{{ view.state }}"
65
>
7-
<div data-qna-poll-interval="{{ polling_interval }}">
86
{% if error_translation_key %}
97
<p class="qna-message qna-message--error" role="alert">
108
{{ error_translation_key|trans({}, 'contao_default') }}
@@ -16,7 +14,12 @@
1614
</p>
1715

1816
{% if view.showQuestionForm %}
19-
<form class="qna-question-form" action="{{ question_form_action }}" method="post">
17+
<form
18+
class="qna-question-form"
19+
action="{{ question_form_action }}"
20+
method="post"
21+
data-turbo-frame="{{ view.questionsFrameId }}"
22+
>
2023
<label class="qna-question-form__label" for="qna-session-{{ view.sessionId }}-question">
2124
{{ 'qna.question.label'|trans({}, 'contao_default') }}
2225
</label>
@@ -26,22 +29,11 @@
2629
name="question"
2730
maxlength="{{ max_question_length }}"
2831
required
29-
></textarea>
32+
>{{ question_value }}</textarea>
3033
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
3134
<button class="qna-button qna-question-form__submit" type="submit">
3235
{{ 'qna.question.submit'|trans({}, 'contao_default') }}
3336
</button>
3437
</form>
3538
{% endif %}
36-
37-
{% if view.showQuestions %}
38-
{% include '@Contao/qna/question_list.html.twig' with {
39-
questions: questions,
40-
frame_id: view.questionsFrameId,
41-
show_vote_buttons: view.showVoteButtons,
42-
vote_urls: vote_urls,
43-
request_token: request_token
44-
} only %}
45-
{% endif %}
46-
</div>
47-
</turbo-frame>
39+
</div>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<turbo-frame
2+
id="{{ view.frameId }}"
3+
class="qna-reader__frame"
4+
data-qna-frame="reader-controls"
5+
data-qna-state="{{ view.state }}"
6+
>
7+
{% include '@Contao/qna/reader_controls.html.twig' %}
8+
</turbo-frame>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<turbo-stream
2+
action="replace"
3+
{% if reset_question_form %}
4+
target="{{ view.controlsContentId }}"
5+
{% else %}
6+
targets="#{{ view.controlsContentId }}:not([data-qna-state='{{ view.state }}'])"
7+
{% endif %}
8+
>
9+
<template>
10+
{% include '@Contao/qna/reader_controls.html.twig' %}
11+
</template>
12+
</turbo-stream>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<div class="qna-reader__questions" data-qna-poll-interval="{{ polling_interval }}" data-qna-state="{{ view.state }}">
2+
{% if error_translation_key %}
3+
<p class="qna-message qna-message--error" role="alert">
4+
{{ error_translation_key|trans({}, 'contao_default') }}
5+
</p>
6+
{% endif %}
7+
8+
{% if view.showQuestions %}
9+
{% include '@Contao/qna/question_list.html.twig' with {
10+
questions: questions,
11+
frame_id: view.questionsFrameId ~ '-list',
12+
show_vote_buttons: view.showVoteButtons,
13+
vote_urls: vote_urls,
14+
request_token: request_token
15+
} only %}
16+
{% endif %}
17+
</div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<turbo-frame
2+
id="{{ view.questionsFrameId }}"
3+
class="qna-reader__questions-frame"
4+
data-qna-frame="reader-questions"
5+
data-qna-state="{{ view.state }}"
6+
>
7+
{% include '@Contao/qna/reader_questions.html.twig' %}
8+
{% include '@Contao/qna/reader_controls_update.html.twig' with {reset_question_form: false} %}
9+
</turbo-frame>

0 commit comments

Comments
 (0)